Skip to content

Commit bf6b0c9

Browse files
committed
Added aliases resource (multiple aliases all at once)
1 parent 09b0adf commit bf6b0c9

6 files changed

Lines changed: 292 additions & 5 deletions

File tree

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"ajv": "^8.12.0",
2727
"ajv-formats": "^2.1.1",
2828
"chalk": "^5.3.0",
29-
"codify-plugin-lib": "1.0.182-beta66",
29+
"codify-plugin-lib": "1.0.182-beta77",
3030
"codify-schemas": "1.0.86-beta11",
3131
"debug": "^4.3.4",
3232
"lodash.isequal": "^4.5.0",

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { Virtualenv } from './resources/python/virtualenv/virtualenv.js';
3131
import { VirtualenvProject } from './resources/python/virtualenv/virtualenv-project.js';
3232
import { ActionResource } from './resources/scripting/action.js';
3333
import { AliasResource } from './resources/shell/alias/alias-resource.js';
34+
import { AliasesResource } from './resources/shell/aliases/aliases-resource.js';
3435
import { PathResource } from './resources/shell/path/path-resource.js';
3536
import { SnapResource } from './resources/snap/snap.js';
3637
import { SshAddResource } from './resources/ssh/ssh-add.js';
@@ -50,6 +51,7 @@ runPlugin(Plugin.create(
5051
new XcodeToolsResource(),
5152
new PathResource(),
5253
new AliasResource(),
54+
new AliasesResource(),
5355
new HomebrewResource(),
5456
new PyenvResource(),
5557
new GitLfsResource(),

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ export class AliasResource extends Resource<AliasConfig> {
2929
parameterSettings: {
3030
value: { canModify: true }
3131
},
32+
importAndDestroy: {
33+
preventImport: true,
34+
},
3235
allowMultiple: {
3336
identifyingParameters: ['alias'],
3437
},
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import {
2+
CreatePlan,
3+
DestroyPlan,
4+
ModifyPlan,
5+
ParameterChange,
6+
RefreshContext,
7+
Resource,
8+
ResourceSettings,
9+
SpawnStatus,
10+
getPty,
11+
z
12+
} from 'codify-plugin-lib';
13+
import { OS } from 'codify-schemas';
14+
import fs from 'node:fs/promises';
15+
16+
import { FileUtils } from '../../../utils/file-utils.js';
17+
import { Utils } from '../../../utils/index.js';
18+
19+
const ALIAS_REGEX = /^'?([^=]+?)'?='?(.*?)'?$/
20+
21+
export const schema = z.object({
22+
aliases: z
23+
.array(z.object({
24+
alias: z.string(),
25+
value: z.string(),
26+
}))
27+
.describe('Aliases to create')
28+
.optional(),
29+
})
30+
31+
export type AliasesConfig = z.infer<typeof schema>;
32+
export class AliasesResource extends Resource<AliasesConfig> {
33+
getSettings(): ResourceSettings<AliasesConfig> {
34+
return {
35+
id: 'aliases',
36+
operatingSystems: [OS.Darwin, OS.Linux],
37+
schema,
38+
parameterSettings: {
39+
aliases: {
40+
type: 'array',
41+
itemType: 'object',
42+
isElementEqual: (a, b) => a.alias === b.alias && a.value === b.value,
43+
filterInStatelessMode: (desired, current) =>
44+
current.filter((c) => desired.some((d) => d.alias === c.alias)),
45+
canModify: true,
46+
}
47+
},
48+
importAndDestroy: {
49+
requiredParameters: ['aliases'],
50+
},
51+
allowMultiple: {
52+
identifyingParameters: ['alias'],
53+
},
54+
}
55+
}
56+
57+
override async refresh(parameters: any, context: RefreshContext<AliasesConfig>): Promise<Partial<AliasesConfig> | null> {
58+
const $ = getPty();
59+
60+
const { data, status } = await $.spawnSafe('alias', { interactive: true });
61+
62+
console.log('Data', data);
63+
64+
if (status === SpawnStatus.ERROR) {
65+
return null;
66+
}
67+
68+
const aliases = data.split(/\n/g)
69+
.map((l) => l.trim())
70+
.map((l) => l.match(ALIAS_REGEX))
71+
.filter(Boolean)
72+
.map((m) => (m ? { alias: m[1], value: m[2] } : null))
73+
.filter(Boolean) as Array<{ alias: string; value: string }>;
74+
75+
console.log('Command type', context.commandType);
76+
console.log('Aliases', aliases);
77+
78+
// If validation plan and no aliases match, return null
79+
if (context.commandType === 'validationPlan'
80+
&& aliases.filter((a) =>
81+
context.originalDesiredConfig?.aliases?.some((d) => d.alias === a.alias)).length === 0
82+
) {
83+
return null;
84+
}
85+
86+
if (!aliases || aliases.length === 0) {
87+
return null;
88+
}
89+
90+
return {
91+
aliases,
92+
}
93+
}
94+
95+
override async create(plan: CreatePlan<AliasesConfig>): Promise<void> {
96+
const shellRcPath = Utils.getPrimaryShellRc();
97+
98+
if (!(await FileUtils.fileExists(shellRcPath))) {
99+
await fs.writeFile(shellRcPath, '', { encoding: 'utf8' });
100+
}
101+
102+
await this.addAliases(plan.desiredConfig.aliases ?? []);
103+
}
104+
105+
async modify(pc: ParameterChange<AliasesConfig>, plan: ModifyPlan<AliasesConfig>): Promise<void> {
106+
const shellRcPath = Utils.getPrimaryShellRc();
107+
108+
if (!(await FileUtils.fileExists(shellRcPath))) {
109+
await fs.writeFile(shellRcPath, '', { encoding: 'utf8' });
110+
}
111+
112+
const { isStateful } = plan;
113+
if (isStateful) {
114+
const aliasesToRemove = pc.previousValue
115+
?.filter((a) => !pc.newValue?.some((c) => c.alias === a.alias)
116+
|| pc.newValue?.some((c) => c.alias === a.alias && c.value !== a.value)
117+
);
118+
const aliasesToAdd = pc.newValue
119+
?.filter((a) => !pc.previousValue?.some((c) => c.alias === a.alias));
120+
121+
await this.removeAliases(aliasesToRemove);
122+
await this.addAliases(aliasesToAdd);
123+
} else {
124+
const aliasesToRemove = pc.previousValue
125+
?.filter((a) => pc.newValue?.some((c) => c.alias === a.alias && c.value !== a.value));
126+
127+
const aliasesToAdd = pc.newValue
128+
?.filter((a) => !pc.previousValue?.some((c) => c.alias === a.alias)
129+
|| pc.previousValue?.some((c) => c.alias === a.alias && c.value !== a.value));
130+
131+
console.log('Aliases to add', aliasesToAdd);
132+
133+
await this.removeAliases(aliasesToRemove);
134+
await this.addAliases(aliasesToAdd);
135+
}
136+
}
137+
138+
async destroy(plan: DestroyPlan<AliasesConfig>): Promise<void> {
139+
console.log(plan.currentConfig.aliases);
140+
await this.removeAliases(plan.currentConfig.aliases ?? []);
141+
}
142+
143+
private async findAlias(alias: string, value: string): Promise<{ path: string; contents: string; } | null> {
144+
const paths = Utils.getShellRcFiles();
145+
146+
const aliasString = this.aliasString(alias, value);
147+
const aliasStringShort = this.aliasStringShort(alias, value);
148+
149+
for (const path of paths) {
150+
if (await FileUtils.fileExists(path)) {
151+
const fileContents = await fs.readFile(path, 'utf8');
152+
153+
if (fileContents.includes(aliasString) || fileContents.includes(aliasStringShort)) {
154+
return {
155+
path,
156+
contents: fileContents,
157+
}
158+
}
159+
}
160+
}
161+
162+
return null;
163+
}
164+
165+
private aliasString(alias: string, value: string): string {
166+
return `alias ${alias}='${value}'`
167+
}
168+
169+
private aliasStringShort(alias: string, value: string): string {
170+
return `alias ${alias}=${value}`
171+
}
172+
173+
private async removeAliases(aliasesToRemove: Array<{ alias: string; value: string }>): Promise<void> {
174+
for (const { alias, value } of aliasesToRemove ?? []) {
175+
const aliasInfo = await this.findAlias(alias, value);
176+
if (!aliasInfo) {
177+
console.warn(`Unable to find alias: ${alias} on the system. Codify isn't able to search all locations on the system. Please delete the alias manually and re-run Codify.`);
178+
continue;
179+
}
180+
181+
const aliasString = this.aliasString(alias, value);
182+
const aliasStringShort = this.aliasStringShort(alias, value);
183+
184+
await FileUtils.removeLineFromFile(aliasInfo.path, aliasString);
185+
await FileUtils.removeLineFromFile(aliasInfo.path, aliasStringShort);
186+
}
187+
}
188+
189+
private async addAliases(aliasesToAdd: Array<{ alias: string; value: string }>): Promise<void> {
190+
for (const { alias, value } of aliasesToAdd ?? []) {
191+
const aliasString = this.aliasString(alias, value);
192+
await FileUtils.addToStartupFile(aliasString);
193+
}
194+
}
195+
}
196+
197+

test/shell/aliases.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
2+
import { describe, expect, it } from 'vitest';
3+
import { PluginTester, testSpawn } from 'codify-plugin-test';
4+
import * as path from 'node:path';
5+
import { TestUtils } from '../test-utils.js';
6+
import { SpawnStatus } from 'codify-plugin-lib';
7+
import { ResourceOperation } from 'codify-schemas';
8+
9+
describe('Aliases resource integration tests', async () => {
10+
const pluginPath = path.resolve('./src/index.ts');
11+
12+
it('Can add aliases to shell rc', { timeout: 300000 }, async () => {
13+
await PluginTester.fullTest(pluginPath, [
14+
{
15+
type: 'aliases',
16+
aliases: [
17+
{ alias: 'my-alias', value: 'ls -l' },
18+
{ alias: 'my-alias2', value: 'pwd' }
19+
]
20+
}
21+
], {
22+
validatePlan: (plans) => {
23+
console.log(JSON.stringify(plans, null, 2))
24+
},
25+
validateApply: async () => {
26+
const { data: aliasOutput } = await testSpawn('alias')
27+
expect(aliasOutput).to.include('my-alias');
28+
expect(aliasOutput).to.include('ls -l');
29+
expect(aliasOutput).to.include('my-alias2');
30+
expect(aliasOutput).to.include('pwd');
31+
32+
// Alias expansion only happens in an interactive shell.
33+
expect((await testSpawn(TestUtils.getInteractiveCommand('my-alias -a'))).data).to.include('src')
34+
expect((await testSpawn(TestUtils.getInteractiveCommand('my-alias2'))).data).to.include((await testSpawn('pwd')).data)
35+
},
36+
testModify: {
37+
modifiedConfigs: [{
38+
type: 'aliases',
39+
aliases: [
40+
{ alias: 'my-alias', value: 'cd .' },
41+
{ alias: 'my-alias2', value: 'cd ..' },
42+
{ alias: 'my-alias3', value: 'cd ../..' }
43+
]
44+
}],
45+
validateModify: async (plans) => {
46+
console.log('Modify plans', JSON.stringify(plans, null, 2));
47+
48+
expect(plans[0]).toMatchObject({
49+
operation: ResourceOperation.MODIFY,
50+
parameters: [{
51+
previousValue: [
52+
{ alias: 'my-alias', value: 'ls -l' },
53+
{ alias: 'my-alias2', value: 'pwd' }
54+
],
55+
newValue: [
56+
{ alias: 'my-alias', value: 'cd .' },
57+
{ alias: 'my-alias2', value: 'cd ..' },
58+
{ alias: 'my-alias3', value: 'cd ../..' }
59+
]
60+
}]
61+
})
62+
63+
const { data: aliasOutput } = await testSpawn('alias')
64+
expect(aliasOutput).to.include('my-alias');
65+
expect(aliasOutput).to.include('my-alias2');
66+
expect(aliasOutput).to.include('my-alias3')
67+
68+
expect((await testSpawn('my-alias')).data).to.eq((await testSpawn('cd .')).data)
69+
expect((await testSpawn('my-alias2')).data).to.eq((await testSpawn('cd ..')).data)
70+
expect((await testSpawn('my-alias3')).data).to.eq((await testSpawn('cd ../..')).data)
71+
}
72+
},
73+
validateDestroy: async () => {
74+
const { data: aliasOutput } = await testSpawn('alias');
75+
expect(aliasOutput).to.not.include('my-alias');
76+
expect(aliasOutput).to.not.include('my-alias2');
77+
expect(aliasOutput).to.not.include('my-alias3');
78+
79+
expect(await testSpawn('my-alias')).toMatchObject({ status: SpawnStatus.ERROR });
80+
expect(await testSpawn('my-alias2')).toMatchObject({ status: SpawnStatus.ERROR });
81+
expect(await testSpawn('my-alias3')).toMatchObject({ status: SpawnStatus.ERROR });
82+
},
83+
});
84+
})
85+
})

0 commit comments

Comments
 (0)