diff --git a/docs/resources/(resources)/shell/symlink.mdx b/docs/resources/(resources)/shell/symlink.mdx new file mode 100644 index 00000000..1c363ab4 --- /dev/null +++ b/docs/resources/(resources)/shell/symlink.mdx @@ -0,0 +1,57 @@ +--- +title: symlink +description: A reference page for the symlink resource +--- + +The symlink resource reference. This resource creates and manages a single symbolic link on +disk, pointing `path` at `target`. Symlinks are a built-in OS concept on both macOS and Linux +— nothing needs to be installed. + +```sh title="Equivalent shell command" +ln -s TARGET PATH +``` + +## Parameters: + +- **path**: *(string, required)* The location where the symlink should be created. + +- **target**: *(string, required, modifiable)* The file or directory the symlink should point + to. The target does not need to already exist — Codify will still create the link. + +## Example usage: + +```json title="codify.jsonc" +[ + { + "type": "symlink", + "path": "~/.vimrc", + "target": "~/dotfiles/vimrc" + } +] +``` + +Symlinking an application into `/Applications`: + +```json title="codify.jsonc" +[ + { + "type": "symlink", + "path": "/Applications/MyApp.app", + "target": "~/Applications/MyApp.app" + } +] +``` + +## Notes: + +- Changing `target` re-links the resource in place (unlink + re-create) rather than + recreating the whole resource. +- If a real file or directory (not a symlink) already exists at `path`, Codify will raise an + error instead of overwriting it — remove it manually first if you want Codify to manage + that location. +- Destroying this resource only removes the symlink itself; the file or directory it points + to is left untouched. +- Missing parent directories of `path` are created automatically. +- See [symlinks](/docs/resources/symlinks) for managing several symlinks in one resource. + When running `codify import`/`codify init`, the `symlinks` resource is used instead of + `symlink` for any discovered links. diff --git a/docs/resources/(resources)/shell/symlinks.mdx b/docs/resources/(resources)/shell/symlinks.mdx new file mode 100644 index 00000000..fc6c1e10 --- /dev/null +++ b/docs/resources/(resources)/shell/symlinks.mdx @@ -0,0 +1,74 @@ +--- +title: symlinks +description: A reference page for the symlinks resource +--- + +The symlinks resource reference. This resource manages multiple symbolic links in a single +resource configuration. Unlike the singular [symlink](/docs/resources/symlink) resource which +creates one link per resource instance, the `symlinks` resource allows you to define and +manage multiple links together as an array. + +```sh title="Equivalent shell command (per entry)" +ln -s TARGET PATH +``` + +## Parameters: + +- **symlinks**: *(array[object], optional)* An array of symlink definitions. Each entry + contains: + - **path**: *(string, required)* The location where the symlink should be created. + - **target**: *(string, required)* The file or directory the symlink should point to. + +## Example usage: + +### Managing multiple symlinks in one resource + +```json title="codify.jsonc" +[ + { + "type": "symlinks", + "symlinks": [ + { "path": "~/.vimrc", "target": "~/dotfiles/vimrc" }, + { "path": "~/.zshrc", "target": "~/dotfiles/zshrc" }, + { "path": "~/.gitconfig", "target": "~/dotfiles/gitconfig" } + ] + } +] +``` + +### Symlinking config directories and applications + +```json title="codify.jsonc" +[ + { + "type": "symlinks", + "symlinks": [ + { "path": "~/.config/nvim", "target": "~/dotfiles/nvim" }, + { "path": "/Applications/MyApp.app", "target": "~/Applications/MyApp.app" } + ] + } +] +``` + +## Comparison: symlinks vs symlink + +**Use `symlinks` when:** +- You want to manage multiple symlinks in a single resource +- You prefer a more compact configuration for a dotfiles-style setup +- This is the resource `codify import`/`codify init` will use when discovering existing links + +**Use `symlink` when:** +- You want fine-grained control over an individual link +- You want to use Codify's resource dependency features for a specific link +- You prefer to distribute link definitions across your configuration + +## Notes: + +- Adding an entry creates that link; removing an entry from the array only removes that + link (the file/directory it pointed to is untouched); changing an entry's `target` + re-links it in place. +- If a real file or directory (not a symlink) already exists at one of the declared `path` + values, Codify will raise an error instead of overwriting it. +- Missing parent directories of each `path` are created automatically. +- Both `symlink` and `symlinks` resources can be used in the same configuration without + conflicts. diff --git a/package.json b/package.json index 12ddf763..a81c9b05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "default", - "version": "1.15.3", + "version": "1.16.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": { diff --git a/src/index.ts b/src/index.ts index e2e6a0aa..78ea8816 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,8 @@ import { AliasesResource } from './resources/shell/aliases/aliases-resource.js'; import { EnvVarResource } from './resources/shell/env-var/env-var-resource.js'; import { EnvVarsResource } from './resources/shell/env-vars/env-vars-resource.js'; import { PathResource } from './resources/shell/path/path-resource.js'; +import { SymlinkResource } from './resources/shell/symlink/symlink-resource.js'; +import { SymlinksResource } from './resources/shell/symlinks/symlinks-resource.js'; import { SnapResource } from './resources/snap/snap.js'; import { SyncthingResource } from './resources/syncthing/syncthing.js'; import { SyncthingDeviceResource } from './resources/syncthing/syncthing-device.js'; @@ -95,6 +97,8 @@ runPlugin(Plugin.create( new AliasesResource(), new EnvVarResource(), new EnvVarsResource(), + new SymlinkResource(), + new SymlinksResource(), new HomebrewResource(), new PyenvResource(), new UvResource(), diff --git a/src/resources/file/file.ts b/src/resources/file/file.ts index bd454eb0..38586bc0 100644 --- a/src/resources/file/file.ts +++ b/src/resources/file/file.ts @@ -96,9 +96,10 @@ export class FileResource extends Resource { } async create(plan: CreatePlan): Promise { - const { contents, path } = plan.desiredConfig; + const { contents, path: filePath } = plan.desiredConfig; - await fs.writeFile(path, contents, 'utf8'); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents, 'utf8'); } async modify(pc: ParameterChange, plan: ModifyPlan): Promise { diff --git a/src/resources/shell/symlink/symlink-resource.ts b/src/resources/shell/symlink/symlink-resource.ts new file mode 100644 index 00000000..53f24d5b --- /dev/null +++ b/src/resources/shell/symlink/symlink-resource.ts @@ -0,0 +1,127 @@ +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'; + +export const schema = z.object({ + path: z.string().describe('The location of the symlink to create.'), + target: z.string().describe('The file or directory the symlink should point to.'), +}) + .describe('Manages a single symbolic link, creating it at `path` and pointing it at `target`.'); + +export type SymlinkConfig = z.infer; + +const defaultConfig: Partial = { + path: '', + target: '', +} + +const exampleDotfile: ExampleConfig = { + title: 'Symlink a dotfile from a dotfiles repo', + description: 'Point a config file at the copy tracked in a version-controlled dotfiles repository.', + configs: [{ + type: 'symlink', + path: '~/.vimrc', + target: '~/dotfiles/vimrc', + }] +} + +const exampleDirectory: ExampleConfig = { + title: 'Symlink an application into /Applications', + description: 'Expose an application installed in a custom location under the standard /Applications directory.', + configs: [{ + type: 'symlink', + path: '/Applications/MyApp.app', + target: '~/Applications/MyApp.app', + }] +} + +export class SymlinkResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'symlink', + defaultConfig, + exampleConfigs: { + example1: exampleDotfile, + example2: exampleDirectory, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + parameterSettings: { + path: { type: 'directory' }, + target: { type: 'directory', canModify: true }, + }, + importAndDestroy: { + preventImport: true, + }, + allowMultiple: { + identifyingParameters: ['path'], + }, + } + } + + override async refresh(parameters: Partial): Promise | null> { + const { path: linkPath } = parameters; + if (!linkPath) { + return null; + } + + let stats; + try { + stats = await fs.lstat(linkPath); + } catch { + return null; + } + + if (!stats.isSymbolicLink()) { + throw new Error(`A file or directory already exists at ${linkPath} and is not a symlink. Please remove it manually and re-run Codify.`); + } + + const target = await fs.readlink(linkPath); + + return { + path: linkPath, + target, + } + } + + override async create(plan: CreatePlan): Promise { + const { path: linkPath, target } = plan.desiredConfig; + + const parentDir = path.dirname(linkPath); + await fs.mkdir(parentDir, { recursive: true }); + + await fs.symlink(target, linkPath); + } + + override async modify(pc: ParameterChange, plan: ModifyPlan): Promise { + if (pc.name !== 'target') { + return; + } + + const { path: linkPath } = plan.currentConfig; + + await fs.unlink(linkPath); + await fs.symlink(plan.desiredConfig.target, linkPath); + } + + override async destroy(plan: DestroyPlan): Promise { + const { path: linkPath } = plan.currentConfig; + + const stats = await fs.lstat(linkPath); + if (!stats.isSymbolicLink()) { + throw new Error(`Refusing to remove ${linkPath} because it is not a symlink. Please remove it manually and re-run Codify.`); + } + + await fs.unlink(linkPath); + } +} diff --git a/src/resources/shell/symlinks/symlinks-resource.ts b/src/resources/shell/symlinks/symlinks-resource.ts new file mode 100644 index 00000000..5a247948 --- /dev/null +++ b/src/resources/shell/symlinks/symlinks-resource.ts @@ -0,0 +1,197 @@ +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 { untildify } from '../../../utils/untildify.js'; + +interface SymlinkEntry { + path: string; + target: string; +} + +export const schema = z.object({ + symlinks: z + .array(z.object({ + path: z.string().describe('The location of the symlink.'), + target: z.string().describe('The file or directory the symlink should point to.'), + })) + .describe('Symlinks to create') + .optional(), +}) + .describe('Symlinks resource. Can be used to manage multiple symlinks.'); + +export type SymlinksConfig = z.infer; + +const defaultConfig: Partial = { + symlinks: [], +} + +const exampleDotfiles: ExampleConfig = { + title: 'Symlink dotfiles from a dotfiles repo', + description: 'Point several config files at the copies tracked in a version-controlled dotfiles repository.', + configs: [{ + type: 'symlinks', + symlinks: [ + { path: '~/.vimrc', target: '~/dotfiles/vimrc' }, + { path: '~/.zshrc', target: '~/dotfiles/zshrc' }, + { path: '~/.gitconfig', target: '~/dotfiles/gitconfig' }, + ], + }] +} + +const exampleAppsAndDirs: ExampleConfig = { + title: 'Symlink config directories and applications', + description: 'Expose a version-controlled config directory and an application installed in a custom location under their conventional paths.', + configs: [{ + type: 'symlinks', + symlinks: [ + { path: '~/.config/nvim', target: '~/dotfiles/nvim' }, + { path: '/Applications/MyApp.app', target: '~/Applications/MyApp.app' }, + ], + }] +} + +export class SymlinksResource extends Resource { + getSettings(): ResourceSettings { + return { + id: 'symlinks', + defaultConfig, + exampleConfigs: { + example1: exampleDotfiles, + example2: exampleAppsAndDirs, + }, + operatingSystems: [OS.Darwin, OS.Linux], + schema, + parameterSettings: { + symlinks: { + type: 'array', + itemType: 'object', + canModify: true, + isElementEqual: (a: SymlinkEntry, b: SymlinkEntry) => + this.resolve(a.path) === this.resolve(b.path) && this.resolve(a.target) === this.resolve(b.target), + filterInStatelessMode: (desired: SymlinkEntry[], current: SymlinkEntry[]) => + current.filter((c) => desired.some((d) => this.resolve(d.path) === this.resolve(c.path))), + }, + }, + importAndDestroy: { + refreshMapper(input) { + if (!input?.symlinks || input.symlinks.length === 0) { + return { symlinks: [] }; + } + + return input; + } + } + } + } + + override async refresh(parameters: Partial): Promise | null> { + const result: SymlinkEntry[] = []; + + for (const { path: linkPath } of parameters.symlinks ?? []) { + const resolvedPath = this.resolve(linkPath); + + let stats; + try { + stats = await fs.lstat(resolvedPath); + } catch { + continue; + } + + if (!stats.isSymbolicLink()) { + throw new Error(`A file or directory already exists at ${linkPath} and is not a symlink. Please remove it manually and re-run Codify.`); + } + + const target = await fs.readlink(resolvedPath); + result.push({ path: linkPath, target }); + } + + if (result.length === 0) { + return null; + } + + return { symlinks: result }; + } + + override async create(plan: CreatePlan): Promise { + await this.addSymlinks(plan.desiredConfig.symlinks ?? []); + } + + override async modify(pc: ParameterChange, plan: ModifyPlan): Promise { + if (pc.name !== 'symlinks') { + return; + } + + const previous = (pc.previousValue as SymlinkEntry[]) ?? []; + const next = (pc.newValue as SymlinkEntry[]) ?? []; + + // In stateless mode, symlinks whose path is dropped from the declared list are simply no + // longer managed (matches aliases/paths resources) rather than deleted from disk. Only a + // stateful plan (previous is the resource's full tracked state) can safely detect true removals. + const toRemove = plan.isStateful + ? previous.filter((p) => !next.some((n) => this.resolve(n.path) === this.resolve(p.path))) + : previous.filter((p) => next.some((n) => + this.resolve(n.path) === this.resolve(p.path) && this.resolve(n.target) !== this.resolve(p.target))); + + const toAdd = next.filter((n) => { + const prev = previous.find((p) => this.resolve(p.path) === this.resolve(n.path)); + return !prev || this.resolve(prev.target) !== this.resolve(n.target); + }); + + await this.removeSymlinks(toRemove); + await this.addSymlinks(toAdd); + } + + override async destroy(plan: DestroyPlan): Promise { + await this.removeSymlinks(plan.currentConfig.symlinks ?? []); + } + + private async addSymlinks(entries: SymlinkEntry[]): Promise { + for (const { path: linkPath, target } of entries) { + const resolvedPath = this.resolve(linkPath); + const resolvedTarget = this.resolve(target); + + await fs.mkdir(path.dirname(resolvedPath), { recursive: true }); + + if (await this.isSymlink(resolvedPath)) { + await fs.unlink(resolvedPath); + } + + await fs.symlink(resolvedTarget, resolvedPath); + } + } + + private async removeSymlinks(entries: SymlinkEntry[]): Promise { + for (const { path: linkPath } of entries) { + const resolvedPath = this.resolve(linkPath); + + if (!(await this.isSymlink(resolvedPath))) { + throw new Error(`Refusing to remove ${linkPath} because it is not a symlink. Please remove it manually and re-run Codify.`); + } + + await fs.unlink(resolvedPath); + } + } + + private async isSymlink(resolvedPath: string): Promise { + try { + return (await fs.lstat(resolvedPath)).isSymbolicLink(); + } catch { + return false; + } + } + + private resolve(linkPath: string): string { + return untildify(linkPath); + } +} diff --git a/test/shell/symlink.test.ts b/test/shell/symlink.test.ts new file mode 100644 index 00000000..57c8cb44 --- /dev/null +++ b/test/shell/symlink.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { PluginTester } from '@codifycli/plugin-test'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import { ResourceOperation } from '@codifycli/schemas'; + +describe('Symlink resource integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Can create a symlink pointing at a file', { timeout: 300000 }, async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codify-symlink-')); + const target = path.join(tempDir, 'target.txt'); + const target2 = path.join(tempDir, 'target2.txt'); + const linkPath = path.join(tempDir, 'link.txt'); + + await fs.writeFile(target, 'hello world'); + await fs.writeFile(target2, 'goodbye world'); + + await PluginTester.fullTest(pluginPath, [ + { + type: 'symlink', + path: linkPath, + target, + } + ], { + validateApply: async () => { + const stats = await fs.lstat(linkPath); + expect(stats.isSymbolicLink()).to.be.true; + expect(await fs.readlink(linkPath)).to.eq(target); + expect(await fs.readFile(linkPath, 'utf8')).to.eq('hello world'); + }, + testModify: { + modifiedConfigs: [{ + type: 'symlink', + path: linkPath, + target: target2, + }], + validateModify: async (plans) => { + expect(plans[0]).toMatchObject({ + operation: ResourceOperation.MODIFY, + }); + + expect(await fs.readlink(linkPath)).to.eq(target2); + expect(await fs.readFile(linkPath, 'utf8')).to.eq('goodbye world'); + } + }, + skipImport: true, + validateDestroy: async () => { + await expect(fs.lstat(linkPath)).rejects.toThrow(); + // The target file itself must not be removed, only the link. + expect(await fs.readFile(target2, 'utf8')).to.eq('goodbye world'); + }, + }); + }) + + it('Can create a symlink pointing at a directory', { timeout: 300000 }, async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codify-symlink-')); + const targetDir = path.join(tempDir, 'target-dir'); + const linkPath = path.join(tempDir, 'nested', 'link-dir'); + + await fs.mkdir(targetDir, { recursive: true }); + await fs.writeFile(path.join(targetDir, 'file.txt'), 'contents'); + + await PluginTester.fullTest(pluginPath, [ + { + type: 'symlink', + path: linkPath, + target: targetDir, + } + ], { + validateApply: async () => { + const stats = await fs.lstat(linkPath); + expect(stats.isSymbolicLink()).to.be.true; + expect(await fs.readFile(path.join(linkPath, 'file.txt'), 'utf8')).to.eq('contents'); + }, + skipImport: true, + validateDestroy: async () => { + await expect(fs.lstat(linkPath)).rejects.toThrow(); + expect(await fs.readFile(path.join(targetDir, 'file.txt'), 'utf8')).to.eq('contents'); + }, + }); + }) +}) diff --git a/test/shell/symlinks.test.ts b/test/shell/symlinks.test.ts new file mode 100644 index 00000000..31075091 --- /dev/null +++ b/test/shell/symlinks.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { PluginTester } from '@codifycli/plugin-test'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import { ResourceOperation } from '@codifycli/schemas'; + +describe('Symlinks resource integration tests', async () => { + const pluginPath = path.resolve('./src/index.ts'); + + it('Can create multiple symlinks', { timeout: 300000 }, async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codify-symlinks-')); + const target1 = path.join(tempDir, 'target1.txt'); + const target2 = path.join(tempDir, 'target2.txt'); + const target3 = path.join(tempDir, 'target3.txt'); + const link1 = path.join(tempDir, 'link1.txt'); + const link2 = path.join(tempDir, 'link2.txt'); + const link3 = path.join(tempDir, 'link3.txt'); + + await fs.writeFile(target1, 'one'); + await fs.writeFile(target2, 'two'); + await fs.writeFile(target3, 'three'); + + await PluginTester.fullTest(pluginPath, [ + { + type: 'symlinks', + symlinks: [ + { path: link1, target: target1 }, + { path: link2, target: target2 }, + ], + } + ], { + validateApply: async () => { + expect(await fs.readlink(link1)).to.eq(target1); + expect(await fs.readlink(link2)).to.eq(target2); + }, + testModify: { + modifiedConfigs: [{ + type: 'symlinks', + symlinks: [ + { path: link1, target: target1 }, + { path: link3, target: target3 }, + ], + }], + validateModify: async (plans) => { + expect(plans[0]).toMatchObject({ + operation: ResourceOperation.MODIFY, + }); + + expect(await fs.readlink(link1)).to.eq(target1); + expect(await fs.readlink(link3)).to.eq(target3); + + // link2 was dropped from the declared config, not removed - Codify only manages + // explicitly declared symlinks in stateless mode, matching aliases/paths resources. + expect(await fs.readlink(link2)).to.eq(target2); + } + }, + validateDestroy: async () => { + await expect(fs.lstat(link1)).rejects.toThrow(); + await expect(fs.lstat(link3)).rejects.toThrow(); + + // Targets themselves are untouched by destroy. + expect(await fs.readFile(target1, 'utf8')).to.eq('one'); + expect(await fs.readFile(target3, 'utf8')).to.eq('three'); + }, + }); + }) +})