@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,188 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { mkdir, open, rm } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { createRequire } from 'node:module';
6
+ import { readRuntimeInfo, resolveRuntimeFile, type RuntimeInfo } from '@cursor-pool/service';
7
+
8
+ export type StartDetachedServiceOptions = {
9
+ runtimeFile: string;
10
+ logFile: string;
11
+ apiBaseUrl?: string;
12
+ configFile?: string;
13
+ serviceEntry?: string;
14
+ command?: string;
15
+ startupTimeoutMs?: number;
16
+ };
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const DEFAULT_SERVICE_ENTRY = require.resolve('@cursor-pool/service/entry');
20
+ const TSX_ESM_LOADER = require.resolve('tsx/esm');
21
+ function resolveTildePath(path: string) {
22
+ if (path.startsWith('~/')) {
23
+ return join(homedir(), path.slice(2));
24
+ }
25
+ return path;
26
+ }
27
+
28
+ async function fetchRuntimeHealth(runtime: RuntimeInfo | null) {
29
+ if (!runtime) {
30
+ return false;
31
+ }
32
+
33
+ try {
34
+ const response = await fetch(`http://${runtime.host}:${runtime.port}/health`);
35
+ const health = (await response.json()) as { ok?: unknown; runtimeId?: unknown };
36
+ return response.ok && health.ok === true && health.runtimeId === runtime.runtimeId;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function delay(ms: number) {
43
+ return new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
44
+ }
45
+
46
+ async function stopDetachedProcessGroup(pid: number | undefined) {
47
+ if (!pid) {
48
+ return;
49
+ }
50
+
51
+ try {
52
+ process.kill(-pid, 'SIGTERM');
53
+ } catch {
54
+ return;
55
+ }
56
+
57
+ for (let attempt = 0; attempt < 10; attempt += 1) {
58
+ await delay(50);
59
+ try {
60
+ process.kill(-pid, 0);
61
+ } catch {
62
+ return;
63
+ }
64
+ }
65
+
66
+ try {
67
+ process.kill(-pid, 'SIGKILL');
68
+ } catch {
69
+ return;
70
+ }
71
+ }
72
+
73
+ export async function waitForRuntimeHealth(
74
+ runtimeFile: string,
75
+ timeoutMs = 5000,
76
+ ) {
77
+ const resolvedRuntimeFile = resolveRuntimeFile(runtimeFile);
78
+ const startedAt = Date.now();
79
+ while (Date.now() - startedAt < timeoutMs) {
80
+ const runtime = await readRuntimeInfo({ runtimeFile: resolvedRuntimeFile });
81
+ if (await fetchRuntimeHealth(runtime)) {
82
+ return true;
83
+ }
84
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 50));
85
+ }
86
+ return false;
87
+ }
88
+
89
+ export async function startDetachedService(
90
+ options: StartDetachedServiceOptions,
91
+ ): Promise<RuntimeInfo> {
92
+ const runtimeFile = resolveRuntimeFile(options.runtimeFile);
93
+ const logFile = resolveTildePath(options.logFile);
94
+ const existingRuntime = await readRuntimeInfo({ runtimeFile });
95
+ if (await fetchRuntimeHealth(existingRuntime)) {
96
+ return existingRuntime!;
97
+ }
98
+ await rm(runtimeFile, { force: true });
99
+ await mkdir(dirname(logFile), { recursive: true });
100
+ const logHandle = await open(logFile, 'a');
101
+ const serviceEntry = options.serviceEntry ?? DEFAULT_SERVICE_ENTRY;
102
+ const command = options.command ?? process.execPath;
103
+ const args = ['--import', TSX_ESM_LOADER, serviceEntry, '--runtime-file', runtimeFile];
104
+ if (options.configFile) {
105
+ args.push('--config-file', options.configFile);
106
+ }
107
+ const child = spawn(
108
+ command,
109
+ args,
110
+ {
111
+ cwd: process.cwd(),
112
+ env: {
113
+ ...process.env,
114
+ ...(options.apiBaseUrl ? { CURSOR_POOL_API_BASE_URL: options.apiBaseUrl } : {}),
115
+ },
116
+ detached: true,
117
+ stdio: ['ignore', logHandle.fd, logHandle.fd],
118
+ },
119
+ );
120
+ let exited = false;
121
+ let startupError: Error | undefined;
122
+ child.once('exit', () => {
123
+ exited = true;
124
+ });
125
+ child.once('error', (error) => {
126
+ startupError = new Error(`Failed to start detached service process: ${(error as Error).message}`);
127
+ });
128
+
129
+ child.unref();
130
+ await logHandle.close();
131
+
132
+ let healthy = false;
133
+ try {
134
+ const startedAt = Date.now();
135
+ const timeoutMs = options.startupTimeoutMs ?? 5000;
136
+ while (Date.now() - startedAt < timeoutMs) {
137
+ if (startupError) {
138
+ throw startupError;
139
+ }
140
+
141
+ const runtime = await readRuntimeInfo({ runtimeFile });
142
+ if (await fetchRuntimeHealth(runtime)) {
143
+ healthy = true;
144
+ break;
145
+ }
146
+ await delay(50);
147
+ }
148
+
149
+ if (startupError) {
150
+ throw startupError;
151
+ }
152
+ } catch (error) {
153
+ if (!exited) {
154
+ await stopDetachedProcessGroup(child.pid);
155
+ }
156
+ throw error;
157
+ }
158
+
159
+ if (!healthy) {
160
+ if (!exited) {
161
+ await stopDetachedProcessGroup(child.pid);
162
+ }
163
+ throw new Error('Detached service failed to become healthy');
164
+ }
165
+
166
+ const runtime = await readRuntimeInfo({ runtimeFile });
167
+ if (!runtime) {
168
+ throw new Error('Detached service did not write runtime file');
169
+ }
170
+ return runtime;
171
+ }
172
+
173
+ export async function stopRuntimeService(runtimeFile: string) {
174
+ const resolvedRuntimeFile = resolveRuntimeFile(runtimeFile);
175
+ const runtime = await readRuntimeInfo({ runtimeFile: resolvedRuntimeFile });
176
+ if (!(await fetchRuntimeHealth(runtime))) {
177
+ return false;
178
+ }
179
+
180
+ try {
181
+ const response = await fetch(`http://${runtime.host}:${runtime.port}/shutdown`, {
182
+ method: 'POST',
183
+ });
184
+ return response.ok;
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
package/src/status.ts ADDED
@@ -0,0 +1,241 @@
1
+ import { readRuntimeInfo, resolveRuntimeFile } from '@cursor-pool/service';
2
+ import { cachedPlatformStatus } from '@cursor-pool/service/platformSession';
3
+ import type { CompatibilityManifestEntry } from '@cursor-pool/shared/manifest';
4
+ import { detectEnvironment, type DetectEnvironmentOptions } from './environment';
5
+ import { findCursor, type FindCursorOptions } from './cursor';
6
+ import { loadCompatEntries, resolveCompatEntry, type CompatManifestFetchResponse } from './compat';
7
+ import { getExtensionState } from './extensionBundle';
8
+ import { getLinkedExtensionState, linkedExtensionPathForDir } from './extensionLink';
9
+ import {
10
+ assertDisposableCursorAppPath,
11
+ readTrialRecord,
12
+ type TrialRecordOptions,
13
+ } from './trial';
14
+ import { resolveCursorTarget, type CursorTargetOptions } from './target';
15
+ import {
16
+ isInstallRecordStale,
17
+ readInstallRecord,
18
+ type InstallRecord,
19
+ type InstallRecordOptions,
20
+ } from './installRecord';
21
+ import { formatPlatformStatus } from './platform';
22
+ import { whoami } from './platform';
23
+ import { formatCursorPatchSetState, readCursorPatchSetState } from './patchSet';
24
+
25
+ export type StatusOptions = FindCursorOptions &
26
+ DetectEnvironmentOptions &
27
+ CursorTargetOptions &
28
+ TrialRecordOptions &
29
+ InstallRecordOptions & {
30
+ runtimeFile?: string;
31
+ platformSessionFile?: string;
32
+ extensionInstallPath?: string;
33
+ cursorExtensionsDir?: string;
34
+ compatEntries?: CompatibilityManifestEntry[];
35
+ apiBaseUrl?: string;
36
+ compatManifestUrl?: string;
37
+ fetchCompatManifest?: (url: string) => Promise<CompatManifestFetchResponse>;
38
+ };
39
+
40
+ type LatestTakeoverResponse = {
41
+ ok?: unknown;
42
+ takeover?: null | {
43
+ state?: unknown;
44
+ requestId?: unknown;
45
+ source?: unknown;
46
+ model?: unknown;
47
+ content?: unknown;
48
+ reason?: unknown;
49
+ };
50
+ };
51
+
52
+ const MAX_CANARY_FIELD_LENGTH = 256;
53
+
54
+ function isSafeCanaryString(value: unknown): value is string {
55
+ return (
56
+ typeof value === 'string' &&
57
+ value.length > 0 &&
58
+ value.length <= MAX_CANARY_FIELD_LENGTH &&
59
+ !/[\u0000-\u001f\u007f]/.test(value)
60
+ );
61
+ }
62
+
63
+ function isSafeCanaryToken(value: unknown): value is string {
64
+ return isSafeCanaryString(value) && !/[\s=]/.test(value);
65
+ }
66
+
67
+ function isSecretLike(value: string) {
68
+ return (
69
+ /^sk-/i.test(value) ||
70
+ /apikey|apiKey|secret|token|authorization|bearer|cursorAuth/i.test(value)
71
+ );
72
+ }
73
+
74
+ function formatCanarySource(value: unknown) {
75
+ return value === 'cursor-agent-exec' || value === 'cursor-always-local' ? value : 'unknown';
76
+ }
77
+
78
+ function formatCanaryModel(value: unknown) {
79
+ if (!isSafeCanaryToken(value) || isSecretLike(value)) {
80
+ return 'unknown';
81
+ }
82
+
83
+ return value;
84
+ }
85
+
86
+ async function isRuntimeHealthy(runtime: Awaited<ReturnType<typeof readRuntimeInfo>>) {
87
+ if (!runtime) {
88
+ return false;
89
+ }
90
+
91
+ try {
92
+ const response = await fetch(`http://${runtime.host}:${runtime.port}/health`);
93
+ const health = (await response.json()) as { ok?: unknown; runtimeId?: unknown };
94
+ return response.ok && health.ok === true && health.runtimeId === runtime.runtimeId;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ function formatTakeoverReason(value: unknown) {
101
+ return isSafeCanaryToken(value) ? value : 'unknown';
102
+ }
103
+
104
+ async function getTakeoverStatus(
105
+ runtime: Awaited<ReturnType<typeof readRuntimeInfo>>,
106
+ serviceRunning: boolean,
107
+ ) {
108
+ if (!runtime || !serviceRunning) {
109
+ return 'takeover: unavailable';
110
+ }
111
+
112
+ try {
113
+ const response = await fetch(`http://${runtime.host}:${runtime.port}/agent/takeover/latest`);
114
+ const latest = (await response.json()) as LatestTakeoverResponse;
115
+
116
+ if (!response.ok || latest.ok !== true) {
117
+ return 'takeover: unavailable';
118
+ }
119
+
120
+ if (latest.takeover === null) {
121
+ return 'takeover: missing';
122
+ }
123
+
124
+ if (latest.takeover?.state === 'answered') {
125
+ return [
126
+ 'takeover: answered',
127
+ `requestId=${isSafeCanaryToken(latest.takeover.requestId) ? latest.takeover.requestId : 'unknown'}`,
128
+ `source=${formatCanarySource(latest.takeover.source)}`,
129
+ `model=${formatCanaryModel(latest.takeover.model)}`,
130
+ `content=${typeof latest.takeover.content === 'string' && latest.takeover.content.length > 0 ? 'present' : 'missing'}`,
131
+ ].join(' ');
132
+ }
133
+
134
+ if (latest.takeover?.state === 'rejected') {
135
+ return [
136
+ 'takeover: rejected',
137
+ `requestId=${isSafeCanaryToken(latest.takeover.requestId) ? latest.takeover.requestId : 'unknown'}`,
138
+ `reason=${formatTakeoverReason(latest.takeover.reason)}`,
139
+ ].join(' ');
140
+ }
141
+
142
+ return 'takeover: unavailable';
143
+ } catch {
144
+ return 'takeover: unavailable';
145
+ }
146
+ }
147
+
148
+ export async function status(options: StatusOptions = {}) {
149
+ const target = resolveCursorTarget(options);
150
+ const appPath =
151
+ target.mode === 'disposable' ? assertDisposableCursorAppPath(target.appPath) : target.appPath;
152
+ const environment = detectEnvironment(options);
153
+ const cursor = await findCursor({ ...options, appPath });
154
+ const compatEntries = await loadCompatEntries({
155
+ compatEntries: options.compatEntries,
156
+ apiBaseUrl: options.apiBaseUrl,
157
+ compatManifestUrl: options.compatManifestUrl,
158
+ fetchManifest: options.fetchCompatManifest,
159
+ });
160
+ const compat = resolveCompatEntry(cursor, environment, { entries: compatEntries });
161
+ const installRecord =
162
+ target.mode === 'real'
163
+ ? await readInstallRecord({ installRecordFile: options.installRecordFile })
164
+ : null;
165
+ const trialRecord =
166
+ target.mode === 'disposable'
167
+ ? await readTrialRecord(cursor.appPath, {
168
+ trialRecordDir: options.trialRecordDir,
169
+ })
170
+ : null;
171
+ const runtimeFile =
172
+ target.mode === 'real'
173
+ ? options.runtimeFile ?? installRecord?.runtimeFile
174
+ : options.runtimeFile ?? trialRecord?.runtimeFile;
175
+ const extensionInstallPath =
176
+ target.mode === 'real'
177
+ ? installRecord?.extensionInstallPath ?? options.extensionInstallPath
178
+ : options.extensionInstallPath ?? trialRecord?.extensionInstallPath;
179
+ const linkedPath =
180
+ target.mode === 'real'
181
+ ? installRecord?.extensionLinkedPath ??
182
+ (options.cursorExtensionsDir
183
+ ? linkedExtensionPathForDir(options.cursorExtensionsDir)
184
+ : undefined)
185
+ : options.cursorExtensionsDir
186
+ ? linkedExtensionPathForDir(options.cursorExtensionsDir)
187
+ : trialRecord?.extensionLinkedPath;
188
+ const runtime = await readRuntimeInfo({ runtimeFile });
189
+ const patchSetState = await readCursorPatchSetState(cursor.appPath, {
190
+ agentExecTargetRelativePath: compat.targetRelativePath,
191
+ platform: environment.platform,
192
+ });
193
+ const serviceRunning = await isRuntimeHealthy(runtime);
194
+ const takeoverStatus = await getTakeoverStatus(runtime, serviceRunning);
195
+ const platformSummary = serviceRunning
196
+ ? await whoami({ runtimeFile })
197
+ : formatPlatformStatus(await cachedPlatformStatus({ sessionFile: options.platformSessionFile }));
198
+ const sourceExtensionState = await getExtensionState(extensionInstallPath);
199
+ const linkedExtensionState = await getLinkedExtensionState(linkedPath);
200
+ const extensionState =
201
+ linkedExtensionState === 'linked' ? 'linked' : sourceExtensionState;
202
+ const actualInstallRecord: InstallRecord | null =
203
+ target.mode === 'real' && installRecord
204
+ ? {
205
+ ...installRecord,
206
+ mode: 'real',
207
+ appPath: cursor.appPath,
208
+ cursorVersion: cursor.version,
209
+ cursorCommit: cursor.commit,
210
+ targetRelativePath: compat.targetRelativePath,
211
+ originalSha256: installRecord.originalSha256,
212
+ compatSupportStatus: compat.supportStatus,
213
+ runtimeFile: runtimeFile ?? installRecord.runtimeFile,
214
+ backupDir: installRecord.backupDir,
215
+ extensionInstallPath: extensionInstallPath ?? installRecord.extensionInstallPath,
216
+ extensionLinkedPath: linkedPath,
217
+ extensionState,
218
+ }
219
+ : null;
220
+ const installRecordState = installRecord
221
+ ? isInstallRecordStale(installRecord, actualInstallRecord)
222
+ ? 'stale'
223
+ : 'recorded'
224
+ : 'missing';
225
+
226
+ return [
227
+ `Cursor ${cursor.version} (${cursor.commit})`,
228
+ `mode: ${target.mode}`,
229
+ `app: ${cursor.appPath}`,
230
+ `compat: ${compat.supportStatus}`,
231
+ `patch: ${formatCursorPatchSetState(patchSetState)}`,
232
+ `service: ${serviceRunning ? 'running' : 'stopped'}`,
233
+ `extension: ${extensionState}`,
234
+ `runtime: ${resolveRuntimeFile(runtimeFile)}`,
235
+ platformSummary,
236
+ target.mode === 'real'
237
+ ? `install-record: ${installRecordState}`
238
+ : `trial: ${trialRecord ? 'recorded' : 'missing'}`,
239
+ takeoverStatus,
240
+ ].join('\n');
241
+ }
package/src/target.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { defaultCursorAppPath } from './cursor';
2
+ import { assertDisposableCursorAppPath } from './trial';
3
+
4
+ export type CursorTargetOptions = {
5
+ appPath?: string;
6
+ realAppPath?: string;
7
+ defaultRealAppPath?: string;
8
+ platform?: NodeJS.Platform;
9
+ };
10
+
11
+ export type CursorTarget =
12
+ | {
13
+ mode: 'disposable';
14
+ appPath: string;
15
+ requiresConfirmation: false;
16
+ }
17
+ | {
18
+ mode: 'real';
19
+ appPath: string;
20
+ requiresConfirmation: true;
21
+ };
22
+
23
+ export function resolveCursorTarget(options: CursorTargetOptions = {}): CursorTarget {
24
+ if (options.appPath) {
25
+ return {
26
+ mode: 'disposable',
27
+ appPath: assertDisposableCursorAppPath(options.appPath, { platform: options.platform }),
28
+ requiresConfirmation: false,
29
+ };
30
+ }
31
+
32
+ return {
33
+ mode: 'real',
34
+ appPath: options.realAppPath ?? options.defaultRealAppPath ?? defaultCursorAppPath(options),
35
+ requiresConfirmation: true,
36
+ };
37
+ }
package/src/trial.ts ADDED
@@ -0,0 +1,133 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { realpathSync } from 'node:fs';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join, normalize } from 'node:path';
6
+ import { DEFAULT_MACOS_CURSOR_APP_PATH } from './cursor';
7
+
8
+ export type ExtensionState = 'bundled' | 'linked' | 'manual-step-required' | 'missing';
9
+
10
+ export type TrialRecord = {
11
+ trialId: string;
12
+ appPath: string;
13
+ cursorVersion: string;
14
+ cursorCommit: string;
15
+ targetRelativePath: string;
16
+ originalSha256: string;
17
+ compatSupportStatus: 'supported' | 'canary' | 'warning' | 'blocked' | 'unknown';
18
+ runtimeFile: string;
19
+ backupDir: string;
20
+ extensionInstallPath: string;
21
+ extensionLinkedPath?: string;
22
+ extensionState: ExtensionState;
23
+ createdAt: string;
24
+ updatedAt: string;
25
+ };
26
+
27
+ export type TrialRecordOptions = {
28
+ trialRecordDir?: string;
29
+ };
30
+
31
+ export type DisposableCursorAppPathOptions = {
32
+ platform?: NodeJS.Platform;
33
+ };
34
+
35
+ export const DEFAULT_TRIAL_RECORD_DIR = '~/.cursor-pool/trials';
36
+
37
+ function resolveHomePath(path: string) {
38
+ return path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
39
+ }
40
+
41
+ function normalizeAppPath(path: string) {
42
+ return normalize(path).replace(/\/+$/, '');
43
+ }
44
+
45
+ function isRealCursorAppPath(path: string) {
46
+ return normalizeAppPath(path).toLowerCase() === DEFAULT_MACOS_CURSOR_APP_PATH.toLowerCase();
47
+ }
48
+
49
+ function resolveExistingRealPathSync(path: string) {
50
+ try {
51
+ return realpathSync(path);
52
+ } catch {
53
+ return path;
54
+ }
55
+ }
56
+
57
+ export function resolveTrialRecordDir(trialRecordDir = DEFAULT_TRIAL_RECORD_DIR) {
58
+ return resolveHomePath(trialRecordDir);
59
+ }
60
+
61
+ export function assertDisposableCursorAppPath(
62
+ appPath: string | undefined,
63
+ options: DisposableCursorAppPathOptions = {},
64
+ ) {
65
+ if (!appPath) {
66
+ throw new Error('MVP-0.1 requires --app-path to point at a disposable Cursor.app copy');
67
+ }
68
+
69
+ const normalized = normalizeAppPath(appPath);
70
+ const realPath = normalizeAppPath(resolveExistingRealPathSync(normalized));
71
+ if (isRealCursorAppPath(normalized) || isRealCursorAppPath(realPath)) {
72
+ throw new Error(
73
+ 'Refusing to install into real Cursor app. Copy /Applications/Cursor.app to a disposable .app path and pass --app-path.',
74
+ );
75
+ }
76
+
77
+ const platform = options.platform ?? process.platform;
78
+ if (platform === 'darwin' && !normalized.endsWith('.app')) {
79
+ throw new Error(`MVP-0.1 trial install path must point at a .app bundle: ${appPath}`);
80
+ }
81
+
82
+ return normalized;
83
+ }
84
+
85
+ export function trialIdForAppPath(appPath: string) {
86
+ return createHash('sha256').update(normalize(appPath)).digest('hex').slice(0, 16);
87
+ }
88
+
89
+ export function trialRecordPathForAppPath(
90
+ appPath: string,
91
+ options: TrialRecordOptions = {},
92
+ ) {
93
+ return join(resolveTrialRecordDir(options.trialRecordDir), `${trialIdForAppPath(appPath)}.json`);
94
+ }
95
+
96
+ export async function writeTrialRecord(
97
+ record: TrialRecord,
98
+ options: TrialRecordOptions = {},
99
+ ) {
100
+ const expectedTrialId = trialIdForAppPath(record.appPath);
101
+ if (record.trialId !== expectedTrialId) {
102
+ throw new Error(
103
+ `Trial record id does not match app path. Expected ${expectedTrialId} for ${record.appPath}.`,
104
+ );
105
+ }
106
+
107
+ const recordPath = trialRecordPathForAppPath(record.appPath, options);
108
+ await mkdir(dirname(recordPath), { recursive: true });
109
+ await writeFile(recordPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
110
+ }
111
+
112
+ export async function readTrialRecord(
113
+ appPath: string,
114
+ options: TrialRecordOptions = {},
115
+ ): Promise<TrialRecord | null> {
116
+ try {
117
+ return JSON.parse(
118
+ await readFile(trialRecordPathForAppPath(appPath, options), 'utf8'),
119
+ ) as TrialRecord;
120
+ } catch (error) {
121
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
122
+ return null;
123
+ }
124
+ throw error;
125
+ }
126
+ }
127
+
128
+ export async function deleteTrialRecord(
129
+ appPath: string,
130
+ options: TrialRecordOptions = {},
131
+ ) {
132
+ await rm(trialRecordPathForAppPath(appPath, options), { force: true });
133
+ }