Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions docs/resources/(resources)/shell/symlink.mdx
Original file line number Diff line number Diff line change
@@ -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.
74 changes: 74 additions & 0 deletions docs/resources/(resources)/shell/symlinks.mdx
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -95,6 +97,8 @@ runPlugin(Plugin.create(
new AliasesResource(),
new EnvVarResource(),
new EnvVarsResource(),
new SymlinkResource(),
new SymlinksResource(),
new HomebrewResource(),
new PyenvResource(),
new UvResource(),
Expand Down
5 changes: 3 additions & 2 deletions src/resources/file/file.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,9 +96,10 @@ export class FileResource extends Resource<FileConfig> {
}

async create(plan: CreatePlan<FileConfig>): Promise<void> {
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<FileConfig>, plan: ModifyPlan<FileConfig>): Promise<void> {
Expand Down
127 changes: 127 additions & 0 deletions src/resources/shell/symlink/symlink-resource.ts
Original file line number Diff line number Diff line change
@@ -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<typeof schema>;

const defaultConfig: Partial<SymlinkConfig> = {
path: '<Replace me here!>',
target: '<Replace me here!>',
}

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<SymlinkConfig> {
getSettings(): ResourceSettings<SymlinkConfig> {
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<SymlinkConfig>): Promise<Partial<SymlinkConfig> | 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<SymlinkConfig>): Promise<void> {
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<SymlinkConfig>, plan: ModifyPlan<SymlinkConfig>): Promise<void> {
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<SymlinkConfig>): Promise<void> {
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);
}
}
Loading
Loading