Skip to content

Commit f509a2b

Browse files
committed
feat: Refactor and separate settings to new stateful parameter. Fix for bin not sourced in tests
1 parent 133b69b commit f509a2b

4 files changed

Lines changed: 149 additions & 79 deletions

File tree

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,24 @@
1-
import { ArrayParameterSetting, SpawnStatus, StatefulParameter, getPty } from '@codifycli/plugin-core';
1+
import { ArrayParameterSetting, Plan, SpawnStatus, StatefulParameter, Utils, getPty } from '@codifycli/plugin-core';
2+
import path from 'node:path';
23

34
import { VscodeConfig } from './vscode.js';
45

6+
const VSCODE_APPLICATION_NAME = 'Visual Studio Code.app';
7+
8+
function getCodeBinary(directory?: string | null): string {
9+
if (Utils.isMacOS()) {
10+
// On macOS the code binary lives inside the app bundle. Use the full path so it
11+
// works immediately after install without requiring a new shell session.
12+
return path.join(
13+
directory ?? '/Applications',
14+
VSCODE_APPLICATION_NAME,
15+
'Contents', 'Resources', 'app', 'bin', 'code',
16+
);
17+
}
18+
// On Linux, the package manager installs code to /usr/bin/code (already on PATH).
19+
return 'code';
20+
}
21+
522
export class ExtensionsParameter extends StatefulParameter<VscodeConfig, string[]> {
623
getSettings(): ArrayParameterSetting {
724
return {
@@ -12,33 +29,36 @@ export class ExtensionsParameter extends StatefulParameter<VscodeConfig, string[
1229
};
1330
}
1431

15-
override async refresh(): Promise<string[] | null> {
32+
override async refresh(desired: string[] | null, config: Partial<VscodeConfig>): Promise<string[] | null> {
1633
const $ = getPty();
17-
const result = await $.spawnSafe('code --list-extensions');
34+
const code = getCodeBinary(config.directory);
35+
const result = await $.spawnSafe(`"${code}" --list-extensions`);
1836
if (result.status !== SpawnStatus.SUCCESS || result.data == null) {
1937
return null;
2038
}
2139
return result.data.split('\n').filter(Boolean);
2240
}
2341

24-
async add(toAdd: string[]): Promise<void> {
42+
async add(valueToAdd: string[], plan: Plan<VscodeConfig>): Promise<void> {
2543
const $ = getPty();
26-
for (const ext of toAdd) {
27-
await $.spawn(`code --install-extension ${ext} --force`, { interactive: true });
44+
const code = getCodeBinary(plan.desiredConfig?.directory);
45+
for (const ext of valueToAdd) {
46+
await $.spawn(`"${code}" --install-extension ${ext} --force`, { interactive: true });
2847
}
2948
}
3049

31-
async modify(newValue: string[], previousValue: string[]): Promise<void> {
50+
async modify(newValue: string[], previousValue: string[], plan: Plan<VscodeConfig>): Promise<void> {
3251
const toAdd = newValue.filter((n) => !previousValue.some((p) => p.toLowerCase() === n.toLowerCase()));
3352
const toRemove = previousValue.filter((p) => !newValue.some((n) => n.toLowerCase() === p.toLowerCase()));
34-
await this.remove(toRemove);
35-
await this.add(toAdd);
53+
await this.remove(toRemove, plan);
54+
await this.add(toAdd, plan);
3655
}
3756

38-
async remove(toRemove: string[]): Promise<void> {
57+
async remove(valueToRemove: string[], plan: Plan<VscodeConfig>): Promise<void> {
3958
const $ = getPty();
40-
for (const ext of toRemove) {
41-
await $.spawnSafe(`code --uninstall-extension ${ext}`);
59+
const code = getCodeBinary(plan.desiredConfig?.directory ?? plan.currentConfig?.directory);
60+
for (const ext of valueToRemove) {
61+
await $.spawnSafe(`"${code}" --uninstall-extension ${ext}`);
4262
}
4363
}
4464
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { Plan, ParameterSetting, SpawnStatus, StatefulParameter, Utils } from '@codifycli/plugin-core';
2+
import fs from 'node:fs/promises';
3+
import os from 'node:os';
4+
import path from 'node:path';
5+
6+
import { VscodeConfig } from './vscode.js';
7+
8+
type Settings = Record<string, unknown>;
9+
10+
export class SettingsParameter extends StatefulParameter<VscodeConfig, Settings> {
11+
getSettings(): ParameterSetting {
12+
return { type: 'object' };
13+
}
14+
15+
override async refresh(): Promise<Settings | null> {
16+
try {
17+
const content = await fs.readFile(getSettingsPath(), 'utf8');
18+
return JSON.parse(content) as Settings;
19+
} catch {
20+
return null;
21+
}
22+
}
23+
24+
async add(valueToAdd: Settings): Promise<void> {
25+
await writeSettings(valueToAdd);
26+
}
27+
28+
async modify(newValue: Settings, previousValue: Settings): Promise<void> {
29+
const filePath = getSettingsPath();
30+
let existing: Settings = {};
31+
try {
32+
existing = JSON.parse(await fs.readFile(filePath, 'utf8'));
33+
} catch { /* file may not exist */ }
34+
35+
// Remove keys that were in the previous declaration but are no longer desired
36+
for (const key of Object.keys(previousValue)) {
37+
if (!(key in newValue)) {
38+
delete existing[key];
39+
}
40+
}
41+
42+
// Apply all new/changed keys
43+
Object.assign(existing, newValue);
44+
45+
await fs.mkdir(path.dirname(filePath), { recursive: true });
46+
await fs.writeFile(filePath, JSON.stringify(existing, null, 2));
47+
}
48+
49+
async remove(valueToRemove: Settings): Promise<void> {
50+
const filePath = getSettingsPath();
51+
try {
52+
const existing = JSON.parse(await fs.readFile(filePath, 'utf8')) as Settings;
53+
for (const key of Object.keys(valueToRemove)) {
54+
delete existing[key];
55+
}
56+
await fs.writeFile(filePath, JSON.stringify(existing, null, 2));
57+
} catch { /* nothing to do if file doesn't exist */ }
58+
}
59+
}
60+
61+
function getSettingsPath(): string {
62+
return Utils.isMacOS()
63+
? path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'settings.json')
64+
: path.join(os.homedir(), '.config', 'Code', 'User', 'settings.json');
65+
}
66+
67+
async function writeSettings(settings: Settings): Promise<void> {
68+
const filePath = getSettingsPath();
69+
let existing: Settings = {};
70+
try {
71+
existing = JSON.parse(await fs.readFile(filePath, 'utf8'));
72+
} catch { /* file may not exist yet */ }
73+
await fs.mkdir(path.dirname(filePath), { recursive: true });
74+
await fs.writeFile(filePath, JSON.stringify({ ...existing, ...settings }, null, 2));
75+
}

src/resources/vscode/vscode.ts

Lines changed: 19 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@ import {
33
DestroyPlan,
44
ExampleConfig,
55
FileUtils,
6-
ModifyPlan,
7-
ParameterChange,
86
Resource,
97
ResourceSettings,
108
Utils,
@@ -18,10 +16,19 @@ import path from 'node:path';
1816

1917
import { SpawnStatus } from '../../utils/codify-spawn.js';
2018
import { ExtensionsParameter } from './extensions-parameter.js';
19+
import { SettingsParameter } from './settings-parameter.js';
2120

2221
const VSCODE_APPLICATION_NAME = 'Visual Studio Code.app';
2322
const DOWNLOAD_URL = (platform: string) => `https://update.code.visualstudio.com/latest/${platform}/stable`;
2423

24+
function getVscodeBinDir(directory: string): string {
25+
return path.join(directory, VSCODE_APPLICATION_NAME, 'Contents', 'Resources', 'app', 'bin');
26+
}
27+
28+
function getVscodePathExport(binDir: string): string {
29+
return `export PATH="${binDir}:$PATH"`;
30+
}
31+
2532
const schema = z.object({
2633
directory: z
2734
.string()
@@ -85,7 +92,7 @@ export class VscodeResource extends Resource<VscodeConfig> {
8592
default: Utils.isMacOS() ? '/Applications' : path.join(os.homedir(), '.local', 'bin'),
8693
},
8794
extensions: { type: 'stateful', definition: new ExtensionsParameter(), order: 1 },
88-
settings: { canModify: true },
95+
settings: { type: 'stateful', definition: new SettingsParameter(), order: 2 },
8996
},
9097
};
9198
}
@@ -107,27 +114,16 @@ export class VscodeResource extends Resource<VscodeConfig> {
107114
} else {
108115
throw new Error('Unsupported operating system');
109116
}
110-
111-
if (plan.desiredConfig.settings) {
112-
await this.applySettings(plan.desiredConfig.settings as Record<string, unknown>);
113-
}
114-
}
115-
116-
override async modify(pc: ParameterChange<VscodeConfig>, _plan: ModifyPlan<VscodeConfig>): Promise<void> {
117-
if (pc.name === 'settings' && pc.newValue) {
118-
await this.applySettings(pc.newValue as Record<string, unknown>);
119-
}
120117
}
121118

122119
override async destroy(plan: DestroyPlan<VscodeConfig>): Promise<void> {
123120
const $ = getPty();
124-
const { directory, settings } = plan.currentConfig;
125-
126-
if (settings) {
127-
await this.removeSettings(Object.keys(settings as Record<string, unknown>));
128-
}
121+
const { directory } = plan.currentConfig;
129122

130123
if (Utils.isMacOS()) {
124+
const binDir = getVscodeBinDir(directory!);
125+
await FileUtils.removeLineFromShellRc(getVscodePathExport(binDir));
126+
131127
const location = path.join(directory!, `"${VSCODE_APPLICATION_NAME}"`);
132128
await $.spawn(`rm -rf ${location}`);
133129
} else if (Utils.isLinux()) {
@@ -178,6 +174,11 @@ export class VscodeResource extends Resource<VscodeConfig> {
178174
} finally {
179175
await $.spawn(`rm -rf ${temporaryDir}`);
180176
}
177+
178+
// Add the VS Code CLI bin dir to PATH in the shell RC so `code` is available in new terminals.
179+
// See: https://code.visualstudio.com/docs/setup/mac#_launching-from-the-command-line
180+
const binDir = getVscodeBinDir(plan.desiredConfig.directory!);
181+
await FileUtils.addToShellRc(getVscodePathExport(binDir));
181182
}
182183

183184
private async installLinux(_plan: CreatePlan<VscodeConfig>): Promise<void> {
@@ -210,33 +211,4 @@ export class VscodeResource extends Resource<VscodeConfig> {
210211
throw new Error('Unsupported Linux distribution. Only Debian-based (Ubuntu, Debian, Mint) and RedHat-based (RHEL, CentOS) systems are supported.');
211212
}
212213

213-
private getSettingsPath(): string {
214-
return Utils.isMacOS()
215-
? path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'settings.json')
216-
: path.join(os.homedir(), '.config', 'Code', 'User', 'settings.json');
217-
}
218-
219-
private async applySettings(settings: Record<string, unknown>): Promise<void> {
220-
const filePath = this.getSettingsPath();
221-
let existing: Record<string, unknown> = {};
222-
try {
223-
const content = await fs.readFile(filePath, 'utf8');
224-
existing = JSON.parse(content);
225-
} catch { /* file may not exist yet */ }
226-
const merged = { ...existing, ...settings };
227-
await fs.mkdir(path.dirname(filePath), { recursive: true });
228-
await fs.writeFile(filePath, JSON.stringify(merged, null, 2));
229-
}
230-
231-
private async removeSettings(keys: string[]): Promise<void> {
232-
const filePath = this.getSettingsPath();
233-
try {
234-
const content = await fs.readFile(filePath, 'utf8');
235-
const existing = JSON.parse(content) as Record<string, unknown>;
236-
for (const key of keys) {
237-
delete existing[key];
238-
}
239-
await fs.writeFile(filePath, JSON.stringify(existing, null, 2));
240-
} catch { /* nothing to do if file doesn't exist */ }
241-
}
242214
}

test/vscode/vscode.test.ts

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import { describe, expect, it } from 'vitest';
2-
import { PluginTester } from '@codifycli/plugin-test';
2+
import { PluginTester, testSpawn } from '@codifycli/plugin-test';
33
import * as path from 'node:path';
44
import fs from 'node:fs/promises';
5-
import os from 'node:os';
5+
import * as os from 'node:os';
66
import { Utils } from '@codifycli/plugin-core';
7-
import { execSync } from 'node:child_process';
87

98
describe('Vscode integration tests', async () => {
109
const pluginPath = path.resolve('./src/index.ts');
1110

12-
const settingsPath = Utils.isMacOS()
11+
// On macOS the code binary is inside the app bundle and not on PATH until a new shell is opened.
12+
const codeBin = Utils.isMacOS()
13+
? '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code'
14+
: 'code';
15+
16+
const settingsFile = Utils.isMacOS()
1317
? path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'settings.json')
1418
: path.join(os.homedir(), '.config', 'Code', 'User', 'settings.json');
1519

@@ -38,23 +42,23 @@ describe('Vscode integration tests', async () => {
3842
extensions: ['ms-python.python'],
3943
}], {
4044
validateApply: async () => {
41-
const result = execSync('code --list-extensions').toString();
42-
expect(result.toLowerCase()).to.include('ms-python.python');
45+
const { data } = await testSpawn(`"${codeBin}" --list-extensions`);
46+
expect(data?.toLowerCase()).to.include('ms-python.python');
4347
},
4448
testModify: {
4549
modifiedConfigs: [{
4650
type: 'vscode',
4751
extensions: ['ms-python.python', 'eamodio.gitlens'],
4852
}],
4953
validateModify: async () => {
50-
const result = execSync('code --list-extensions').toString();
51-
expect(result.toLowerCase()).to.include('ms-python.python');
52-
expect(result.toLowerCase()).to.include('eamodio.gitlens');
54+
const { data } = await testSpawn(`"${codeBin}" --list-extensions`);
55+
expect(data?.toLowerCase()).to.include('ms-python.python');
56+
expect(data?.toLowerCase()).to.include('eamodio.gitlens');
5357
},
5458
},
5559
validateDestroy: async () => {
56-
const result = execSync('code --list-extensions').toString();
57-
expect(result.toLowerCase()).not.to.include('eamodio.gitlens');
60+
const { data } = await testSpawn(`"${codeBin}" --list-extensions`);
61+
expect(data?.toLowerCase()).not.to.include('eamodio.gitlens');
5862
},
5963
});
6064
});
@@ -65,7 +69,8 @@ describe('Vscode integration tests', async () => {
6569
settings: { 'editor.fontSize': 14, 'editor.formatOnSave': true },
6670
}], {
6771
validateApply: async () => {
68-
const content = JSON.parse(await fs.readFile(settingsPath, 'utf8'));
72+
const { data } = await testSpawn(`cat "${settingsFile}"`);
73+
const content = JSON.parse(data!);
6974
expect(content['editor.fontSize']).to.equal(14);
7075
expect(content['editor.formatOnSave']).to.be.true;
7176
},
@@ -75,18 +80,16 @@ describe('Vscode integration tests', async () => {
7580
settings: { 'editor.fontSize': 16, 'editor.formatOnSave': true },
7681
}],
7782
validateModify: async () => {
78-
const content = JSON.parse(await fs.readFile(settingsPath, 'utf8'));
83+
const { data } = await testSpawn(`cat "${settingsFile}"`);
84+
const content = JSON.parse(data!);
7985
expect(content['editor.fontSize']).to.equal(16);
8086
},
8187
},
8288
validateDestroy: async () => {
83-
try {
84-
const content = JSON.parse(await fs.readFile(settingsPath, 'utf8'));
85-
expect(content['editor.fontSize']).to.be.undefined;
86-
expect(content['editor.formatOnSave']).to.be.undefined;
87-
} catch {
88-
// settings.json removed entirely — also valid
89-
}
89+
const { data } = await testSpawn(`cat "${settingsFile}" 2>/dev/null || echo "{}"`);
90+
const content = JSON.parse(data!);
91+
expect(content['editor.fontSize']).to.be.undefined;
92+
expect(content['editor.formatOnSave']).to.be.undefined;
9093
},
9194
});
9295
});

0 commit comments

Comments
 (0)