diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 604b92c34d091..71c7b6994ca86 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -1782,67 +1782,84 @@ it.effect("reconciles SSH host settings without publishing no-op changes", () => ), ); -it.effect("a failed tool update reports npm's exit code but not its output", () => - Effect.gen(function* () { - const host: DeviceHost.DeviceHost["Service"] = { - id: LOCAL_DEVICE_HOST_ID, - label: "This machine", - summary: Effect.succeed({ +it.effect.each(["exit", "missing"] as const)( + "a failed tool update reports a safe %s failure", + (failure) => + Effect.gen(function* () { + const host: DeviceHost.DeviceHost["Service"] = { id: LOCAL_DEVICE_HOST_ID, - kind: "local", - label: "Test server", - platforms: [], - hubInstalled: false, - agentDeviceInstalled: false, - }), - platformAvailability: (platform) => Effect.succeed({ platform, available: false }), - ensureReady: () => Effect.die("Updating a tool does not start the hub"), - ensureAgentReady: () => Effect.die("Updating a tool does not start the agent"), - current: Effect.succeed(null), - stopAgent: Effect.void, - stop: Effect.void, - }; - const npmFailure = { - code: ChildProcessSpawner.ExitCode(243), - stdout: "", - stderr: "npm error EACCES https://private:credential@registry.example.test/expo-device-hub", - timedOut: false, - stdoutTruncated: false, - stderrTruncated: false, - stdoutInvalidUtf8: false, - stderrInvalidUtf8: false, - }; - const service = yield* DeviceService.make.pipe( - Effect.provideService(DeviceHost.DeviceHost, host), - Effect.provideService(ProcessRunner.ProcessRunner, { - run: () => Effect.succeed(npmFailure), - }), - Effect.provideService( - HttpClient.HttpClient, - HttpClient.make(() => Effect.die("Updating a tool makes no hub requests")), - ), - ); + label: "This machine", + summary: Effect.succeed({ + id: LOCAL_DEVICE_HOST_ID, + kind: "local", + label: "Test server", + platforms: [], + hubInstalled: false, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: false }), + ensureReady: () => Effect.die("Updating a tool does not start the hub"), + ensureAgentReady: () => Effect.die("Updating a tool does not start the agent"), + current: Effect.succeed(null), + stopAgent: Effect.void, + stop: Effect.void, + }; + const npmFailure = { + code: ChildProcessSpawner.ExitCode(243), + stdout: "", + stderr: "npm error EACCES https://private:credential@registry.example.test/expo-device-hub", + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; + const service = yield* DeviceService.make.pipe( + Effect.provideService(DeviceHost.DeviceHost, host), + Effect.provideService(ProcessRunner.ProcessRunner, { + run: ({ command, args }) => + failure === "exit" + ? Effect.succeed(npmFailure) + : Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command, + argumentCount: args.length, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: "private diagnostic text", + }), + }), + ), + }), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("Updating a tool makes no hub requests")), + ), + ); - const error = yield* service.updateTool("hub").pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "DeviceOperationError", - reason: "command_failed", - exitCode: 243, - }); - expect(error.message).toBe( - "Device update device tool failed: The device command failed (exit code 243).", - ); - }).pipe( - Effect.provide( - Layer.mergeAll( - ServerSettings.layerTest({ enableDeviceSupport: true }), - layerSecretResolver, - ServerConfig.layerTest(process.cwd(), { prefix: "t3-device-update-" }), - NodeCrypto.layer, - NetService.layer, - ).pipe(Layer.provideMerge(NodeServices.layer)), + const error = yield* service.updateTool("hub").pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DeviceOperationError", + reason: failure === "exit" ? "command_failed" : "package_manager_missing", + }); + expect(error.message).toBe( + failure === "exit" + ? "Device update device tool failed: The device command failed (exit code 243)." + : "Device update device tool failed: Device support needs npm, which is not on the T3 server's PATH. Install npm (it comes with Node.js from nodejs.org), restart T3 Code, then retry.", + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ enableDeviceSupport: true }), + layerSecretResolver, + ServerConfig.layerTest(process.cwd(), { prefix: "t3-device-update-" }), + NodeCrypto.layer, + NetService.layer, + ).pipe(Layer.provideMerge(NodeServices.layer)), + ), ), - ), ); describe("agent session release on close", () => { diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 15e159062a32d..78b31b87aefd3 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -1430,8 +1430,9 @@ export const make = Effect.gen(function* () { (cause) => new DeviceOperationError({ operation: "update device tool", - reason: "command_failed", - // Only the exit code goes into the shown message; npm's stderr can carry registry credentials. + reason: + cause.reason === "npm_not_found" ? "package_manager_missing" : "command_failed", + // npm's stderr can carry registry credentials; expose only the reason and exit code. ...(cause.exitCode !== undefined ? { exitCode: cause.exitCode } : {}), cause, }), diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index ad52b213f10fe..dee7daa445242 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -588,6 +588,7 @@ export class DeviceOperationError extends Schema.TaggedError