Skip to content

Commit 13ce1e4

Browse files
committed
feat: add support for sh/bash/zsh startup
1 parent 70b9da5 commit 13ce1e4

File tree

4 files changed

+242
-26
lines changed

4 files changed

+242
-26
lines changed

src/extension.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import { TerminalActivationImpl } from './features/terminal/terminalActivationSt
5959
import { getEnvironmentForTerminal } from './features/terminal/utils';
6060
import { PowershellStartupProvider } from './features/terminal/startup/powershellStartup';
6161
import { ShellStartupActivationManagerImpl } from './features/terminal/startup/activateUsingShellStartup';
62+
import { BashStartupProvider } from './features/terminal/startup/bashStartup';
6263

6364
export async function activate(context: ExtensionContext): Promise<PythonEnvironmentApi> {
6465
const start = new StopWatch();
@@ -85,7 +86,7 @@ export async function activate(context: ExtensionContext): Promise<PythonEnviron
8586
context.subscriptions.push(envManagers);
8687

8788
const terminalActivation = new TerminalActivationImpl();
88-
const shellStartupProviders = [new PowershellStartupProvider()];
89+
const shellStartupProviders = [new PowershellStartupProvider(), new BashStartupProvider()];
8990
const shellStartupActivationManager = new ShellStartupActivationManagerImpl(
9091
context.environmentVariableCollection,
9192
shellStartupProviders,
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import * as fs from 'fs-extra';
2+
import * as path from 'path';
3+
import * as os from 'os';
4+
import { ShellStartupProvider } from './startupProvider';
5+
import { EnvironmentVariableCollection } from 'vscode';
6+
import { PythonCommandRunConfiguration, PythonEnvironment, TerminalShellType } from '../../../api';
7+
import { getActivationCommandForShell } from '../../common/activation';
8+
import { quoteArgs } from '../../execution/execUtils';
9+
import { traceInfo, traceVerbose } from '../../../common/logging';
10+
11+
const bashActivationEnvVarKey = 'VSCODE_BASH_ACTIVATE';
12+
13+
async function getBashProfiles(): Promise<string[]> {
14+
const homeDir = os.homedir();
15+
const profiles: string[] = [path.join(homeDir, '.bashrc'), path.join(homeDir, '.bash_profile')];
16+
17+
// Filter to only existing profiles or the first one if none exist
18+
const existingProfiles = await Promise.all(
19+
profiles.map(async (profile) => ({
20+
profilePath: profile,
21+
exists: await fs.pathExists(profile),
22+
})),
23+
);
24+
25+
const result = existingProfiles.filter((p) => p.exists);
26+
if (result.length === 0) {
27+
// If no profile exists, return the first one so we can create it
28+
return [profiles[0]];
29+
}
30+
31+
return result.map((p) => p.profilePath);
32+
}
33+
34+
async function getZshProfiles(): Promise<string[]> {
35+
const homeDir = os.homedir();
36+
const profiles: string[] = [path.join(homeDir, '.zshrc')];
37+
38+
// Filter to only existing profiles or the first one if none exist
39+
const existingProfiles = await Promise.all(
40+
profiles.map(async (profile) => ({
41+
profilePath: profile,
42+
exists: await fs.pathExists(profile),
43+
})),
44+
);
45+
46+
const result = existingProfiles.filter((p) => p.exists);
47+
if (result.length === 0) {
48+
// If no profile exists, return the first one so we can create it
49+
return [profiles[0]];
50+
}
51+
52+
return result.map((p) => p.profilePath);
53+
}
54+
55+
const regionStart = '# vscode python environment activation begin';
56+
const regionEnd = '# vscode python environment activation end';
57+
58+
function getActivationContent(): string {
59+
const lineSep = '\n';
60+
return `${lineSep}${lineSep}${regionStart}${lineSep}if [ -n "$${bashActivationEnvVarKey}" ]; then${lineSep} eval "$${bashActivationEnvVarKey}"${lineSep}fi${lineSep}${regionEnd}${lineSep}`;
61+
}
62+
63+
async function isStartupSetup(profiles: string[]): Promise<boolean> {
64+
if (profiles.length === 0) {
65+
return false;
66+
}
67+
68+
// Check if any profile has our activation content
69+
const results = await Promise.all(
70+
profiles.map(async (profile) => {
71+
if (await fs.pathExists(profile)) {
72+
const content = await fs.readFile(profile, 'utf8');
73+
if (content.includes(bashActivationEnvVarKey)) {
74+
return true;
75+
}
76+
}
77+
return false;
78+
}),
79+
);
80+
81+
return results.some((result) => result);
82+
}
83+
84+
async function setupStartup(profiles: string[]): Promise<boolean> {
85+
if (profiles.length === 0) {
86+
traceVerbose('Cannot setup Bash startup - No profiles found');
87+
return false;
88+
}
89+
90+
const activationContent = getActivationContent();
91+
let successfulUpdates = 0;
92+
93+
for (const profile of profiles) {
94+
try {
95+
// Create profile directory if it doesn't exist
96+
await fs.mkdirp(path.dirname(profile));
97+
98+
// Create or update profile
99+
if (!(await fs.pathExists(profile))) {
100+
// Create new profile with our content
101+
await fs.writeFile(profile, activationContent);
102+
traceInfo(`Created new profile at: ${profile}\n${activationContent}`);
103+
successfulUpdates++;
104+
} else {
105+
// Update existing profile
106+
const content = await fs.readFile(profile, 'utf8');
107+
if (!content.includes(bashActivationEnvVarKey)) {
108+
await fs.writeFile(profile, `${content}${activationContent}`);
109+
traceInfo(`Updated existing profile at: ${profile}\n${activationContent}`);
110+
successfulUpdates++;
111+
} else {
112+
// Already contains our activation code
113+
successfulUpdates++;
114+
}
115+
}
116+
} catch (err) {
117+
traceVerbose(`Failed to setup ${profile} startup`, err);
118+
}
119+
}
120+
121+
// Return true only if all profiles were successfully updated
122+
return profiles.length > 0 && successfulUpdates === profiles.length;
123+
}
124+
125+
async function removeBashStartup(profiles: string[]): Promise<boolean> {
126+
let successfulRemovals = 0;
127+
128+
for (const profile of profiles) {
129+
if (!(await fs.pathExists(profile))) {
130+
successfulRemovals++; // Count as success if file doesn't exist since there's nothing to remove
131+
continue;
132+
}
133+
134+
try {
135+
const content = await fs.readFile(profile, 'utf8');
136+
if (content.includes(bashActivationEnvVarKey)) {
137+
// Use regex to remove the entire region including newlines
138+
const pattern = new RegExp(`${regionStart}[\\s\\S]*?${regionEnd}\\n?`, 'g');
139+
const newContent = content.replace(pattern, '');
140+
await fs.writeFile(profile, newContent);
141+
traceInfo(`Removed activation from profile at: ${profile}`);
142+
successfulRemovals++;
143+
} else {
144+
successfulRemovals++; // Count as success if activation is not present
145+
}
146+
} catch (err) {
147+
traceVerbose(`Failed to remove ${profile} startup`, err);
148+
}
149+
}
150+
151+
// Return true only if all profiles were successfully processed
152+
return profiles.length > 0 && successfulRemovals === profiles.length;
153+
}
154+
155+
function getCommandAsString(command: PythonCommandRunConfiguration[]): string {
156+
const parts = [];
157+
for (const cmd of command) {
158+
const args = cmd.args ?? [];
159+
// For bash, we need to ensure proper quoting
160+
parts.push(quoteArgs([cmd.executable, ...args]).join(' '));
161+
}
162+
return parts.join(' && ');
163+
}
164+
165+
export class BashStartupProvider implements ShellStartupProvider {
166+
public readonly name: string = 'sh|bash|zsh';
167+
168+
async isSetup(): Promise<boolean> {
169+
const bashProfiles = await getBashProfiles();
170+
const zshProfiles = await getZshProfiles();
171+
const result = await Promise.all([isStartupSetup(bashProfiles), isStartupSetup(zshProfiles)]);
172+
return result.every((res) => res);
173+
}
174+
175+
async setupScripts(): Promise<boolean> {
176+
const bashProfiles = await getBashProfiles();
177+
const zshProfiles = await getZshProfiles();
178+
const result = await Promise.all([setupStartup(bashProfiles), setupStartup(zshProfiles)]);
179+
return result.every((res) => res);
180+
}
181+
182+
async teardownScripts(): Promise<boolean> {
183+
const bashProfiles = await getBashProfiles();
184+
const zshProfiles = await getZshProfiles();
185+
const result = await Promise.all([removeBashStartup(bashProfiles), removeBashStartup(zshProfiles)]);
186+
return result.every((res) => res);
187+
}
188+
189+
async updateEnvVariables(collection: EnvironmentVariableCollection, env: PythonEnvironment): Promise<void> {
190+
const bashActivation = getActivationCommandForShell(env, TerminalShellType.bash);
191+
if (bashActivation) {
192+
const command = getCommandAsString(bashActivation);
193+
collection.replace(bashActivationEnvVarKey, command);
194+
} else {
195+
collection.delete(bashActivationEnvVarKey);
196+
}
197+
}
198+
199+
async removeEnvVariables(envCollection: EnvironmentVariableCollection): Promise<void> {
200+
envCollection.delete(bashActivationEnvVarKey);
201+
}
202+
203+
async getEnvVariables(env?: PythonEnvironment): Promise<Map<string, string | undefined> | undefined> {
204+
if (env) {
205+
const bashActivation = getActivationCommandForShell(env, TerminalShellType.bash);
206+
return bashActivation
207+
? new Map([[bashActivationEnvVarKey, getCommandAsString(bashActivation)]])
208+
: undefined;
209+
} else {
210+
return new Map([[bashActivationEnvVarKey, undefined]]);
211+
}
212+
}
213+
}

src/features/terminal/startup/powershellStartup.ts

Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@ import * as path from 'path';
33
import { isWindows } from '../../../common/utils/platformUtils';
44
import { ShellStartupProvider } from './startupProvider';
55
import { EnvironmentVariableCollection } from 'vscode';
6-
import { PythonCommandRunConfiguration, PythonEnvironment, TerminalShellType } from '../../../api';
6+
import { PythonEnvironment, TerminalShellType } from '../../../api';
77
import { getActivationCommandForShell } from '../../common/activation';
8-
import { quoteArgs } from '../../execution/execUtils';
98
import { traceInfo, traceVerbose } from '../../../common/logging';
10-
import { runCommand } from './utils';
9+
import { getCommandAsString, runCommand } from './utils';
1110

1211
const pwshActivationEnvVarKey = 'VSCODE_PWSH_ACTIVATE';
1312

@@ -47,18 +46,19 @@ async function isPowerShellStartupSetup(): Promise<boolean> {
4746
}
4847

4948
// Check if any profile has our activation content
50-
for (const profile of profiles) {
51-
if (!(await fs.pathExists(profile.profilePath))) {
52-
continue;
53-
}
54-
55-
const content = await fs.readFile(profile.profilePath, 'utf8');
56-
if (content.includes(pwshActivationEnvVarKey)) {
57-
return true;
58-
}
59-
}
49+
const results = await Promise.all(
50+
profiles.map(async (profile) => {
51+
if (await fs.pathExists(profile.profilePath)) {
52+
const content = await fs.readFile(profile.profilePath, 'utf8');
53+
if (content.includes(pwshActivationEnvVarKey)) {
54+
return true;
55+
}
56+
}
57+
return false;
58+
}),
59+
);
6060

61-
return false;
61+
return results.some((result) => result);
6262
}
6363

6464
async function setupPowerShellStartup(): Promise<boolean> {
@@ -129,15 +129,6 @@ async function removePowerShellStartup(): Promise<boolean> {
129129
return profiles.length > 0 && successfulRemovals === profiles.length;
130130
}
131131

132-
function getCommandAsString(command: PythonCommandRunConfiguration[]): string {
133-
const parts = [];
134-
for (const cmd of command) {
135-
const args = cmd.args ?? [];
136-
parts.push(quoteArgs([cmd.executable, ...args]).join(' '));
137-
}
138-
return parts.join(' && ');
139-
}
140-
141132
export class PowershellStartupProvider implements ShellStartupProvider {
142133
public readonly name: string = 'PowerShell';
143134
async isSetup(): Promise<boolean> {
@@ -155,7 +146,7 @@ export class PowershellStartupProvider implements ShellStartupProvider {
155146
async updateEnvVariables(collection: EnvironmentVariableCollection, env: PythonEnvironment): Promise<void> {
156147
const pwshActivation = getActivationCommandForShell(env, TerminalShellType.powershell);
157148
if (pwshActivation) {
158-
const command = getCommandAsString(pwshActivation);
149+
const command = getCommandAsString(pwshActivation, '&&');
159150
collection.replace(pwshActivationEnvVarKey, command);
160151
} else {
161152
collection.delete(pwshActivationEnvVarKey);
@@ -170,7 +161,7 @@ export class PowershellStartupProvider implements ShellStartupProvider {
170161
if (env) {
171162
const pwshActivation = getActivationCommandForShell(env, TerminalShellType.powershell);
172163
return pwshActivation
173-
? new Map([[pwshActivationEnvVarKey, getCommandAsString(pwshActivation)]])
164+
? new Map([[pwshActivationEnvVarKey, getCommandAsString(pwshActivation, '&&')]])
174165
: undefined;
175166
} else {
176167
return new Map([[pwshActivationEnvVarKey, undefined]]);

src/features/terminal/startup/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import * as cp from 'child_process';
22
import { traceVerbose } from '../../../common/logging';
3+
import { PythonCommandRunConfiguration } from '../../../api';
4+
import { quoteArgs } from '../../execution/execUtils';
35

46
export async function runCommand(command: string): Promise<string | undefined> {
57
return new Promise((resolve) => {
@@ -13,3 +15,12 @@ export async function runCommand(command: string): Promise<string | undefined> {
1315
});
1416
});
1517
}
18+
19+
export function getCommandAsString(command: PythonCommandRunConfiguration[], delimiter: string): string {
20+
const parts = [];
21+
for (const cmd of command) {
22+
const args = cmd.args ?? [];
23+
parts.push(quoteArgs([cmd.executable, ...args]).join(' '));
24+
}
25+
return parts.join(` ${delimiter} `);
26+
}

0 commit comments

Comments
 (0)