Skip to content

Commit aad8e5a

Browse files
committed
Add new refresh command
1 parent 904ac3f commit aad8e5a

File tree

3 files changed

+317
-1
lines changed

3 files changed

+317
-1
lines changed

src/commands/refresh.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import chalk from 'chalk';
2+
import fs from 'node:fs/promises';
3+
import path from 'node:path';
4+
5+
import { BaseCommand } from '../common/base-command.js';
6+
import { ImportOrchestrator } from '../orchestrators/import.js';
7+
import { ShellUtils } from '../utils/shell.js';
8+
import { RefreshOrchestrator } from '../orchestrators/refresh.js';
9+
10+
export default class Refresh extends BaseCommand {
11+
static strict = false;
12+
static override description =
13+
`Generate Codify configurations from already installed packages.
14+
15+
Use a space-separated list of arguments to specify the resource types to import.
16+
If a codify.jsonc file already exists, omit arguments to update the file to match the system.
17+
18+
${chalk.bold('Modes:')}
19+
1. ${chalk.bold('No args:')} If no args are specified and an *.codify.jsonc already exists, Codify
20+
will update the existing file with new changes on the system.
21+
22+
${chalk.underline('Command:')}
23+
codify import
24+
25+
2. ${chalk.bold('With args:')} Specify specific resources to import using arguments. Wild card matching is supported
26+
using '*' and '?' (${chalk.italic('Note: in zsh * expands to the current dir and needs to be escaped using \\* or \'*\'')}).
27+
A prompt will be shown if more information is required to complete the import.
28+
29+
${chalk.underline('Examples:')}
30+
codify import nvm asdf*
31+
codify import \\* (for importing all supported resources)
32+
33+
The results can be saved in one of three ways:
34+
a. To an existing *.codify.jsonc file
35+
b. To a new file
36+
c. Printed to the console only
37+
38+
Codify will attempt to smartly insert new configurations while preserving existing spacing and formatting.
39+
40+
For more information, visit: https://docs.codifycli.com/commands/import`
41+
42+
static override examples = [
43+
'<%= config.bin %> <%= command.id %> homebrew nvm asdf',
44+
'<%= config.bin %> <%= command.id %>',
45+
'<%= config.bin %> <%= command.id %> git-clone --path ../my/other/folder',
46+
'<%= config.bin %> <%= command.id %> \\*'
47+
]
48+
49+
public async run(): Promise<void> {
50+
const { raw, flags } = await this.parse(Refresh)
51+
52+
if (flags.path) {
53+
this.log(`Applying Codify from: ${flags.path}`);
54+
}
55+
56+
const resolvedPath = flags.path ?? '.';
57+
58+
const args = raw
59+
.filter((r) => r.type === 'arg')
60+
.map((r) => r.input);
61+
62+
const cleanedArgs = await this.cleanupZshStarExpansion(args);
63+
64+
await RefreshOrchestrator.run({
65+
verbosityLevel: flags.debug ? 3 : 0,
66+
typeIds: cleanedArgs,
67+
path: resolvedPath,
68+
secureMode: flags.secure,
69+
}, this.reporter)
70+
71+
process.exit(0)
72+
}
73+
74+
private async cleanupZshStarExpansion(args: string[]): Promise<string[]> {
75+
const combinedArgs = args.join(' ');
76+
const zshStarExpansion = (await ShellUtils.isZshShell())
77+
? (await fs.readdir(process.cwd())).filter((name) => !name.startsWith('.')).join(' ')
78+
: ''
79+
80+
return combinedArgs
81+
.replaceAll(zshStarExpansion, '*')
82+
.split(' ')
83+
}
84+
}

src/entities/project.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ ${JSON.stringify(projectConfigs, null, 2)}`);
6666
return validate(this.codifyFiles[0])
6767
}
6868

69-
filter(ids: string[]): Project {
69+
filterInPlace(ids: string[]): Project {
7070
this.resourceConfigs = this.resourceConfigs.filter((r) => ids.find((id) => r.id.includes(id)));
7171
this.stateConfigs = this.stateConfigs?.filter((s) => ids.includes(s.id)) ?? null;
7272

src/orchestrators/refresh.ts

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { PluginInitOrchestrator } from '../common/initialize-plugins.js';
2+
import { Project } from '../entities/project.js';
3+
import { ResourceConfig } from '../entities/resource-config.js';
4+
import { ResourceInfo } from '../entities/resource-info.js';
5+
import { ctx, ProcessName, SubProcessName } from '../events/context.js';
6+
import { FileModificationCalculator } from '../generators/file-modification-calculator.js';
7+
import { ModificationType } from '../generators/index.js';
8+
import { FileUpdater } from '../generators/writer.js';
9+
import { CodifyParser } from '../parser/index.js';
10+
import { DependencyMap, PluginManager } from '../plugins/plugin-manager.js';
11+
import { Reporter } from '../ui/reporters/reporter.js';
12+
import { groupBy, sleep } from '../utils/index.js';
13+
import { wildCardMatch } from '../utils/wild-card-match.js';
14+
15+
export type RefreshResult = { result: ResourceConfig[], errors: string[] }
16+
17+
export interface RefreshArgs {
18+
typeIds?: string[];
19+
path: string;
20+
secureMode?: boolean;
21+
verbosityLevel?: number;
22+
}
23+
24+
export class RefreshOrchestrator {
25+
static async run(
26+
args: RefreshArgs,
27+
reporter: Reporter
28+
) {
29+
const typeIds = args.typeIds?.filter(Boolean)
30+
ctx.processStarted(ProcessName.IMPORT)
31+
32+
const initializationResult = await PluginInitOrchestrator.run(
33+
{ ...args, allowEmptyProject: true },
34+
reporter,
35+
);
36+
const { project } = initializationResult;
37+
38+
// if ((!typeIds || typeIds.length === 0) && project.isEmpty()) {
39+
// throw new Error('At least one resource [type] must be specified. Ex: "codify refresh homebrew". Or the import command must be run in a directory with a valid codify file')
40+
// }
41+
42+
const { pluginManager } = initializationResult;
43+
await pluginManager.validate(project);
44+
const importResult = await RefreshOrchestrator.import(
45+
pluginManager,
46+
project.resourceConfigs.filter((r) => !typeIds || typeIds.includes(r.type))
47+
);
48+
49+
ctx.processFinished(ProcessName.IMPORT);
50+
51+
reporter.displayImportResult(importResult, false);
52+
53+
const resourceInfoList = await pluginManager.getMultipleResourceInfo(
54+
project.resourceConfigs.map((r) => r.type),
55+
);
56+
57+
await RefreshOrchestrator.updateExistingFiles(
58+
reporter,
59+
project,
60+
importResult,
61+
resourceInfoList,
62+
project.codifyFiles[0],
63+
pluginManager,
64+
);
65+
}
66+
67+
static async import(
68+
pluginManager: PluginManager,
69+
resources: ResourceConfig[],
70+
): Promise<RefreshResult> {
71+
const importedConfigs: ResourceConfig[] = [];
72+
const errors: string[] = [];
73+
74+
ctx.subprocessStarted(SubProcessName.IMPORT_RESOURCE);
75+
76+
await Promise.all(resources.map(async (resource) => {
77+
78+
try {
79+
const response = await pluginManager.importResource(resource.toJson());
80+
81+
if (response.result !== null && response.result.length > 0) {
82+
importedConfigs.push(...response
83+
?.result
84+
?.map((r) =>
85+
// Keep the name on the resource if possible, this makes it easier to identify where the import came from
86+
ResourceConfig.fromJson({ ...r, core: { ...r.core, name: resource.name } })
87+
) ?? []
88+
);
89+
} else {
90+
errors.push(`Unable to import resource '${resource.type}', resource not found`);
91+
}
92+
} catch (error: any) {
93+
errors.push(error.message ?? error);
94+
}
95+
96+
}))
97+
98+
ctx.subprocessFinished(SubProcessName.IMPORT_RESOURCE);
99+
100+
return {
101+
result: importedConfigs,
102+
errors,
103+
}
104+
}
105+
106+
private static matchTypeIds(typeIds: string[], validTypeIds: string[]): string[] {
107+
const result: string[] = [];
108+
const unsupportedTypeIds: string[] = [];
109+
110+
for (const typeId of typeIds) {
111+
if (!typeId.includes('*') && !typeId.includes('?')) {
112+
const matched = validTypeIds.includes(typeId);
113+
if (!matched) {
114+
unsupportedTypeIds.push(typeId);
115+
continue;
116+
}
117+
118+
result.push(typeId)
119+
continue;
120+
}
121+
122+
const matched = validTypeIds.filter((valid) => wildCardMatch(valid, typeId))
123+
if (matched.length === 0) {
124+
unsupportedTypeIds.push(typeId);
125+
continue;
126+
}
127+
128+
result.push(...matched);
129+
}
130+
131+
if (unsupportedTypeIds.length > 0) {
132+
throw new Error(`The following resources cannot be imported. No plugins found that support the following types:
133+
${JSON.stringify(unsupportedTypeIds)}`);
134+
}
135+
136+
return result;
137+
}
138+
139+
private static async validate(typeIds: string[], project: Project, pluginManager: PluginManager, dependencyMap: DependencyMap): Promise<void> {
140+
project.validateTypeIds(dependencyMap);
141+
142+
const unsupportedTypeIds = typeIds.filter((type) => !dependencyMap.has(type));
143+
if (unsupportedTypeIds.length > 0) {
144+
throw new Error(`The following resources cannot be imported. No plugins found that support the following types:
145+
${JSON.stringify(unsupportedTypeIds)}`);
146+
}
147+
}
148+
149+
private static async updateExistingFiles(
150+
reporter: Reporter,
151+
existingProject: Project,
152+
importResult: RefreshResult,
153+
resourceInfoList: ResourceInfo[],
154+
preferredFile: string, // File to write any new resources (unknown file path)
155+
pluginManager: PluginManager,
156+
): Promise<void> {
157+
const groupedResults = groupBy(importResult.result, (r) =>
158+
existingProject.findSpecific(r.type, r.name)?.sourceMapKey?.split('#')?.[0] ?? 'unknown'
159+
)
160+
161+
// New resources exists (they don't belong to any existing files)
162+
if (groupedResults.unknown) {
163+
groupedResults[preferredFile] = [
164+
...(groupedResults.unknown ?? []),
165+
...(groupedResults[preferredFile] ?? []),
166+
]
167+
delete groupedResults.unknown;
168+
}
169+
170+
const diffs = await Promise.all(Object.entries(groupedResults).map(async ([filePath, imported]) => {
171+
const existing = await CodifyParser.parse(filePath!);
172+
RefreshOrchestrator.attachResourceInfo(imported, resourceInfoList);
173+
RefreshOrchestrator.attachResourceInfo(existing.resourceConfigs, resourceInfoList);
174+
175+
const modificationCalculator = new FileModificationCalculator(existing);
176+
const modification = await modificationCalculator.calculate(
177+
imported.map((resource) => ({
178+
modification: ModificationType.INSERT_OR_UPDATE,
179+
resource
180+
})),
181+
// Handle matching here since we need the plugin to determine if two configs represent the same underlying resource
182+
async (resource, array) => {
183+
const match = await pluginManager.match(resource, array.filter((r) => r.type === resource.type));
184+
return array.findIndex((i) => i.isDeepEqual(match));
185+
}
186+
);
187+
188+
return { file: filePath!, modification };
189+
}));
190+
191+
// No changes to be made
192+
if (diffs.every((d) => d.modification.diff === '')) {
193+
reporter.displayMessage('\nNo changes are needed! Exiting...')
194+
195+
// Wait for the message to display before we exit
196+
await sleep(100);
197+
return;
198+
}
199+
200+
reporter.displayFileModifications(diffs);
201+
const shouldSave = await reporter.promptConfirmation('Save the changes?');
202+
if (!shouldSave) {
203+
reporter.displayMessage('\nSkipping save! Exiting...');
204+
205+
// Wait for the message to display before we exit
206+
await sleep(100);
207+
return;
208+
}
209+
210+
for (const diff of diffs) {
211+
await FileUpdater.write(diff.file, diff.modification.newFile);
212+
}
213+
214+
reporter.displayMessage('\n🎉 Imported completed and saved to file 🎉');
215+
216+
// Wait for the message to display before we exit
217+
await sleep(100);
218+
}
219+
220+
// We have to attach additional info to the imported configs to make saving easier
221+
private static attachResourceInfo(resources: ResourceConfig[], resourceInfoList: ResourceInfo[]): void {
222+
resources.forEach((resource) => {
223+
const matchedInfo = resourceInfoList.find((info) => info.type === resource.type)!;
224+
if (!matchedInfo) {
225+
throw new Error(`Could not find type ${resource.type} in the resource info`);
226+
}
227+
228+
resource.attachResourceInfo(matchedInfo);
229+
})
230+
}
231+
}
232+

0 commit comments

Comments
 (0)