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
6 changes: 6 additions & 0 deletions apps/desktop/src/shell/DesktopShellEnvironment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ describe("DesktopShellEnvironment", () => {
PATH: "C:\\Windows\\System32",
APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
ProgramFiles: "C:\\Program Files",
USERPROFILE: "C:\\Users\\testuser",
};
const commands: ChildProcess.Command[] = [];
Expand All @@ -246,7 +247,12 @@ describe("DesktopShellEnvironment", () => {
[
"C:\\Profile\\Node",
"C:\\Windows\\System32",
"C:\\Program Files\\Git\\cmd",
"C:\\Program Files\\GitHub CLI",
"C:\\Users\\testuser\\AppData\\Roaming\\npm",
"C:\\Users\\testuser\\AppData\\Local\\Microsoft\\WindowsApps",
"C:\\Users\\testuser\\AppData\\Local\\Programs\\Git\\cmd",
"C:\\Users\\testuser\\AppData\\Local\\Programs\\GitHub CLI",
"C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
"C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
"C:\\Users\\testuser\\AppData\\Local\\pnpm",
Expand Down
24 changes: 2 additions & 22 deletions apps/desktop/src/shell/DesktopShellEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
import { hideWindowsConsole } from "@threadlines/shared/childProcess";
import { resolveKnownWindowsCliDirs } from "@threadlines/shared/shell";

import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";

Expand Down Expand Up @@ -105,27 +106,6 @@ const listLoginShellCandidates = (config: ShellEnvironmentConfig): ReadonlyArray
return candidates;
};

const knownWindowsCliDirs = (env: NodeJS.ProcessEnv): ReadonlyArray<string> => [
...trimNonEmpty(env.APPDATA).pipe(
Option.match({
onNone: () => [],
onSome: (value) => [`${value}\\npm`],
}),
),
...trimNonEmpty(env.LOCALAPPDATA).pipe(
Option.match({
onNone: () => [],
onSome: (value) => [`${value}\\Programs\\nodejs`, `${value}\\Volta\\bin`, `${value}\\pnpm`],
}),
),
...trimNonEmpty(env.USERPROFILE).pipe(
Option.match({
onNone: () => [],
onSome: (value) => [`${value}\\.bun\\bin`, `${value}\\scoop\\shims`],
}),
),
];

const startMarker = (name: string) => `__THREADLINES_ENV_${name}_START__`;
const endMarker = (name: string) => `__THREADLINES_ENV_${name}_END__`;

Expand Down Expand Up @@ -268,7 +248,7 @@ const installWindowsEnvironment = Effect.fn("desktop.shellEnvironment.installWin
});
const mergedPath = mergePaths("win32", [
trimNonEmpty(profile.PATH),
trimNonEmpty(knownWindowsCliDirs(config.env).join(";")),
trimNonEmpty(resolveKnownWindowsCliDirs(config.env).join(";")),
trimNonEmpty(noProfile.PATH),
readEnvPath(config.env),
]);
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/git/GitAuthRemediationService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";

import { GitManagerError, VcsProcessSpawnError } from "@threadlines/contracts";
import { ServerConfig } from "../config.ts";
import { THREADLINES_GITHUB_CLI_ENV } from "../sourceControl/GitHubCliEnvironment.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as GitAuthRemediationService from "./GitAuthRemediationService.ts";
Expand All @@ -31,13 +32,14 @@ type FakeGhBehavior = "authed" | "unauthenticated" | "missing";
interface RecordedGhCall {
readonly command: string;
readonly args: ReadonlyArray<string>;
readonly env: NodeJS.ProcessEnv | undefined;
}

const makeFakeVcsProcess = (behavior: FakeGhBehavior, calls: RecordedGhCall[]) =>
VcsProcess.VcsProcess.of({
run: (input) =>
Effect.suspend(() => {
calls.push({ command: input.command, args: input.args });
calls.push({ command: input.command, args: input.args, env: input.env });
if (behavior === "missing") {
return Effect.fail(
new VcsProcessSpawnError({
Expand Down Expand Up @@ -133,6 +135,7 @@ it.layer(TestLayer)("GitAuthRemediationService", (it) => {
{
command: "gh",
args: ["auth", "status", "--hostname", UNREACHABLE_HOST],
env: THREADLINES_GITHUB_CLI_ENV,
},
]);

Expand Down Expand Up @@ -241,6 +244,7 @@ it.layer(TestLayer)("GitAuthRemediationService", (it) => {
{
command: "gh",
args: ["auth", "setup-git", "--hostname", UNREACHABLE_HOST],
env: THREADLINES_GITHUB_CLI_ENV,
},
]);
}),
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/git/GitAuthRemediationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from "@threadlines/shared/git";
import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import { THREADLINES_GITHUB_CLI_ENV } from "../sourceControl/GitHubCliEnvironment.ts";

export interface GitAuthRemediationServiceShape {
readonly plan: (
Expand Down Expand Up @@ -119,6 +120,7 @@ export const make = Effect.fn("makeGitAuthRemediationService")(function* () {
command: "gh",
args: ["auth", "status", "--hostname", host],
cwd,
env: THREADLINES_GITHUB_CLI_ENV,
allowNonZeroExit: true,
timeoutMs: GH_PROBE_TIMEOUT_MS,
})
Expand Down Expand Up @@ -257,6 +259,7 @@ export const make = Effect.fn("makeGitAuthRemediationService")(function* () {
command: "gh",
args: ["auth", "setup-git", "--hostname", host],
cwd,
env: THREADLINES_GITHUB_CLI_ENV,
allowNonZeroExit: true,
timeoutMs: APPLY_TIMEOUT_MS,
})
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ const processOutput = (stdout: string): VcsProcess.VcsProcessOutput => ({
});

const mockRun = vi.fn<VcsProcess.VcsProcessShape["run"]>();
const GITHUB_CLI_BACKGROUND_ENV = {
GH_NO_UPDATE_NOTIFIER: "1",
GH_TELEMETRY: "0",
} as const;

const layer = GitHubCli.layer.pipe(
Layer.provide(
Expand Down Expand Up @@ -84,6 +88,7 @@ describe("GitHubCli.layer", () => {
"number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner",
],
cwd: "/repo",
env: GITHUB_CLI_BACKGROUND_ENV,
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
Expand Down Expand Up @@ -255,6 +260,7 @@ describe("GitHubCli.layer", () => {
"nameWithOwner,url,sshUrl",
],
cwd: "/repo",
env: GITHUB_CLI_BACKGROUND_ENV,
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
Expand Down Expand Up @@ -302,13 +308,15 @@ describe("GitHubCli.layer", () => {
"platform",
],
cwd: "/repo",
env: GITHUB_CLI_BACKGROUND_ENV,
timeoutMs: 30_000,
});
expect(mockRun).toHaveBeenNthCalledWith(2, {
operation: "GitHubCli.execute",
command: "gh",
args: ["api", "repos/octocat/example-app", "--jq", ".default_branch"],
cwd: "/repo",
env: GITHUB_CLI_BACKGROUND_ENV,
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
Expand Down Expand Up @@ -346,6 +354,7 @@ describe("GitHubCli.layer", () => {
"/tmp/body.md",
],
cwd: "/repo",
env: GITHUB_CLI_BACKGROUND_ENV,
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "@threadlines/contracts";

import * as VcsProcess from "../vcs/VcsProcess.ts";
import { THREADLINES_GITHUB_CLI_ENV } from "./GitHubCliEnvironment.ts";
import * as GitHubPullRequests from "./gitHubPullRequests.ts";

const DEFAULT_TIMEOUT_MS = 30_000;
Expand Down Expand Up @@ -263,6 +264,7 @@ export const make = Effect.fn("makeGitHubCli")(function* () {
command: "gh",
args: input.args,
cwd: input.cwd,
env: THREADLINES_GITHUB_CLI_ENV,
timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS,
})
.pipe(Effect.mapError((error) => normalizeGitHubCliError("execute", error)));
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/sourceControl/GitHubCliEnvironment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const THREADLINES_GITHUB_CLI_ENV = {
GH_NO_UPDATE_NOTIFIER: "1",
GH_TELEMETRY: "0",
} as const satisfies NodeJS.ProcessEnv;
2 changes: 2 additions & 0 deletions apps/server/src/sourceControl/GitHubSourceControlProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { parseGitHubRepositoryNameWithOwnerFromRemoteUrl } from "@threadlines/shared/git";

import * as GitHubCli from "./GitHubCli.ts";
import { THREADLINES_GITHUB_CLI_ENV } from "./GitHubCliEnvironment.ts";
import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
import * as GitHubPullRequests from "./gitHubPullRequests.ts";
import * as SourceControlProvider from "./SourceControlProvider.ts";
Expand Down Expand Up @@ -139,6 +140,7 @@ export const discovery = {
executable: "gh",
versionArgs: ["--version"],
authArgs: ["auth", "status", "--json", "hosts"],
env: THREADLINES_GITHUB_CLI_ENV,
parseAuth: parseGitHubAuth,
installHint:
"Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).",
Expand Down
58 changes: 53 additions & 5 deletions apps/server/src/sourceControl/SourceControlDiscovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,16 @@ import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
import * as BitbucketApi from "./BitbucketApi.ts";
import * as GitHubCli from "./GitHubCli.ts";
import { THREADLINES_GITHUB_CLI_ENV } from "./GitHubCliEnvironment.ts";
import * as GitLabCli from "./GitLabCli.ts";
import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";

const hasGitAndGhCommand = (command: string) => command === "git" || command === "gh";
const hasGitGhAndWingetCommand = (command: string) =>
command === "git" || command === "gh" || command === "winget";
const hasAllCommandsExceptJj = (command: string) => command !== "jj";
const noCommandsAvailable = () => false;
const noLatestToolVersion = () => Effect.succeed(null);

const sourceControlProviderRegistryTestLayer = (input: {
readonly bitbucket: Partial<BitbucketApi.BitbucketApiShape>;
Expand Down Expand Up @@ -65,6 +68,9 @@ it.effect("reports implemented tools separately from locally available executabl
const processMock = {
run: (input: VcsProcess.VcsProcessInput) => {
processCommands.push(input.command);
if (input.command === "gh") {
assert.deepStrictEqual(input.env, THREADLINES_GITHUB_CLI_ENV);
}
if (input.command === "git") {
return Effect.succeed(processOutput("git version 2.51.0\n"));
}
Expand Down Expand Up @@ -104,7 +110,12 @@ it.effect("reports implemented tools separately from locally available executabl
} satisfies Partial<VcsProcess.VcsProcessShape>;
const testLayer = Layer.effect(
SourceControlDiscovery.SourceControlDiscovery,
SourceControlDiscovery.make({ commandAvailable: hasGitAndGhCommand }),
SourceControlDiscovery.make({
commandAvailable: hasGitGhAndWingetCommand,
platform: "win32",
latestVersionResolver: (target) =>
Effect.succeed(target === "github-cli" ? "2.98.0" : "2.55.0.windows.4"),
}),
).pipe(
Layer.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-discovery-" }),
Expand All @@ -113,7 +124,7 @@ it.effect("reports implemented tools separately from locally available executabl
Layer.provide(
sourceControlProviderRegistryTestLayer({
process: processMock,
commandAvailable: hasGitAndGhCommand,
commandAvailable: hasGitGhAndWingetCommand,
bitbucket: {
probeAuth: Effect.succeed({
status: "unauthenticated",
Expand Down Expand Up @@ -181,6 +192,37 @@ it.effect("reports implemented tools separately from locally available executabl
const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");
assert.ok(bitbucket);
assert.strictEqual(bitbucket.executable, undefined);
const github = result.sourceControlProviders.find((item) => item.kind === "github");
assert.ok(github);
assert.deepStrictEqual(github.versionAdvisory, {
status: "recommended_update",
severity: "warning",
currentVersion: "2.83.0",
latestVersion: "2.98.0",
recommendedVersion: "2.97.0",
checkedAt: github.versionAdvisory?.checkedAt ?? null,
message:
"This GitHub CLI version can briefly open terminal windows during background telemetry on Windows and is below the recommended security-fix release.",
notificationKey: "github-cli:security:2.97.0",
actions: [
{
label: "Update now",
kind: "runUpdate",
target: "github-cli",
},
{
label: "Copy WinGet command",
kind: "copyCommand",
value:
"winget upgrade --id GitHub.cli --exact --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity",
},
{
label: "Open releases",
kind: "openUrl",
value: "https://github.com/cli/cli/releases/latest",
},
],
});
assert.deepStrictEqual(
processCommands.filter(
(command) => command === "jj" || command === "glab" || command === "az",
Expand Down Expand Up @@ -242,7 +284,10 @@ Logged in to gitlab.com as gitlab-user
} satisfies Partial<VcsProcess.VcsProcessShape>;
const testLayer = Layer.effect(
SourceControlDiscovery.SourceControlDiscovery,
SourceControlDiscovery.make({ commandAvailable: hasAllCommandsExceptJj }),
SourceControlDiscovery.make({
commandAvailable: hasAllCommandsExceptJj,
latestVersionResolver: noLatestToolVersion,
}),
).pipe(
Layer.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-auth-discovery-" }),
Expand Down Expand Up @@ -323,7 +368,10 @@ it.effect("skips unavailable discovery commands before spawning probes", () => {
} satisfies Partial<VcsProcess.VcsProcessShape>;
const testLayer = Layer.effect(
SourceControlDiscovery.SourceControlDiscovery,
SourceControlDiscovery.make({ commandAvailable: noCommandsAvailable }),
SourceControlDiscovery.make({
commandAvailable: noCommandsAvailable,
latestVersionResolver: noLatestToolVersion,
}),
).pipe(
Layer.provide(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-skip-discovery-" }),
Expand Down
Loading
Loading