Skip to content

Commit 4ee88af

Browse files
committed
feat: Added env-var resource
1 parent b5cbbbc commit 4ee88af

10 files changed

Lines changed: 686 additions & 1 deletion

File tree

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.1.0-beta.6",
3+
"version": "1.1.0-beta.8",
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
@@ -35,6 +35,8 @@ import { RbenvResource } from './resources/ruby/rbenv/rbenv.js';
3535
import { ActionResource } from './resources/scripting/action.js';
3636
import { AliasResource } from './resources/shell/alias/alias-resource.js';
3737
import { AliasesResource } from './resources/shell/aliases/aliases-resource.js';
38+
import { EnvVarResource } from './resources/shell/env-var/env-var-resource.js';
39+
import { EnvVarsResource } from './resources/shell/env-vars/env-vars-resource.js';
3840
import { PathResource } from './resources/shell/path/path-resource.js';
3941
import { SnapResource } from './resources/snap/snap.js';
4042
import { SyncthingResource } from './resources/syncthing/syncthing.js';
@@ -60,6 +62,8 @@ runPlugin(Plugin.create(
6062
new PathResource(),
6163
new AliasResource(),
6264
new AliasesResource(),
65+
new EnvVarResource(),
66+
new EnvVarsResource(),
6367
new HomebrewResource(),
6468
new PyenvResource(),
6569
new UvResource(),
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# env-var / env-vars
2+
3+
Declaratively manage shell environment variables by writing `export` declarations to your shell startup script (`~/.zshrc` or `~/.bashrc`).
4+
5+
Use this resource to ensure variables like `$PNPM_HOME`, `$PYENV_ROOT`, or `$BUN_INSTALL` are always set on a fresh machine.
6+
7+
## Resources
8+
9+
| Resource | Description |
10+
|---|---|
11+
| `env-var` | Manages a single environment variable. Supports multiple independent declarations via `allowMultiple`. |
12+
| `env-vars` | Manages a collection of environment variables in one config block. |
13+
14+
## Usage
15+
16+
### Single variable (`env-var`)
17+
18+
```jsonc
19+
// codify.jsonc
20+
[
21+
{ "type": "env-var", "variable": "PNPM_HOME", "value": "$HOME/Library/pnpm" },
22+
{ "type": "env-var", "variable": "BUN_INSTALL", "value": "$HOME/.bun" }
23+
]
24+
```
25+
26+
### Multiple variables (`env-vars`)
27+
28+
```jsonc
29+
// codify.jsonc
30+
[
31+
{
32+
"type": "env-vars",
33+
"vars": [
34+
{ "variable": "PNPM_HOME", "value": "$HOME/Library/pnpm" },
35+
{ "variable": "BUN_INSTALL", "value": "$HOME/.bun" },
36+
{ "variable": "PYENV_ROOT", "value": "$HOME/.pyenv" }
37+
]
38+
}
39+
]
40+
```
41+
42+
## Parameters
43+
44+
### `env-var`
45+
46+
| Parameter | Type | Required | Description |
47+
|---|---|---|---|
48+
| `variable` | `string` | Yes | Environment variable name (e.g. `PNPM_HOME`) |
49+
| `value` | `string` | Yes | Value to assign |
50+
51+
### `env-vars`
52+
53+
| Parameter | Type | Default | Description |
54+
|---|---|---|---|
55+
| `vars` | `Array<{ variable, value }>` | `[]` | List of variables to manage |
56+
| `declarationsOnly` | `boolean` | `true` | When true, only manages variables explicitly exported in shell RC files. When false, considers all variables in the live environment. |
57+
58+
## Notes
59+
60+
- **PATH is excluded.** Use the `path` resource to manage `$PATH` entries.
61+
- **Write target.** All new declarations are written to the primary shell RC file (`~/.zshrc` for zsh, `~/.bashrc` for bash).
62+
- **Read scope.** During refresh, all known RC files are scanned so declarations added by other tools are detected.
63+
- **Value format.** Values are written quoted: `export NAME="value"`. Shell variable references like `$HOME` are preserved as-is.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import {
2+
CreatePlan,
3+
DestroyPlan,
4+
ExampleConfig,
5+
ModifyPlan,
6+
ParameterChange,
7+
Resource,
8+
ResourceSettings,
9+
Utils,
10+
} from '@codifycli/plugin-core';
11+
import { OS, StringIndexedObject } from '@codifycli/schemas';
12+
import fs from 'node:fs/promises';
13+
14+
import { FileUtils } from '../../../utils/file-utils.js';
15+
import Schema from './env-var-schema.json';
16+
17+
export interface EnvVarConfig extends StringIndexedObject {
18+
variable: string;
19+
value: string;
20+
}
21+
22+
const ENV_DECLARATION_REGEX = /^\s*export\s+([A-Z_a-z][A-Z0-9_a-z]*)\s*=\s*(["']?)(.+?)\2\s*(?:#.*)?$/gm;
23+
24+
const defaultConfig: Partial<EnvVarConfig> = {
25+
variable: '<Replace me here!>',
26+
value: '<Replace me here!>',
27+
}
28+
29+
const examplePnpmHome: ExampleConfig = {
30+
title: 'Set PNPM_HOME',
31+
description: 'Declare the PNPM_HOME environment variable so pnpm global binaries are available in a new shell.',
32+
configs: [{
33+
type: 'env-var',
34+
variable: 'PNPM_HOME',
35+
value: '$HOME/Library/pnpm',
36+
}]
37+
}
38+
39+
const exampleAsdfDataDir: ExampleConfig = {
40+
title: 'Set ASDF_DATA_DIR',
41+
description: 'Override the default asdf data directory to a custom location.',
42+
configs: [{
43+
type: 'env-var',
44+
variable: 'ASDF_DATA_DIR',
45+
value: '$HOME/.asdf',
46+
}]
47+
}
48+
49+
export class EnvVarResource extends Resource<EnvVarConfig> {
50+
private readonly filePaths = Utils.getShellRcFiles();
51+
52+
getSettings(): ResourceSettings<EnvVarConfig> {
53+
return {
54+
id: 'env-var',
55+
defaultConfig,
56+
exampleConfigs: {
57+
example1: examplePnpmHome,
58+
example2: exampleAsdfDataDir,
59+
},
60+
operatingSystems: [OS.Darwin, OS.Linux],
61+
schema: Schema,
62+
parameterSettings: {
63+
value: { canModify: true, isSensitive: true },
64+
},
65+
importAndDestroy: {
66+
preventImport: true,
67+
},
68+
allowMultiple: {
69+
identifyingParameters: ['variable'],
70+
},
71+
}
72+
}
73+
74+
override async refresh(parameters: Partial<EnvVarConfig>): Promise<Partial<EnvVarConfig> | null> {
75+
for (const filePath of this.filePaths) {
76+
if (!(await FileUtils.fileExists(filePath))) {
77+
continue;
78+
}
79+
80+
const contents = await fs.readFile(filePath, 'utf8');
81+
const declarations = this.findAllDeclarations(contents);
82+
const found = declarations.find((d) => d.variable === parameters.variable);
83+
84+
if (found) {
85+
return found;
86+
}
87+
}
88+
89+
return null;
90+
}
91+
92+
override async create(plan: CreatePlan<EnvVarConfig>): Promise<void> {
93+
const shellRcPath = Utils.getPrimaryShellRc();
94+
if (!(await FileUtils.fileExists(shellRcPath))) {
95+
await fs.writeFile(shellRcPath, '', { encoding: 'utf8' });
96+
}
97+
98+
await FileUtils.addToStartupFile(this.declarationString(plan.desiredConfig.variable, plan.desiredConfig.value));
99+
}
100+
101+
override async modify(pc: ParameterChange<EnvVarConfig>, plan: ModifyPlan<EnvVarConfig>): Promise<void> {
102+
if (pc.name !== 'value') {
103+
return;
104+
}
105+
106+
const { variable, value } = plan.currentConfig;
107+
const found = await this.findDeclaration(variable, value);
108+
if (!found) {
109+
throw new Error(`Unable to find env var declaration: ${variable}. Please remove it manually and re-run Codify.`);
110+
}
111+
112+
const lines = found.contents.trimEnd().split(/\n/);
113+
const lineIndex = lines.findIndex((l) => l.trim() === this.declarationString(variable, value));
114+
if (lineIndex === -1) {
115+
throw new Error(`Unable to find line for ${variable} in ${found.path}. Please remove it manually and re-run Codify.`);
116+
}
117+
118+
lines.splice(lineIndex, 1, this.declarationString(plan.desiredConfig.variable, plan.desiredConfig.value));
119+
await fs.writeFile(found.path, lines.join('\n'), 'utf8');
120+
}
121+
122+
override async destroy(plan: DestroyPlan<EnvVarConfig>): Promise<void> {
123+
const { variable, value } = plan.currentConfig;
124+
const found = await this.findDeclaration(variable, value);
125+
if (!found) {
126+
throw new Error(`Unable to find env var declaration: ${variable}. Please remove it manually and re-run Codify.`);
127+
}
128+
129+
await FileUtils.removeLineFromFile(found.path, this.declarationString(variable, value));
130+
}
131+
132+
private async findDeclaration(variable: string, value: string): Promise<{ path: string; contents: string } | null> {
133+
const declaration = this.declarationString(variable, value);
134+
135+
for (const filePath of this.filePaths) {
136+
if (!(await FileUtils.fileExists(filePath))) {
137+
continue;
138+
}
139+
140+
const contents = await fs.readFile(filePath, 'utf8');
141+
if (contents.includes(declaration)) {
142+
return { path: filePath, contents };
143+
}
144+
}
145+
146+
return null;
147+
}
148+
149+
findAllDeclarations(contents: string): Array<{ variable: string; value: string }> {
150+
const results: Array<{ variable: string; value: string }> = [];
151+
const matches = contents.matchAll(ENV_DECLARATION_REGEX);
152+
153+
for (const match of matches) {
154+
const [, variable, , value] = match;
155+
if (variable === 'PATH') {
156+
continue;
157+
}
158+
results.push({ variable, value });
159+
}
160+
161+
return results;
162+
}
163+
164+
private declarationString(variable: string, value: string): string {
165+
return `export ${variable}="${value}"`;
166+
}
167+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema",
3+
"$id": "https://www.codifycli.com/env-var.json",
4+
"$comment": "https://codifycli.com/docs/resources/shell/env-var/",
5+
"title": "Env-var resource",
6+
"description": "Manages a single shell environment variable by writing an export declaration to the shell startup script.",
7+
"type": "object",
8+
"properties": {
9+
"variable": {
10+
"type": "string",
11+
"pattern": "^[A-Z_a-z][A-Z0-9_a-z]*$",
12+
"description": "The environment variable name (e.g. PNPM_HOME)"
13+
},
14+
"value": {
15+
"type": "string",
16+
"description": "The environment variable value (e.g. $HOME/.local/share/pnpm)"
17+
}
18+
},
19+
"required": ["variable", "value"],
20+
"additionalProperties": false
21+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { EnvVarsResource } from './env-vars-resource.js';
3+
4+
describe('EnvVarsResource unit tests', () => {
5+
it('parses a simple unquoted export', () => {
6+
const resource = new EnvVarsResource();
7+
const result = resource.findAllDeclarations('export FOO=bar\n');
8+
expect(result).toMatchObject([{ variable: 'FOO', value: 'bar' }]);
9+
});
10+
11+
it('parses a double-quoted export', () => {
12+
const resource = new EnvVarsResource();
13+
const result = resource.findAllDeclarations('export PNPM_HOME="/Users/me/Library/pnpm"\n');
14+
expect(result).toMatchObject([{ variable: 'PNPM_HOME', value: '/Users/me/Library/pnpm' }]);
15+
});
16+
17+
it('parses a value containing shell variables', () => {
18+
const resource = new EnvVarsResource();
19+
const result = resource.findAllDeclarations('export PYENV_ROOT="$HOME/.pyenv"\n');
20+
expect(result).toMatchObject([{ variable: 'PYENV_ROOT', value: '$HOME/.pyenv' }]);
21+
});
22+
23+
it('parses multiple exports in one file', () => {
24+
const resource = new EnvVarsResource();
25+
const result = resource.findAllDeclarations(`
26+
export PNPM_HOME="/Users/me/Library/pnpm"
27+
export BUN_INSTALL="$HOME/.bun"
28+
export DENO_INSTALL="$HOME/.deno"
29+
`);
30+
expect(result).toMatchObject([
31+
{ variable: 'PNPM_HOME', value: '/Users/me/Library/pnpm' },
32+
{ variable: 'BUN_INSTALL', value: '$HOME/.bun' },
33+
{ variable: 'DENO_INSTALL', value: '$HOME/.deno' },
34+
]);
35+
});
36+
37+
it('excludes PATH declarations', () => {
38+
const resource = new EnvVarsResource();
39+
const result = resource.findAllDeclarations(`
40+
export PNPM_HOME="/Users/me/Library/pnpm"
41+
export PATH="$PNPM_HOME:$PATH"
42+
`);
43+
expect(result).toMatchObject([{ variable: 'PNPM_HOME', value: '/Users/me/Library/pnpm' }]);
44+
expect(result.find((r) => r.variable === 'PATH')).toBeUndefined();
45+
});
46+
47+
it('ignores non-export assignments', () => {
48+
const resource = new EnvVarsResource();
49+
const result = resource.findAllDeclarations(`
50+
FOO=bar
51+
MY_VAR=something
52+
export REAL_VAR="value"
53+
`);
54+
expect(result).toMatchObject([{ variable: 'REAL_VAR', value: 'value' }]);
55+
expect(result.length).toBe(1);
56+
});
57+
58+
it('ignores commented-out lines', () => {
59+
const resource = new EnvVarsResource();
60+
const result = resource.findAllDeclarations(`
61+
# export COMMENTED_OUT="nope"
62+
export ACTIVE="yes"
63+
`);
64+
expect(result).toMatchObject([{ variable: 'ACTIVE', value: 'yes' }]);
65+
expect(result.length).toBe(1);
66+
});
67+
68+
it('parses declaration at end of file with no trailing newline', () => {
69+
const resource = new EnvVarsResource();
70+
const result = resource.findAllDeclarations('export PNPM_HOME="/Users/me/Library/pnpm"');
71+
expect(result).toMatchObject([{ variable: 'PNPM_HOME', value: '/Users/me/Library/pnpm' }]);
72+
});
73+
74+
it('parses single-quoted values', () => {
75+
const resource = new EnvVarsResource();
76+
const result = resource.findAllDeclarations("export MY_TOKEN='abc123'\n");
77+
expect(result).toMatchObject([{ variable: 'MY_TOKEN', value: 'abc123' }]);
78+
});
79+
80+
it('handles mixed content (comments, paths, exports)', () => {
81+
const resource = new EnvVarsResource();
82+
const result = resource.findAllDeclarations(`
83+
# bun
84+
export BUN_INSTALL="$HOME/.bun"
85+
export PATH="$BUN_INSTALL/bin:$PATH"
86+
87+
export PYENV_ROOT="$HOME/.pyenv"
88+
export PATH="$PYENV_ROOT/bin:$PATH"
89+
eval "$(pyenv init -)"
90+
91+
export PNPM_HOME="$HOME/Library/pnpm"
92+
`);
93+
expect(result).toMatchObject([
94+
{ variable: 'BUN_INSTALL', value: '$HOME/.bun' },
95+
{ variable: 'PYENV_ROOT', value: '$HOME/.pyenv' },
96+
{ variable: 'PNPM_HOME', value: '$HOME/Library/pnpm' },
97+
]);
98+
expect(result.find((r) => r.variable === 'PATH')).toBeUndefined();
99+
});
100+
});

0 commit comments

Comments
 (0)