# Files changed · PR \#23541 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23541/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(server\): make stale CLI service recovery actionable](https://git.cafe/pingdotgg/t3code/pull/23541.md)

Version: 16

Head commit: beca79a99399bd0c343406edb8be49ab74feb1f7

Base commit: 51a7118b700b19191fa0614785e0149a6d8a6e72

Comparison base: 51a7118b700b19191fa0614785e0149a6d8a6e72

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/server/src/cli/invocation.ts

Status: modified; +1; −1

```diff
@@ -48,7 +48,7 @@
  * counts only when its package is this exact version: a different `t3` run
  * from an agent's shell must not claim to be the npm install.
  */
-const resolveEntryPath = Effect.gen(function* () {
+export const resolveEntryPath = Effect.gen(function* () {
   const npmLauncher = (yield* HostProcess.Environment).T3CODE_NPM_LAUNCHER_PATH?.trim();
   if (npmLauncher) {
     const fs = yield* FileSystem.FileSystem;
```

## apps/server/src/cli/service.test.ts

Status: modified; +39; −0

```diff
@@ -7,6 +7,7 @@
 import * as FileSystem from "effect/FileSystem";
 import * as Layer from "effect/Layer";
 import * as Terminal from "effect/Terminal";
+import * as TestConsole from "effect/testing/TestConsole";
 import { Command } from "effect/cli";
 import { afterEach, vi } from "vite-plus/test";
 
@@ -88,6 +89,44 @@
         expect(installOptions).toEqual([]);
       }),
   );
+
+  // regression: #13239 a stale nvm-installed CLI reported "already using" its own version
+  it.effect.each(["install", "update"] as const)(
+    "%s names the CLI that found nothing to change",
+    (command) =>
+      Effect.gen(function* () {
+        const fs = yield* FileSystem.FileSystem;
+        const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-cli-test-" });
+        const { service, installOptions } = makeTestService(status);
+        vi.spyOn(BootService, "layer").mockReturnValue(
+          Layer.succeed(BootService.BootService, service),
+        );
+        const node = "/home/me/.nvm/versions/node/v26.8.2/bin/node";
+        const script = "/home/me/.nvm/versions/node/v26.8.2/lib/node_modules/t3/dist/bin.mjs";
+
+        const output = yield* Command.runWith(serviceCommand, { version: packageJson.version })([
+          command,
+          "--base-dir",
+          baseDir,
+        ]).pipe(
+          Effect.andThen(TestConsole.logLines),
+          Effect.map((lines) => lines.join("\n")),
+          Effect.provideService(HostProcess.Environment, {}),
+          Effect.provideService(HostProcess.IsExecutable, false),
+          Effect.provideService(HostProcess.ExecutablePath, node),
+          Effect.provideService(HostProcess.Arguments, [node, script, "service", command]),
+          Effect.provide(
+            Layer.mergeAll(
+              ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })),
+              Layer.fresh(TestConsole.layer),
+            ),
+          ),
+        );
+        expect(output).toContain(`t3@${packageJson.version}`);
+        expect(output).toContain(script);
+        expect(installOptions).toEqual([]);
+      }),
+  );
 });
 
 it.effect("leaves a newer service unchanged during onboarding without prompting", () =>
```

## apps/server/src/cli/service.ts

Status: modified; +7; −4

```diff
@@ -12,6 +12,7 @@
 import type * as ServerConfig from "../config.ts";
 import * as ProcessRunner from "../processRunner.ts";
 import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
+import { resolveEntryPath } from "./invocation.ts";
 
 export const layer = (config: ServerConfig.ServerConfig["Service"]) =>
   BootService.layer({
@@ -102,9 +103,9 @@
   ].join("\n");
 }
 
+const runServiceCommand = Effect.fn("cli.service.run")(function* <A, E, R>(
-const runServiceCommand = Effect.fn("cli.service.run")(function* <A, E>(
   flags: { readonly baseDir: Parameters<typeof resolveCliAuthConfig>[0]["baseDir"] },
-  run: Effect.Effect<A, E, BootService.BootService>,
+  run: Effect.Effect<A, E, R>,
 ) {
   const logLevel = yield* GlobalFlag.LogLevel;
   const config = yield* resolveCliAuthConfig(flags, logLevel);
@@ -128,7 +129,7 @@
         const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
         if (!result.changed) {
           yield* Console.log(
+            `T3 Code service is already installed with t3@${packageJson.version} (${yield* resolveEntryPath}).`,
-            `T3 Code service is already installed with t3@${packageJson.version}.`,
           );
           return;
         }
@@ -150,7 +151,9 @@
         );
         const result = yield* reconcileService({ allowDowngrade: flags.allowDowngrade });
         if (!result.changed) {
+          yield* Console.log(
+            `T3 Code service is already using t3@${packageJson.version} (${yield* resolveEntryPath}).`,
-          yield* Console.log(`T3 Code service is already using t3@${packageJson.version}.`);
+          );
           return;
         }
         yield* Console.log(formatServiceChange(result));
```

## apps/server/src/cloud/servicePreflight.test.ts

Status: modified; +16; −12

```diff
@@ -1,18 +1,22 @@
 import { expect, it } from "@effect/vitest";
 
 import { runServicePreflight } from "./servicePreflight.ts";
+import { SERVICE_LAUNCHER_PROTOCOL } from "./serviceProtocol.ts";
 
-it.each([1])("blocks legacy launcher protocol %i", (launcherProtocol) => {
-  expect(
-    runServicePreflight({
-      databasePath: "/missing/state.sqlite",
-      launcherProtocol,
-      version: "1.2.3",
-    }),
-  ).toEqual({
-    status: "blocked",
+// regression: #13239 a stale CLI shadowing the updated one kept "updating" to itself
-    version: "1.2.3",
-    reason:
-      "This release requires a newer T3 Code service launcher. Update it on the server machine.",
+it.each([1])("blocks legacy launcher protocol %i with exact recovery steps", (launcherProtocol) => {
+  const result = runServicePreflight({
+    databasePath: "/missing/state.sqlite",
+    launcherProtocol,
+    version: "9.8.7",
   });
+
+  expect(result).toMatchObject({ status: "blocked", version: "9.8.7" });
+  const reason = result.status === "blocked" ? result.reason : "";
+  expect(reason).toMatch(new RegExp(`\\b${launcherProtocol}\\b`));
+  expect(reason).toMatch(new RegExp(`\\b${SERVICE_LAUNCHER_PROTOCOL}\\b`));
+  expect(reason).toContain("curl -fsSL https://t3.codes/install.sh | T3CODE_VERSION=9.8.7 sh");
+  expect(reason).toContain("~/.local/bin/t3 service install");
+  expect(reason).toContain("t3 --version");
+  expect(reason).toContain("which -a t3");
 });
```

## apps/server/src/cloud/servicePreflight.ts

Status: modified; +8; −2

```diff
@@ -23,8 +23,14 @@
     return {
       status: "blocked",
       version,
+      // A stale `t3` earlier on PATH (an old npm/nvm global) makes `t3 service install`
+      // reinstall itself and report success, so name the exact install and the path check.
+      reason: [
-      reason:
-        "This release requires a newer T3 Code service launcher. Update it on the server machine.",
+        `The installed service launcher speaks protocol ${input.launcherProtocol}, but t3@${version} requires protocol ${SERVICE_LAUNCHER_PROTOCOL}.`,
+        `On the server machine, install this exact CLI: \`curl -fsSL https://t3.codes/install.sh | T3CODE_VERSION=${version} sh\`.`,
+        `Check that \`t3 --version\` prints ${version} and that \`which -a t3\` lists no older t3 (such as an npm or nvm install) first, then run \`~/.local/bin/t3 service install\` (the installer's default path).`,
+        "Updating the desktop app does not update this CLI, and `t3 service update` only repairs the service with whichever CLI runs it.",
+      ].join(" "),
     };
   }
 
```
