Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions apps/server/src/sourceControl/SourceControlDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ it.effect("reports implemented tools separately from locally available executabl
platform: "win32",
latestVersionResolver: (target) =>
Effect.succeed(target === "github-cli" ? "2.98.0" : "2.55.0.windows.4"),
winGetVersionResolver: (target) =>
Effect.succeed(target === "github-cli" ? "2.98.0" : "2.55.0.windows.4"),
}),
).pipe(
Layer.provide(
Expand Down Expand Up @@ -424,3 +426,66 @@ it.effect("skips unavailable discovery commands before spawning probes", () => {
assert.deepStrictEqual(processCommands, []);
}).pipe(Effect.provide(testLayer));
});

it.effect("offers allowlisted Homebrew installs for missing macOS tools", () => {
const hasOnlyHomebrew = (command: string) => command === "brew";
const processMock = {
run: (input: VcsProcess.VcsProcessInput) =>
Effect.fail(
new VcsProcessSpawnError({
operation: input.operation,
command: input.command,
cwd: input.cwd,
cause: new Error(`${input.command} should not be spawned`),
}),
),
} satisfies Partial<VcsProcess.VcsProcessShape>;
const testLayer = Layer.effect(
SourceControlDiscovery.SourceControlDiscovery,
SourceControlDiscovery.make({
commandAvailable: hasOnlyHomebrew,
platform: "darwin",
latestVersionResolver: noLatestToolVersion,
}),
).pipe(
Layer.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-brew-discovery-" }),
),
Layer.provide(Layer.mock(VcsProcess.VcsProcess)(processMock)),
Layer.provide(
sourceControlProviderRegistryTestLayer({
process: processMock,
commandAvailable: hasOnlyHomebrew,
bitbucket: {
probeAuth: Effect.succeed({
status: "unauthenticated",
account: Option.none(),
host: Option.some("bitbucket.org"),
detail: Option.none(),
}),
},
}),
),
Layer.provideMerge(NodeServices.layer),
);

return Effect.gen(function* () {
const discovery = yield* SourceControlDiscovery.SourceControlDiscovery;
const result = yield* discovery.discover;
const actions = [...result.versionControlSystems, ...result.sourceControlProviders].flatMap(
(item) => item.versionAdvisory?.actions.filter((action) => action.kind === "runUpdate") ?? [],
);

assert.deepStrictEqual(
actions.map((action) =>
action.kind === "runUpdate" ? [action.target, action.operation] : null,
),
[
["git", "install"],
["github-cli", "install"],
["gitlab-cli", "install"],
["azure-cli", "install"],
],
);
}).pipe(Effect.provide(testLayer));
});
41 changes: 31 additions & 10 deletions apps/server/src/sourceControl/SourceControlDiscovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ import { ServerConfig } from "../config.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
import { selectSourceControlToolPackageManager } from "./SourceControlToolPackages.ts";
import * as SourceControlToolVersionAdvisory from "./SourceControlToolVersionAdvisory.ts";
import * as SourceControlWinGet from "./SourceControlWinGet.ts";

interface DiscoveryProbe {
readonly label: string;
Expand Down Expand Up @@ -68,6 +70,7 @@ export interface SourceControlDiscoveryShape {
export interface SourceControlDiscoveryOptions {
readonly commandAvailable?: SourceControlProviderDiscovery.CommandAvailability;
readonly latestVersionResolver?: SourceControlToolVersionAdvisory.LatestVersionResolver;
readonly winGetVersionResolver?: SourceControlWinGet.LatestWinGetVersionResolver;
readonly platform?: NodeJS.Platform;
}

Expand Down Expand Up @@ -104,8 +107,13 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* (
const platform = options?.platform ?? process.platform;
const commandAvailable =
options?.commandAvailable ?? ((command) => isCommandAvailable(command, { platform }));
const latestVersionResolver = options?.latestVersionResolver;
const canRunToolUpdate = platform === "win32" && commandAvailable("winget");
const latestVersionResolver =
options?.latestVersionResolver ?? (() => Effect.succeed<string | null>(null));
const sourceControlToolPackageManager = selectSourceControlToolPackageManager({
platform,
commandAvailable,
});
const canRunToolUpdate = platform === "win32" && sourceControlToolPackageManager === "winget";

const probe = <Kind extends VcsDriverKind>(
input: DiscoveryProbe & { readonly kind: Kind },
Expand Down Expand Up @@ -167,14 +175,21 @@ export const make = Effect.fn("makeSourceControlDiscovery")(function* (
const withVersionAdvisory = <Item extends VcsDiscoveryItem | SourceControlProviderDiscoveryItem>(
item: Item,
): Effect.Effect<Item> =>
latestVersionResolver
? SourceControlToolVersionAdvisory.withSourceControlToolVersionAdvisory({
item,
platform,
latestVersionResolver,
canRunUpdate: canRunToolUpdate,
})
: Effect.succeed(item);
SourceControlToolVersionAdvisory.withSourceControlToolVersionAdvisory({
item,
platform,
latestVersionResolver,
...(options?.winGetVersionResolver
? { winGetVersionResolver: options.winGetVersionResolver }
: {}),
canRunUpdate: canRunToolUpdate,
packageManager: sourceControlToolPackageManager,
canRunInstall:
sourceControlToolPackageManager !== null &&
item.status !== "available" &&
item.executable !== undefined &&
!commandAvailable(item.executable),
});

return SourceControlDiscovery.of({
discover: Effect.all({
Expand Down Expand Up @@ -203,11 +218,17 @@ export const layer = Layer.effect(
SourceControlDiscovery,
Effect.gen(function* () {
const httpClient = yield* HttpClient.HttpClient;
const config = yield* ServerConfig;
const vcsProcess = yield* VcsProcess.VcsProcess;
return yield* make({
latestVersionResolver: (target) =>
SourceControlToolVersionAdvisory.resolveLatestToolVersion(target).pipe(
Effect.provideService(HttpClient.HttpClient, httpClient),
),
winGetVersionResolver: SourceControlWinGet.makeLatestWinGetVersionResolver({
cwd: config.cwd,
vcsProcess,
}),
});
}),
);
118 changes: 116 additions & 2 deletions apps/server/src/sourceControl/SourceControlToolMaintenance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@ import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { ChildProcessSpawner } from "effect/unstable/process";
import { VcsProcessExitError } from "@threadlines/contracts";

import { ServerConfig } from "../config.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as SourceControlToolMaintenance from "./SourceControlToolMaintenance.ts";
import * as SourceControlToolPackages from "./SourceControlToolPackages.ts";
import * as SourceControlWinGet from "./SourceControlWinGet.ts";

const processOutput: VcsProcess.VcsProcessOutput = {
exitCode: ChildProcessSpawner.ExitCode(0),
Expand All @@ -19,6 +22,40 @@ const processOutput: VcsProcess.VcsProcessOutput = {
stderrTruncated: false,
};

it("parses and normalizes the latest versions reported by WinGet", () => {
assert.strictEqual(
SourceControlWinGet.parseLatestWinGetVersion(
"git",
"Found Git [Git.Git]\r\nVersion\r\n--------\r\n2.55.0.3\r\n2.55.0.2\r\n",
),
"2.55.0.windows.3",
);
assert.strictEqual(
SourceControlWinGet.parseLatestWinGetVersion(
"github-cli",
"Found GitHub CLI [GitHub.cli]\nVersion\n-------\n2.98.0\n2.97.0\n",
),
"2.98.0",
);
});

it("uses Linuxbrew without treating sudo package managers as one-click capable", () => {
assert.strictEqual(
SourceControlToolPackages.selectSourceControlToolPackageManager({
platform: "linux",
commandAvailable: (command) => command === "brew" || command === "apt-get",
}),
"homebrew",
);
assert.strictEqual(
SourceControlToolPackages.selectSourceControlToolPackageManager({
platform: "linux",
commandAvailable: (command) => command === "apt-get",
}),
null,
);
});

it("verifies installed versions from raw discovery output without an advisory", () => {
assert.strictEqual(
SourceControlToolMaintenance.currentSourceControlToolVersion(
Expand Down Expand Up @@ -94,13 +131,13 @@ it.effect("runs only the allowlisted source control WinGet update recipes", () =
}).pipe(Effect.provide(layer));
});

it.effect("refuses one-click updates outside the verified Windows WinGet path", () => {
it.effect("refuses one-click updates when no supported package manager is available", () => {
let calls = 0;
const layer = Layer.effect(
SourceControlToolMaintenance.SourceControlToolMaintenance,
SourceControlToolMaintenance.make({
platform: "linux",
commandAvailable: () => true,
commandAvailable: () => false,
}),
).pipe(
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "source-tool-update-test-" })),
Expand All @@ -124,6 +161,83 @@ it.effect("refuses one-click updates outside the verified Windows WinGet path",
}).pipe(Effect.provide(layer));
});

it.effect("runs allowlisted Homebrew install and update recipes on macOS", () => {
const calls: VcsProcess.VcsProcessInput[] = [];
const layer = Layer.effect(
SourceControlToolMaintenance.SourceControlToolMaintenance,
SourceControlToolMaintenance.make({
platform: "darwin",
commandAvailable: (command) => command === "brew" || command === "git",
}),
).pipe(
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "source-tool-update-test-" })),
Layer.provide(
Layer.mock(VcsProcess.VcsProcess)({
run: (input) => {
calls.push(input);
return Effect.succeed(processOutput);
},
}),
),
Layer.provideMerge(NodeServices.layer),
);

return Effect.gen(function* () {
const maintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance;
yield* maintenance.update({ target: "git" });
yield* maintenance.update({ target: "github-cli", operation: "install" });
yield* maintenance.update({ target: "azure-cli", operation: "install" });

assert.deepStrictEqual(
calls.map((call) => [call.command, ...call.args]),
[
["brew", "upgrade", "git"],
["brew", "install", "gh"],
["brew", "install", "azure-cli"],
["az", "extension", "add", "--name", "azure-devops"],
],
);
}).pipe(Effect.provide(layer));
});

it.effect("explains when WinGet has no applicable package update", () => {
const layer = Layer.effect(
SourceControlToolMaintenance.SourceControlToolMaintenance,
SourceControlToolMaintenance.make({
platform: "win32",
commandAvailable: (command) => command === "winget",
}),
).pipe(
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "source-tool-update-test-" })),
Layer.provide(
Layer.mock(VcsProcess.VcsProcess)({
run: (input) =>
Effect.fail(
new VcsProcessExitError({
operation: input.operation,
command: [input.command, ...input.args].join(" "),
cwd: input.cwd,
exitCode: 0x8a15002b,
detail: "No applicable update found",
}),
),
}),
),
Layer.provideMerge(NodeServices.layer),
);

return Effect.gen(function* () {
const maintenance = yield* SourceControlToolMaintenance.SourceControlToolMaintenance;
const result = yield* Effect.result(maintenance.update({ target: "git" }));

assert.strictEqual(result._tag, "Failure");
if (result._tag === "Failure") {
assert.match(result.failure.reason, /does not currently offer a newer compatible Git/i);
assert.match(result.failure.reason, /official release/i);
}
}).pipe(Effect.provide(layer));
});

it.effect("serializes all source control updates through one WinGet lock", () =>
Effect.gen(function* () {
const started = yield* Deferred.make<void>();
Expand Down
Loading
Loading