diff --git a/docs/resources/(resources)/env-file.mdx b/docs/resources/(resources)/env-file.mdx new file mode 100644 index 00000000..8a9e676c --- /dev/null +++ b/docs/resources/(resources)/env-file.mdx @@ -0,0 +1,66 @@ +--- +title: env-file +description: A reference page for the env-file resource +--- + +The env-file resource manages a single `.env` file at a specified directory. It supports writing key-value pairs directly or syncing the file from Codify cloud. The resource is marked sensitive and will not be automatically imported. + +## Parameters + +- **dir** *(string, required)*: The directory where the env file is located. +- **name** *(string, optional)*: The filename. Defaults to `.env`. Use this to manage files like `.env.local`, `.dev.vars`, or `.env.production`. +- **contents** *(array[object], optional)*: Key-value pairs to write into the file. Each entry has `key` (string) and `value` (string). Mutually exclusive with `remoteFile`. +- **remoteFile** *(string, optional)*: A Codify cloud file reference (`codify://:`). The file is downloaded and written on each apply. Mutually exclusive with `contents`. + +## Example usage + +### Declare env file contents + +```json title="codify.jsonc" +[ + { + "type": "env-file", + "dir": "~/projects/my-app", + "contents": [ + { "key": "DATABASE_URL", "value": "postgres://localhost:5432/mydb" }, + { "key": "API_KEY", "value": "" }, + { "key": "DEBUG", "value": "false" } + ] + } +] +``` + +### Use a named env file + +```json title="codify.jsonc" +[ + { + "type": "env-file", + "dir": "~/projects/my-worker", + "name": ".dev.vars", + "contents": [ + { "key": "API_TOKEN", "value": "" } + ] + } +] +``` + +### Sync from Codify cloud + +```json title="codify.jsonc" +[ + { + "type": "env-file", + "dir": "~/projects/my-app", + "name": ".env.local", + "remoteFile": "codify://:" + } +] +``` + +## Notes + +- The resource is marked as **sensitive**: values in `contents` are not shown in plan output. +- Import is disabled (`preventImport: true`) — existing `.env` files on the system are never automatically imported. +- Both `contents` and `remoteFile` cannot be specified at the same time. +- Multiple `env-file` resources can coexist in the same directory as long as they use different `name` values. diff --git a/docs/resources/(resources)/env-files.mdx b/docs/resources/(resources)/env-files.mdx new file mode 100644 index 00000000..75e06941 --- /dev/null +++ b/docs/resources/(resources)/env-files.mdx @@ -0,0 +1,72 @@ +--- +title: env-files +description: A reference page for the env-files resource +--- + +The env-files resource manages multiple `.env` files inside a single directory. It is useful for projects that rely on several env files at once (e.g. `.env`, `.env.local`, `.dev.vars`). The resource is marked sensitive and will not be automatically imported. + +## Parameters + +- **dir** *(string, required)*: The directory containing the env files. +- **envFiles** *(array[object], required)*: The env files to manage. Each entry has: + - **name** *(string, required)*: The filename (e.g. `.env`, `.env.local`, `.dev.vars`). + - **contents** *(array[object], optional)*: Key-value pairs written into the file. Each entry has `key` and `value` strings. Mutually exclusive with `remoteFile`. + - **remoteFile** *(string, optional)*: A Codify cloud file reference (`codify://:`). Mutually exclusive with `contents`. + +## Example usage + +### Manage multiple Cloudflare Worker env files + +```json title="codify.jsonc" +[ + { + "type": "env-files", + "dir": "~/projects/my-worker", + "envFiles": [ + { + "name": ".dev.vars", + "contents": [ + { "key": "API_TOKEN", "value": "" }, + { "key": "ENVIRONMENT", "value": "development" } + ] + }, + { + "name": ".env.production", + "contents": [ + { "key": "API_TOKEN", "value": "" }, + { "key": "ENVIRONMENT", "value": "production" } + ] + } + ] + } +] +``` + +### Sync multiple env files from Codify cloud + +```json title="codify.jsonc" +[ + { + "type": "env-files", + "dir": "~/projects/my-app", + "envFiles": [ + { + "name": ".env", + "remoteFile": "codify://:" + }, + { + "name": ".env.local", + "remoteFile": "codify://:" + } + ] + } +] +``` + +## Notes + +- The resource is marked as **sensitive**: values in `contents` are not shown in plan output. +- Import is disabled (`preventImport: true`) — existing env files on the system are never automatically imported. +- Only one `env-files` resource per directory is allowed. Use multiple `env-file` resources if you prefer managing files individually. +- Within each `envFiles` entry, `contents` and `remoteFile` cannot both be specified. +- When an entry is removed from `envFiles` during modify, the corresponding file is deleted from disk. diff --git a/docs/resources/(resources)/macos-settings.mdx b/docs/resources/(resources)/macos-settings.mdx index 0d3dc0c5..6b2f6d25 100644 --- a/docs/resources/(resources)/macos-settings.mdx +++ b/docs/resources/(resources)/macos-settings.mdx @@ -40,7 +40,8 @@ All sections and their sub-keys are optional. You only need to declare the setti | `position` | `"left"` \| `"bottom"` \| `"right"` | `"bottom"` | Position of the Dock on screen. | | `iconSize` | integer (16–128) | `48` | Dock icon size in pixels. | | `autohide` | boolean | `false` | Automatically hide and show the Dock when the cursor moves near the screen edge. | -| `autohideDelay` | number | `0.2` | Seconds to wait before showing the Dock when it is hidden. Set to `0` for instant reveal. | +| `hoverDelay` | number | `0.2` | Seconds to wait before the Dock appears when hovering near the screen edge. Set to `0` for instant reveal. | +| `animationSpeed` | number | `0.5` | Duration in seconds of the Dock slide-in/out animation. Set to `0` to disable the animation entirely. | | `showRecents` | boolean | `true` | Show recently opened apps in a dedicated section of the Dock. | | `minimizeEffect` | `"genie"` \| `"scale"` \| `"suck"` | `"genie"` | Window minimize animation style. | @@ -62,7 +63,8 @@ The table below shows the underlying `defaults` key used for each friendly param | dock | `position` | `com.apple.dock` | `orientation` | | dock | `iconSize` | `com.apple.dock` | `tilesize` | | dock | `autohide` | `com.apple.dock` | `autohide` | -| dock | `autohideDelay` | `com.apple.dock` | `autohide-delay` | +| dock | `hoverDelay` | `com.apple.dock` | `autohide-delay` | +| dock | `animationSpeed` | `com.apple.dock` | `autohide-time-modifier` | | dock | `showRecents` | `com.apple.dock` | `show-recents` | | dock | `minimizeEffect` | `com.apple.dock` | `mineffect` | diff --git a/package-lock.json b/package-lock.json index 1208c797..64af52e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "default", - "version": "1.5.0-beta.2", + "version": "1.6.0-beta.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "default", - "version": "1.5.0-beta.2", + "version": "1.6.0-beta.7", "license": "ISC", "dependencies": { - "@codifycli/plugin-core": "1.2.0", + "@codifycli/plugin-core": "1.2.2", "@codifycli/schemas": "1.2.0", "ajv": "^8.18.0", "ajv-formats": "^2.1.1", @@ -171,9 +171,9 @@ } }, "node_modules/@codifycli/plugin-core": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@codifycli/plugin-core/-/plugin-core-1.2.0.tgz", - "integrity": "sha512-GvGRSZ1xtwF5TiiauV/VUGNnJPQ6TUhtGZfXqnIwCozdPgTFp3AYH49q7Pbd7AYAG+5pnFUa9J4yO6WNUfDeWA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@codifycli/plugin-core/-/plugin-core-1.2.2.tgz", + "integrity": "sha512-DwTWkdwU7bd21g5IMi3xkdsT3zXomm/4g+FjSV/S92qYG0ywasCKvqmDAAs6Bg7YImuYhJxrJICwbpx7zzLfvQ==", "license": "ISC", "dependencies": { "@codifycli/schemas": "^1.2.0", diff --git a/package.json b/package.json index 20423813..deb16045 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.5.0", + "version": "1.6.0", "description": "Default plugin for Codify - provides 50+ declarative resources for managing development tools and system configuration across macOS and Linux", "main": "dist/index.js", "scripts": { @@ -41,7 +41,7 @@ "license": "ISC", "type": "module", "dependencies": { - "@codifycli/plugin-core": "1.2.0", + "@codifycli/plugin-core": "1.2.2", "@codifycli/schemas": "1.2.0", "ajv": "^8.18.0", "ajv-formats": "^2.1.1", diff --git a/src/index.ts b/src/index.ts index a1da540d..b0145bc8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ import { AwsProfileResource } from './resources/aws-cli/profile/aws-profile.js'; import { DnfResource } from './resources/dnf/dnf.js'; import { GoenvResource } from './resources/go/goenv/goenv.js'; import { DockerResource } from './resources/docker/docker.js'; +import { EnvFileResource } from './resources/file/env-file/env-file-resource.js'; +import { EnvFilesResource } from './resources/file/env-file/env-files-resource.js'; import { FileResource } from './resources/file/file.js'; import { RemoteFileResource } from './resources/file/remote-file.js'; import { GitResource } from './resources/git/git/git-resource.js'; @@ -98,6 +100,8 @@ runPlugin(Plugin.create( new ActionResource(), new FileResource(), new RemoteFileResource(), + new EnvFileResource(), + new EnvFilesResource(), new Virtualenv(), new VirtualenvProject(), new Pnpm(), diff --git a/src/resources/file/env-file/env-file-resource.ts b/src/resources/file/env-file/env-file-resource.ts new file mode 100644 index 00000000..d3cd49d8 --- /dev/null +++ b/src/resources/file/env-file/env-file-resource.ts @@ -0,0 +1,176 @@ +import { + CreatePlan, + DestroyPlan, + ExampleConfig, + ModifyPlan, + ParameterChange, + Resource, + ResourceSettings, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import { createHash } from 'node:crypto'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { FileUtils } from '../../../utils/file-utils.js'; +import { + EnvEntry, + extractCodifyFileInfo, + fetchRemoteCodifyFileHash, + isRemoteCodifyFile, + parseEnvFile, + serializeEnvFile, + writeRemoteCodifyFile, +} from './env-file-utils.js'; + +const schema = z + .object({ + dir: z.string().describe('The directory where the env file is located.'), + filename: z.string().optional().describe('The name of the env file. Defaults to .env.'), + contents: z + .array( + z.object({ + key: z.string().describe('The environment variable key (conventionally UPPER_SNAKE_CASE).'), + value: z.string().describe('The environment variable value.'), + }), + ) + .optional() + .describe('Key-value pairs to write into the env file.'), + remoteFile: z + .string() + .optional() + .describe('Codify remote file reference (codify://:).'), + hash: z.string().optional(), + }) + .refine((d) => !(d.contents !== undefined && d.remoteFile !== undefined), { + message: 'Only one of contents or remoteFile may be specified.', + }); + +export type EnvFileConfig = z.infer; + +const defaultConfig: Partial = { + dir: '', + filename: '.env', + contents: [], +}; + +const exampleContents: ExampleConfig = { + title: 'Manage a project .env file', + description: 'Declare the key-value pairs for a project .env file so they stay consistent across machines.', + configs: [ + { + type: 'env-file', + dir: '~/projects/my-app', + contents: [ + { key: 'DATABASE_URL', value: 'postgres://localhost:5432/mydb' }, + { key: 'API_KEY', value: '' }, + { key: 'DEBUG', value: 'false' }, + ], + }, + ], +}; + +const exampleRemote: ExampleConfig = { + title: 'Sync a .env file from Codify cloud', + description: 'Pull a .env file stored securely in Codify cloud and write it to a project directory, keeping it in sync on every apply.', + configs: [ + { + type: 'env-file', + dir: '~/projects/my-app', + filename: '.env.local', + remoteFile: 'codify://:', + }, + ], +}; + +export class EnvFileResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'env-file', + defaultConfig, + exampleConfigs: { + example1: exampleContents, + example2: exampleRemote, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + parameterSettings: { + dir: { type: 'directory' }, + filename: { default: '.env' }, + contents: { + type: 'array', + itemType: 'object', + canModify: true, + isSensitive: true, + isElementEqual: (a: EnvEntry, b: EnvEntry) => a.key === b.key && a.value === b.value, + }, + remoteFile: { type: 'string', canModify: true }, + hash: { type: 'string', canModify: true }, + }, + allowMultiple: { + identifyingParameters: ['dir', 'filename'], + }, + isSensitive: true, + transformation: { + to: async (input: Partial) => { + if (input.remoteFile && isRemoteCodifyFile(input.remoteFile)) { + const { documentId, fileId } = extractCodifyFileInfo(input.remoteFile); + const hash = await fetchRemoteCodifyFileHash(documentId, fileId); + return { ...input, hash: hash ?? undefined }; + } + return input; + }, + from: async (input: Partial) => input, + }, + }; + } + + async refresh(parameters: Partial): Promise | null> { + const filePath = this.resolveFilePath(parameters.dir!, parameters.filename); + + if (!(await FileUtils.fileExists(filePath))) { + return null; + } + + const content = await fs.readFile(filePath, 'utf8'); + + if (parameters.remoteFile) { + const hash = createHash('md5').update(content).digest('hex'); + return { dir: parameters.dir, filename: parameters.filename, remoteFile: parameters.remoteFile, hash }; + } + + const contents = parseEnvFile(content); + return { dir: parameters.dir, filename: parameters.filename, contents }; + } + + async create(plan: CreatePlan): Promise { + const { dir, filename, contents, remoteFile } = plan.desiredConfig; + const resolvedDir = path.resolve(dir); + const filePath = path.join(resolvedDir, filename ?? '.env'); + + if (!(await FileUtils.dirExists(resolvedDir))) { + await fs.mkdir(resolvedDir, { recursive: true }); + } + + if (remoteFile) { + const { documentId, fileId } = extractCodifyFileInfo(remoteFile); + await writeRemoteCodifyFile(documentId, fileId, filePath); + } else { + await fs.writeFile(filePath, serializeEnvFile(contents ?? []), 'utf8'); + } + } + + async modify(pc: ParameterChange, plan: ModifyPlan): Promise { + return this.create(plan as unknown as CreatePlan); + } + + async destroy(plan: DestroyPlan): Promise { + const filePath = this.resolveFilePath(plan.currentConfig.dir, plan.currentConfig.filename); + await fs.rm(filePath); + } + + private resolveFilePath(dir: string, filename?: string): string { + return path.join(path.resolve(dir), filename ?? '.env'); + } +} diff --git a/src/resources/file/env-file/env-file-utils.ts b/src/resources/file/env-file/env-file-utils.ts new file mode 100644 index 00000000..3f30e259 --- /dev/null +++ b/src/resources/file/env-file/env-file-utils.ts @@ -0,0 +1,65 @@ +import { CodifyCliSender } from '@codifycli/plugin-core'; +import * as fsSync from 'node:fs'; +import fs from 'node:fs/promises'; +import { Readable } from 'node:stream'; +import { finished } from 'node:stream/promises'; + +export interface EnvEntry { + key: string; + value: string; +} + +const ENV_PARSE_REGEX = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(["']?)(.+?)\2\s*$/; + +export function parseEnvFile(content: string): EnvEntry[] { + const results: EnvEntry[] = []; + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + const match = ENV_PARSE_REGEX.exec(line); + if (match) { + results.push({ key: match[1], value: match[3] }); + } + } + return results; +} + +export function serializeEnvFile(entries: EnvEntry[]): string { + return entries.map(({ key, value }) => `${key}="${value}"`).join('\n') + '\n'; +} + +export function isRemoteCodifyFile(url: string): boolean { + return url?.startsWith('codify://'); +} + +export function extractCodifyFileInfo(url: string): { documentId: string; fileId: string } { + const match = /codify:\/\/([^:]+):(.+)/.exec(url); + if (!match) { + throw new Error(`Invalid codify remote file URL: ${url}`); + } + return { documentId: match[1], fileId: match[2] }; +} + +export async function fetchRemoteCodifyFileHash(documentId: string, fileId: string): Promise { + const credentials = await CodifyCliSender.getCodifyCliCredentials(); + const response = await fetch( + `https://api.codifycli.com/v1/documents/${documentId}/file/${fileId}/hash`, + { method: 'GET', headers: { Authorization: `Bearer ${credentials}` } }, + ); + if (!response.ok) return null; + const data: any = await response.json(); + return data.hash ?? null; +} + +export async function writeRemoteCodifyFile(documentId: string, fileId: string, destPath: string): Promise { + const credentials = await CodifyCliSender.getCodifyCliCredentials(); + const response = await fetch( + `https://api.codifycli.com/v1/documents/${documentId}/file/${fileId}`, + { method: 'GET', headers: { Authorization: `Bearer ${credentials}` } }, + ); + if (!response.ok) { + throw new Error(`Failed to fetch remote file codify://${documentId}:${fileId}: ${await response.text()}`); + } + const fileStream = fsSync.createWriteStream(destPath, { flags: 'w' }); + await finished(Readable.fromWeb(response.body as any).pipe(fileStream)); +} diff --git a/src/resources/file/env-file/env-files-resource.ts b/src/resources/file/env-file/env-files-resource.ts new file mode 100644 index 00000000..a6d47376 --- /dev/null +++ b/src/resources/file/env-file/env-files-resource.ts @@ -0,0 +1,255 @@ +import { + CreatePlan, + DestroyPlan, + ExampleConfig, + ModifyPlan, + ParameterChange, + Resource, + ResourceSettings, + z, +} from '@codifycli/plugin-core'; +import { OS } from '@codifycli/schemas'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import { FileUtils } from '../../../utils/file-utils.js'; +import { + extractCodifyFileInfo, + isRemoteCodifyFile, + parseEnvFile, + serializeEnvFile, + writeRemoteCodifyFile, +} from './env-file-utils.js'; + +const envFileEntrySchema = z + .object({ + name: z.string().describe('The name of the env file (e.g. .env, .env.local, .dev.vars).'), + contents: z + .array( + z.object({ + key: z.string().describe('The environment variable key (conventionally UPPER_SNAKE_CASE).'), + value: z.string().describe('The environment variable value.'), + }), + ) + .optional() + .describe('Key-value pairs to write into the env file.'), + remoteFile: z + .string() + .optional() + .describe('Codify remote file reference (codify://:).'), + }) + .refine((d) => !(d.contents !== undefined && d.remoteFile !== undefined), { + message: 'Only one of contents or remoteFile may be specified.', + }); + +export type EnvFileEntry = z.infer; + +const schema = z.object({ + dir: z.string().describe('The directory containing the env files.'), + envFiles: z + .array(envFileEntrySchema) + .describe('The env files to manage in the specified directory.'), +}); + +export type EnvFilesConfig = z.infer; + +const defaultConfig: Partial = { + dir: '', + envFiles: [], +}; + +const exampleCloudflare: ExampleConfig = { + title: 'Manage Cloudflare Worker env files', + description: 'Keep all environment files for a Cloudflare Workers project — development vars, local overrides, and production values — in sync across machines.', + configs: [ + { + type: 'env-files', + dir: '~/projects/my-worker', + envFiles: [ + { + name: '.dev.vars', + contents: [ + { key: 'API_TOKEN', value: '' }, + { key: 'ENVIRONMENT', value: 'development' }, + ], + }, + { + name: '.env.production', + contents: [ + { key: 'API_TOKEN', value: '' }, + { key: 'ENVIRONMENT', value: 'production' }, + ], + }, + ], + }, + ], +}; + +const exampleRemote: ExampleConfig = { + title: 'Sync multiple env files from Codify cloud', + description: 'Pull several env files stored securely in Codify cloud and write them to a project directory so secrets are shared consistently across team members.', + configs: [ + { + type: 'env-files', + dir: '~/projects/my-app', + envFiles: [ + { + name: '.env', + remoteFile: 'codify://:', + }, + { + name: '.env.local', + remoteFile: 'codify://:', + }, + ], + }, + ], +}; + +export class EnvFilesResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'env-files', + defaultConfig, + exampleConfigs: { + example1: exampleCloudflare, + example2: exampleRemote, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + parameterSettings: { + dir: { type: 'directory' }, + envFiles: { + type: 'array', + itemType: 'object', + canModify: true, + isSensitive: true, + isElementEqual: (a: EnvFileEntry, b: EnvFileEntry) => + a.name === b.name && + a.remoteFile === b.remoteFile && + JSON.stringify(a.contents) === JSON.stringify(b.contents), + filterInStatelessMode: (desired: EnvFileEntry[], current: EnvFileEntry[]) => + current.filter((c) => desired.some((d) => d.name === c.name)), + }, + }, + isSensitive: true, + allowMultiple: { + identifyingParameters: ['dir'], + }, + }; + } + + async refresh(parameters: Partial): Promise | null> { + const { dir, envFiles } = parameters; + if (!dir) return null; + + const resolvedDir = path.resolve(dir); + + if (!envFiles?.length) { + return this.refreshFromDir(dir, resolvedDir); + } + + const result: EnvFileEntry[] = []; + + for (const entry of envFiles) { + const filePath = path.join(resolvedDir, entry.name); + if (!(await FileUtils.fileExists(filePath))) continue; + + const content = await fs.readFile(filePath, 'utf8'); + + if (entry.remoteFile) { + result.push({ name: entry.name, remoteFile: entry.remoteFile }); + } else { + result.push({ name: entry.name, contents: parseEnvFile(content) }); + } + } + + if (result.length === 0) return null; + return { dir, envFiles: result }; + } + + private async refreshFromDir(dir: string, resolvedDir: string): Promise | null> { + if (!(await FileUtils.dirExists(resolvedDir))) return null; + + const allFiles = await fs.readdir(resolvedDir); + const envFileNames = allFiles.filter((f) => /^\.env(\..+)?$/.test(f) || f === '.dev.vars'); + + const result: EnvFileEntry[] = []; + + for (const name of envFileNames) { + const filePath = path.join(resolvedDir, name); + const content = await fs.readFile(filePath, 'utf8'); + result.push({ name, contents: parseEnvFile(content) }); + } + + if (result.length === 0) return null; + return { dir, envFiles: result }; + } + + async create(plan: CreatePlan): Promise { + const resolvedDir = path.resolve(plan.desiredConfig.dir); + + if (!(await FileUtils.dirExists(resolvedDir))) { + await fs.mkdir(resolvedDir, { recursive: true }); + } + + for (const entry of plan.desiredConfig.envFiles) { + await this.writeEntry(resolvedDir, entry); + } + } + + async modify(pc: ParameterChange, plan: ModifyPlan): Promise { + if (pc.name !== 'envFiles') return; + + const resolvedDir = path.resolve(plan.desiredConfig.dir); + + const toRemove = (pc.previousValue as EnvFileEntry[])?.filter( + (p) => !(pc.newValue as EnvFileEntry[])?.some((n) => n.name === p.name), + ); + + const toWrite = (pc.newValue as EnvFileEntry[])?.filter((n) => { + const prev = (pc.previousValue as EnvFileEntry[])?.find((p) => p.name === n.name); + if (!prev) return true; + return ( + prev.remoteFile !== n.remoteFile || + JSON.stringify(prev.contents) !== JSON.stringify(n.contents) + ); + }); + + for (const entry of toRemove ?? []) { + const filePath = path.join(resolvedDir, entry.name); + if (await FileUtils.fileExists(filePath)) { + await fs.rm(filePath); + } + } + + for (const entry of toWrite ?? []) { + await this.writeEntry(resolvedDir, entry); + } + } + + async destroy(plan: DestroyPlan): Promise { + const resolvedDir = path.resolve(plan.currentConfig.dir); + + for (const entry of plan.currentConfig.envFiles) { + const filePath = path.join(resolvedDir, entry.name); + if (await FileUtils.fileExists(filePath)) { + await fs.rm(filePath); + } + } + } + + private async writeEntry(resolvedDir: string, entry: EnvFileEntry): Promise { + const filePath = path.join(resolvedDir, entry.name); + + if (entry.remoteFile) { + if (!isRemoteCodifyFile(entry.remoteFile)) { + throw new Error(`Invalid remote file URL: ${entry.remoteFile}`); + } + const { documentId, fileId } = extractCodifyFileInfo(entry.remoteFile); + await writeRemoteCodifyFile(documentId, fileId, filePath); + } else { + await fs.writeFile(filePath, serializeEnvFile(entry.contents ?? []), 'utf8'); + } + } +} diff --git a/src/resources/macos/macos-settings/macos-settings-resource.ts b/src/resources/macos/macos-settings/macos-settings-resource.ts index ceab9339..23492989 100644 --- a/src/resources/macos/macos-settings/macos-settings-resource.ts +++ b/src/resources/macos/macos-settings/macos-settings-resource.ts @@ -1,52 +1,73 @@ +import isEqual from 'lodash.isequal'; + import { - CreatePlan, - DestroyPlan, + ApplyNotes, + CodifyCliSender, ExampleConfig, - ModifyPlan, - ParameterChange, + Plan, Resource, ResourceSettings, SpawnStatus, + StatefulParameter, getPty, z, - CodifyCliSender, - ApplyNotes, } from '@codifycli/plugin-core'; import { OS } from '@codifycli/schemas'; const mouseSchema = z.object({ - naturalScrolling: z.boolean().optional(), - acceleration: z.boolean().optional(), - speed: z.number().min(0).max(3).optional(), -}).optional(); + naturalScrolling: z.boolean().optional() + .describe('Scroll content in the natural direction (content follows finger). When false, uses the traditional scroll direction.'), + acceleration: z.boolean().optional() + .describe('Enable mouse acceleration. When false, the cursor moves at a fixed speed regardless of how fast the mouse is moved.'), + speed: z.number().min(0).max(3).optional() + .describe('Mouse tracking speed (0–3). Higher values make the cursor move farther per physical movement.'), +}).optional() + .describe('Mouse settings.'); const keyboardSchema = z.object({ - keyRepeat: z.number().int().min(1).optional(), - initialKeyRepeat: z.number().int().min(10).optional(), - pressAndHold: z.boolean().optional(), - fnKeysAsStandardKeys: z.boolean().optional(), - keyboardNavigation: z.boolean().optional(), -}).optional(); + keyRepeat: z.number().int().min(1).optional() + .describe('Rate of key repeat while a key is held. Lower = faster (1 is fastest; 120 effectively disables repeat).'), + initialKeyRepeat: z.number().int().min(10).optional() + .describe('Delay before key repeat begins, in ticks. Lower = shorter delay (10 minimum).'), + pressAndHold: z.boolean().optional() + .describe('When true, holding a key shows the accent character picker. When false, the key repeats instead.'), + fnKeysAsStandardKeys: z.boolean().optional() + .describe('When true, the F1–F12 keys act as standard function keys; press Fn to trigger special actions (brightness, volume, etc.).'), + keyboardNavigation: z.boolean().optional() + .describe('When true, enables Tab-based focus navigation in system dialogs (equivalent to "Keyboard navigation" in System Settings).'), +}).optional() + .describe('Keyboard settings.'); const trackpadSchema = z.object({ - speed: z.number().min(0).max(3).optional(), -}).optional(); + speed: z.number().min(0).max(3).optional() + .describe('Trackpad tracking speed (0–3). Higher values make the cursor move farther per swipe distance.'), +}).optional() + .describe('Trackpad settings.'); const dockSchema = z.object({ - position: z.enum(['left', 'bottom', 'right']).optional(), - iconSize: z.number().int().min(16).max(128).optional(), - autohide: z.boolean().optional(), - autohideDelay: z.number().min(0).optional(), - showRecents: z.boolean().optional(), - minimizeEffect: z.enum(['genie', 'scale', 'suck']).optional(), -}).optional(); + position: z.enum(['left', 'bottom', 'right']).optional() + .describe('Position of the Dock on screen.'), + iconSize: z.number().int().min(16).max(128).optional() + .describe('Dock icon size in pixels (16–128).'), + autohide: z.boolean().optional() + .describe('Automatically hide and show the Dock when the cursor moves near the screen edge.'), + hoverDelay: z.number().min(0).optional() + .describe('Seconds to wait before the Dock appears when hovering near the screen edge. Set to 0 for instant reveal. Default is 0.2.'), + animationSpeed: z.number().min(0).optional() + .describe('Duration in seconds of the Dock slide-in/out animation. Set to 0 to disable the animation entirely. Default is 0.5.'), + showRecents: z.boolean().optional() + .describe('Show recently opened apps in a dedicated section of the Dock.'), + minimizeEffect: z.enum(['genie', 'scale', 'suck']).optional() + .describe('Window minimize animation style.'), +}).optional() + .describe('Dock settings.'); export const schema = z.object({ mouse: mouseSchema, keyboard: keyboardSchema, trackpad: trackpadSchema, dock: dockSchema, -}); +}).describe('Manages common macOS system preferences using the built-in defaults command. Covers mouse, keyboard, trackpad, and Dock settings.'); export type MacosSettingsConfig = z.infer; type MouseConfig = NonNullable; @@ -54,240 +75,156 @@ type KeyboardConfig = NonNullable; type TrackpadConfig = NonNullable; type DockConfig = NonNullable; -const defaultConfig: Partial = { - mouse: {}, - keyboard: {}, - dock: {}, -}; - -const exampleCommonPrefs: ExampleConfig = { - title: 'Common macOS preferences', - description: 'Configure natural scrolling, fast key repeat, and a minimal Dock for a consistent setup on any new Mac.', - configs: [{ - type: 'macos-settings', - os: ['macOS'], - mouse: { - naturalScrolling: true, - }, - keyboard: { - keyRepeat: 2, - initialKeyRepeat: 15, - pressAndHold: false, - }, - dock: { - position: 'left', - iconSize: 36, - autohide: true, - showRecents: false, - }, - }], -}; - -const exampleNonAppleKeyboard: ExampleConfig = { - title: 'Non-Apple keyboard setup', - description: 'Disable natural scrolling and enable standard function keys for a non-Apple keyboard or mouse.', - configs: [{ - type: 'macos-settings', - os: ['macOS'], - mouse: { - naturalScrolling: false, - acceleration: false, - }, - keyboard: { - fnKeysAsStandardKeys: true, - }, - }], -}; - -export class MacosSettingsResource extends Resource { - getSettings(): ResourceSettings { - return { - id: 'macos-settings', - operatingSystems: [OS.Darwin], - schema, - defaultConfig, - exampleConfigs: { - example1: exampleCommonPrefs, - example2: exampleNonAppleKeyboard, - }, - parameterSettings: { - mouse: { type: 'object', canModify: true }, - keyboard: { type: 'object', canModify: true }, - trackpad: { type: 'object', canModify: true }, - dock: { type: 'object', canModify: true }, - }, - }; - } - - override async refresh(parameters: Partial): Promise | null> { - const result: Partial = {}; - let anyFound = false; - - if (parameters.mouse) { - const mouse = await this.readMouseSettings(parameters.mouse); - if (mouse !== null) { result.mouse = mouse; anyFound = true; } - } - if (parameters.keyboard) { - const keyboard = await this.readKeyboardSettings(parameters.keyboard); - if (keyboard !== null) { result.keyboard = keyboard; anyFound = true; } - } - if (parameters.trackpad) { - const trackpad = await this.readTrackpadSettings(parameters.trackpad); - if (trackpad !== null) { result.trackpad = trackpad; anyFound = true; } - } - if (parameters.dock) { - const dock = await this.readDockSettings(parameters.dock); - if (dock !== null) { result.dock = dock; anyFound = true; } - } - - return anyFound ? result : null; - } - - override async create(plan: CreatePlan): Promise { - const { desiredConfig } = plan; - - if (desiredConfig.mouse) { - await this.applyMouseSettings(desiredConfig.mouse); - } - if (desiredConfig.keyboard) { - await this.applyKeyboardSettings(desiredConfig.keyboard); - } - if (desiredConfig.trackpad) { - await this.applyTrackpadSettings(desiredConfig.trackpad); - } - if (desiredConfig.dock) { - await this.applyDockSettings(desiredConfig.dock); - } - } +// ---- Low-level defaults read helpers ---- - override async modify(pc: ParameterChange, plan: ModifyPlan): Promise { - const { desiredConfig } = plan; +async function readBool(domain: string, key: string): Promise { + const $ = getPty(); + const { data, status } = await $.spawnSafe(`defaults read "${domain}" "${key}"`); + if (status === SpawnStatus.ERROR) return null; + const val = data.trim(); + return val === '1' || val === 'true' || val === 'YES'; +} - if (pc.name === 'mouse' && desiredConfig.mouse) { - await this.applyMouseSettings(desiredConfig.mouse); - } else if (pc.name === 'keyboard' && desiredConfig.keyboard) { - await this.applyKeyboardSettings(desiredConfig.keyboard); - } else if (pc.name === 'trackpad' && desiredConfig.trackpad) { - await this.applyTrackpadSettings(desiredConfig.trackpad); - } else if (pc.name === 'dock' && desiredConfig.dock) { - await this.applyDockSettings(desiredConfig.dock); - } - } +async function readInt(domain: string, key: string): Promise { + const $ = getPty(); + const { data, status } = await $.spawnSafe(`defaults read "${domain}" "${key}"`); + if (status === SpawnStatus.ERROR) return null; + const val = parseInt(data.trim(), 10); + return isNaN(val) ? null : val; +} - override async destroy(plan: DestroyPlan): Promise { - const { currentConfig } = plan; - const $ = getPty(); - let dockChanged = false; +async function readFloat(domain: string, key: string): Promise { + const $ = getPty(); + const { data, status } = await $.spawnSafe(`defaults read "${domain}" "${key}"`); + if (status === SpawnStatus.ERROR) return null; + const val = parseFloat(data.trim()); + return isNaN(val) ? null : val; +} - if (currentConfig.mouse) { - await this.deleteMouseSettings(currentConfig.mouse); - } - if (currentConfig.keyboard) { - await this.deleteKeyboardSettings(currentConfig.keyboard); - } - if (currentConfig.trackpad) { - await this.deleteTrackpadSettings(currentConfig.trackpad); - } - if (currentConfig.dock) { - await this.deleteDockSettings(currentConfig.dock); - dockChanged = true; - } +async function readString(domain: string, key: string): Promise { + const $ = getPty(); + const { data, status } = await $.spawnSafe(`defaults read "${domain}" "${key}"`); + if (status === SpawnStatus.ERROR) return null; + return data.trim() || null; +} - if (dockChanged) { - await $.spawnSafe('killall Dock'); - } - } +// ---- Mouse ---- - // ---- Mouse ---- +class MouseSettingsParameter extends StatefulParameter { + override getSettings() { return { isEqual }; } - private async readMouseSettings(desired: MouseConfig): Promise { + async refresh(desired: MouseConfig | null, _config: Partial): Promise { + if (!desired) return null; const result: MouseConfig = {}; let anyFound = false; if ('naturalScrolling' in desired) { - const v = await this.readBool('NSGlobalDomain', 'com.apple.swipescrolldirection'); + const v = await readBool('NSGlobalDomain', 'com.apple.swipescrolldirection'); if (v !== null) { result.naturalScrolling = v; anyFound = true; } } if ('acceleration' in desired) { - const linear = await this.readBool('NSGlobalDomain', 'com.apple.mouse.linear'); - // com.apple.mouse.linear=true means acceleration is DISABLED; invert for user-friendly name + const linear = await readBool('NSGlobalDomain', 'com.apple.mouse.linear'); if (linear !== null) { result.acceleration = !linear; anyFound = true; } } if ('speed' in desired) { - const v = await this.readFloat('NSGlobalDomain', 'com.apple.mouse.scaling'); + const v = await readFloat('NSGlobalDomain', 'com.apple.mouse.scaling'); if (v !== null) { result.speed = v; anyFound = true; } } return anyFound ? result : null; } - private async applyMouseSettings(settings: MouseConfig): Promise { + async add(value: MouseConfig, _plan: Plan): Promise { + await this.applyMouseSettings(value); + } + + async modify(newValue: MouseConfig, _previousValue: MouseConfig, _plan: Plan): Promise { + await this.applyMouseSettings(newValue); + } + + async remove(value: MouseConfig, _plan: Plan): Promise { const $ = getPty(); + if ('naturalScrolling' in value) { + await $.spawnSafe('defaults delete NSGlobalDomain com.apple.swipescrolldirection'); + } + if ('acceleration' in value) { + await $.spawnSafe('defaults delete NSGlobalDomain com.apple.mouse.linear'); + } + if ('speed' in value) { + await $.spawnSafe('defaults delete NSGlobalDomain com.apple.mouse.scaling'); + } + CodifyCliSender.sendApplyNote(ApplyNotes.RESTART_REQUIRED, 'macos-settings'); + } + private async applyMouseSettings(settings: MouseConfig): Promise { + const $ = getPty(); if (settings.naturalScrolling !== undefined) { await $.spawn(`defaults write NSGlobalDomain com.apple.swipescrolldirection -bool ${settings.naturalScrolling}`); } if (settings.acceleration !== undefined) { - // linear=true means no acceleration; invert the user-facing boolean await $.spawn(`defaults write NSGlobalDomain com.apple.mouse.linear -bool ${!settings.acceleration}`); } if (settings.speed !== undefined) { await $.spawn(`defaults write NSGlobalDomain com.apple.mouse.scaling -float ${settings.speed}`); } - - await CodifyCliSender.sendApplyNote(ApplyNotes.RESTART_REQUIRED, 'macos-settings') + CodifyCliSender.sendApplyNote(ApplyNotes.RESTART_REQUIRED, 'macos-settings'); } +} - private async deleteMouseSettings(settings: MouseConfig): Promise { - const $ = getPty(); - - if ('naturalScrolling' in settings) { - await $.spawnSafe('defaults delete NSGlobalDomain com.apple.swipescrolldirection'); - } - if ('acceleration' in settings) { - await $.spawnSafe('defaults delete NSGlobalDomain com.apple.mouse.linear'); - } - if ('speed' in settings) { - await $.spawnSafe('defaults delete NSGlobalDomain com.apple.mouse.scaling'); - } - - await CodifyCliSender.sendApplyNote(ApplyNotes.RESTART_REQUIRED, 'macos-settings') - } +// ---- Keyboard ---- - // ---- Keyboard ---- +class KeyboardSettingsParameter extends StatefulParameter { + override getSettings() { return { isEqual }; } - private async readKeyboardSettings(desired: KeyboardConfig): Promise { + async refresh(desired: KeyboardConfig | null, _config: Partial): Promise { + if (!desired) return null; const result: KeyboardConfig = {}; let anyFound = false; if ('keyRepeat' in desired) { - const v = await this.readInt('NSGlobalDomain', 'KeyRepeat'); + const v = await readInt('NSGlobalDomain', 'KeyRepeat'); if (v !== null) { result.keyRepeat = v; anyFound = true; } } if ('initialKeyRepeat' in desired) { - const v = await this.readInt('NSGlobalDomain', 'InitialKeyRepeat'); + const v = await readInt('NSGlobalDomain', 'InitialKeyRepeat'); if (v !== null) { result.initialKeyRepeat = v; anyFound = true; } } if ('pressAndHold' in desired) { - const v = await this.readBool('NSGlobalDomain', 'ApplePressAndHoldEnabled'); + const v = await readBool('NSGlobalDomain', 'ApplePressAndHoldEnabled'); if (v !== null) { result.pressAndHold = v; anyFound = true; } } if ('fnKeysAsStandardKeys' in desired) { - const v = await this.readBool('NSGlobalDomain', 'com.apple.keyboard.fnState'); + const v = await readBool('NSGlobalDomain', 'com.apple.keyboard.fnState'); if (v !== null) { result.fnKeysAsStandardKeys = v; anyFound = true; } } if ('keyboardNavigation' in desired) { - const v = await this.readInt('NSGlobalDomain', 'AppleKeyboardUIMode'); - // AppleKeyboardUIMode: 0=disabled, 2=enabled + const v = await readInt('NSGlobalDomain', 'AppleKeyboardUIMode'); if (v !== null) { result.keyboardNavigation = v === 2; anyFound = true; } } return anyFound ? result : null; } - private async applyKeyboardSettings(settings: KeyboardConfig): Promise { + async add(value: KeyboardConfig, _plan: Plan): Promise { + await this.applyKeyboardSettings(value); + } + + async modify(newValue: KeyboardConfig, _previousValue: KeyboardConfig, _plan: Plan): Promise { + await this.applyKeyboardSettings(newValue); + } + + async remove(value: KeyboardConfig, _plan: Plan): Promise { const $ = getPty(); + if ('keyRepeat' in value) await $.spawnSafe('defaults delete NSGlobalDomain KeyRepeat'); + if ('initialKeyRepeat' in value) await $.spawnSafe('defaults delete NSGlobalDomain InitialKeyRepeat'); + if ('pressAndHold' in value) await $.spawnSafe('defaults delete NSGlobalDomain ApplePressAndHoldEnabled'); + if ('fnKeysAsStandardKeys' in value) await $.spawnSafe('defaults delete NSGlobalDomain com.apple.keyboard.fnState'); + if ('keyboardNavigation' in value) await $.spawnSafe('defaults delete NSGlobalDomain AppleKeyboardUIMode'); + CodifyCliSender.sendApplyNote(ApplyNotes.RESTART_REQUIRED, 'macos-settings'); + } + private async applyKeyboardSettings(settings: KeyboardConfig): Promise { + const $ = getPty(); if (settings.keyRepeat !== undefined) { await $.spawn(`defaults write NSGlobalDomain KeyRepeat -int ${settings.keyRepeat}`); } @@ -301,106 +238,124 @@ export class MacosSettingsResource extends Resource { await $.spawn(`defaults write NSGlobalDomain com.apple.keyboard.fnState -bool ${settings.fnKeysAsStandardKeys}`); } if (settings.keyboardNavigation !== undefined) { - // Map boolean to the int value macOS expects (0=disabled, 2=enabled) await $.spawn(`defaults write NSGlobalDomain AppleKeyboardUIMode -int ${settings.keyboardNavigation ? 2 : 0}`); } - - await CodifyCliSender.sendApplyNote(ApplyNotes.RESTART_REQUIRED, 'macos-settings') + CodifyCliSender.sendApplyNote(ApplyNotes.RESTART_REQUIRED, 'macos-settings'); } +} - private async deleteKeyboardSettings(settings: KeyboardConfig): Promise { - const $ = getPty(); - - if ('keyRepeat' in settings) { - await $.spawnSafe('defaults delete NSGlobalDomain KeyRepeat'); - } - if ('initialKeyRepeat' in settings) { - await $.spawnSafe('defaults delete NSGlobalDomain InitialKeyRepeat'); - } - if ('pressAndHold' in settings) { - await $.spawnSafe('defaults delete NSGlobalDomain ApplePressAndHoldEnabled'); - } - if ('fnKeysAsStandardKeys' in settings) { - await $.spawnSafe('defaults delete NSGlobalDomain com.apple.keyboard.fnState'); - } - if ('keyboardNavigation' in settings) { - await $.spawnSafe('defaults delete NSGlobalDomain AppleKeyboardUIMode'); - } - - await CodifyCliSender.sendApplyNote(ApplyNotes.RESTART_REQUIRED, 'macos-settings') - } +// ---- Trackpad ---- - // ---- Trackpad ---- +class TrackpadSettingsParameter extends StatefulParameter { + override getSettings() { return { isEqual }; } - private async readTrackpadSettings(desired: TrackpadConfig): Promise { + async refresh(desired: TrackpadConfig | null, _config: Partial): Promise { + if (!desired) return null; const result: TrackpadConfig = {}; let anyFound = false; if ('speed' in desired) { - const v = await this.readFloat('NSGlobalDomain', 'com.apple.trackpad.scaling'); + const v = await readFloat('NSGlobalDomain', 'com.apple.trackpad.scaling'); if (v !== null) { result.speed = v; anyFound = true; } } return anyFound ? result : null; } - private async applyTrackpadSettings(settings: TrackpadConfig): Promise { - const $ = getPty(); - - if (settings.speed !== undefined) { - await $.spawn(`defaults write NSGlobalDomain com.apple.trackpad.scaling -float ${settings.speed}`); - } + async add(value: TrackpadConfig, _plan: Plan): Promise { + await this.applyTrackpadSettings(value); + } - await CodifyCliSender.sendApplyNote(ApplyNotes.RESTART_REQUIRED, 'macos-settings') + async modify(newValue: TrackpadConfig, _previousValue: TrackpadConfig, _plan: Plan): Promise { + await this.applyTrackpadSettings(newValue); } - private async deleteTrackpadSettings(settings: TrackpadConfig): Promise { + async remove(value: TrackpadConfig, _plan: Plan): Promise { const $ = getPty(); + if ('speed' in value) await $.spawnSafe('defaults delete NSGlobalDomain com.apple.trackpad.scaling'); + CodifyCliSender.sendApplyNote(ApplyNotes.RESTART_REQUIRED, 'macos-settings'); + } - if ('speed' in settings) { - await $.spawnSafe('defaults delete NSGlobalDomain com.apple.trackpad.scaling'); + private async applyTrackpadSettings(settings: TrackpadConfig): Promise { + const $ = getPty(); + if (settings.speed !== undefined) { + await $.spawn(`defaults write NSGlobalDomain com.apple.trackpad.scaling -float ${settings.speed}`); } - - await CodifyCliSender.sendApplyNote(ApplyNotes.RESTART_REQUIRED, 'macos-settings') + CodifyCliSender.sendApplyNote(ApplyNotes.RESTART_REQUIRED, 'macos-settings'); } +} - // ---- Dock ---- +// ---- Dock ---- - private async readDockSettings(desired: DockConfig): Promise { +class DockSettingsParameter extends StatefulParameter { + override getSettings() { return { isEqual }; } + + async refresh(desired: DockConfig | null, _config: Partial): Promise { + if (!desired) return null; const result: DockConfig = {}; let anyFound = false; if ('position' in desired) { - const v = await this.readString('com.apple.dock', 'orientation'); + const v = await readString('com.apple.dock', 'orientation'); if (v !== null) { result.position = v as DockConfig['position']; anyFound = true; } } if ('iconSize' in desired) { - const v = await this.readInt('com.apple.dock', 'tilesize'); + const v = await readInt('com.apple.dock', 'tilesize'); if (v !== null) { result.iconSize = v; anyFound = true; } } if ('autohide' in desired) { - const v = await this.readBool('com.apple.dock', 'autohide'); + const v = await readBool('com.apple.dock', 'autohide'); if (v !== null) { result.autohide = v; anyFound = true; } } - if ('autohideDelay' in desired) { - const v = await this.readFloat('com.apple.dock', 'autohide-delay'); - if (v !== null) { result.autohideDelay = v; anyFound = true; } + if ('hoverDelay' in desired) { + const v = await readFloat('com.apple.dock', 'autohide-delay'); + if (v !== null) { result.hoverDelay = v; anyFound = true; } + } + if ('animationSpeed' in desired) { + const v = await readFloat('com.apple.dock', 'autohide-time-modifier'); + if (v !== null) { result.animationSpeed = v; anyFound = true; } } if ('showRecents' in desired) { - const v = await this.readBool('com.apple.dock', 'show-recents'); + const v = await readBool('com.apple.dock', 'show-recents'); if (v !== null) { result.showRecents = v; anyFound = true; } } if ('minimizeEffect' in desired) { - const v = await this.readString('com.apple.dock', 'mineffect'); + const v = await readString('com.apple.dock', 'mineffect'); if (v !== null) { result.minimizeEffect = v as DockConfig['minimizeEffect']; anyFound = true; } } return anyFound ? result : null; } - private async applyDockSettings(settings: DockConfig): Promise { + async add(value: DockConfig, _plan: Plan): Promise { + await this.applyDockSettings(value); + } + + async modify(newValue: DockConfig, _previousValue: DockConfig, _plan: Plan): Promise { + await this.applyDockSettings(newValue); + } + + async remove(value: DockConfig, _plan: Plan): Promise { const $ = getPty(); + const keyMap: Record = { + position: 'orientation', + iconSize: 'tilesize', + autohide: 'autohide', + hoverDelay: 'autohide-delay', + animationSpeed: 'autohide-time-modifier', + showRecents: 'show-recents', + minimizeEffect: 'mineffect', + }; + for (const [prop, key] of Object.entries(keyMap)) { + if (prop in value) { + await $.spawnSafe(`defaults delete com.apple.dock "${key}"`); + } + } + await $.spawnSafe('killall Dock'); + } + private async applyDockSettings(settings: DockConfig): Promise { + const $ = getPty(); if (settings.position !== undefined) { await $.spawn(`defaults write com.apple.dock orientation -string "${settings.position}"`); } @@ -410,8 +365,11 @@ export class MacosSettingsResource extends Resource { if (settings.autohide !== undefined) { await $.spawn(`defaults write com.apple.dock autohide -bool ${settings.autohide}`); } - if (settings.autohideDelay !== undefined) { - await $.spawn(`defaults write com.apple.dock "autohide-delay" -float ${settings.autohideDelay}`); + if (settings.hoverDelay !== undefined) { + await $.spawn(`defaults write com.apple.dock "autohide-delay" -float ${settings.hoverDelay}`); + } + if (settings.animationSpeed !== undefined) { + await $.spawn(`defaults write com.apple.dock "autohide-time-modifier" -float ${settings.animationSpeed}`); } if (settings.showRecents !== undefined) { await $.spawn(`defaults write com.apple.dock "show-recents" -bool ${settings.showRecents}`); @@ -419,58 +377,85 @@ export class MacosSettingsResource extends Resource { if (settings.minimizeEffect !== undefined) { await $.spawn(`defaults write com.apple.dock mineffect -string "${settings.minimizeEffect}"`); } - await $.spawnSafe('killall Dock'); } +} - private async deleteDockSettings(settings: DockConfig): Promise { - const $ = getPty(); - const keyMap: Record = { - position: 'orientation', - iconSize: 'tilesize', - autohide: 'autohide', - autohideDelay: 'autohide-delay', - showRecents: 'show-recents', - minimizeEffect: 'mineffect', - }; +// ---- Resource ---- - for (const [prop, key] of Object.entries(keyMap)) { - if (prop in settings) { - await $.spawnSafe(`defaults delete com.apple.dock "${key}"`); - } - } - } +const defaultConfig: Partial = { + mouse: {}, + keyboard: {}, + dock: {}, +}; - // ---- Low-level defaults read helpers ---- +const exampleCommonPrefs: ExampleConfig = { + title: 'Common macOS preferences', + description: 'Configure natural scrolling, fast key repeat, and a minimal Dock for a consistent setup on any new Mac.', + configs: [{ + type: 'macos-settings', + os: ['macOS'], + mouse: { + naturalScrolling: true, + }, + keyboard: { + keyRepeat: 2, + initialKeyRepeat: 15, + pressAndHold: false, + }, + dock: { + position: 'left', + iconSize: 36, + autohide: true, + showRecents: false, + }, + }], +}; - private async readBool(domain: string, key: string): Promise { - const $ = getPty(); - const { data, status } = await $.spawnSafe(`defaults read "${domain}" "${key}"`); - if (status === SpawnStatus.ERROR) return null; - const val = data.trim(); - return val === '1' || val === 'true' || val === 'YES'; - } +const exampleNonAppleKeyboard: ExampleConfig = { + title: 'Non-Apple keyboard setup', + description: 'Disable natural scrolling and enable standard function keys for a non-Apple keyboard or mouse.', + configs: [{ + type: 'macos-settings', + os: ['macOS'], + mouse: { + naturalScrolling: false, + acceleration: false, + }, + keyboard: { + fnKeysAsStandardKeys: true, + }, + }], +}; - private async readInt(domain: string, key: string): Promise { - const $ = getPty(); - const { data, status } = await $.spawnSafe(`defaults read "${domain}" "${key}"`); - if (status === SpawnStatus.ERROR) return null; - const val = parseInt(data.trim(), 10); - return isNaN(val) ? null : val; +export class MacosSettingsResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'macos-settings', + operatingSystems: [OS.Darwin], + schema, + defaultConfig, + exampleConfigs: { + example1: exampleCommonPrefs, + example2: exampleNonAppleKeyboard, + }, + parameterSettings: { + mouse: { type: 'stateful', definition: new MouseSettingsParameter(), order: 1 }, + keyboard: { type: 'stateful', definition: new KeyboardSettingsParameter(), order: 2 }, + trackpad: { type: 'stateful', definition: new TrackpadSettingsParameter(), order: 3 }, + dock: { type: 'stateful', definition: new DockSettingsParameter(), order: 4 }, + }, + importAndDestroy: { + preventDestroy: true, + }, + }; } - private async readFloat(domain: string, key: string): Promise { - const $ = getPty(); - const { data, status } = await $.spawnSafe(`defaults read "${domain}" "${key}"`); - if (status === SpawnStatus.ERROR) return null; - const val = parseFloat(data.trim()); - return isNaN(val) ? null : val; + override async refresh(_parameters: Partial): Promise | null> { + return {}; } - private async readString(domain: string, key: string): Promise { - const $ = getPty(); - const { data, status } = await $.spawnSafe(`defaults read "${domain}" "${key}"`); - if (status === SpawnStatus.ERROR) return null; - return data.trim() || null; - } + async create(): Promise {} + + async destroy(): Promise {} } diff --git a/src/resources/python/pip/pip.ts b/src/resources/python/pip/pip.ts index 10263457..2b9831ba 100644 --- a/src/resources/python/pip/pip.ts +++ b/src/resources/python/pip/pip.ts @@ -118,7 +118,7 @@ export class Pip extends Resource { // The diffing algo is not smart enough to differentiate between same two items but different (modify) and same two items but same (keep). const parsedInstalledPackages = JSON.parse(installedPackages) .map(({ name, version }: { name: string; version: string}) => { - const match = parameters.install!.find((p) => { + const match = parameters.install?.find((p) => { if (typeof p === 'string') { return p === name; } @@ -158,8 +158,8 @@ export class Pip extends Resource { const { install: desiredInstall, virtualEnv } = plan.desiredConfig; const { install: currentInstall } = plan.currentConfig; - const toInstall = desiredInstall.filter((d) => !this.findMatchingForModify(d, currentInstall)); - const toUninstall = currentInstall.filter((c) => !this.findMatchingForModify(c, desiredInstall)); + const toInstall = (desiredInstall ?? []).filter((d) => !this.findMatchingForModify(d, currentInstall)); + const toUninstall = (currentInstall ?? []).filter((c) => !this.findMatchingForModify(c, desiredInstall)); if (toUninstall.length > 0) { await this.pipUninstall(toUninstall, virtualEnv); @@ -209,8 +209,8 @@ export class Pip extends Resource { ) } - findMatchingForModify(a: PipListResult | string, bList: Array): PipListResult | string | undefined { - return bList.find((b) => this.isEqual(a, b)) + findMatchingForModify(a: PipListResult | string, bList: Array | null | undefined): PipListResult | string | undefined { + return (bList ?? []).find((b) => this.isEqual(a, b)) } isEqual(a: PipListResult | string, b: PipListResult | string): boolean { diff --git a/test/android/android-studio.test.ts b/test/android/android-studio.test.ts index 42549b41..b3657cac 100644 --- a/test/android/android-studio.test.ts +++ b/test/android/android-studio.test.ts @@ -7,7 +7,7 @@ import * as fs from 'node:fs/promises'; describe('Android studios tests', async () => { const pluginPath = path.resolve('./src/index.ts'); - it('Can install the latest Android studios', { timeout: 300000 }, async () => { + it('Can install the latest Android studios', { timeout: 600000 }, async () => { const isMacOS = Utils.isMacOS(); const appPath = isMacOS ? '/Applications/Android Studio.app' diff --git a/test/file/env-file.test.ts b/test/file/env-file.test.ts new file mode 100644 index 00000000..bc971db0 --- /dev/null +++ b/test/file/env-file.test.ts @@ -0,0 +1,114 @@ +import { afterAll, describe, expect, it } from 'vitest'; +import { PluginTester } from '@codifycli/plugin-test'; +import * as path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { ResourceOperation } from '@codifycli/schemas'; + +const pluginPath = path.resolve('./src/index.ts'); +const testDir = path.join(os.tmpdir(), 'codify-env-file-test'); + +describe('env-file resource integration tests', async () => { + it('Can create, modify, and destroy a .env file with contents', { timeout: 300000 }, async () => { + await PluginTester.fullTest(pluginPath, [ + { + type: 'env-file', + dir: testDir, + filename: '.env', + contents: [ + { key: 'FOO', value: 'bar' }, + { key: 'BAZ', value: 'qux' }, + ], + }, + ], { + validateApply: async (plans) => { + expect(plans[0].operation).to.eq(ResourceOperation.CREATE); + const envPath = path.join(testDir, '.env'); + expect(fs.existsSync(envPath)).to.be.true; + const content = fs.readFileSync(envPath, 'utf8'); + expect(content).to.include('FOO="bar"'); + expect(content).to.include('BAZ="qux"'); + }, + testModify: { + modifiedConfigs: [ + { + type: 'env-file', + dir: testDir, + filename: '.env', + contents: [ + { key: 'FOO', value: 'updated' }, + { key: 'NEW_KEY', value: 'hello' }, + ], + }, + ], + validateModify: async (plans) => { + expect(plans[0].operation).to.eq(ResourceOperation.MODIFY); + const envPath = path.join(testDir, '.env'); + const content = fs.readFileSync(envPath, 'utf8'); + expect(content).to.include('FOO="updated"'); + expect(content).to.include('NEW_KEY="hello"'); + }, + }, + skipImport: true, + validateDestroy: async () => { + const envPath = path.join(testDir, '.env'); + expect(fs.existsSync(envPath)).to.be.false; + }, + }); + }); + + it('Can create, modify, and destroy multiple env files with env-files', { timeout: 300000 }, async () => { + await PluginTester.fullTest(pluginPath, [ + { + type: 'env-files', + dir: testDir, + envFiles: [ + { + name: '.env', + contents: [{ key: 'APP_ENV', value: 'development' }], + }, + { + name: '.env.local', + contents: [{ key: 'SECRET', value: 'abc123' }], + }, + ], + }, + ], { + validateApply: async (plans) => { + expect(plans[0].operation).to.eq(ResourceOperation.CREATE); + expect(fs.existsSync(path.join(testDir, '.env'))).to.be.true; + expect(fs.existsSync(path.join(testDir, '.env.local'))).to.be.true; + expect(fs.readFileSync(path.join(testDir, '.env'), 'utf8')).to.include('APP_ENV="development"'); + expect(fs.readFileSync(path.join(testDir, '.env.local'), 'utf8')).to.include('SECRET="abc123"'); + }, + testModify: { + modifiedConfigs: [ + { + type: 'env-files', + dir: testDir, + envFiles: [ + { + name: '.env', + contents: [{ key: 'APP_ENV', value: 'production' }], + }, + ], + }, + ], + validateModify: async (plans) => { + expect(plans[0].operation).to.eq(ResourceOperation.MODIFY); + expect(fs.readFileSync(path.join(testDir, '.env'), 'utf8')).to.include('APP_ENV="production"'); + }, + }, + skipImport: true, + validateDestroy: async () => { + expect(fs.existsSync(path.join(testDir, '.env'))).to.be.false; + }, + }); + }); + + afterAll(() => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/macos/macos-settings.test.ts b/test/macos/macos-settings.test.ts index 01be39b8..27d3b978 100644 --- a/test/macos/macos-settings.test.ts +++ b/test/macos/macos-settings.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { afterAll, describe, expect, it } from 'vitest'; import { PluginTester, testSpawn } from '@codifycli/plugin-test'; import * as path from 'node:path'; import { Utils } from '@codifycli/plugin-core'; @@ -15,6 +15,7 @@ describe('macos-settings resource integration tests', { skip: !Utils.isMacOS() } }, }, ], { + skipUninstall: true, validateApply: async () => { const { data } = await testSpawn('defaults read NSGlobalDomain com.apple.swipescrolldirection'); expect(data.trim()).toBe('0'); @@ -31,13 +32,6 @@ describe('macos-settings resource integration tests', { skip: !Utils.isMacOS() } expect(data.trim()).toBe('1'); }, }, - validateDestroy: async () => { - // After destroy, the key may be deleted (returns error) or reset to default - const { data } = await testSpawn('defaults read NSGlobalDomain com.apple.swipescrolldirection'); - // Default is true (1) when key is absent, or the key was deleted — either is acceptable - const val = data.trim(); - expect(['', '1'].includes(val) || val.includes('does not exist')).toBe(true); - }, }); }); @@ -52,6 +46,7 @@ describe('macos-settings resource integration tests', { skip: !Utils.isMacOS() } }, }, ], { + skipUninstall: true, validateApply: async () => { const { data: autohide } = await testSpawn('defaults read com.apple.dock autohide'); expect(autohide.trim()).toBe('1'); @@ -79,14 +74,6 @@ describe('macos-settings resource integration tests', { skip: !Utils.isMacOS() } expect(parseInt(tilesize.trim(), 10)).toBe(48); }, }, - validateDestroy: async () => { - // After destroy, keys should be deleted — reads will fail or return defaults - const { data: tilesize } = await testSpawn('defaults read com.apple.dock tilesize'); - const val = tilesize.trim(); - const parsed = parseInt(val, 10); - // Either deleted (error output or NaN) or reset to system default (48) - expect(val.includes('does not exist') || isNaN(parsed) || parsed === 48).toBe(true); - }, }); }); @@ -101,6 +88,7 @@ describe('macos-settings resource integration tests', { skip: !Utils.isMacOS() } }, }, ], { + skipUninstall: true, validateApply: async () => { const { data: keyRepeat } = await testSpawn('defaults read NSGlobalDomain KeyRepeat'); expect(parseInt(keyRepeat.trim(), 10)).toBe(2); @@ -128,13 +116,17 @@ describe('macos-settings resource integration tests', { skip: !Utils.isMacOS() } expect(parseInt(initialKeyRepeat.trim(), 10)).toBe(68); }, }, - validateDestroy: async () => { - const { data: keyRepeat } = await testSpawn('defaults read NSGlobalDomain KeyRepeat'); - const val = keyRepeat.trim(); - const parsed = parseInt(val, 10); - // Either deleted (error output or NaN) or reset to system default (6) - expect(val.includes('does not exist') || isNaN(parsed) || parsed === 6).toBe(true); - }, }); }); + + afterAll(async () => { + await testSpawn('defaults delete NSGlobalDomain com.apple.swipescrolldirection'); + await testSpawn('defaults delete com.apple.dock tilesize'); + await testSpawn('defaults delete com.apple.dock autohide'); + await testSpawn('defaults delete com.apple.dock show-recents'); + await testSpawn('defaults delete NSGlobalDomain KeyRepeat'); + await testSpawn('defaults delete NSGlobalDomain InitialKeyRepeat'); + await testSpawn('defaults delete NSGlobalDomain ApplePressAndHoldEnabled'); + await testSpawn('killall Dock'); + }); });