| author | |
| committer | |
| log | d28077e585d5123b9a562f4ebef257969c81aa31 |
| tree | 608c0baa614291a3f3471f5f75d4fcd72b1617a9 |
| parent | d7fe47fd0957a34fc2e8bb05f12db19bac3403b6 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 164 insertions(+), 5 deletions(-)
apps/server/src/provider/Drivers/CursorDriver.test.ts created+62| ... | ... | @@ -0,0 +1,62 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { ProviderInstanceId } from "@t3tools/contracts"; | |
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as Layer from "effect/Layer"; | |
| 7 | import { HttpClient } from "effect/unstable/http"; | |
| 8 | import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; | |
| 9 | ||
| 10 | import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; | |
| 11 | import { ServerConfig } from "../../config.ts"; | |
| 12 | import { ServerSettingsService } from "../../serverSettings.ts"; | |
| 13 | import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; | |
| 14 | import { CursorDriver } from "./CursorDriver.ts"; | |
| 15 | ||
| 16 | const testLayer = ServerConfig.layerTest(process.cwd(), { | |
| 17 | prefix: "t3-cursor-driver-copy-command-", | |
| 18 | }).pipe( | |
| 19 | Layer.provideMerge(NodeServices.layer), | |
| 20 | Layer.provideMerge(ServerSettingsService.layerTest()), | |
| 21 | Layer.provideMerge( | |
| 22 | Layer.mock(BackgroundPolicy.BackgroundPolicy)({ | |
| 23 | shouldRunScopeWork: () => Effect.succeed(false), | |
| 24 | }), | |
| 25 | ), | |
| 26 | Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), | |
| 27 | Layer.provideMerge( | |
| 28 | Layer.succeed( | |
| 29 | HttpClient.HttpClient, | |
| 30 | HttpClient.make(() => Effect.die("Disabled Cursor must not make an HTTP request")), | |
| 31 | ), | |
| 32 | ), | |
| 33 | ); | |
| 34 | ||
| 35 | it.layer(testLayer)("CursorDriver", (it) => { | |
| 36 | it.effect("formats a copied update command for the injected Windows environment", () => | |
| 37 | Effect.gen(function* () { | |
| 38 | const binaryPath = "C:\\Users\\Example User\\.local\\bin\\cursor-agent.exe"; | |
| 39 | const instance = yield* CursorDriver.create({ | |
| 40 | instanceId: ProviderInstanceId.make("cursor-copy-command"), | |
| 41 | displayName: "Cursor test", | |
| 42 | enabled: false, | |
| 43 | environment: [], | |
| 44 | config: { ...CursorDriver.defaultConfig(), binaryPath }, | |
| 45 | }); | |
| 46 | ||
| 47 | expect(instance.snapshot.maintenanceCapabilities.update).toMatchObject({ | |
| 48 | command: `& '${binaryPath}' update`, | |
| 49 | executable: binaryPath, | |
| 50 | args: ["update"], | |
| 51 | }); | |
| 52 | expect((yield* instance.snapshot.refresh).status).toBe("disabled"); | |
| 53 | }).pipe( | |
| 54 | Effect.provideService(HostProcessPlatform, "win32"), | |
| 55 | Effect.provideService( | |
| 56 | ChildProcessSpawner.ChildProcessSpawner, | |
| 57 | ChildProcessSpawner.make(() => Effect.die("Disabled Cursor must not spawn a process")), | |
| 58 | ), | |
| 59 | Effect.scoped, | |
| 60 | ), | |
| 61 | ); | |
| 62 | }); |
apps/server/src/provider/Drivers/CursorDriver.ts+1| ... | ... | @@ -62,6 +62,7 @@ const UPDATE: ProviderMaintenanceCapabilitiesResolver = { |
| 62 | 62 | updateExecutable: options?.binaryPath?.trim() || "cursor-agent", |
| 63 | 63 | updateArgs: ["update"], |
| 64 | 64 | updateLockKey: "cursor-agent", |
| 65 | ...(options?.platform ? { platform: options.platform } : {}), | |
| 65 | 66 | }), |
| 66 | 67 | }; |
| 67 | 68 |
apps/server/src/provider/providerMaintenance.test.ts+71| ... | ... | @@ -447,6 +447,77 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { |
| 447 | 447 | expect(nativePackageToolUpdate.resolve({ binaryPath }).update?.executable).toBe(binaryPath); |
| 448 | 448 | }); |
| 449 | 449 | |
| 450 | for (const directoryName of ["plain", "with spaces", "with 'quotes' and $dollars"]) { | |
| 451 | it.effect.skipIf(windowsHost)( | |
| 452 | `runs the copied native update command from a ${directoryName} path`, | |
| 453 | () => | |
| 454 | Effect.gen(function* () { | |
| 455 | const fs = yield* FileSystem.FileSystem; | |
| 456 | const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-copy-native-update-" }); | |
| 457 | const binDir = NodePath.join(tempDir, directoryName, ".scoped-package-tool", "bin"); | |
| 458 | yield* fs.makeDirectory(binDir, { recursive: true }); | |
| 459 | const binaryPath = NodePath.join(binDir, "scoped-package-tool"); | |
| 460 | yield* fs.writeFileString(binaryPath, '#!/bin/sh\nprintf "%s" "$1"\n'); | |
| 461 | yield* fs.chmod(binaryPath, 0o755); | |
| 462 | ||
| 463 | const capabilities = scopedPackageToolUpdate.resolve({ binaryPath }); | |
| 464 | const advisory = createProviderVersionAdvisory({ | |
| 465 | driver: driver("scopedPackageTool"), | |
| 466 | currentVersion: "1.0.0", | |
| 467 | latestVersion: "2.0.0", | |
| 468 | maintenanceCapabilities: capabilities, | |
| 469 | }); | |
| 470 | expect(advisory.updateCommand).not.toBeNull(); | |
| 471 | if (!advisory.updateCommand) return; | |
| 472 | const result = NodeChildProcess.spawnSync("/bin/sh", ["-c", advisory.updateCommand], { | |
| 473 | env: { PATH: "" }, | |
| 474 | encoding: "utf8", | |
| 475 | }); | |
| 476 | expect(result.error).toBeUndefined(); | |
| 477 | expect(result.status, result.stderr).toBe(0); | |
| 478 | expect(result.stdout).toBe("upgrade"); | |
| 479 | }).pipe(Effect.scoped), | |
| 480 | ); | |
| 481 | } | |
| 482 | ||
| 483 | it.each([ | |
| 484 | { | |
| 485 | binaryPath: "C:\\Users\\Example\\.local\\bin\\native-package-tool.exe", | |
| 486 | command: "C:\\Users\\Example\\.local\\bin\\native-package-tool.exe update", | |
| 487 | }, | |
| 488 | { | |
| 489 | binaryPath: "C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe", | |
| 490 | command: "& 'C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe' update", | |
| 491 | }, | |
| 492 | { | |
| 493 | binaryPath: "C:\\Users\\O'Connor\\.local\\bin\\native-package-tool.exe", | |
| 494 | command: "& 'C:\\Users\\O''Connor\\.local\\bin\\native-package-tool.exe' update", | |
| 495 | }, | |
| 496 | { | |
| 497 | binaryPath: "C:\\Users\\O\u2019Connor\\.local\\bin\\native-package-tool.exe", | |
| 498 | command: "& 'C:\\Users\\O\u2019\u2019Connor\\.local\\bin\\native-package-tool.exe' update", | |
| 499 | }, | |
| 500 | ])("formats a copied PowerShell native update for $binaryPath", ({ binaryPath, command }) => { | |
| 501 | expect(nativePackageToolUpdate.resolve({ binaryPath, platform: "win32" }).update).toEqual({ | |
| 502 | command, | |
| 503 | executable: binaryPath, | |
| 504 | args: ["update"], | |
| 505 | lockKey: "native-package-tool-native", | |
| 506 | }); | |
| 507 | }); | |
| 508 | ||
| 509 | it.effect("uses the environment's platform for copied native update commands", () => | |
| 510 | Effect.gen(function* () { | |
| 511 | const binaryPath = "C:\\Users\\Example User\\.local\\bin\\native-package-tool.exe"; | |
| 512 | const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( | |
| 513 | nativePackageToolUpdate, | |
| 514 | { binaryPath, env: { PATH: "" } }, | |
| 515 | ); | |
| 516 | expect(capabilities.update?.command).toBe(`& '${binaryPath}' update`); | |
| 517 | expect(capabilities.update?.executable).toBe(binaryPath); | |
| 518 | }).pipe(Effect.provideService(HostProcessPlatform, "win32")), | |
| 519 | ); | |
| 520 | ||
| 450 | 521 | it("uses the resolved launcher when its symlink target identifies a native install", () => { |
| 451 | 522 | const launcher = "/custom tools/native-launcher"; |
| 452 | 523 | const capabilities = nativePackageToolUpdate.resolve({ |
apps/server/src/provider/providerMaintenance.ts+30-5| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | } from "@t3tools/contracts"; |
| 6 | 6 | import { compareSemverVersions } from "@t3tools/shared/semver"; |
| 7 | 7 | import { resolveCommandPath } from "@t3tools/shared/shell"; |
| 8 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 8 | 9 | import * as Config from "effect/Config"; |
| 9 | 10 | import * as Context from "effect/Context"; |
| 10 | 11 | import * as DateTime from "effect/DateTime"; |
| ... | ... | @@ -52,6 +53,7 @@ export interface ProviderMaintenanceCommandAction { |
| 52 | 53 | |
| 53 | 54 | export interface ProviderMaintenanceCapabilityResolutionOptions { |
| 54 | 55 | readonly binaryPath?: string | null; |
| 56 | readonly platform?: NodeJS.Platform; | |
| 55 | 57 | readonly env?: NodeJS.ProcessEnv; |
| 56 | 58 | readonly resolvedCommandPath?: string | null; |
| 57 | 59 | readonly realCommandPath?: string | null; |
| ... | ... | @@ -94,18 +96,34 @@ function nonEmptyString(value: unknown): string | null { |
| 94 | 96 | return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; |
| 95 | 97 | } |
| 96 | 98 | |
| 99 | function quoteUpdateExecutable(executable: string, platform: NodeJS.Platform): string { | |
| 100 | const safePath = platform === "win32" ? /^[\w./:\\-]+$/ : /^[\w./:-]+$/; | |
| 101 | if (safePath.test(executable)) return executable; | |
| 102 | // Windows terminals default to PowerShell, where a quoted executable needs &. | |
| 103 | return platform === "win32" | |
| 104 | ? `& '${executable.replace(/['\u2018\u2019]/g, "$&$&")}'` | |
| 105 | : `'${executable.replaceAll("'", "'\\''")}'`; | |
| 106 | } | |
| 107 | ||
| 97 | 108 | export function makeProviderMaintenanceCapabilities(input: { |
| 98 | 109 | readonly provider: ProviderDriverKind; |
| 99 | 110 | readonly packageName: string | null; |
| 100 | 111 | readonly updateExecutable: string | null; |
| 101 | 112 | readonly updateArgs: ReadonlyArray<string>; |
| 102 | 113 | readonly updateLockKey: string | null; |
| 114 | readonly platform?: NodeJS.Platform; | |
| 103 | 115 | }): ProviderMaintenanceCapabilities { |
| 104 | 116 | const update = |
| 105 | 117 | input.updateExecutable === null || input.updateLockKey === null |
| 106 | 118 | ? null |
| 107 | 119 | : { |
| 108 | command: [input.updateExecutable, ...input.updateArgs].join(" "), | |
| 120 | command: [ | |
| 121 | quoteUpdateExecutable( | |
| 122 | input.updateExecutable, | |
| 123 | input.platform ?? HostProcessPlatform.defaultValue(), | |
| 124 | ), | |
| 125 | ...input.updateArgs, | |
| 126 | ].join(" "), | |
| 109 | 127 | executable: input.updateExecutable, |
| 110 | 128 | args: input.updateArgs, |
| 111 | 129 | lockKey: input.updateLockKey, |
| ... | ... | @@ -210,6 +228,7 @@ function makeHomebrewProviderMaintenanceCapabilities( |
| 210 | 228 | function makeNativeProviderMaintenanceCapabilities( |
| 211 | 229 | definition: PackageManagedProviderMaintenanceDefinition, |
| 212 | 230 | commandPath: string, |
| 231 | platform: NodeJS.Platform, | |
| 213 | 232 | ): ProviderMaintenanceCapabilities | null { |
| 214 | 233 | if (!definition.nativeUpdate) { |
| 215 | 234 | return null; |
| ... | ... | @@ -221,6 +240,7 @@ function makeNativeProviderMaintenanceCapabilities( |
| 221 | 240 | updateExecutable: commandPath, |
| 222 | 241 | updateArgs: definition.nativeUpdate.args, |
| 223 | 242 | updateLockKey: definition.nativeUpdate.lockKey, |
| 243 | platform, | |
| 224 | 244 | }); |
| 225 | 245 | } |
| 226 | 246 | |
| ... | ... | @@ -298,8 +318,11 @@ export function resolvePackageManagedProviderMaintenance( |
| 298 | 318 | commandPaths.some((commandPath) => nativeUpdate.isCommandPath(commandPath)) |
| 299 | 319 | ) { |
| 300 | 320 | return ( |
| 301 | makeNativeProviderMaintenanceCapabilities(definition, resolvedCommandPath) ?? | |
| 302 | makeNpmGlobalProviderMaintenanceCapabilities(definition) | |
| 321 | makeNativeProviderMaintenanceCapabilities( | |
| 322 | definition, | |
| 323 | resolvedCommandPath, | |
| 324 | options?.platform ?? HostProcessPlatform.defaultValue(), | |
| 325 | ) ?? makeNpmGlobalProviderMaintenanceCapabilities(definition) | |
| 303 | 326 | ); |
| 304 | 327 | } |
| 305 | 328 | if (commandPaths.some(isVitePlusGlobalCommandPath)) { |
| ... | ... | @@ -361,8 +384,9 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( |
| 361 | 384 | options?: Omit<ProviderMaintenanceCapabilityResolutionOptions, "realCommandPath">, |
| 362 | 385 | ) { |
| 363 | 386 | const binaryPath = nonEmptyString(options?.binaryPath); |
| 387 | const platform = options?.platform ?? (yield* HostProcessPlatform); | |
| 364 | 388 | if (!binaryPath) { |
| 365 | return resolver.resolve(options); | |
| 389 | return resolver.resolve({ ...options, platform }); | |
| 366 | 390 | } |
| 367 | 391 | |
| 368 | 392 | const env = options?.env ?? (yield* readCommandLookupEnv); |
| ... | ... | @@ -371,7 +395,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( |
| 371 | 395 | Effect.catchTag("CommandResolutionError", () => Effect.succeed(null)), |
| 372 | 396 | )) ?? (hasPathSeparator(binaryPath) ? binaryPath : null); |
| 373 | 397 | if (!resolvedCommandPath) { |
| 374 | return resolver.resolve(options); | |
| 398 | return resolver.resolve({ ...options, platform }); | |
| 375 | 399 | } |
| 376 | 400 | |
| 377 | 401 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | ... | @@ -380,6 +404,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( |
| 380 | 404 | .pipe(Effect.orElseSucceed(() => resolvedCommandPath)); |
| 381 | 405 | return resolver.resolve({ |
| 382 | 406 | ...options, |
| 407 | platform, | |
| 383 | 408 | env, |
| 384 | 409 | resolvedCommandPath, |
| 385 | 410 | realCommandPath, |