Skip to content

Commit 538888f

Browse files
committed
feat: pick an existing instance to destroy instead of entering raw values
For resources that need identifying values to destroy (allowMultiple / importAndDestroy.requiredParameters), destroy now finds the existing instances on the system using the same import call as import-all and lets the user pick one. "Enter the values manually" is always offered and is used automatically when no instances are found, the plugin's import fails, or the resource is entirely sensitive. - The selected instance's imported parameters are used as the destroy config so the plugin can match it exactly. - Labels show the required values; sensitive values are masked. - Discovery runs under a new "Finding existing resources" subprocess. - promptUserForValues is no longer called with an empty list. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016QnBk2Qs7TDiTz4V6x1wKK
1 parent 93e0312 commit 538888f

4 files changed

Lines changed: 157 additions & 11 deletions

File tree

‎src/events/context.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export enum SubProcessName {
4747
VALIDATE = 'validate',
4848
GET_REQUIRED_PARAMETERS = 'get_required_parameters',
4949
IMPORT_RESOURCE = 'import_resource',
50+
FIND_EXISTING_RESOURCES = 'find_existing_resources',
5051
TEST_INITIALIZE_AND_VALIDATE = 'test_initialize_and_validate',
5152
TEST_CHECKING_VM_INSTALLED = 'test_checking_vm_installed',
5253
TEST_STARTING_VM = 'test_starting_vm',

‎src/orchestrators/destroy.ts‎

Lines changed: 99 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export class DestroyOrchestrator {
105105
await DestroyOrchestrator.validateTypeIds(matchedTypes, project, pluginManager, resourceDefinitions);
106106

107107
const resourceInfoList = (await pluginManager.getMultipleResourceInfo(matchedTypes));
108-
const resourcesToDestroy = await DestroyOrchestrator.getDestroyParameters(reporter, project, resourceInfoList);
108+
const resourcesToDestroy = await DestroyOrchestrator.getDestroyParameters(reporter, initializeResult, resourceInfoList);
109109

110110
const destroyProject = new Project(
111111
null,
@@ -187,30 +187,118 @@ ${JSON.stringify(unsupportedTypeIds)}`);
187187
}
188188
}
189189

190-
private static async getDestroyParameters(reporter: Reporter, project: Project, resourceInfoList: ResourceInfo[]): Promise<Array<ResourceConfig>> {
190+
/**
191+
* Resources that support multiple instances need identifying values before they can be destroyed (ex: which git
192+
* repository). Instead of asking for raw values, existing instances are found on the system (same as import all) and
193+
* the user picks one. The user can always choose to enter the values manually instead.
194+
*/
195+
private static async getDestroyParameters(
196+
reporter: Reporter,
197+
initializeResult: InitializationResult,
198+
resourceInfoList: ResourceInfo[],
199+
): Promise<Array<ResourceConfig>> {
200+
const { project, pluginManager, resourceDefinitions } = initializeResult;
201+
191202
// Figure out which resources we need to prompt the user for additional info (based on the resource info)
192203
const [noPrompt, askPrompt] = resourceInfoList.reduce((result, info) => {
193204
info.getRequiredParameters().length === 0 ? result[0].push(info) : result[1].push(info);
194205
return result;
195206
}, [<ResourceInfo[]>[], <ResourceInfo[]>[]])
196207

197-
askPrompt.forEach((info) => {
198-
const matchedResources = project.findAll(info.type);
199-
if (matchedResources.length > 0) {
200-
info.attachDefaultValues(matchedResources[0]);
208+
const existingInstances = askPrompt.length > 0
209+
? await ctx.subprocess(SubProcessName.FIND_EXISTING_RESOURCES, () => Promise.all(askPrompt.map((info) =>
210+
DestroyOrchestrator.findExistingInstances(pluginManager, info, resourceDefinitions.get(info.type)?.sensitiveParameters ?? [])
211+
)))
212+
: [];
213+
214+
const selected: ResourceConfig[] = [];
215+
const enterManually: ResourceInfo[] = [];
216+
for (const [idx, info] of askPrompt.entries()) {
217+
const instances = existingInstances[idx];
218+
if (instances.length === 0) {
219+
enterManually.push(info);
220+
continue;
201221
}
202-
})
203222

204-
if (askPrompt.length > 0) {
205-
await reporter.displayImportWarning(askPrompt.map((r) => r.type), noPrompt.map((r) => r.type));
223+
const choice = await reporter.promptOptions(
224+
`Multiple instances of "${info.type}" can exist. Which one would you like to destroy?`,
225+
[...instances.map((i) => i.label), 'Enter the values manually'],
226+
);
227+
228+
if (choice < instances.length) {
229+
selected.push(instances[choice].resource);
230+
} else {
231+
enterManually.push(info);
232+
}
206233
}
207234

208-
const userSupplied = await reporter.promptUserForValues(askPrompt, PromptType.DESTROY);
235+
let userSupplied: ResourceConfig[] = [];
236+
if (enterManually.length > 0) {
237+
enterManually.forEach((info) => {
238+
const matchedResources = project.findAll(info.type);
239+
if (matchedResources.length > 0) {
240+
info.attachDefaultValues(matchedResources[0]);
241+
}
242+
})
243+
244+
await reporter.displayImportWarning(enterManually.map((r) => r.type), noPrompt.map((r) => r.type));
245+
userSupplied = await reporter.promptUserForValues(enterManually, PromptType.DESTROY);
246+
}
209247

210248
return [
211249
...noPrompt.map((info) => new ResourceConfig({ type: info.type })),
212-
...userSupplied
250+
...selected,
251+
...userSupplied,
213252
]
214253
}
215254

255+
/**
256+
* Find the instances of a resource that exist on the system using the plugin's import (the same call as import all).
257+
* Each instance is labelled by the values required to identify it. Returns an empty list if no instances are found.
258+
*/
259+
private static async findExistingInstances(
260+
pluginManager: PluginManager,
261+
info: ResourceInfo,
262+
sensitiveParameters: string[],
263+
): Promise<Array<{ label: string; resource: ResourceConfig }>> {
264+
// Never list the values of a resource that is entirely sensitive
265+
if (sensitiveParameters.includes('*')) {
266+
return [];
267+
}
268+
269+
let imported: ResourceConfig[];
270+
try {
271+
const response = await pluginManager.importResource({ core: { type: info.type }, parameters: {} }, true);
272+
imported = (response.result ?? []).map((r) => ResourceConfig.fromJson(r));
273+
} catch (error) {
274+
ctx.debug(`Unable to find existing instances of ${info.type}: ${error}`);
275+
return [];
276+
}
277+
278+
const requiredNames = info.getRequiredParameters().map((p) => p.name);
279+
const seen = new Set<string>();
280+
const labelCounts = new Map<string, number>();
281+
282+
return imported
283+
// The imported instance is used as is: it contains every value the plugin needs to match it on the system
284+
.filter((resource) => {
285+
const key = JSON.stringify(resource.parameters);
286+
if (seen.has(key)) return false;
287+
seen.add(key);
288+
return true;
289+
})
290+
.map((resource) => {
291+
const baseLabel = requiredNames
292+
.filter((name) => resource.parameters[name] !== undefined)
293+
.map((name) => `${name}: ${sensitiveParameters.includes(name) ? '********' : JSON.stringify(resource.parameters[name])}`)
294+
.join(', ') || resource.type;
295+
296+
// Keep labels unique when instances only differ in values that aren't shown
297+
const count = (labelCounts.get(baseLabel) ?? 0) + 1;
298+
labelCounts.set(baseLabel, count);
299+
300+
return { label: count > 1 ? `${baseLabel} (${count})` : baseLabel, resource };
301+
});
302+
}
303+
216304
}

‎src/ui/reporters/default-reporter.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const ProgressLabelMapping = {
3939
[SubProcessName.VALIDATE]: 'Validating configs',
4040
[SubProcessName.GET_REQUIRED_PARAMETERS]: 'Getting required parameters',
4141
[SubProcessName.IMPORT_RESOURCE]: 'Importing resource',
42+
[SubProcessName.FIND_EXISTING_RESOURCES]: 'Finding existing resources',
4243
[SubProcessName.TEST_INITIALIZE_AND_VALIDATE]: 'Initializing and validating your configs',
4344
[SubProcessName.TEST_CHECKING_VM_INSTALLED]: 'Checking if VM is installed',
4445
[SubProcessName.TEST_STARTING_VM]: 'Starting VM',

‎test/orchestrator/destroy/destroy.test.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,62 @@ describe('Destroy orchestrator tests', () => {
190190
// expect(MockOs.get('mock')).to.be.undefined;
191191
});
192192

193+
it('Lists existing instances and destroys the selected one', async () => {
194+
const promptUserForValues = vi.fn(() => []);
195+
const promptOptions = vi.fn((message: string, options: string[]) => {
196+
expect(message).to.contain('"mock"');
197+
expect(options).toEqual(['propB: 1', 'Enter the values manually']);
198+
return 0;
199+
});
200+
201+
const reporter = new MockReporter({
202+
promptOptions,
203+
promptUserForValues,
204+
validatePlan(plan: Plan) {
205+
expect(plan.getResourcePlan('mock')).toMatchObject({ operation: ResourceOperation.DESTROY });
206+
},
207+
});
208+
209+
MockOs.create('mock', { propA: 'current', propB: 1, directory: '~/home' });
210+
211+
await DestroyOrchestrator.run({ typeIds: ['mock'], path: path.join(__dirname, 'codify.json') }, reporter);
212+
213+
expect(promptOptions).toHaveBeenCalledTimes(1);
214+
expect(promptUserForValues).not.toHaveBeenCalled();
215+
expect(MockOs.get('mock')).to.be.undefined;
216+
});
217+
218+
it('Allows entering the values manually instead of selecting an instance', async () => {
219+
const promptUserForValues = vi.fn((info: ResourceInfo[]) => {
220+
expect(info.map((i) => i.type)).toEqual(['mock']);
221+
return [new ResourceConfig({ type: 'mock', propA: 'current', propB: 1 })];
222+
});
223+
224+
const reporter = new MockReporter({
225+
promptOptions: (_, options) => options.length - 1, // 'Enter the values manually'
226+
promptUserForValues,
227+
});
228+
229+
MockOs.create('mock', { propA: 'current', propB: 1, directory: '~/home' });
230+
231+
await DestroyOrchestrator.run({ typeIds: ['mock'], path: path.join(__dirname, 'codify.json') }, reporter);
232+
233+
expect(promptUserForValues).toHaveBeenCalledTimes(1);
234+
expect(MockOs.get('mock')).to.be.undefined;
235+
});
236+
237+
it('Falls back to entering values manually when no instances exist', async () => {
238+
const promptOptions = vi.fn(() => 0);
239+
const promptUserForValues = vi.fn(() => [new ResourceConfig({ type: 'mock', propA: 'current', propB: 1 })]);
240+
241+
const reporter = new MockReporter({ promptOptions, promptUserForValues });
242+
243+
await DestroyOrchestrator.run({ typeIds: ['mock'], path: path.join(__dirname, 'codify.json') }, reporter);
244+
245+
expect(promptOptions).not.toHaveBeenCalled();
246+
expect(promptUserForValues).toHaveBeenCalledTimes(1);
247+
});
248+
193249
afterEach(() => {
194250
vi.resetAllMocks();
195251
MockOs.reset();

0 commit comments

Comments
 (0)