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 {...@@ -29,7 +29,13 @@ import {
29import { withInstanceIdentity } from "./instanceIdentity.ts";29import { withInstanceIdentity } from "./instanceIdentity.ts";
30import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";30import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts";
31import { discoverGrokSkills } from "./GrokSkills.ts";31import { 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";
33import {39import {
34 haveProviderSnapshotSettingsChanged,40 haveProviderSnapshotSettingsChanged,
35 makeProviderSnapshotSettingsSource,41 makeProviderSnapshotSettingsSource,
...@@ -38,10 +44,32 @@ import {...@@ -38,10 +44,32 @@ import {
38const decodeGrokSettings = Schema.decodeSync(GrokSettings);44const decodeGrokSettings = Schema.decodeSync(GrokSettings);
3945
40const DRIVER_KIND = ProviderDriverKind.make("grok");46const DRIVER_KIND = ProviderDriverKind.make("grok");
41const MAINTENANCE_CAPABILITIES = makeManualOnlyProviderMaintenanceCapabilities({47// npm's `latest` tracks Grok's stable channel, the one `grok update` installs
42 provider: DRIVER_KIND,48// by default, so the registry stays the source for "latest".
43 packageName: null,49const GROK_NPM_PACKAGE = "@xai-official/grok";
44});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
46export type GrokDriverEnv =74export type GrokDriverEnv =
47 | BackgroundPolicy.BackgroundPolicy75 | BackgroundPolicy.BackgroundPolicy
...@@ -85,6 +113,16 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {...@@ -85,6 +113,16 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
85 continuationGroupKey: continuationIdentity.continuationKey,113 continuationGroupKey: continuationIdentity.continuationKey,
86 });114 });
87 const effectiveConfig = { ...config, enabled } satisfies GrokSettings;115 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 );
88 const adapter = yield* makeGrokAdapter(effectiveConfig, {126 const adapter = yield* makeGrokAdapter(effectiveConfig, {
89 environment: processEnv,127 environment: processEnv,
90 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),128 ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}),
...@@ -110,7 +148,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {...@@ -110,7 +148,7 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
110148
111 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);149 const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings);
112 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<GrokSettings>>({150 const snapshot = yield* makeManagedServerProvider<ProviderSnapshotSettings<GrokSettings>>({
113 resolveMaintenance: () => Effect.succeed(MAINTENANCE_CAPABILITIES),151 resolveMaintenance,
114 getSettings: snapshotSettings.getSettings,152 getSettings: snapshotSettings.getSettings,
115 streamSettings: snapshotSettings.streamSettings,153 streamSettings: snapshotSettings.streamSettings,
116 haveSettingsChanged: haveProviderSnapshotSettingsChanged,154 haveSettingsChanged: haveProviderSnapshotSettingsChanged,
...@@ -118,13 +156,17 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {...@@ -118,13 +156,17 @@ export const GrokDriver: ProviderDriver<GrokSettings, GrokDriverEnv> = {
118 buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),156 buildInitialGrokProviderSnapshot(settings.provider).pipe(Effect.map(stampIdentity)),
119 checkProvider,157 checkProvider,
120 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>158 enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) =>
121 enrichGrokSnapshot({159 resolveMaintenance().pipe(
122 snapshot: currentSnapshot,160 Effect.flatMap((maintenanceCapabilities) =>
123 maintenanceCapabilities: MAINTENANCE_CAPABILITIES,161 enrichGrokSnapshot({
124 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,162 snapshot: currentSnapshot,
125 publishSnapshot,163 maintenanceCapabilities,
126 httpClient,164 enableProviderUpdateChecks: settings.enableProviderUpdateChecks,
127 }),165 publishSnapshot,
166 httpClient,
167 }),
168 ),
169 ),
128 }).pipe(170 }).pipe(
129 Effect.mapError(171 Effect.mapError(
130 (cause) =>172 (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...@@ -64,7 +64,9 @@ it. Homebrew and npm are proven by the real path (symlinks followed): a versione
64`brew --prefix`, or `<prefix>/lib/node_modules/<pkg>/` (Windows: the shim beside `node_modules`).64`brew --prefix`, or `<prefix>/lib/node_modules/<pkg>/` (Windows: the shim beside `node_modules`).
65Native installer layouts and the global bin directories of pnpm, Bun, and Vite+ may match on either65Native installer layouts and the global bin directories of pnpm, Bun, and Vite+ may match on either
66the resolved path or its real target, since those installers place real files or their own symlinks66the 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 pin67there. 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
68`--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the70`--prefix` because the `npm` on `PATH` can belong to a different Node than the one that owns the
69provider. Homebrew71provider. Homebrew
70compares against `brew info` since casks trail npm by hours; native installs share npm's version72compares against `brew info` since casks trail npm by hours; native installs share npm's version