Skip to content

Commit 8681538

Browse files
committed
feat: Improved path resource by finally modifying it to search through user files for paths instead of spewing everything in path. Fixed tests and improved git clone to have a better matcher
1 parent 97cb43e commit 8681538

File tree

9 files changed

+243
-61
lines changed

9 files changed

+243
-61
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"ajv": "^8.12.0",
2424
"ajv-formats": "^2.1.1",
2525
"semver": "^7.6.0",
26-
"codify-plugin-lib": "1.0.148",
26+
"codify-plugin-lib": "1.0.156",
2727
"codify-schemas": "1.0.63",
2828
"chalk": "^5.3.0",
2929
"debug": "^4.3.4",
@@ -50,7 +50,7 @@
5050
"@types/debug": "4.1.12",
5151
"@types/plist": "^3.0.5",
5252
"@types/lodash.isequal": "^4.5.8",
53-
"codify-plugin-test": "0.0.47",
53+
"codify-plugin-test": "0.0.49",
5454
"commander": "^12.1.0",
5555
"eslint": "^8.51.0",
5656
"eslint-config-oclif": "^5",

src/resources/git/clone/git-clone.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
import { CreatePlan, DestroyPlan, getPty, Resource, ResourceSettings } from 'codify-plugin-lib';
1+
import { CreatePlan, DestroyPlan, Resource, ResourceSettings, getPty } from 'codify-plugin-lib';
22
import { ResourceConfig } from 'codify-schemas';
33
import path from 'node:path';
44

55
import { codifySpawn } from '../../../utils/codify-spawn.js';
66
import { FileUtils } from '../../../utils/file-utils.js';
7-
import { untildify } from '../../../utils/untildify.js';
87
import Schema from './git-clone-schema.json';
98

109

@@ -29,7 +28,22 @@ export class GitCloneResource extends Resource<GitCloneConfig> {
2928
requiredParameters: ['directory']
3029
},
3130
allowMultiple: {
32-
identifyingParameters: ['repository'],
31+
matcher: (desired, current) => {
32+
const desiredPath = desired.parentDirectory
33+
? path.resolve(desired.parentDirectory, this.extractBasename(desired.repository!)!)
34+
: path.resolve(desired.directory!);
35+
36+
const currentPath = current.parentDirectory
37+
? path.resolve(current.parentDirectory, this.extractBasename(current.repository!)!)
38+
: path.resolve(current.directory!);
39+
40+
const isNotCaseSensitive = process.platform === 'darwin';
41+
if (isNotCaseSensitive) {
42+
return desiredPath.toLowerCase() === currentPath.toLowerCase()
43+
}
44+
45+
return desiredPath === currentPath;
46+
}
3347
},
3448
dependencies: [
3549
'ssh-key',
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { PathResource } from './path-resource';
3+
4+
describe('PathResource unit tests', () => {
5+
it('Can match path declarations', () => {
6+
const pathResource = new PathResource();
7+
8+
const result = pathResource.findAllPathDeclarations(
9+
`
10+
# bun completions
11+
[ -s "/Users/kevinwang/.bun/_bun" ] && source "/Users/kevinwang/.bun/_bun"
12+
13+
# bun
14+
export BUN_INSTALL="$HOME/.bun"
15+
export PATH="$BUN_INSTALL/bin:$PATH"
16+
17+
export DENO_INSTALL="/Users/kevinwang/.deno"
18+
export PATH="$DENO_INSTALL/bin:$PATH"
19+
20+
export PATH="$HOME/.jenv/bin:$PATH"
21+
eval "$(jenv init -)"
22+
23+
export ANDROID_SDK_ROOT="$HOME/Library/Android/sdk"
24+
`)
25+
26+
console.log(result);
27+
28+
expect(result).toMatchObject([
29+
{
30+
declaration: 'export PATH="$BUN_INSTALL/bin:$PATH"',
31+
path: '$BUN_INSTALL/bin'
32+
},
33+
{
34+
declaration: 'export PATH="$DENO_INSTALL/bin:$PATH"',
35+
path: '$DENO_INSTALL/bin'
36+
},
37+
{
38+
declaration: 'export PATH="$HOME/.jenv/bin:$PATH"',
39+
path: '$HOME/.jenv/bin'
40+
}
41+
])
42+
43+
})
44+
45+
it('Can match path declarations 2', () => {
46+
const pathResource = new PathResource();
47+
48+
const result = pathResource.findAllPathDeclarations(
49+
`
50+
export NVM_DIR="$HOME/.nvm"
51+
[ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh" # This loads nvm
52+
[ -s "$NVM_DIR/bash_completion" ] && \\. "$NVM_DIR/bash_completion" # This loads nvm bash_completion
53+
54+
export PYENV_ROOT="$HOME/.pyenv"
55+
[[ -d $PYENV_ROOT/bin ]] && export PATH="$PYENV_ROOT/bin:$PATH"
56+
eval "$(pyenv init -)"
57+
58+
alias gcc='git commit -v'
59+
`)
60+
61+
expect(result).toMatchObject([
62+
{
63+
declaration: "export PATH=\"$PYENV_ROOT/bin:$PATH\"",
64+
path: "$PYENV_ROOT/bin",
65+
}
66+
])
67+
68+
})
69+
70+
it('Can match path declarations 3', () => {
71+
const pathResource = new PathResource();
72+
73+
const result = pathResource.findAllPathDeclarations(
74+
`
75+
export PATH=/Users/kevinwang/a/random/path:$PATH;
76+
export PATH=/Users/kevinwang/.nvm/.bin/2:$PATH;
77+
export PATH=/Users/kevinwang/.nvm/.bin/3:$PATH;
78+
`);
79+
80+
expect(result).toMatchObject([
81+
{
82+
declaration: 'export PATH=/Users/kevinwang/a/random/path:$PATH;',
83+
path: '/Users/kevinwang/a/random/path'
84+
},
85+
{
86+
declaration: 'export PATH=/Users/kevinwang/.nvm/.bin/2:$PATH;',
87+
path: '/Users/kevinwang/.nvm/.bin/2'
88+
},
89+
{
90+
declaration: 'export PATH=/Users/kevinwang/.nvm/.bin/3:$PATH;',
91+
path: '/Users/kevinwang/.nvm/.bin/3'
92+
}
93+
])
94+
95+
})
96+
97+
})

src/resources/shell/path/path-resource.ts

Lines changed: 104 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
DestroyPlan,
44
getPty,
55
ModifyPlan,
6-
ParameterChange,
6+
ParameterChange, RefreshContext, resolvePathWithVariables,
77
Resource,
88
ResourceSettings
99
} from 'codify-plugin-lib';
@@ -20,26 +20,44 @@ export interface PathConfig extends StringIndexedObject {
2020
path: string;
2121
paths: string[];
2222
prepend: boolean;
23+
declarationsOnly: boolean;
2324
}
2425

2526
export class PathResource extends Resource<PathConfig> {
27+
private readonly PATH_DECLARATION_REGEX = /((export PATH=)|(path+=\()|(path=\())(.+?)[\n;]/g;
28+
private readonly PATH_REGEX = /(?<=[="':(])([^"'\n\r]+?)(?=["':)\n;])/g
29+
private readonly filePaths = [
30+
path.join(os.homedir(), '.zshrc'),
31+
path.join(os.homedir(), '.zprofile'),
32+
path.join(os.homedir(), '.zshenv'),
33+
]
34+
2635
getSettings(): ResourceSettings<PathConfig> {
2736
return {
2837
id: 'path',
2938
schema: Schema,
3039
parameterSettings: {
3140
path: { type: 'directory' },
3241
paths: { canModify: true, type: 'array', itemType: 'directory' },
33-
prepend: { default: false }
42+
prepend: { default: false, setting: true },
43+
declarationsOnly: { default: false, setting: true },
3444
},
3545
importAndDestroy:{
36-
refreshKeys: ['paths'],
46+
refreshKeys: ['paths', 'declarationsOnly'],
3747
defaultRefreshValues: {
38-
paths: []
48+
paths: [],
49+
declarationsOnly: true,
3950
}
4051
},
4152
allowMultiple: {
42-
identifyingParameters: ['path', 'paths']
53+
matcher: (desired, current) => {
54+
if (desired.path) {
55+
return desired.path === current.path;
56+
}
57+
58+
const currentPaths = new Set(current.paths)
59+
return desired.paths?.some((p) => currentPaths.has(p));
60+
}
4361
}
4462
}
4563
}
@@ -50,11 +68,47 @@ export class PathResource extends Resource<PathConfig> {
5068
}
5169
}
5270

53-
override async refresh(parameters: Partial<PathConfig>): Promise<Partial<PathConfig> | null> {
54-
const $ = getPty();
71+
override async refresh(parameters: Partial<PathConfig>, context: RefreshContext<PathConfig>): Promise<Partial<PathConfig> | null> {
72+
// If declarations only, we only look into files to find potential paths
73+
if (parameters.declarationsOnly || context.isStateful) {
74+
const pathsResult = new Set<string>();
75+
76+
for (const path of this.filePaths) {
77+
if (!(await FileUtils.fileExists(path))) {
78+
continue;
79+
}
80+
81+
const contents = await fs.readFile(path, 'utf8');
82+
const pathDeclarations = this.findAllPathDeclarations(contents);
5583

84+
if (parameters.path && pathDeclarations.some((d) => resolvePathWithVariables(untildify(d.path)) === parameters.path)) {
85+
return parameters;
86+
}
87+
88+
if (parameters.paths) {
89+
pathDeclarations
90+
.map((d) => d.path)
91+
.forEach((d) => pathsResult.add(resolvePathWithVariables(untildify(d))));
92+
}
93+
}
94+
95+
if (parameters.path || pathsResult.size === 0) {
96+
return null;
97+
}
98+
99+
return {
100+
...parameters,
101+
paths: [...pathsResult],
102+
}
103+
}
104+
105+
// Otherwise look in path variable to see if it exists
106+
const $ = getPty();
56107
const { data: existingPaths } = await $.spawnSafe('echo $PATH')
57-
if (parameters.path !== undefined && (existingPaths.includes(parameters.path) || existingPaths.includes(untildify(parameters.path)))) {
108+
109+
if (parameters.path !== undefined && (
110+
existingPaths.includes(parameters.path)
111+
)) {
58112
return parameters;
59113
}
60114

@@ -73,8 +127,8 @@ export class PathResource extends Resource<PathConfig> {
73127
const userPaths = existingPaths.split(':')
74128
.filter((p) => !systemPaths.includes(p))
75129

76-
if (parameters.paths !== undefined) {
77-
return { paths: userPaths, prepend: parameters.prepend };
130+
if (parameters.paths && userPaths.length > 0) {
131+
return { ...parameters, paths: userPaths };
78132
}
79133

80134
return null;
@@ -131,68 +185,63 @@ export class PathResource extends Resource<PathConfig> {
131185
}
132186

133187
private async removePath(pathValue: string): Promise<void> {
134-
const fileInfo = await this.findPathDeclaration(pathValue);
135-
if (!fileInfo) {
188+
const foundPaths = await this.findPath(pathValue);
189+
if (foundPaths.length === 0) {
136190
throw new Error(`Could not find path declaration: ${pathValue}. Please manually remove the path and then re-run Codify`);
137191
}
138192

139-
const { content, pathsFound, filePath } = fileInfo;
140-
141-
const fileLines = content
142-
.split(/\n/);
193+
for (const foundPath of foundPaths) {
194+
console.log(`Removing path: ${pathValue} from ${foundPath.file}`)
195+
await FileUtils.removeFromFile(foundPath.file, foundPath.pathDeclaration.declaration);
196+
}
197+
}
143198

144-
for (const pathFound of pathsFound) {
145-
const line = fileLines
146-
.findIndex((l) => l.includes(pathFound));
199+
private async findPath(pathToFind: string): Promise<Array<{ file: string; pathDeclaration: PathDeclaration }>> {
200+
const result = [];
147201

148-
if (line === -1) {
149-
throw new Error(`Could not find path declaration: ${pathValue}. Please manually remove the path and then re-run Codify`);
202+
for (const filePath of this.filePaths) {
203+
if (!(await FileUtils.fileExists(filePath))) {
204+
continue;
150205
}
151206

152-
fileLines.splice(line, 1);
207+
const contents = await fs.readFile(filePath, 'utf8');
208+
const pathDeclarations = this.findAllPathDeclarations(contents);
209+
210+
const foundDeclarations = pathDeclarations.filter((d) => d.path === pathToFind);
211+
result.push(...foundDeclarations.map((d) => ({ pathDeclaration: d, file: filePath })));
153212
}
154213

155-
console.log(`Removing path: ${pathValue} from ${filePath}`)
156-
await fs.writeFile(filePath, fileLines.join('\n'), { encoding: 'utf8' });
214+
return result;
157215
}
158216

159-
private async findPathDeclaration(value: string): Promise<PathDeclaration | null> {
160-
const filePaths = [
161-
path.join(os.homedir(), '.zshrc'),
162-
path.join(os.homedir(), '.zprofile'),
163-
path.join(os.homedir(), '.zshenv'),
164-
];
165-
166-
const searchTerms = [
167-
`export PATH=${value}:$PATH`,
168-
`export PATH=$PATH:${value}`,
169-
`path+=('${value}')`,
170-
`path+=(${value})`,
171-
`path=('${value}' $path)`,
172-
`path=(${value} $path)`
173-
]
174-
175-
for (const filePath of filePaths) {
176-
if (await FileUtils.fileExists(filePath)) {
177-
const fileContents = await fs.readFile(filePath, 'utf8');
178-
179-
const pathsFound = searchTerms.filter((st) => fileContents.includes(st));
180-
if (pathsFound.length > 0) {
181-
return {
182-
filePath,
183-
content: fileContents,
184-
pathsFound,
185-
}
217+
findAllPathDeclarations(contents: string): PathDeclaration[] {
218+
const results = [];
219+
const pathDeclarations = contents.matchAll(this.PATH_DECLARATION_REGEX);
220+
221+
for (const declaration of pathDeclarations) {
222+
const trimmedDeclaration = declaration[0];
223+
const paths = trimmedDeclaration.matchAll(this.PATH_REGEX);
224+
225+
for (const path of paths) {
226+
const trimmedPath = path[0];
227+
if (trimmedPath === '$PATH') {
228+
continue;
186229
}
230+
231+
results.push({
232+
declaration: trimmedDeclaration.trim(),
233+
path: trimmedPath,
234+
});
187235
}
188236
}
189237

190-
return null;
238+
return results;
191239
}
192240
}
193241

194242
interface PathDeclaration {
195-
filePath: string;
196-
content: string;
197-
pathsFound: string[];
243+
// The entire declaration. Ex for: export PATH="$PYENV_ROOT/bin:$PATH", it's export PATH="$PYENV_ROOT/bin:$PATH"
244+
declaration: string;
245+
// The path being added. Ex for: export PATH="$PYENV_ROOT/bin:$PATH", it's $PYENV_ROOT/bin
246+
path: string;
198247
}

src/resources/shell/path/path-schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
"prepend": {
2020
"type": "boolean",
2121
"description": "Whether or not to prepend to the path."
22+
},
23+
"declarationsOnly": {
24+
"type": "boolean",
25+
"default": false,
26+
"description": "Only plan and manage explicitly declared paths found in shell startup scripts. This value is forced to true for stateful mode"
2227
}
2328
},
2429
"additionalProperties": false

0 commit comments

Comments
 (0)