Skip to content

Commit 740cace

Browse files
Kevin/vscode improvements (#36)
* feat: Add extension and settings support for vscode * feat: Improve CLAUADE.md * feat: Refactor and separate settings to new stateful parameter. Fix for bin not sourced in tests * fix: Errorenous tests * fix: Linux installs
1 parent 59a6acf commit 740cace

9 files changed

Lines changed: 412 additions & 86 deletions

File tree

CLAUDE.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,11 @@ Zod is preferred because types are automatically inferred from the schema, preve
198198
- Uses `PluginTester.fullTest()` from `@codifycli/plugin-test`
199199
- Tests create → modify → destroy flow
200200
- Includes validation callbacks
201+
- **Always use `testSpawn` from `@codifycli/plugin-test` for shell commands in validation callbacks.** `testSpawn` sources the user's shell RC (`.zshrc`, `.bashrc`) before running the command, so PATH and shell aliases are available — just like a real terminal session. Never use `execSync` in integration tests.
201202

202203
**Integration Test Pattern:**
203204
```typescript
204-
import { PluginTester } from '@codifycli/plugin-test'
205+
import { PluginTester, testSpawn } from '@codifycli/plugin-test'
205206

206207
await PluginTester.fullTest(pluginPath, [
207208
{ type: 'alias', alias: 'my-alias', value: 'ls -l' }
@@ -247,6 +248,16 @@ const { data } = await $.spawn('command', {
247248
})
248249
```
249250

251+
**Shell RC sourcing differs by lifecycle method.** During `refresh`, the framework uses a `BackgroundPty` that automatically sources the user's shell RC, so PATH and shell functions are available without any extra options. During all other lifecycle methods (`create`, `modify`, `destroy`), the RC is **not** sourced automatically — pass `{ interactive: true }` when the command needs PATH entries or shell aliases that come from the RC file (e.g. a tool that was just installed by adding itself to `.zshrc`).
252+
253+
```typescript
254+
// refresh — shell RC sourced automatically, no option needed
255+
const result = await $.spawnSafe('my-tool --version')
256+
257+
// create/modify/destroy — must opt in to get sourced shell
258+
await $.spawn('my-tool configure', { interactive: true })
259+
```
260+
250261
**Never use `sudo` inside `$.spawn` or `$.spawnSafe`.** Use `{ requiresRoot: true }` in the options instead. The framework handles privilege escalation through the parent process.
251262

252263
```typescript
@@ -412,6 +423,37 @@ parameterSettings: {
412423
}
413424
```
414425

426+
### Stateful Parameters for State-Bearing Parameters
427+
428+
If a parameter has its own independent state on the system (e.g. a list of installed packages, a JSON settings file, a set of config keys), implement it as a `StatefulParameter` rather than handling it inline in `create`/`modify`/`destroy`. This keeps the main resource class clean and gives the framework full visibility into the parameter's lifecycle.
429+
430+
**Rule of thumb:** if you find yourself reading current state, diffing, and writing back inside `modify()` on the resource, it should be a `StatefulParameter` instead.
431+
432+
```typescript
433+
export class MyParameter extends StatefulParameter<MyConfig, ValueType> {
434+
getSettings(): ParameterSetting { ... }
435+
async refresh(desired, config): Promise<ValueType | null> { /* read current state */ }
436+
async add(value, plan): Promise<void> { /* apply from scratch */ }
437+
async modify(newValue, previousValue, plan): Promise<void> { /* diff and update */ }
438+
async remove(value, plan): Promise<void> { /* clean up */ }
439+
}
440+
```
441+
442+
```typescript
443+
// Wrong — inline state management clutters the resource
444+
async modify(pc, plan) {
445+
if (pc.name === 'settings') {
446+
const current = JSON.parse(await fs.readFile(settingsPath, 'utf8'));
447+
await fs.writeFile(settingsPath, JSON.stringify({ ...current, ...pc.newValue }));
448+
}
449+
}
450+
451+
// Correct — delegate to a StatefulParameter
452+
parameterSettings: {
453+
settings: { type: 'stateful', definition: new SettingsParameter(), order: 1 }
454+
}
455+
```
456+
415457
### defaultConfig and exampleConfigs
416458

417459
Every resource should have a `defaultConfig` and `exampleConfigs`. These are surfaced in the Codify Editor to help users get started quickly.

completions-cron/src/__generated__/completions-index.ts

Lines changed: 32 additions & 30 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/resources/(resources)/vscode.mdx

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,37 @@ title: vscode
33
description: A reference page for the vscode resource
44
---
55

6-
The vscode resource reference. This resource installs `vscode` to your system. Vscode is a popular
7-
lightweight code editor developed by Microsoft. Vscode supports many coding languages via plugins.
6+
The vscode resource reference. This resource installs `vscode` to your system and manages extensions
7+
and editor settings. Vscode is a popular lightweight code editor developed by Microsoft.
88
For more information on Vscode [see here](https://code.visualstudio.com)
99

1010
## Parameters:
1111

12-
- **directory**: *(string)* A custom directory to install the vscode application into. The default is
13-
`$HOME/Applications/`.
12+
- **directory**: *(string)* A custom directory to install the vscode application into. Defaults to
13+
`/Applications` on macOS and `$HOME/.local/bin` on Linux.
14+
15+
- **extensions**: *(string[])* A list of VS Code extension IDs to install (e.g. `"ms-python.python"`).
16+
Extensions are managed statefully — Codify will install missing extensions and uninstall ones removed
17+
from the list.
18+
19+
- **settings**: *(object)* Key-value pairs to merge into `settings.json`. On apply, the declared keys
20+
are written to the user settings file. On destroy, only the declared keys are removed.
1421

1522
## Example usage:
1623

1724
```json title="codify.jsonc"
1825
[
1926
{
20-
"type": "vscode"
27+
"type": "vscode",
28+
"extensions": [
29+
"ms-python.python",
30+
"ms-python.vscode-pylance",
31+
"eamodio.gitlens"
32+
],
33+
"settings": {
34+
"editor.fontSize": 14,
35+
"editor.formatOnSave": true
36+
}
2137
}
2238
]
2339
```
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export default async function loadVscodeExtensions(): Promise<string[]> {
2+
const body = {
3+
filters: [{
4+
criteria: [{ filterType: 8, value: 'Microsoft.VisualStudio.Code' }],
5+
pageSize: 200,
6+
sortBy: 4,
7+
}],
8+
flags: 914,
9+
};
10+
11+
const response = await fetch('https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery', {
12+
method: 'POST',
13+
headers: {
14+
'Content-Type': 'application/json',
15+
'Accept': 'application/json;api-version=7.2-preview.1',
16+
},
17+
body: JSON.stringify(body),
18+
});
19+
20+
const data = await response.json() as any;
21+
return data.results[0].extensions.map(
22+
(e: any) => `${e.publisher.publisherName}.${e.extensionName}` as string
23+
);
24+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { ArrayParameterSetting, Plan, SpawnStatus, StatefulParameter, Utils, getPty } from '@codifycli/plugin-core';
2+
import path from 'node:path';
3+
4+
import { VscodeConfig } from './vscode.js';
5+
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+
22+
export class ExtensionsParameter extends StatefulParameter<VscodeConfig, string[]> {
23+
getSettings(): ArrayParameterSetting {
24+
return {
25+
type: 'array',
26+
isElementEqual(desired, current) {
27+
return desired.toLowerCase() === current.toLowerCase();
28+
},
29+
};
30+
}
31+
32+
override async refresh(desired: string[] | null, config: Partial<VscodeConfig>): Promise<string[] | null> {
33+
const $ = getPty();
34+
const code = getCodeBinary(config.directory);
35+
const result = await $.spawnSafe(`"${code}" --list-extensions`);
36+
if (result.status !== SpawnStatus.SUCCESS || result.data == null) {
37+
return null;
38+
}
39+
return result.data.split('\n').filter(Boolean);
40+
}
41+
42+
async add(valueToAdd: string[], plan: Plan<VscodeConfig>): Promise<void> {
43+
const $ = getPty();
44+
const code = getCodeBinary(plan.desiredConfig?.directory);
45+
for (const ext of valueToAdd) {
46+
await $.spawn(`"${code}" --install-extension ${ext} --force`, { interactive: true });
47+
}
48+
}
49+
50+
async modify(newValue: string[], previousValue: string[], plan: Plan<VscodeConfig>): Promise<void> {
51+
const toAdd = newValue.filter((n) => !previousValue.some((p) => p.toLowerCase() === n.toLowerCase()));
52+
const toRemove = previousValue.filter((p) => !newValue.some((n) => n.toLowerCase() === p.toLowerCase()));
53+
await this.remove(toRemove, plan);
54+
await this.add(toAdd, plan);
55+
}
56+
57+
async remove(valueToRemove: string[], plan: Plan<VscodeConfig>): Promise<void> {
58+
const $ = getPty();
59+
const code = getCodeBinary(plan.desiredConfig?.directory ?? plan.currentConfig?.directory);
60+
for (const ext of valueToRemove) {
61+
await $.spawnSafe(`"${code}" --uninstall-extension ${ext}`);
62+
}
63+
}
64+
}
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-schema.json

Lines changed: 0 additions & 16 deletions
This file was deleted.

0 commit comments

Comments
 (0)