Skip to content

Commit da30030

Browse files
feat: Add symlink resource (#90)
* feat: Add symlink resource (auto-generated from issue #89) * fix: Linux test fixes for symlink resource * feat: improve file resource. Allow it to create intermediate folders * chore: bump version --------- Co-authored-by: kevinwang5658 <20214115+kevinwang5658@users.noreply.github.com> Co-authored-by: kevinwang5658 <kevinwang5658@gmail.com>
1 parent c4fd56d commit da30030

9 files changed

Lines changed: 615 additions & 3 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
title: symlink
3+
description: A reference page for the symlink resource
4+
---
5+
6+
The symlink resource reference. This resource creates and manages a single symbolic link on
7+
disk, pointing `path` at `target`. Symlinks are a built-in OS concept on both macOS and Linux
8+
— nothing needs to be installed.
9+
10+
```sh title="Equivalent shell command"
11+
ln -s TARGET PATH
12+
```
13+
14+
## Parameters:
15+
16+
- **path**: *(string, required)* The location where the symlink should be created.
17+
18+
- **target**: *(string, required, modifiable)* The file or directory the symlink should point
19+
to. The target does not need to already exist — Codify will still create the link.
20+
21+
## Example usage:
22+
23+
```json title="codify.jsonc"
24+
[
25+
{
26+
"type": "symlink",
27+
"path": "~/.vimrc",
28+
"target": "~/dotfiles/vimrc"
29+
}
30+
]
31+
```
32+
33+
Symlinking an application into `/Applications`:
34+
35+
```json title="codify.jsonc"
36+
[
37+
{
38+
"type": "symlink",
39+
"path": "/Applications/MyApp.app",
40+
"target": "~/Applications/MyApp.app"
41+
}
42+
]
43+
```
44+
45+
## Notes:
46+
47+
- Changing `target` re-links the resource in place (unlink + re-create) rather than
48+
recreating the whole resource.
49+
- If a real file or directory (not a symlink) already exists at `path`, Codify will raise an
50+
error instead of overwriting it — remove it manually first if you want Codify to manage
51+
that location.
52+
- Destroying this resource only removes the symlink itself; the file or directory it points
53+
to is left untouched.
54+
- Missing parent directories of `path` are created automatically.
55+
- See [symlinks](/docs/resources/symlinks) for managing several symlinks in one resource.
56+
When running `codify import`/`codify init`, the `symlinks` resource is used instead of
57+
`symlink` for any discovered links.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
title: symlinks
3+
description: A reference page for the symlinks resource
4+
---
5+
6+
The symlinks resource reference. This resource manages multiple symbolic links in a single
7+
resource configuration. Unlike the singular [symlink](/docs/resources/symlink) resource which
8+
creates one link per resource instance, the `symlinks` resource allows you to define and
9+
manage multiple links together as an array.
10+
11+
```sh title="Equivalent shell command (per entry)"
12+
ln -s TARGET PATH
13+
```
14+
15+
## Parameters:
16+
17+
- **symlinks**: *(array[object], optional)* An array of symlink definitions. Each entry
18+
contains:
19+
- **path**: *(string, required)* The location where the symlink should be created.
20+
- **target**: *(string, required)* The file or directory the symlink should point to.
21+
22+
## Example usage:
23+
24+
### Managing multiple symlinks in one resource
25+
26+
```json title="codify.jsonc"
27+
[
28+
{
29+
"type": "symlinks",
30+
"symlinks": [
31+
{ "path": "~/.vimrc", "target": "~/dotfiles/vimrc" },
32+
{ "path": "~/.zshrc", "target": "~/dotfiles/zshrc" },
33+
{ "path": "~/.gitconfig", "target": "~/dotfiles/gitconfig" }
34+
]
35+
}
36+
]
37+
```
38+
39+
### Symlinking config directories and applications
40+
41+
```json title="codify.jsonc"
42+
[
43+
{
44+
"type": "symlinks",
45+
"symlinks": [
46+
{ "path": "~/.config/nvim", "target": "~/dotfiles/nvim" },
47+
{ "path": "/Applications/MyApp.app", "target": "~/Applications/MyApp.app" }
48+
]
49+
}
50+
]
51+
```
52+
53+
## Comparison: symlinks vs symlink
54+
55+
**Use `symlinks` when:**
56+
- You want to manage multiple symlinks in a single resource
57+
- You prefer a more compact configuration for a dotfiles-style setup
58+
- This is the resource `codify import`/`codify init` will use when discovering existing links
59+
60+
**Use `symlink` when:**
61+
- You want fine-grained control over an individual link
62+
- You want to use Codify's resource dependency features for a specific link
63+
- You prefer to distribute link definitions across your configuration
64+
65+
## Notes:
66+
67+
- Adding an entry creates that link; removing an entry from the array only removes that
68+
link (the file/directory it pointed to is untouched); changing an entry's `target`
69+
re-links it in place.
70+
- If a real file or directory (not a symlink) already exists at one of the declared `path`
71+
values, Codify will raise an error instead of overwriting it.
72+
- Missing parent directories of each `path` are created automatically.
73+
- Both `symlink` and `symlinks` resources can be used in the same configuration without
74+
conflicts.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "default",
3-
"version": "1.15.3",
3+
"version": "1.16.0",
44
"description": "Default plugin for Codify - provides 50+ declarative resources for managing development tools and system configuration across macOS and Linux",
55
"main": "dist/index.js",
66
"scripts": {

‎src/index.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ import { AliasesResource } from './resources/shell/aliases/aliases-resource.js';
5454
import { EnvVarResource } from './resources/shell/env-var/env-var-resource.js';
5555
import { EnvVarsResource } from './resources/shell/env-vars/env-vars-resource.js';
5656
import { PathResource } from './resources/shell/path/path-resource.js';
57+
import { SymlinkResource } from './resources/shell/symlink/symlink-resource.js';
58+
import { SymlinksResource } from './resources/shell/symlinks/symlinks-resource.js';
5759
import { SnapResource } from './resources/snap/snap.js';
5860
import { SyncthingResource } from './resources/syncthing/syncthing.js';
5961
import { SyncthingDeviceResource } from './resources/syncthing/syncthing-device.js';
@@ -95,6 +97,8 @@ runPlugin(Plugin.create(
9597
new AliasesResource(),
9698
new EnvVarResource(),
9799
new EnvVarsResource(),
100+
new SymlinkResource(),
101+
new SymlinksResource(),
98102
new HomebrewResource(),
99103
new PyenvResource(),
100104
new UvResource(),

‎src/resources/file/file.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,9 +96,10 @@ export class FileResource extends Resource<FileConfig> {
9696
}
9797

9898
async create(plan: CreatePlan<FileConfig>): Promise<void> {
99-
const { contents, path } = plan.desiredConfig;
99+
const { contents, path: filePath } = plan.desiredConfig;
100100

101-
await fs.writeFile(path, contents, 'utf8');
101+
await fs.mkdir(path.dirname(filePath), { recursive: true });
102+
await fs.writeFile(filePath, contents, 'utf8');
102103
}
103104

104105
async modify(pc: ParameterChange<FileConfig>, plan: ModifyPlan<FileConfig>): Promise<void> {
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import {
2+
CreatePlan,
3+
DestroyPlan,
4+
ExampleConfig,
5+
ModifyPlan,
6+
ParameterChange,
7+
Resource,
8+
ResourceSettings,
9+
z,
10+
} from '@codifycli/plugin-core';
11+
import { OS } from '@codifycli/schemas';
12+
import fs from 'node:fs/promises';
13+
import path from 'node:path';
14+
15+
export const schema = z.object({
16+
path: z.string().describe('The location of the symlink to create.'),
17+
target: z.string().describe('The file or directory the symlink should point to.'),
18+
})
19+
.describe('Manages a single symbolic link, creating it at `path` and pointing it at `target`.');
20+
21+
export type SymlinkConfig = z.infer<typeof schema>;
22+
23+
const defaultConfig: Partial<SymlinkConfig> = {
24+
path: '<Replace me here!>',
25+
target: '<Replace me here!>',
26+
}
27+
28+
const exampleDotfile: ExampleConfig = {
29+
title: 'Symlink a dotfile from a dotfiles repo',
30+
description: 'Point a config file at the copy tracked in a version-controlled dotfiles repository.',
31+
configs: [{
32+
type: 'symlink',
33+
path: '~/.vimrc',
34+
target: '~/dotfiles/vimrc',
35+
}]
36+
}
37+
38+
const exampleDirectory: ExampleConfig = {
39+
title: 'Symlink an application into /Applications',
40+
description: 'Expose an application installed in a custom location under the standard /Applications directory.',
41+
configs: [{
42+
type: 'symlink',
43+
path: '/Applications/MyApp.app',
44+
target: '~/Applications/MyApp.app',
45+
}]
46+
}
47+
48+
export class SymlinkResource extends Resource<SymlinkConfig> {
49+
getSettings(): ResourceSettings<SymlinkConfig> {
50+
return {
51+
id: 'symlink',
52+
defaultConfig,
53+
exampleConfigs: {
54+
example1: exampleDotfile,
55+
example2: exampleDirectory,
56+
},
57+
operatingSystems: [OS.Darwin, OS.Linux],
58+
schema,
59+
parameterSettings: {
60+
path: { type: 'directory' },
61+
target: { type: 'directory', canModify: true },
62+
},
63+
importAndDestroy: {
64+
preventImport: true,
65+
},
66+
allowMultiple: {
67+
identifyingParameters: ['path'],
68+
},
69+
}
70+
}
71+
72+
override async refresh(parameters: Partial<SymlinkConfig>): Promise<Partial<SymlinkConfig> | null> {
73+
const { path: linkPath } = parameters;
74+
if (!linkPath) {
75+
return null;
76+
}
77+
78+
let stats;
79+
try {
80+
stats = await fs.lstat(linkPath);
81+
} catch {
82+
return null;
83+
}
84+
85+
if (!stats.isSymbolicLink()) {
86+
throw new Error(`A file or directory already exists at ${linkPath} and is not a symlink. Please remove it manually and re-run Codify.`);
87+
}
88+
89+
const target = await fs.readlink(linkPath);
90+
91+
return {
92+
path: linkPath,
93+
target,
94+
}
95+
}
96+
97+
override async create(plan: CreatePlan<SymlinkConfig>): Promise<void> {
98+
const { path: linkPath, target } = plan.desiredConfig;
99+
100+
const parentDir = path.dirname(linkPath);
101+
await fs.mkdir(parentDir, { recursive: true });
102+
103+
await fs.symlink(target, linkPath);
104+
}
105+
106+
override async modify(pc: ParameterChange<SymlinkConfig>, plan: ModifyPlan<SymlinkConfig>): Promise<void> {
107+
if (pc.name !== 'target') {
108+
return;
109+
}
110+
111+
const { path: linkPath } = plan.currentConfig;
112+
113+
await fs.unlink(linkPath);
114+
await fs.symlink(plan.desiredConfig.target, linkPath);
115+
}
116+
117+
override async destroy(plan: DestroyPlan<SymlinkConfig>): Promise<void> {
118+
const { path: linkPath } = plan.currentConfig;
119+
120+
const stats = await fs.lstat(linkPath);
121+
if (!stats.isSymbolicLink()) {
122+
throw new Error(`Refusing to remove ${linkPath} because it is not a symlink. Please remove it manually and re-run Codify.`);
123+
}
124+
125+
await fs.unlink(linkPath);
126+
}
127+
}

0 commit comments

Comments
 (0)