authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-24 17:37:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-24 17:37:21-07:00
log72447f23d9d9102f647f95f548aadf92edd5b400
tree10562eb0295082aa994aa7cae390c10e256f1f28
parent9c524d57718e639a6abe0a5fa3cbf6e9b086df86
signature Signed by PGP key B5690EEEBB952194

feat(grok): offer one-click updates through `grok update` (#13523)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

3 files changed, 155 insertions(+), 14 deletions(-)

apps/server/src/provider/Drivers/GrokDriver.test.ts created+97
......@@ -0,0 +1,97 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { ProviderInstanceId } from "@t3tools/contracts";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import { HttpClient } from "effect/unstable/http";
10import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
11
12import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts";
13import { ServerConfig } from "../../config.ts";
14import { ServerSettingsService } from "../../serverSettings.ts";
15import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts";
16import { GrokDriver } from "./GrokDriver.ts";
17
18const testLayer = ServerConfig.layerTest(process.cwd(), {
19 prefix: "t3-grok-driver-update-",
20}).pipe(
21 Layer.provideMerge(NodeServices.layer),
22 Layer.provideMerge(ServerSettingsService.layerTest()),
23 Layer.provideMerge(
24 Layer.mock(BackgroundPolicy.BackgroundPolicy)({
25 shouldRunScopeWork: () => Effect.succeed(false),
26 }),
27 ),
28 Layer.provideMerge(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)),
29 Layer.provideMerge(
30 Layer.succeed(
31 HttpClient.HttpClient,
32 HttpClient.make(() => Effect.die("Disabled Grok must not make an HTTP request")),
33 ),
34 ),
35);
36
37const noSpawner = ChildProcessSpawner.make(() =>
38 Effect.die("Disabled Grok must not spawn a process"),
39);
40
41// The `#!/bin/sh` stub below cannot be resolved as an executable on Windows.
42const windowsHost = HostProcessPlatform.defaultValue() === "win32";
43
44it.layer(testLayer)("GrokDriver", (it) => {
45 it.effect.skipIf(windowsHost)("updates through the configured executable's own updater", () =>
46 Effect.gen(function* () {
47 const fs = yield* FileSystem.FileSystem;
48 const path = yield* Path.Path;
49 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-driver-" });
50 const grokHome = path.join(tempDir, "Grok Home");
51 const binaryPath = path.join(grokHome, "bin", "grok");
52 yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true });
53 yield* fs.writeFileString(binaryPath, "#!/bin/sh\n");
54 yield* fs.chmod(binaryPath, 0o755);
55
56 const instance = yield* GrokDriver.create({
57 instanceId: ProviderInstanceId.make("grok-update"),
58 displayName: "Grok test",
59 enabled: false,
60 environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }],
61 config: { ...GrokDriver.defaultConfig(), binaryPath },
62 });
63
64 const capabilities = yield* instance.snapshot.resolveMaintenance();
65 expect(capabilities.packageName).toBe("@xai-official/grok");
66 expect(capabilities.update).toMatchObject({
67 command: `'${binaryPath}' update`,
68 executable: binaryPath,
69 args: ["update"],
70 });
71 // `grok update` installs under GROK_HOME, so it must target this instance's home.
72 expect(capabilities.update?.env?.GROK_HOME).toBe(grokHome);
73 }).pipe(
74 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner),
75 Effect.scoped,
76 ),
77 );
78
79 it.effect("stays manual-only when the configured executable does not exist", () =>
80 Effect.gen(function* () {
81 const fs = yield* FileSystem.FileSystem;
82 const path = yield* Path.Path;
83 const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-grok-missing-" });
84 const instance = yield* GrokDriver.create({
85 instanceId: ProviderInstanceId.make("grok-missing"),
86 displayName: "Grok test",
87 enabled: false,
88 environment: [],
89 config: { ...GrokDriver.defaultConfig(), binaryPath: path.join(tempDir, "grok") },
90 });
91 expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull();
92 }).pipe(
93 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawner),
94 Effect.scoped,
95 ),
96 );
97});
apps/server/src/provider/Drivers/GrokDriver.ts+55-13
......@@ -29,7 +29,13 @@ import {
2929import { withInstanceIdentity } from "./instanceIdentity.ts";
3030import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
3131import { discoverGrokSkills } from "./GrokSkills.ts";
32import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts";
32import {
33 makeCachedProviderMaintenanceResolution,
34 makeManualOnlyProviderMaintenanceCapabilities,
35 makeProviderMaintenanceCapabilities,
36 type ProviderMaintenanceCapabilitiesResolver,
37 resolveProviderMaintenanceCapabilitiesEffect,
38} from "../providerMaintenance.ts";
3339import {
3440 haveProviderSnapshotSettingsChanged,
3541 makeProviderSnapshotSettingsSource,
......@@ -38,10 +44,32 @@ import {
3844const decodeGrokSettings = Schema.decodeSync(GrokSettings);
3945
4046const DRIVER_KIND = ProviderDriverKind.make("grok");
41const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({
42 provider: DRIVER_KIND,
43 packageName: null,
44});
47// npm's `latest` tracks Grok's stable channel, the one `grok update` installs
48// by default, so the registry stays the source for "latest".
49const GROK_NPM_PACKAGE = "@xai-official/grok";
50// `grok update` finds the installer that owns the binary itself, so the
51// resolved executable is its own updater. It installs under `GROK_HOME`, so it
52// runs with the instance's environment. No executable means nothing to update,
53// not "whatever is on PATH".
54const UPDATE: ProviderMaintenanceCapabilitiesResolver = {
55 resolve: (context) =>
56 Effect.succeed(
57 context
58 ? makeProviderMaintenanceCapabilities({
59 provider: DRIVER_KIND,
60 packageName: GROK_NPM_PACKAGE,
61 updateExecutable: context.resolvedCommandPath,
62 updateArgs: ["update"],
63 updateLockKey: "grok",
64 platform: context.platform,
65 env: context.env,
66 })
67 : makeManualOnlyProviderMaintenanceCapabilities({
68 provider: DRIVER_KIND,
69 packageName: GROK_NPM_PACKAGE,
70 }),
71 ),
72};
4573
4674export type GrokDriverEnv =
4775 | BackgroundPolicy.BackgroundPolicy
......@@ -85,6 +113,16 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
85113 continuationGroupKey: continuationIdentity.continuationKey,
86114 });
87115 const effectiveConfig = { ...config, enabled } satisfies GrokSettings;
116 const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution(
117 resolveProviderMaintenanceCapabilitiesEffect(UPDATE, {
118 binaryPath: effectiveConfig.binaryPath,
119 env: processEnv,
120 }).pipe(
121 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
122 Effect.provideService(FileSystem.FileSystem, fileSystem),
123 Effect.provideService(Path.Path, path),
124 ),
125 );
88126 const adapter = yield* makeGrokAdapter(effectiveConfig, {
89127 environment: processEnv,
90128 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
......@@ -110,7 +148,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
110148
111149 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
112150 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<GrokSettings>>({
113 resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES),
151 resolveMaintenance,
114152 getSettings: snapshotSettings.getSettings,
115153 streamSettings: snapshotSettings.streamSettings,
116154 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
......@@ -118,13 +156,17 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
118156 buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
119157 checkProvider,
120158 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
121 enrichGrokSnapshot({
122 snapshot: currentSnapshot,
123 maintenanceCapabilities: MAINTENANCE_CAPABILITIES,
124 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
125 publishSnapshot,
126 httpClient,
127 }),
159 resolveMaintenance().pipe(
160 Effect.flatMap((maintenanceCapabilities) =>
161 enrichGrokSnapshot({
162 snapshot: currentSnapshot,
163 maintenanceCapabilities,
164 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
165 publishSnapshot,
166 httpClient,
167 }),
168 ),
169 ),
128170 }).pipe(
129171 Effect.mapError(
130172 (cause) =>
docs/internals/providers.md+3-1
......@@ -64,7 +64,9 @@ it. Homebrew and npm are proven by the real path (symlinks followed): a versione
6464`brew --prefix`, or `<prefix>/lib/node_modules/<pkg>/` (Windows: the shim beside `node_modules`).
6565Native installer layouts and the global bin directories of pnpm, Bun, and Vite+ may match on either
6666the resolved path or its real target, since those installers place real files or their own symlinks
67there. Anything unproven stays manual-only but still reports the version gap. npm updates pin
67there. Cursor and Grok are the exception: their only updater is the CLI itself, which detects its
68own installer, so any resolved executable runs `<binary> update`. Anything unproven stays
69manual-only but still reports the version gap. npm updates pin
6870`--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the
6971provider. Homebrew
7072compares against `brew info` since casks trail npm by hours; native installs share npm's version