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
44 changes: 43 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,11 @@ Zod is preferred because types are automatically inferred from the schema, preve
- Uses `PluginTester.fullTest()` from `@codifycli/plugin-test`
- Tests create → modify → destroy flow
- Includes validation callbacks
- **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.

**Integration Test Pattern:**
```typescript
import { PluginTester } from '@codifycli/plugin-test'
import { PluginTester, testSpawn } from '@codifycli/plugin-test'

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

**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`).

```typescript
// refresh — shell RC sourced automatically, no option needed
const result = await $.spawnSafe('my-tool --version')

// create/modify/destroy — must opt in to get sourced shell
await $.spawn('my-tool configure', { interactive: true })
```

**Never use `sudo` inside `$.spawn` or `$.spawnSafe`.** Use `{ requiresRoot: true }` in the options instead. The framework handles privilege escalation through the parent process.

```typescript
Expand Down Expand Up @@ -412,6 +423,37 @@ parameterSettings: {
}
```

### Stateful Parameters for State-Bearing Parameters

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.

**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.

```typescript
export class MyParameter extends StatefulParameter<MyConfig, ValueType> {
getSettings(): ParameterSetting { ... }
async refresh(desired, config): Promise<ValueType | null> { /* read current state */ }
async add(value, plan): Promise<void> { /* apply from scratch */ }
async modify(newValue, previousValue, plan): Promise<void> { /* diff and update */ }
async remove(value, plan): Promise<void> { /* clean up */ }
}
```

```typescript
// Wrong — inline state management clutters the resource
async modify(pc, plan) {
if (pc.name === 'settings') {
const current = JSON.parse(await fs.readFile(settingsPath, 'utf8'));
await fs.writeFile(settingsPath, JSON.stringify({ ...current, ...pc.newValue }));
}
}

// Correct — delegate to a StatefulParameter
parameterSettings: {
settings: { type: 'stateful', definition: new SettingsParameter(), order: 1 }
}
```

### defaultConfig and exampleConfigs

Every resource should have a `defaultConfig` and `exampleConfigs`. These are surfaced in the Codify Editor to help users get started quickly.
Expand Down
62 changes: 32 additions & 30 deletions completions-cron/src/__generated__/completions-index.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 21 additions & 5 deletions docs/resources/(resources)/vscode.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,37 @@ title: vscode
description: A reference page for the vscode resource
---

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

## Parameters:

- **directory**: *(string)* A custom directory to install the vscode application into. The default is
`$HOME/Applications/`.
- **directory**: *(string)* A custom directory to install the vscode application into. Defaults to
`/Applications` on macOS and `$HOME/.local/bin` on Linux.

- **extensions**: *(string[])* A list of VS Code extension IDs to install (e.g. `"ms-python.python"`).
Extensions are managed statefully — Codify will install missing extensions and uninstall ones removed
from the list.

- **settings**: *(object)* Key-value pairs to merge into `settings.json`. On apply, the declared keys
are written to the user settings file. On destroy, only the declared keys are removed.

## Example usage:

```json title="codify.jsonc"
[
{
"type": "vscode"
"type": "vscode",
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"eamodio.gitlens"
],
"settings": {
"editor.fontSize": 14,
"editor.formatOnSave": true
}
}
]
```
24 changes: 24 additions & 0 deletions src/resources/vscode/completions/vscode.extensions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export default async function loadVscodeExtensions(): Promise<string[]> {
const body = {
filters: [{
criteria: [{ filterType: 8, value: 'Microsoft.VisualStudio.Code' }],
pageSize: 200,
sortBy: 4,
}],
flags: 914,
};

const response = await fetch('https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json;api-version=7.2-preview.1',
},
body: JSON.stringify(body),
});

const data = await response.json() as any;
return data.results[0].extensions.map(
(e: any) => `${e.publisher.publisherName}.${e.extensionName}` as string
);
}
64 changes: 64 additions & 0 deletions src/resources/vscode/extensions-parameter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { ArrayParameterSetting, Plan, SpawnStatus, StatefulParameter, Utils, getPty } from '@codifycli/plugin-core';
import path from 'node:path';

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

const VSCODE_APPLICATION_NAME = 'Visual Studio Code.app';

function getCodeBinary(directory?: string | null): string {
if (Utils.isMacOS()) {
// On macOS the code binary lives inside the app bundle. Use the full path so it
// works immediately after install without requiring a new shell session.
return path.join(
directory ?? '/Applications',
VSCODE_APPLICATION_NAME,
'Contents', 'Resources', 'app', 'bin', 'code',
);
}
// On Linux, the package manager installs code to /usr/bin/code (already on PATH).
return 'code';
}

export class ExtensionsParameter extends StatefulParameter<VscodeConfig, string[]> {
getSettings(): ArrayParameterSetting {
return {
type: 'array',
isElementEqual(desired, current) {
return desired.toLowerCase() === current.toLowerCase();
},
};
}

override async refresh(desired: string[] | null, config: Partial<VscodeConfig>): Promise<string[] | null> {
const $ = getPty();
const code = getCodeBinary(config.directory);
const result = await $.spawnSafe(`"${code}" --list-extensions`);
if (result.status !== SpawnStatus.SUCCESS || result.data == null) {
return null;
}
return result.data.split('\n').filter(Boolean);
}

async add(valueToAdd: string[], plan: Plan<VscodeConfig>): Promise<void> {
const $ = getPty();
const code = getCodeBinary(plan.desiredConfig?.directory);
for (const ext of valueToAdd) {
await $.spawn(`"${code}" --install-extension ${ext} --force`, { interactive: true });
}
}

async modify(newValue: string[], previousValue: string[], plan: Plan<VscodeConfig>): Promise<void> {
const toAdd = newValue.filter((n) => !previousValue.some((p) => p.toLowerCase() === n.toLowerCase()));
const toRemove = previousValue.filter((p) => !newValue.some((n) => n.toLowerCase() === p.toLowerCase()));
await this.remove(toRemove, plan);
await this.add(toAdd, plan);
}

async remove(valueToRemove: string[], plan: Plan<VscodeConfig>): Promise<void> {
const $ = getPty();
const code = getCodeBinary(plan.desiredConfig?.directory ?? plan.currentConfig?.directory);
for (const ext of valueToRemove) {
await $.spawnSafe(`"${code}" --uninstall-extension ${ext}`);
}
}
}
75 changes: 75 additions & 0 deletions src/resources/vscode/settings-parameter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { Plan, ParameterSetting, SpawnStatus, StatefulParameter, Utils } from '@codifycli/plugin-core';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

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

type Settings = Record<string, unknown>;

export class SettingsParameter extends StatefulParameter<VscodeConfig, Settings> {
getSettings(): ParameterSetting {
return { type: 'object' };
}

override async refresh(): Promise<Settings | null> {
try {
const content = await fs.readFile(getSettingsPath(), 'utf8');
return JSON.parse(content) as Settings;
} catch {
return null;
}
}

async add(valueToAdd: Settings): Promise<void> {
await writeSettings(valueToAdd);
}

async modify(newValue: Settings, previousValue: Settings): Promise<void> {
const filePath = getSettingsPath();
let existing: Settings = {};
try {
existing = JSON.parse(await fs.readFile(filePath, 'utf8'));
} catch { /* file may not exist */ }

// Remove keys that were in the previous declaration but are no longer desired
for (const key of Object.keys(previousValue)) {
if (!(key in newValue)) {
delete existing[key];
}
}

// Apply all new/changed keys
Object.assign(existing, newValue);

await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(existing, null, 2));
}

async remove(valueToRemove: Settings): Promise<void> {
const filePath = getSettingsPath();
try {
const existing = JSON.parse(await fs.readFile(filePath, 'utf8')) as Settings;
for (const key of Object.keys(valueToRemove)) {
delete existing[key];
}
await fs.writeFile(filePath, JSON.stringify(existing, null, 2));
} catch { /* nothing to do if file doesn't exist */ }
}
}

function getSettingsPath(): string {
return Utils.isMacOS()
? path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'settings.json')
: path.join(os.homedir(), '.config', 'Code', 'User', 'settings.json');
}

async function writeSettings(settings: Settings): Promise<void> {
const filePath = getSettingsPath();
let existing: Settings = {};
try {
existing = JSON.parse(await fs.readFile(filePath, 'utf8'));
} catch { /* file may not exist yet */ }
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify({ ...existing, ...settings }, null, 2));
}
16 changes: 0 additions & 16 deletions src/resources/vscode/vscode-schema.json

This file was deleted.

Loading
Loading