diff --git a/CLAUDE.md b/CLAUDE.md index 8e5a506c..03793bda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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' } @@ -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 @@ -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 { + getSettings(): ParameterSetting { ... } + async refresh(desired, config): Promise { /* read current state */ } + async add(value, plan): Promise { /* apply from scratch */ } + async modify(newValue, previousValue, plan): Promise { /* diff and update */ } + async remove(value, plan): Promise { /* 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. diff --git a/completions-cron/src/__generated__/completions-index.ts b/completions-cron/src/__generated__/completions-index.ts index e9005b8d..26df34b8 100644 --- a/completions-cron/src/__generated__/completions-index.ts +++ b/completions-cron/src/__generated__/completions-index.ts @@ -1,21 +1,22 @@ // AUTO-GENERATED by scripts/generate-completions-index.ts - DO NOT EDIT // Re-run `npm run build:completions` to regenerate -import mod0 from '../../../src/resources/snap/completions/snap.install.js'; -import mod1 from '../../../src/resources/ruby/rbenv/completions/rbenv.rubyVersions.js'; -import mod2 from '../../../src/resources/python/uv/completions/uv.tools.js'; -import mod3 from '../../../src/resources/python/uv/completions/uv.pythonVersions.js'; -import mod4 from '../../../src/resources/python/pyenv/completions/pyenv.pythonVersions.js'; -import mod5 from '../../../src/resources/python/pip/completions/pip.install.js'; -import mod6 from '../../../src/resources/ollama/completions/ollama.models.js'; -import mod7 from '../../../src/resources/javascript/pnpm/completions/pnpm.globalEnvNodeVersion.js'; -import mod8 from '../../../src/resources/javascript/nvm/completions/nvm.nodeVersions.js'; -import mod9 from '../../../src/resources/javascript/npm/completions/npm.install.js'; -import mod10 from '../../../src/resources/homebrew/completions/homebrew.formulae.js'; -import mod11 from '../../../src/resources/homebrew/completions/homebrew.casks.js'; -import mod12 from '../../../src/resources/asdf/completions/asdf.plugins.js'; -import mod13 from '../../../src/resources/asdf/completions/asdf-plugin.plugin.js'; -import mod14 from '../../../src/resources/apt/completions/apt.install.js'; +import mod0 from '../../../src/resources/vscode/completions/vscode.extensions.js'; +import mod1 from '../../../src/resources/snap/completions/snap.install.js'; +import mod2 from '../../../src/resources/ruby/rbenv/completions/rbenv.rubyVersions.js'; +import mod3 from '../../../src/resources/python/uv/completions/uv.tools.js'; +import mod4 from '../../../src/resources/python/uv/completions/uv.pythonVersions.js'; +import mod5 from '../../../src/resources/python/pyenv/completions/pyenv.pythonVersions.js'; +import mod6 from '../../../src/resources/python/pip/completions/pip.install.js'; +import mod7 from '../../../src/resources/ollama/completions/ollama.models.js'; +import mod8 from '../../../src/resources/javascript/pnpm/completions/pnpm.globalEnvNodeVersion.js'; +import mod9 from '../../../src/resources/javascript/nvm/completions/nvm.nodeVersions.js'; +import mod10 from '../../../src/resources/javascript/npm/completions/npm.install.js'; +import mod11 from '../../../src/resources/homebrew/completions/homebrew.formulae.js'; +import mod12 from '../../../src/resources/homebrew/completions/homebrew.casks.js'; +import mod13 from '../../../src/resources/asdf/completions/asdf.plugins.js'; +import mod14 from '../../../src/resources/asdf/completions/asdf-plugin.plugin.js'; +import mod15 from '../../../src/resources/apt/completions/apt.install.js'; export interface CompletionModule { resourceType: string @@ -24,19 +25,20 @@ export interface CompletionModule { } export const completionModules: CompletionModule[] = [ - { resourceType: 'snap', parameterPath: '/install', fetch: mod0 }, - { resourceType: 'rbenv', parameterPath: '/rubyVersions', fetch: mod1 }, - { resourceType: 'uv', parameterPath: '/tools', fetch: mod2 }, - { resourceType: 'uv', parameterPath: '/pythonVersions', fetch: mod3 }, - { resourceType: 'pyenv', parameterPath: '/pythonVersions', fetch: mod4 }, - { resourceType: 'pip', parameterPath: '/install', fetch: mod5 }, - { resourceType: 'ollama', parameterPath: '/models', fetch: mod6 }, - { resourceType: 'pnpm', parameterPath: '/globalEnvNodeVersion', fetch: mod7 }, - { resourceType: 'nvm', parameterPath: '/nodeVersions', fetch: mod8 }, - { resourceType: 'npm', parameterPath: '/install', fetch: mod9 }, - { resourceType: 'homebrew', parameterPath: '/formulae', fetch: mod10 }, - { resourceType: 'homebrew', parameterPath: '/casks', fetch: mod11 }, - { resourceType: 'asdf', parameterPath: '/plugins', fetch: mod12 }, - { resourceType: 'asdf-plugin', parameterPath: '/plugin', fetch: mod13 }, - { resourceType: 'apt', parameterPath: '/install', fetch: mod14 }, + { resourceType: 'vscode', parameterPath: '/extensions', fetch: mod0 }, + { resourceType: 'snap', parameterPath: '/install', fetch: mod1 }, + { resourceType: 'rbenv', parameterPath: '/rubyVersions', fetch: mod2 }, + { resourceType: 'uv', parameterPath: '/tools', fetch: mod3 }, + { resourceType: 'uv', parameterPath: '/pythonVersions', fetch: mod4 }, + { resourceType: 'pyenv', parameterPath: '/pythonVersions', fetch: mod5 }, + { resourceType: 'pip', parameterPath: '/install', fetch: mod6 }, + { resourceType: 'ollama', parameterPath: '/models', fetch: mod7 }, + { resourceType: 'pnpm', parameterPath: '/globalEnvNodeVersion', fetch: mod8 }, + { resourceType: 'nvm', parameterPath: '/nodeVersions', fetch: mod9 }, + { resourceType: 'npm', parameterPath: '/install', fetch: mod10 }, + { resourceType: 'homebrew', parameterPath: '/formulae', fetch: mod11 }, + { resourceType: 'homebrew', parameterPath: '/casks', fetch: mod12 }, + { resourceType: 'asdf', parameterPath: '/plugins', fetch: mod13 }, + { resourceType: 'asdf-plugin', parameterPath: '/plugin', fetch: mod14 }, + { resourceType: 'apt', parameterPath: '/install', fetch: mod15 }, ] diff --git a/docs/resources/(resources)/vscode.mdx b/docs/resources/(resources)/vscode.mdx index a0beb76b..8b0d382c 100644 --- a/docs/resources/(resources)/vscode.mdx +++ b/docs/resources/(resources)/vscode.mdx @@ -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 + } } ] ``` diff --git a/src/resources/vscode/completions/vscode.extensions.ts b/src/resources/vscode/completions/vscode.extensions.ts new file mode 100644 index 00000000..fc545bdb --- /dev/null +++ b/src/resources/vscode/completions/vscode.extensions.ts @@ -0,0 +1,24 @@ +export default async function loadVscodeExtensions(): Promise { + 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 + ); +} diff --git a/src/resources/vscode/extensions-parameter.ts b/src/resources/vscode/extensions-parameter.ts new file mode 100644 index 00000000..81458784 --- /dev/null +++ b/src/resources/vscode/extensions-parameter.ts @@ -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 { + getSettings(): ArrayParameterSetting { + return { + type: 'array', + isElementEqual(desired, current) { + return desired.toLowerCase() === current.toLowerCase(); + }, + }; + } + + override async refresh(desired: string[] | null, config: Partial): Promise { + 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): Promise { + 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): Promise { + 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): Promise { + const $ = getPty(); + const code = getCodeBinary(plan.desiredConfig?.directory ?? plan.currentConfig?.directory); + for (const ext of valueToRemove) { + await $.spawnSafe(`"${code}" --uninstall-extension ${ext}`); + } + } +} diff --git a/src/resources/vscode/settings-parameter.ts b/src/resources/vscode/settings-parameter.ts new file mode 100644 index 00000000..65850fef --- /dev/null +++ b/src/resources/vscode/settings-parameter.ts @@ -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; + +export class SettingsParameter extends StatefulParameter { + getSettings(): ParameterSetting { + return { type: 'object' }; + } + + override async refresh(): Promise { + try { + const content = await fs.readFile(getSettingsPath(), 'utf8'); + return JSON.parse(content) as Settings; + } catch { + return null; + } + } + + async add(valueToAdd: Settings): Promise { + await writeSettings(valueToAdd); + } + + async modify(newValue: Settings, previousValue: Settings): Promise { + 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 { + 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 { + 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)); +} diff --git a/src/resources/vscode/vscode-schema.json b/src/resources/vscode/vscode-schema.json deleted file mode 100644 index 8ad60f3d..00000000 --- a/src/resources/vscode/vscode-schema.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "https://www.codifycli.com/vscode.json", - "$comment": "https://codifycli.com/docs/resources/vscode/", - "title": "Vscode resource", - "description": "Installs Vscode.", - "type": "object", - "properties": { - "directory": { - "type": "string", - "description": "The directory to install VSCode into. Defaults to /Applications.", - "default": "/Applications" - } - }, - "additionalProperties": false -} diff --git a/src/resources/vscode/vscode.ts b/src/resources/vscode/vscode.ts index 215d147d..4463a720 100644 --- a/src/resources/vscode/vscode.ts +++ b/src/resources/vscode/vscode.ts @@ -1,48 +1,108 @@ import { CreatePlan, DestroyPlan, + ExampleConfig, FileUtils, - getPty, Resource, ResourceSettings, - Utils + Utils, + getPty, + z, } from '@codifycli/plugin-core'; -import { OS, ResourceConfig } from '@codifycli/schemas'; +import { OS } from '@codifycli/schemas'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { SpawnStatus } from '../../utils/codify-spawn.js'; -import Schema from './vscode-schema.json'; +import { ExtensionsParameter } from './extensions-parameter.js'; +import { SettingsParameter } from './settings-parameter.js'; const VSCODE_APPLICATION_NAME = 'Visual Studio Code.app'; - const DOWNLOAD_URL = (platform: string) => `https://update.code.visualstudio.com/latest/${platform}/stable`; -export interface VscodeConfig extends ResourceConfig { - directory: string; +function getVscodeBinDir(directory: string): string { + return path.join(directory, VSCODE_APPLICATION_NAME, 'Contents', 'Resources', 'app', 'bin'); +} + +function getVscodePathExport(binDir: string): string { + return `export PATH="${binDir}:$PATH"`; } +const schema = z.object({ + directory: z + .string() + .describe('The directory to install VSCode into. Defaults to /Applications on macOS.') + .optional(), + extensions: z + .array(z.string()) + .describe('VS Code extensions to install, e.g. ["ms-python.python", "eamodio.gitlens"].') + .optional(), + settings: z + .record(z.string(), z.unknown()) + .describe('VS Code settings to merge into settings.json.') + .optional(), +}); + +export type VscodeConfig = z.infer; + +const defaultConfig: Partial = { + extensions: [], +}; + +const examplePython: ExampleConfig = { + title: 'Python development setup', + description: 'Install VS Code with Python, Pylance, and GitLens extensions and common editor settings.', + configs: [{ + type: 'vscode', + extensions: ['ms-python.python', 'ms-python.vscode-pylance', 'eamodio.gitlens'], + settings: { 'editor.fontSize': 14, 'editor.formatOnSave': true }, + }], +}; + +const exampleCustomEditor: ExampleConfig = { + title: 'VS Code with custom editor settings', + description: 'Install VS Code with Vim keybindings, GitHub Copilot, and a custom editor font.', + configs: [{ + type: 'vscode', + extensions: ['vscodevim.vim', 'github.copilot'], + settings: { + 'editor.fontFamily': 'JetBrains Mono', + 'editor.fontSize': 15, + 'editor.tabSize': 2, + 'editor.formatOnSave': true, + }, + }], +}; + export class VscodeResource extends Resource { getSettings(): ResourceSettings { return { id: 'vscode', operatingSystems: [OS.Darwin, OS.Linux], - schema: Schema, + schema, + defaultConfig, + exampleConfigs: { + example1: examplePython, + example2: exampleCustomEditor, + }, parameterSettings: { - directory: { type: 'directory', default: Utils.isMacOS() ? '/Applications' : path.join(os.homedir(), '.local', 'bin') } + directory: { + type: 'directory', + default: Utils.isMacOS() ? '/Applications' : path.join(os.homedir(), '.local', 'bin'), + }, + extensions: { type: 'stateful', definition: new ExtensionsParameter(), order: 1 }, + settings: { type: 'stateful', definition: new SettingsParameter(), order: 2 }, }, }; } override async refresh(parameters: Partial): Promise | null> { const directory = parameters.directory!; - const isInstalled = await this.isVscodeInstalled(directory); if (!isInstalled) { return null; } - return parameters; } @@ -61,10 +121,12 @@ export class VscodeResource extends Resource { const { directory } = plan.currentConfig; if (Utils.isMacOS()) { - const location = path.join(directory, `"${VSCODE_APPLICATION_NAME}"`); + const binDir = getVscodeBinDir(directory!); + await FileUtils.removeLineFromShellRc(getVscodePathExport(binDir)); + + const location = path.join(directory!, `"${VSCODE_APPLICATION_NAME}"`); await $.spawn(`rm -rf ${location}`); } else if (Utils.isLinux()) { - if (Utils.isDebianBased()) { await $.spawnSafe('apt-get remove code -y', { requiresRoot: true }); } else if (Utils.isRedhatBased()) { @@ -73,7 +135,6 @@ export class VscodeResource extends Resource { throw new Error('Unsupported Linux distribution. Only Debian-based (Ubuntu, Debian, Mint) and RedHat-based (RHEL, CentOS) systems are supported.'); } - // Remove user data and config await $.spawnSafe(`rm -rf ${path.join(os.homedir(), '.config/Code')}`); await $.spawnSafe(`rm -rf ${path.join(os.homedir(), '.vscode')}`); } else { @@ -92,7 +153,6 @@ export class VscodeResource extends Resource { } if (Utils.isLinux()) { - // Check if code command exists in PATH const $ = getPty(); const result = await $.spawnSafe('which code'); return result.status === SpawnStatus.SUCCESS; @@ -103,28 +163,26 @@ export class VscodeResource extends Resource { private async installMacOS(plan: CreatePlan): Promise { const $ = getPty(); - // Create a temporary tmp dir const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-')); try { - // Download vscode await $.spawn(`curl -H "user-agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36" -SL "${DOWNLOAD_URL('darwin-universal')}" -o vscode.zip`, { cwd: temporaryDir }); await $.spawn('unzip -q vscode.zip', { cwd: temporaryDir }); - // Move VSCode to the applications folder const { directory } = plan.desiredConfig; await $.spawn(`mv "${VSCODE_APPLICATION_NAME}" ${directory}`, { cwd: temporaryDir }); } finally { await $.spawn(`rm -rf ${temporaryDir}`); } + + // Add the VS Code CLI bin dir to PATH in the shell RC so `code` is available in new terminals. + // See: https://code.visualstudio.com/docs/setup/mac#_launching-from-the-command-line + const binDir = getVscodeBinDir(plan.desiredConfig.directory!); + await FileUtils.addToShellRc(getVscodePathExport(binDir)); } private async installLinux(_plan: CreatePlan): Promise { - console.log('Installing VSCode on Linux...'); - const $ = getPty(); - - // Detect distribution and architecture const isArm = await Utils.isArmArch(); if (Utils.isDebianBased()) { @@ -134,9 +192,9 @@ export class VscodeResource extends Resource { try { await FileUtils.downloadFile(downloadLink, vscodeDebPath); - + await $.spawn('apt-get update -y', { requiresRoot: true, env: { DEBIAN_FRONTEND: 'noninteractive' } }); await $.spawn('debconf-set-selections <<< "code code/add-microsoft-repo boolean true"', { requiresRoot: true }); - await $.spawn('apt-get install ./vscode.deb -y', { cwd: tmpDir, requiresRoot: true, env: { DEBIAN_FRONTEND: 'noninteractive', NEEDRESTART_MODE: 'a' } }); + await $.spawn('apt-get install ./vscode.deb -y --fix-missing', { cwd: tmpDir, requiresRoot: true, env: { DEBIAN_FRONTEND: 'noninteractive', NEEDRESTART_MODE: 'a' } }); } finally { await fs.rm(tmpDir, { recursive: true, force: true }); } @@ -153,4 +211,5 @@ export class VscodeResource extends Resource { throw new Error('Unsupported Linux distribution. Only Debian-based (Ubuntu, Debian, Mint) and RedHat-based (RHEL, CentOS) systems are supported.'); } + } diff --git a/test/vscode/vscode.test.ts b/test/vscode/vscode.test.ts index 15236c29..3d9b7f53 100644 --- a/test/vscode/vscode.test.ts +++ b/test/vscode/vscode.test.ts @@ -1,30 +1,90 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { PluginTester } from '@codifycli/plugin-test'; +import { describe, expect, it } from 'vitest'; +import { PluginTester, testSpawn } from '@codifycli/plugin-test'; import * as path from 'node:path'; import fs from 'node:fs/promises'; +import * as os from 'node:os'; import { Utils } from '@codifycli/plugin-core'; describe('Vscode integration tests', async () => { const pluginPath = path.resolve('./src/index.ts'); + // On macOS the code binary is inside the app bundle and not on PATH until a new shell is opened. + const codeBin = Utils.isMacOS() + ? '/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code' + : 'code'; + + const settingsFile = Utils.isMacOS() + ? path.join(os.homedir(), 'Library', 'Application Support', 'Code', 'User', 'settings.json') + : path.join(os.homedir(), '.config', 'Code', 'User', 'settings.json'); + it('Can install vscode', { timeout: 300000 }, async () => { await PluginTester.fullTest(pluginPath, [{ type: 'vscode', - directory: '/Applications' + directory: '/Applications', }], { validateApply: async () => { if (Utils.isMacOS()) { - const programPath = '/Applications/Visual Studio Code.app' - const lstat = await fs.lstat(programPath); + const lstat = await fs.lstat('/Applications/Visual Studio Code.app'); expect(lstat.isDirectory()).to.be.true; } }, validateDestroy: async () => { if (Utils.isMacOS()) { - const programPath = '/Applications/Visual Studio Code.app' - expect(async () => await fs.lstat(programPath)).to.throw; + expect(async () => await fs.lstat('/Applications/Visual Studio Code.app')).to.throw; } - } + }, + }); + }); + + it('Can manage extensions', { timeout: 300000 }, async () => { + await PluginTester.fullTest(pluginPath, [{ + type: 'vscode', + extensions: ['ms-python.python'], + }], { + validateApply: async () => { + const { data } = await testSpawn(`"${codeBin}" --list-extensions`); + expect(data?.toLowerCase()).to.include('ms-python.python'); + }, + testModify: { + modifiedConfigs: [{ + type: 'vscode', + extensions: ['ms-python.python', 'eamodio.gitlens'], + }], + validateModify: async () => { + const { data } = await testSpawn(`"${codeBin}" --list-extensions`); + expect(data?.toLowerCase()).to.include('ms-python.python'); + expect(data?.toLowerCase()).to.include('eamodio.gitlens'); + }, + }, + validateDestroy: async () => { + const { data } = await testSpawn(`"${codeBin}" --list-extensions`); + expect(data?.toLowerCase()).not.to.include('eamodio.gitlens'); + }, + }); + }); + + it('Can manage settings', { timeout: 300000 }, async () => { + await PluginTester.fullTest(pluginPath, [{ + type: 'vscode', + settings: { 'editor.fontSize': 14, 'editor.formatOnSave': true }, + }], { + validateApply: async () => { + const { data } = await testSpawn(`cat "${settingsFile}"`); + const content = JSON.parse(data!); + expect(content['editor.fontSize']).to.equal(14); + expect(content['editor.formatOnSave']).to.be.true; + }, + testModify: { + modifiedConfigs: [{ + type: 'vscode', + settings: { 'editor.fontSize': 16, 'editor.formatOnSave': true }, + }], + validateModify: async () => { + const { data } = await testSpawn(`cat "${settingsFile}"`); + const content = JSON.parse(data!); + expect(content['editor.fontSize']).to.equal(16); + }, + }, }); - }) -}) + }); +});