@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,773 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawn } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import test from 'node:test';
9
+ import { getServiceStatus } from '../../extension/src/api';
10
+ import { sha256File } from '../../patcher/src/hash';
11
+ import {
12
+ CURSOR_POOL_AGENT_EXEC_PROVIDER_REGISTER_ANCHOR,
13
+ CURSOR_POOL_PATCH_MARKER,
14
+ } from '../../patcher/src/marker';
15
+ import { backupPathForCursorAgentExec } from '../../patcher/src/patchCursorAgentExec';
16
+ import type { CompatibilityManifestEntry } from '../../shared/src/manifest';
17
+ import { getExtensionState } from '../src/extensionBundle';
18
+ import { getLinkedExtensionState, linkedExtensionPathForDir } from '../src/extensionLink';
19
+ import { install } from '../src/install';
20
+ import {
21
+ installIdForAppPath,
22
+ readInstallRecord,
23
+ writeInstallRecord,
24
+ type InstallRecord,
25
+ } from '../src/installRecord';
26
+ import { restore } from '../src/restore';
27
+ import { status } from '../src/status';
28
+ import { readTrialRecord, trialIdForAppPath, writeTrialRecord } from '../src/trial';
29
+ import { uninstall } from '../src/uninstall';
30
+
31
+ const cursorVersion = '3.5.38';
32
+ const cursorCommit = '009bb5a3600dd98fe1c1f25798f767f686e14750';
33
+ const targetRelativePath =
34
+ 'Contents/Resources/app/extensions/cursor-agent-exec/dist/main.js';
35
+ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
36
+ const serviceServerPath = resolve(repoRoot, 'packages/service/src/server.ts');
37
+
38
+ async function createFixtureApp(prefix: string) {
39
+ const tempDir = await mkdtemp(join(tmpdir(), prefix));
40
+ const appPath = join(tempDir, 'Cursor.app');
41
+ const targetPath = join(appPath, targetRelativePath);
42
+ const targetContent = `function main() { return "agent"; }\n${CURSOR_POOL_AGENT_EXEC_PROVIDER_REGISTER_ANCHOR}\nmain();\n`;
43
+ await mkdir(join(appPath, 'Contents/Resources/app/extensions/cursor-agent-exec/dist'), {
44
+ recursive: true,
45
+ });
46
+ await writeFile(
47
+ join(appPath, 'Contents/Resources/app/product.json'),
48
+ JSON.stringify({ version: cursorVersion, commit: cursorCommit }),
49
+ 'utf8',
50
+ );
51
+ await writeFile(targetPath, targetContent, 'utf8');
52
+
53
+ const originalHash = createHash('sha256').update(targetContent).digest('hex');
54
+ const compatEntry: CompatibilityManifestEntry = {
55
+ platform: process.platform,
56
+ arch: process.arch,
57
+ cursorVersion,
58
+ cursorCommit,
59
+ supportStatus: 'supported',
60
+ targetRelativePath,
61
+ expectedSha256: originalHash,
62
+ structureSignature: 'fixture',
63
+ patchStrategy: 'cursor-agent-exec-snippet',
64
+ verifyMarker: 'cursor-pool',
65
+ restoreStrategy: 'external-backup',
66
+ minCliVersion: '0.0.0',
67
+ minExtensionVersion: '0.0.0',
68
+ minServiceVersion: '0.0.0',
69
+ requiresWritableAppBundle: true,
70
+ requiresAdHocResign: false,
71
+ userMessage: 'fixture supported',
72
+ };
73
+
74
+ return {
75
+ appPath,
76
+ tempDir,
77
+ targetPath,
78
+ targetContent,
79
+ originalHash,
80
+ runtimeFile: join(tempDir, 'runtime.json'),
81
+ backupDir: join(tempDir, 'backups'),
82
+ compatEntry,
83
+ };
84
+ }
85
+
86
+ async function assertRuntimeFileMissing(runtimeFile: string) {
87
+ await assert.rejects(stat(runtimeFile), /ENOENT/);
88
+ }
89
+
90
+ function createInstallRecordFixture(
91
+ fixture: Awaited<ReturnType<typeof createFixtureApp>>,
92
+ overrides: Partial<InstallRecord> = {},
93
+ ): InstallRecord {
94
+ const now = '2026-05-30T00:00:00.000Z';
95
+ return {
96
+ installId: installIdForAppPath(fixture.appPath),
97
+ mode: 'real',
98
+ appPath: fixture.appPath,
99
+ cursorVersion,
100
+ cursorCommit,
101
+ targetRelativePath,
102
+ originalSha256: fixture.originalHash,
103
+ compatSupportStatus: 'supported',
104
+ runtimeFile: fixture.runtimeFile,
105
+ backupDir: fixture.backupDir,
106
+ extensionInstallPath: join(fixture.tempDir, 'extensions/cursor-pool-status'),
107
+ extensionLinkedPath: linkedExtensionPathForDir(join(fixture.tempDir, 'Cursor-Extensions')),
108
+ extensionState: 'linked',
109
+ cliVersion: '0.0.0',
110
+ extensionVersion: '0.0.0',
111
+ serviceVersion: '0.0.0',
112
+ lastOperation: 'install',
113
+ lastOperationStatus: 'ok',
114
+ createdAt: now,
115
+ updatedAt: now,
116
+ ...overrides,
117
+ };
118
+ }
119
+
120
+ async function waitForChildService(runtimeFile: string) {
121
+ const child = spawn(
122
+ 'pnpm',
123
+ [
124
+ 'exec',
125
+ 'tsx',
126
+ '-e',
127
+ [
128
+ `import { startServer } from ${JSON.stringify(serviceServerPath)};`,
129
+ 'void (async () => {',
130
+ ' const service = await startServer({ runtimeFile: process.env.RUNTIME_FILE });',
131
+ ' console.log(JSON.stringify({ port: service.port }));',
132
+ '})();',
133
+ ].join('\n'),
134
+ ],
135
+ {
136
+ cwd: repoRoot,
137
+ env: { ...process.env, RUNTIME_FILE: runtimeFile },
138
+ stdio: ['ignore', 'pipe', 'pipe'],
139
+ },
140
+ );
141
+
142
+ const stderrChunks: Buffer[] = [];
143
+ child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk));
144
+
145
+ const port = await new Promise<number>((resolve, reject) => {
146
+ const timeout = setTimeout(() => {
147
+ reject(
148
+ new Error(
149
+ `Timed out waiting for child service. stderr: ${Buffer.concat(stderrChunks).toString('utf8')}`,
150
+ ),
151
+ );
152
+ }, 5000);
153
+
154
+ child.once('error', (error) => {
155
+ clearTimeout(timeout);
156
+ reject(error);
157
+ });
158
+ child.stdout.once('data', (chunk: Buffer) => {
159
+ clearTimeout(timeout);
160
+ try {
161
+ resolve((JSON.parse(chunk.toString('utf8')) as { port: number }).port);
162
+ } catch (error) {
163
+ reject(error);
164
+ }
165
+ });
166
+ });
167
+
168
+ return { child, port };
169
+ }
170
+
171
+ test('e2e install/status/restore/status/uninstall flow uses fixture app only', async () => {
172
+ const fixture = await createFixtureApp('cursor-pool-cli-e2e-flow-');
173
+ const cursorExtensionsDir = join(fixture.tempDir, 'Extensions');
174
+ const linkedPath = linkedExtensionPathForDir(cursorExtensionsDir);
175
+
176
+ try {
177
+ assert.equal(await sha256File(fixture.targetPath), fixture.originalHash);
178
+
179
+ const initialStatus = await status({
180
+ appPath: fixture.appPath,
181
+ runtimeFile: fixture.runtimeFile,
182
+ trialRecordDir: join(fixture.tempDir, 'trials'),
183
+ extensionInstallPath: join(fixture.tempDir, 'extensions/cursor-pool-status'),
184
+ compatEntries: [fixture.compatEntry],
185
+ });
186
+ assert.match(initialStatus, /patch: missing/);
187
+ assert.match(initialStatus, /service: stopped/);
188
+ assert.deepEqual(await getServiceStatus(fixture.runtimeFile), {
189
+ service: 'stopped',
190
+ runtime: null,
191
+ });
192
+
193
+ const installOutput = await install({
194
+ appPath: fixture.appPath,
195
+ runtimeFile: fixture.runtimeFile,
196
+ backupDir: fixture.backupDir,
197
+ trialRecordDir: join(fixture.tempDir, 'trials'),
198
+ extensionInstallPath: join(fixture.tempDir, 'extensions/cursor-pool-status'),
199
+ cursorExtensionsDir,
200
+ compatEntries: [fixture.compatEntry],
201
+ stopServiceAfterInstall: true,
202
+ });
203
+ assert.match(installOutput, /extension: linked/);
204
+ assert.match(installOutput, /trial: recorded/);
205
+ assert.match(installOutput, /service: running/);
206
+ assert.match(installOutput, /patch: applied/);
207
+ assert.equal(await getLinkedExtensionState(linkedPath), 'linked');
208
+ assert.match(await readFile(fixture.targetPath, 'utf8'), new RegExp(CURSOR_POOL_PATCH_MARKER));
209
+ assert.notEqual(await sha256File(fixture.targetPath), fixture.originalHash);
210
+ assert.equal(
211
+ await readFile(
212
+ await backupPathForCursorAgentExec(
213
+ fixture.appPath,
214
+ fixture.targetPath,
215
+ fixture.backupDir,
216
+ ),
217
+ 'utf8',
218
+ ),
219
+ fixture.targetContent,
220
+ );
221
+
222
+ const installedStatus = await status({
223
+ appPath: fixture.appPath,
224
+ trialRecordDir: join(fixture.tempDir, 'trials'),
225
+ compatEntries: [fixture.compatEntry],
226
+ });
227
+ assert.match(installedStatus, /app: .*Cursor\.app/);
228
+ assert.match(installedStatus, /patch: applied/);
229
+ assert.match(installedStatus, /service: stopped/);
230
+ assert.match(installedStatus, /extension: linked/);
231
+ assert.match(installedStatus, /runtime: /);
232
+ assert.match(installedStatus, /trial: recorded/);
233
+
234
+ const extensionStatus = await getServiceStatus(fixture.runtimeFile);
235
+ assert.equal(extensionStatus.service, 'stopped');
236
+
237
+ const restoreOutput = await restore({
238
+ appPath: fixture.appPath,
239
+ backupDir: fixture.backupDir,
240
+ });
241
+ assert.match(restoreOutput, /restore: ok/);
242
+ assert.match(restoreOutput, /app: .*Cursor\.app/);
243
+ assert.match(restoreOutput, /patch: missing/);
244
+ assert.equal(await sha256File(fixture.targetPath), fixture.originalHash);
245
+ assert.equal(await readFile(fixture.targetPath, 'utf8'), fixture.targetContent);
246
+
247
+ const restoredStatus = await status({
248
+ appPath: fixture.appPath,
249
+ runtimeFile: fixture.runtimeFile,
250
+ trialRecordDir: join(fixture.tempDir, 'trials'),
251
+ extensionInstallPath: join(fixture.tempDir, 'extensions/cursor-pool-status'),
252
+ compatEntries: [fixture.compatEntry],
253
+ });
254
+ assert.match(restoredStatus, /patch: missing/);
255
+ assert.match(restoredStatus, /service: stopped/);
256
+
257
+ const restoredExtensionStatus = await getServiceStatus(fixture.runtimeFile);
258
+ assert.equal(restoredExtensionStatus.service, 'stopped');
259
+
260
+ const uninstallOutput = await uninstall({
261
+ appPath: fixture.appPath,
262
+ trialRecordDir: join(fixture.tempDir, 'trials'),
263
+ });
264
+ assert.match(uninstallOutput, /restore: skipped/);
265
+ assert.match(uninstallOutput, /mode: disposable/);
266
+ assert.match(uninstallOutput, /extension: removed/);
267
+ assert.match(uninstallOutput, /service: stopped/);
268
+ assert.match(uninstallOutput, /trial: removed/);
269
+ assert.match(uninstallOutput, /uninstall: ok/);
270
+ assert.equal(await sha256File(fixture.targetPath), fixture.originalHash);
271
+ assert.equal(await getLinkedExtensionState(linkedPath), 'missing');
272
+ await assertRuntimeFileMissing(fixture.runtimeFile);
273
+
274
+ const uninstalledStatus = await status({
275
+ appPath: fixture.appPath,
276
+ runtimeFile: fixture.runtimeFile,
277
+ trialRecordDir: join(fixture.tempDir, 'trials'),
278
+ extensionInstallPath: join(fixture.tempDir, 'extensions/cursor-pool-status'),
279
+ compatEntries: [fixture.compatEntry],
280
+ });
281
+ assert.match(uninstalledStatus, /patch: missing/);
282
+ assert.match(uninstalledStatus, /service: stopped/);
283
+ assert.deepEqual(await getServiceStatus(fixture.runtimeFile), {
284
+ service: 'stopped',
285
+ runtime: null,
286
+ });
287
+ } finally {
288
+ await rm(fixture.tempDir, { recursive: true, force: true });
289
+ }
290
+ });
291
+
292
+ test('uninstall stops a service discovered only from runtime file', async () => {
293
+ const fixture = await createFixtureApp('cursor-pool-cli-e2e-child-uninstall-');
294
+ const { child, port } = await waitForChildService(fixture.runtimeFile);
295
+
296
+ try {
297
+ const health = await fetch(`http://127.0.0.1:${port}/health`);
298
+ assert.equal(health.ok, true);
299
+
300
+ const uninstallOutput = await uninstall({
301
+ appPath: fixture.appPath,
302
+ runtimeFile: fixture.runtimeFile,
303
+ backupDir: fixture.backupDir,
304
+ trialRecordDir: join(fixture.tempDir, 'trials'),
305
+ extensionInstallPath: join(fixture.tempDir, 'extensions/cursor-pool-status'),
306
+ });
307
+ assert.match(uninstallOutput, /service: stopped/);
308
+ await assertRuntimeFileMissing(fixture.runtimeFile);
309
+ await assert.rejects(fetch(`http://127.0.0.1:${port}/health`), /fetch failed/);
310
+ } finally {
311
+ child.kill();
312
+ await rm(fixture.tempDir, { recursive: true, force: true });
313
+ }
314
+ });
315
+
316
+ test('disposable uninstall without a trial record does not remove default install assets', async () => {
317
+ const fixture = await createFixtureApp('cursor-pool-cli-e2e-unrecorded-uninstall-');
318
+ const originalHome = process.env.HOME;
319
+ const sandboxHome = join(fixture.tempDir, 'home');
320
+ const defaultRuntimeFile = join(sandboxHome, '.cursor-pool/runtime.json');
321
+ const defaultExtensionPath = join(sandboxHome, '.cursor-pool/extensions/cursor-pool-status');
322
+
323
+ try {
324
+ process.env.HOME = sandboxHome;
325
+ await mkdir(dirname(defaultRuntimeFile), { recursive: true });
326
+ await writeFile(defaultRuntimeFile, '{"sentinel":true}\n', 'utf8');
327
+ await mkdir(join(defaultExtensionPath, 'dist'), { recursive: true });
328
+ await writeFile(join(defaultExtensionPath, 'package.json'), '{"name":"sentinel"}\n', 'utf8');
329
+ await writeFile(join(defaultExtensionPath, 'dist/extension.js'), 'sentinel\n', 'utf8');
330
+
331
+ const uninstallOutput = await uninstall({
332
+ appPath: fixture.appPath,
333
+ trialRecordDir: join(fixture.tempDir, 'trials'),
334
+ });
335
+
336
+ assert.match(uninstallOutput, /restore: skipped/);
337
+ assert.match(uninstallOutput, /mode: disposable/);
338
+ assert.match(uninstallOutput, /extension: skipped/);
339
+ assert.match(uninstallOutput, /service: skipped/);
340
+ assert.match(uninstallOutput, /trial: removed/);
341
+ assert.match(uninstallOutput, /uninstall: ok/);
342
+ assert.equal(await readFile(defaultRuntimeFile, 'utf8'), '{"sentinel":true}\n');
343
+ assert.equal(await getExtensionState(defaultExtensionPath), 'bundled');
344
+ } finally {
345
+ if (originalHome === undefined) {
346
+ delete process.env.HOME;
347
+ } else {
348
+ process.env.HOME = originalHome;
349
+ }
350
+ await rm(fixture.tempDir, { recursive: true, force: true });
351
+ }
352
+ });
353
+
354
+ test('uninstall discovers backup, runtime, and extension paths from trial record', async () => {
355
+ const fixture = await createFixtureApp('cursor-pool-cli-e2e-record-uninstall-');
356
+ const trialRecordDir = join(fixture.tempDir, 'trials');
357
+ const extensionInstallPath = join(fixture.tempDir, 'extensions/cursor-pool-status');
358
+
359
+ try {
360
+ await install({
361
+ appPath: fixture.appPath,
362
+ runtimeFile: fixture.runtimeFile,
363
+ backupDir: fixture.backupDir,
364
+ trialRecordDir,
365
+ extensionInstallPath,
366
+ compatEntries: [fixture.compatEntry],
367
+ stopServiceAfterInstall: true,
368
+ });
369
+
370
+ const uninstallOutput = await uninstall({
371
+ appPath: fixture.appPath,
372
+ trialRecordDir,
373
+ });
374
+
375
+ assert.match(uninstallOutput, /restore: ok/);
376
+ assert.match(uninstallOutput, /patch: missing/);
377
+ assert.match(uninstallOutput, /extension: removed/);
378
+ assert.match(uninstallOutput, /service: stopped/);
379
+ assert.match(uninstallOutput, /trial: removed/);
380
+ assert.equal(await sha256File(fixture.targetPath), fixture.originalHash);
381
+ await assertRuntimeFileMissing(fixture.runtimeFile);
382
+ assert.equal(await getExtensionState(extensionInstallPath), 'missing');
383
+ assert.equal(await readTrialRecord(fixture.appPath, { trialRecordDir }), null);
384
+ } finally {
385
+ await rm(fixture.tempDir, { recursive: true, force: true });
386
+ }
387
+ });
388
+
389
+ test('uninstall removes linked extension recorded from a custom cursor extensions dir', async () => {
390
+ const fixture = await createFixtureApp('cursor-pool-cli-e2e-custom-linked-uninstall-');
391
+ const trialRecordDir = join(fixture.tempDir, 'trials');
392
+ const extensionInstallPath = join(fixture.tempDir, 'extensions/cursor-pool-status');
393
+ const cursorExtensionsDir = join(fixture.tempDir, 'Cursor-Pool-Trial-Extensions');
394
+ const linkedPath = linkedExtensionPathForDir(cursorExtensionsDir);
395
+
396
+ try {
397
+ await install({
398
+ appPath: fixture.appPath,
399
+ runtimeFile: fixture.runtimeFile,
400
+ backupDir: fixture.backupDir,
401
+ trialRecordDir,
402
+ extensionInstallPath,
403
+ cursorExtensionsDir,
404
+ compatEntries: [fixture.compatEntry],
405
+ stopServiceAfterInstall: true,
406
+ });
407
+
408
+ assert.equal(await getLinkedExtensionState(linkedPath), 'linked');
409
+
410
+ const uninstallOutput = await uninstall({
411
+ appPath: fixture.appPath,
412
+ trialRecordDir,
413
+ });
414
+
415
+ assert.match(uninstallOutput, /extension: removed/);
416
+ assert.equal(await getLinkedExtensionState(linkedPath), 'missing');
417
+ assert.equal(await getExtensionState(extensionInstallPath), 'missing');
418
+ assert.equal(await readTrialRecord(fixture.appPath, { trialRecordDir }), null);
419
+ } finally {
420
+ await rm(fixture.tempDir, { recursive: true, force: true });
421
+ }
422
+ });
423
+
424
+ test('uninstall removes linked extension from an explicit custom cursor extensions dir', async () => {
425
+ const fixture = await createFixtureApp('cursor-pool-cli-e2e-explicit-custom-linked-uninstall-');
426
+ const trialRecordDir = join(fixture.tempDir, 'trials');
427
+ const extensionInstallPath = join(fixture.tempDir, 'extensions/cursor-pool-status');
428
+ const cursorExtensionsDir = join(fixture.tempDir, 'Cursor-Pool-Trial-Extensions');
429
+ const linkedPath = linkedExtensionPathForDir(cursorExtensionsDir);
430
+
431
+ try {
432
+ await install({
433
+ appPath: fixture.appPath,
434
+ runtimeFile: fixture.runtimeFile,
435
+ backupDir: fixture.backupDir,
436
+ trialRecordDir,
437
+ extensionInstallPath,
438
+ cursorExtensionsDir,
439
+ compatEntries: [fixture.compatEntry],
440
+ stopServiceAfterInstall: true,
441
+ });
442
+
443
+ assert.equal(await getLinkedExtensionState(linkedPath), 'linked');
444
+
445
+ const uninstallOutput = await uninstall({
446
+ appPath: fixture.appPath,
447
+ trialRecordDir,
448
+ cursorExtensionsDir,
449
+ });
450
+
451
+ assert.match(uninstallOutput, /uninstall: ok/);
452
+ assert.match(uninstallOutput, /extension: removed/);
453
+ assert.equal(await getLinkedExtensionState(linkedPath), 'missing');
454
+ assert.equal(await getExtensionState(extensionInstallPath), 'missing');
455
+ } finally {
456
+ await rm(fixture.tempDir, { recursive: true, force: true });
457
+ }
458
+ });
459
+
460
+ test('uninstall rejects unsafe recorded linked extension path without removing it', async () => {
461
+ const fixture = await createFixtureApp('cursor-pool-cli-e2e-unsafe-linked-uninstall-');
462
+ const trialRecordDir = join(fixture.tempDir, 'trials');
463
+ const unsafeLinkedPath = join(fixture.tempDir, 'unsafe-parent/cursor-pool.extension-0.0.0');
464
+ const sentinelPath = join(unsafeLinkedPath, 'sentinel.txt');
465
+ const now = '2026-05-30T00:00:00.000Z';
466
+
467
+ await mkdir(unsafeLinkedPath, { recursive: true });
468
+ await writeFile(sentinelPath, 'do not remove\n', 'utf8');
469
+ await writeTrialRecord(
470
+ {
471
+ trialId: trialIdForAppPath(fixture.appPath),
472
+ appPath: fixture.appPath,
473
+ cursorVersion,
474
+ cursorCommit,
475
+ targetRelativePath,
476
+ originalSha256: fixture.originalHash,
477
+ compatSupportStatus: 'supported',
478
+ runtimeFile: fixture.runtimeFile,
479
+ backupDir: fixture.backupDir,
480
+ extensionInstallPath: join(fixture.tempDir, 'extensions/cursor-pool-status'),
481
+ extensionLinkedPath: unsafeLinkedPath,
482
+ extensionState: 'linked',
483
+ createdAt: now,
484
+ updatedAt: now,
485
+ },
486
+ { trialRecordDir },
487
+ );
488
+
489
+ try {
490
+ await assert.rejects(
491
+ uninstall({
492
+ appPath: fixture.appPath,
493
+ trialRecordDir,
494
+ }),
495
+ /Unsafe linked extension path/,
496
+ );
497
+
498
+ assert.equal(await readFile(sentinelPath, 'utf8'), 'do not remove\n');
499
+ } finally {
500
+ await rm(fixture.tempDir, { recursive: true, force: true });
501
+ }
502
+ });
503
+
504
+ test('uninstall restores changed target bytes even when the patch marker is absent', async () => {
505
+ const fixture = await createFixtureApp('cursor-pool-cli-e2e-changed-target-uninstall-');
506
+ const trialRecordDir = join(fixture.tempDir, 'trials');
507
+ const extensionInstallPath = join(fixture.tempDir, 'extensions/cursor-pool-status');
508
+
509
+ try {
510
+ await install({
511
+ appPath: fixture.appPath,
512
+ runtimeFile: fixture.runtimeFile,
513
+ backupDir: fixture.backupDir,
514
+ trialRecordDir,
515
+ extensionInstallPath,
516
+ compatEntries: [fixture.compatEntry],
517
+ stopServiceAfterInstall: true,
518
+ });
519
+ await writeFile(fixture.targetPath, 'function main() { return "rewritten"; }\nmain();\n');
520
+
521
+ const uninstallOutput = await uninstall({
522
+ appPath: fixture.appPath,
523
+ trialRecordDir,
524
+ });
525
+
526
+ assert.match(uninstallOutput, /restore: ok/);
527
+ assert.match(uninstallOutput, /patch: missing/);
528
+ assert.equal(await sha256File(fixture.targetPath), fixture.originalHash);
529
+ assert.equal(await readFile(fixture.targetPath, 'utf8'), fixture.targetContent);
530
+ await assertRuntimeFileMissing(fixture.runtimeFile);
531
+ assert.equal(await getExtensionState(extensionInstallPath), 'missing');
532
+ assert.equal(await readTrialRecord(fixture.appPath, { trialRecordDir }), null);
533
+ } finally {
534
+ await rm(fixture.tempDir, { recursive: true, force: true });
535
+ }
536
+ });
537
+
538
+ test('real-mode uninstall discovers paths from install record and removes only project files', async () => {
539
+ const fixture = await createFixtureApp('cursor-pool-cli-e2e-real-uninstall-');
540
+ const installRecordFile = join(fixture.tempDir, 'install-record.json');
541
+ const extensionInstallPath = join(fixture.tempDir, 'extensions/cursor-pool-status');
542
+ const cursorExtensionsDir = join(fixture.tempDir, '.cursor/extensions');
543
+ const linkedPath = linkedExtensionPathForDir(cursorExtensionsDir);
544
+ const unrelatedExtensionDir = join(cursorExtensionsDir, 'unrelated.extension');
545
+ const unrelatedSentinel = join(unrelatedExtensionDir, 'sentinel.txt');
546
+
547
+ try {
548
+ await mkdir(unrelatedExtensionDir, { recursive: true });
549
+ await writeFile(unrelatedSentinel, 'do not remove\n', 'utf8');
550
+
551
+ await install({
552
+ realAppPath: fixture.appPath,
553
+ installRecordFile,
554
+ runtimeFile: fixture.runtimeFile,
555
+ backupDir: fixture.backupDir,
556
+ extensionInstallPath,
557
+ cursorExtensionsDir,
558
+ compatEntries: [fixture.compatEntry],
559
+ stopServiceAfterInstall: true,
560
+ yes: true,
561
+ isInteractive: false,
562
+ });
563
+
564
+ assert.match(await readFile(fixture.targetPath, 'utf8'), new RegExp(CURSOR_POOL_PATCH_MARKER));
565
+ assert.equal(await getLinkedExtensionState(linkedPath), 'linked');
566
+
567
+ const uninstallOutput = await uninstall({
568
+ realAppPath: fixture.appPath,
569
+ installRecordFile,
570
+ yes: true,
571
+ isInteractive: false,
572
+ });
573
+
574
+ assert.match(uninstallOutput, /mode: real/);
575
+ assert.match(uninstallOutput, /restore: ok/);
576
+ assert.match(uninstallOutput, /extension: removed/);
577
+ assert.match(uninstallOutput, /service: stopped/);
578
+ assert.match(uninstallOutput, /install-record: removed/);
579
+ assert.match(uninstallOutput, /uninstall: ok/);
580
+ assert.equal(await sha256File(fixture.targetPath), fixture.originalHash);
581
+ await assertRuntimeFileMissing(fixture.runtimeFile);
582
+ assert.equal(await getExtensionState(extensionInstallPath), 'missing');
583
+ assert.equal(await getLinkedExtensionState(linkedPath), 'missing');
584
+ assert.equal(await readFile(unrelatedSentinel, 'utf8'), 'do not remove\n');
585
+ assert.equal(await readInstallRecord({ installRecordFile }), null);
586
+ } finally {
587
+ await rm(fixture.tempDir, { recursive: true, force: true });
588
+ }
589
+ });
590
+
591
+ test('real-mode uninstall ignores explicit override paths', async () => {
592
+ const fixture = await createFixtureApp('cursor-pool-cli-e2e-real-uninstall-overrides-');
593
+ const installRecordFile = join(fixture.tempDir, 'install-record.json');
594
+ const extensionInstallPath = join(fixture.tempDir, 'extensions/cursor-pool-status');
595
+ const cursorExtensionsDir = join(fixture.tempDir, '.cursor/extensions');
596
+ const linkedPath = linkedExtensionPathForDir(cursorExtensionsDir);
597
+ const overrideBackupDir = join(fixture.tempDir, 'override/backups');
598
+ const overrideRuntimeFile = join(fixture.tempDir, 'override/runtime.json');
599
+ const overrideExtensionInstallPath = join(
600
+ fixture.tempDir,
601
+ 'override/extensions/cursor-pool-status',
602
+ );
603
+ const overrideCursorExtensionsDir = join(fixture.tempDir, 'override/.cursor/extensions');
604
+ const overrideLinkedPath = linkedExtensionPathForDir(overrideCursorExtensionsDir);
605
+ const prompts: string[] = [];
606
+
607
+ try {
608
+ await mkdir(dirname(overrideRuntimeFile), { recursive: true });
609
+ await writeFile(overrideRuntimeFile, '{"sentinel":true}\n', 'utf8');
610
+ await mkdir(join(overrideExtensionInstallPath, 'dist'), { recursive: true });
611
+ await writeFile(
612
+ join(overrideExtensionInstallPath, 'package.json'),
613
+ '{"name":"override"}\n',
614
+ 'utf8',
615
+ );
616
+ await writeFile(
617
+ join(overrideExtensionInstallPath, 'dist/extension.js'),
618
+ 'override extension\n',
619
+ 'utf8',
620
+ );
621
+ await mkdir(join(overrideLinkedPath, 'dist'), { recursive: true });
622
+ await writeFile(join(overrideLinkedPath, 'package.json'), '{"name":"override-linked"}\n', 'utf8');
623
+ await writeFile(join(overrideLinkedPath, 'dist/extension.js'), 'override linked\n', 'utf8');
624
+
625
+ await install({
626
+ realAppPath: fixture.appPath,
627
+ installRecordFile,
628
+ runtimeFile: fixture.runtimeFile,
629
+ backupDir: fixture.backupDir,
630
+ extensionInstallPath,
631
+ cursorExtensionsDir,
632
+ compatEntries: [fixture.compatEntry],
633
+ stopServiceAfterInstall: true,
634
+ yes: true,
635
+ isInteractive: false,
636
+ });
637
+
638
+ const uninstallOutput = await uninstall({
639
+ realAppPath: fixture.appPath,
640
+ installRecordFile,
641
+ backupDir: overrideBackupDir,
642
+ runtimeFile: overrideRuntimeFile,
643
+ extensionInstallPath: overrideExtensionInstallPath,
644
+ cursorExtensionsDir: overrideCursorExtensionsDir,
645
+ isInteractive: true,
646
+ askConfirmation: (prompt) => {
647
+ prompts.push(prompt);
648
+ return true;
649
+ },
650
+ });
651
+
652
+ assert.match(uninstallOutput, /mode: real/);
653
+ assert.match(uninstallOutput, /install-record: removed/);
654
+ assert.equal(await sha256File(fixture.targetPath), fixture.originalHash);
655
+ await assertRuntimeFileMissing(fixture.runtimeFile);
656
+ assert.equal(await getExtensionState(extensionInstallPath), 'missing');
657
+ assert.equal(await getLinkedExtensionState(linkedPath), 'missing');
658
+ assert.equal(await readFile(overrideRuntimeFile, 'utf8'), '{"sentinel":true}\n');
659
+ assert.equal(await getExtensionState(overrideExtensionInstallPath), 'bundled');
660
+ assert.equal(await getLinkedExtensionState(overrideLinkedPath), 'linked');
661
+ assert.equal(prompts.length, 1);
662
+ assert.match(prompts[0], new RegExp(fixture.backupDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
663
+ assert.match(prompts[0], new RegExp(fixture.runtimeFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
664
+ assert.match(
665
+ prompts[0],
666
+ new RegExp(extensionInstallPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
667
+ );
668
+ assert.match(prompts[0], new RegExp(linkedPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
669
+ assert.doesNotMatch(
670
+ prompts[0],
671
+ new RegExp(overrideBackupDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
672
+ );
673
+ assert.equal(await readInstallRecord({ installRecordFile }), null);
674
+ } finally {
675
+ await rm(fixture.tempDir, { recursive: true, force: true });
676
+ }
677
+ });
678
+
679
+ test('real-mode uninstall rejects mismatched install record before mutation', async () => {
680
+ const fixture = await createFixtureApp('cursor-pool-cli-e2e-real-uninstall-safety-');
681
+ const otherFixture = await createFixtureApp('cursor-pool-cli-e2e-real-uninstall-other-');
682
+ const installRecordFile = join(fixture.tempDir, 'install-record.json');
683
+ const extensionInstallPath = join(fixture.tempDir, 'extensions/cursor-pool-status');
684
+ const cursorExtensionsDir = join(fixture.tempDir, '.cursor/extensions');
685
+ const linkedPath = linkedExtensionPathForDir(cursorExtensionsDir);
686
+
687
+ try {
688
+ await install({
689
+ realAppPath: fixture.appPath,
690
+ installRecordFile,
691
+ runtimeFile: fixture.runtimeFile,
692
+ backupDir: fixture.backupDir,
693
+ extensionInstallPath,
694
+ cursorExtensionsDir,
695
+ compatEntries: [fixture.compatEntry],
696
+ stopServiceAfterInstall: true,
697
+ yes: true,
698
+ isInteractive: false,
699
+ });
700
+
701
+ const wrongRecord = createInstallRecordFixture(otherFixture);
702
+ await writeInstallRecord(wrongRecord, { installRecordFile });
703
+
704
+ await assert.rejects(
705
+ uninstall({
706
+ realAppPath: fixture.appPath,
707
+ installRecordFile,
708
+ yes: true,
709
+ isInteractive: false,
710
+ }),
711
+ /Install record does not match Cursor app/,
712
+ );
713
+
714
+ assert.match(await readFile(fixture.targetPath, 'utf8'), new RegExp(CURSOR_POOL_PATCH_MARKER));
715
+ assert.equal(await getLinkedExtensionState(linkedPath), 'linked');
716
+ assert.deepEqual(await readInstallRecord({ installRecordFile }), wrongRecord);
717
+ } finally {
718
+ await rm(fixture.tempDir, { recursive: true, force: true });
719
+ await rm(otherFixture.tempDir, { recursive: true, force: true });
720
+ }
721
+ });
722
+
723
+ test('e2e install failure rolls back target bytes, runtime, and service', async () => {
724
+ const fixture = await createFixtureApp('cursor-pool-cli-e2e-rollback-');
725
+ let startedPort: number | undefined;
726
+
727
+ try {
728
+ await assert.rejects(
729
+ install({
730
+ appPath: fixture.appPath,
731
+ runtimeFile: fixture.runtimeFile,
732
+ backupDir: fixture.backupDir,
733
+ trialRecordDir: join(fixture.tempDir, 'trials'),
734
+ extensionInstallPath: join(fixture.tempDir, 'extensions/cursor-pool-status'),
735
+ compatEntries: [fixture.compatEntry],
736
+ patchCursorAgentExec: async (appPath, options) => {
737
+ const { patchCursorAgentExec } = await import(
738
+ '../../patcher/src/patchCursorAgentExec'
739
+ );
740
+ const result = await patchCursorAgentExec(appPath, options);
741
+ const runtime = JSON.parse(await readFile(fixture.runtimeFile, 'utf8')) as {
742
+ port: number;
743
+ };
744
+ startedPort = runtime.port;
745
+ throw new Error('synthetic post-patch failure');
746
+ },
747
+ }),
748
+ /synthetic post-patch failure/,
749
+ );
750
+
751
+ assert.equal(await sha256File(fixture.targetPath), fixture.originalHash);
752
+ assert.doesNotMatch(await readFile(fixture.targetPath, 'utf8'), /cursor-pool/);
753
+ await assertRuntimeFileMissing(fixture.runtimeFile);
754
+ assert.equal(typeof startedPort, 'number');
755
+ await assert.rejects(fetch(`http://127.0.0.1:${startedPort}/health`), /fetch failed/);
756
+
757
+ const rolledBackStatus = await status({
758
+ appPath: fixture.appPath,
759
+ runtimeFile: fixture.runtimeFile,
760
+ trialRecordDir: join(fixture.tempDir, 'trials'),
761
+ extensionInstallPath: join(fixture.tempDir, 'extensions/cursor-pool-status'),
762
+ compatEntries: [fixture.compatEntry],
763
+ });
764
+ assert.match(rolledBackStatus, /patch: missing/);
765
+ assert.match(rolledBackStatus, /service: stopped/);
766
+ assert.deepEqual(await getServiceStatus(fixture.runtimeFile), {
767
+ service: 'stopped',
768
+ runtime: null,
769
+ });
770
+ } finally {
771
+ await rm(fixture.tempDir, { recursive: true, force: true });
772
+ }
773
+ });