Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,5 +179,6 @@ async startInstance(instanceId: string, wakeUp = true): Promise<void> {
- Run tests before submitting changes
- Check that builds complete successfully
- Follow the contribution guidelines in CONTRIBUTING.md
- **Add changelog entries to CHANGELOG.md for functional changes or enhancements** - Focus on the user-facing effect rather than technical implementation details

When working with this codebase, prioritize correctness, maintainability, and following established patterns over clever solutions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
-->

## __WORK IN PROGRESS__
* (@Apollon77) Allows only numbers for ts and tc fields in state when provided for setState
* (@GermanBluefox) Added typing for visIconSets in `io-package.json`(for vis-2 SVG icon sets)
* (@copilot) Added conditional deletion of storage meta folder files when deleting adapter instances to prevent accidental removal of user data like vis projects
* (@foxriver76) Added objects warn limit per instance
* (@Apollon77) Allows only numbers for `ts` and `lc` fields in state when provided for setState
* (@GermanBluefox) Added typing for `visIconSets` in `io-package.json`(for vis-2 SVG icon sets)
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export { Vendor } from '@/lib/setup/setupVendor.js';
export { Upload } from '@/lib/setup/setupUpload.js';
export { Upgrade } from '@/lib/setup/setupUpgrade.js';
export { BackupRestore } from '@/lib/setup/setupBackup.js';
export { Install } from '@/lib/setup/setupInstall.js';
export { PacketManager, type UpgradePacket } from '@/lib/setup/setupPacketManager.js';
export * from '@/lib/_Types.js';
6 changes: 5 additions & 1 deletion packages/cli/src/lib/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ function initYargs(): ReturnType<typeof yargs> {
describe: 'Remove instance custom attribute from all objects',
type: 'boolean',
},
'with-meta': {
describe: 'Also delete meta files without asking for confirmation',
type: 'boolean',
},
})
.command('update [<repositoryUrl>]', 'Update repository and list adapters', {
updatable: {
Expand Down Expand Up @@ -1109,7 +1113,7 @@ async function processCommand(
});

console.log(`Delete instance "${adapter}.${instance}"`);
await install.deleteInstance(adapter, parseInt(instance));
await install.deleteInstance(adapter, parseInt(instance), params['with-meta']);
callback();
});
} else {
Expand Down
174 changes: 155 additions & 19 deletions packages/cli/src/lib/setup/setupInstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1083,29 +1083,34 @@ export class Install {
* @param knownObjIDs
* @param adapter
* @param metaFilesToDelete
* @param instance optional instance number for filtering to instance-specific meta objects
*/
async _enumerateAdapterMeta(knownObjIDs: string[], adapter: string, metaFilesToDelete: string[]): Promise<void> {
async _enumerateAdapterMeta(
knownObjIDs: string[],
adapter: string,
metaFilesToDelete: string[],
instance?: number,
): Promise<void> {
try {
const adapterPrefix = `${adapter}${instance !== undefined ? `.${instance}` : ''}.`;
const doc = await this.objects.getObjectViewAsync('system', 'meta', {
startkey: `${adapter}.`,
endkey: `${adapter}.\u9999`,
startkey: `${adapterPrefix}`,
endkey: `${adapterPrefix}\u9999`,
});

if (doc.rows.length) {
const adapterRegex = new RegExp(`^${adapter}\\.`);

// add non-duplicates to the list
const newObjs = doc.rows
.filter(row => row.value._id)
.map(row => row.value._id)
.filter(id => adapterRegex.test(id))
.filter(id => id.startsWith(adapterPrefix))
.filter(id => knownObjIDs.indexOf(id) === -1);
knownObjIDs.push(...newObjs);
// meta ids can also be present as files
metaFilesToDelete.push(...newObjs);

if (newObjs.length) {
console.log(`host.${hostname} Counted ${newObjs.length} meta of ${adapter}`);
console.log(`host.${hostname} Counted ${newObjs.length} meta of ${adapterPrefix}*`);
}
}
} catch (err) {
Expand Down Expand Up @@ -1369,6 +1374,109 @@ export class Install {
}
}

/**
* Delete a list of files from the objects database
*
* @param filesToDelete Array of file objects with id and optional name properties
*/
private async _deleteFiles(
filesToDelete: {
id: string;
name?: string;
}[],
): Promise<void> {
for (const file of filesToDelete) {
try {
await this.objects.unlinkAsync(file.id, file.name ?? '');
console.log(`host.${hostname} file ${file.id + (file.name ? `/${file.name}` : '')} deleted`);
} catch (err) {
err !== tools.ERRORS.ERROR_NOT_FOUND &&
err.message !== tools.ERRORS.ERROR_NOT_FOUND &&
console.error(`host.${hostname} Cannot delete ${file.id} files folder: ${err.message}`);
}
}
}

/**
* Check if there are meta files that would be deleted for an instance
*
* @param adapter adapter name like hm-rpc
* @param instance instance number like 0
* @returns Promise<boolean> true if there are meta files to delete
*/
private async _hasInstanceMetaFiles(adapter: string, instance: number): Promise<boolean> {
const knownObjectIDs: string[] = [];
const metaFilesToDelete: string[] = [];

// Enumerate meta files for this instance
await this._enumerateAdapterMeta(knownObjectIDs, adapter, metaFilesToDelete, instance);

// Return true if there are meta files beyond the instance folder itself
return metaFilesToDelete.length > 0;
}

/**
* Read the adapter's io-package.json and check if deletion of meta files is allowed
*
* @param adapter adapter name like hm-rpc
* @returns Promise<boolean> true if allowDeletionOfFilesInMetaObject is set to true
*/
private async _isMetaFileDeletionAllowed(adapter: string): Promise<boolean> {
try {
const adapterDir = tools.getAdapterDir(adapter);
if (!adapterDir || !fs.existsSync(path.join(adapterDir, 'io-package.json'))) {
return false;
}

const ioPackage = await fs.readJSON(path.join(adapterDir, 'io-package.json'));
return ioPackage.common?.allowDeletionOfFilesInMetaObject === true;
} catch {
// If we can't read the io-package.json, assume meta file deletion is not allowed
return false;
}
}

/**
* Ask user interactively if they want to delete meta files
*
* @returns Promise<boolean> true if user agrees to delete meta files
*/
private async _askUserToDeleteMetaFiles(): Promise<boolean> {
// Check if running in interactive TTY
if (!process.stdin.isTTY || !process.stdout.isTTY) {
return false; // In non-interactive environment, don't delete meta files
}
const rl = (await import('node:readline')).createInterface({
input: process.stdin,
output: process.stdout,
});

return new Promise(resolve => {
rl.question(
'This instance has meta files (e.g., vis projects) that will be permanently deleted. Do you want to continue? [y/N]: ',
(answer: string) => {
rl.close();
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
},
);
});
}
private async _deleteInstanceFiles(adapter: string, instance: number): Promise<void> {
const knownObjectIDs: string[] = [];
const metaFilesToDelete: string[] = [];

// Enumerate meta files for this instance
await this._enumerateAdapterMeta(knownObjectIDs, adapter, metaFilesToDelete, instance);

// Create the files to delete list - only instance-specific files
const filesToDelete = [{ id: `${adapter}.${instance}` }, ...metaFilesToDelete.map(id => ({ id }))];

if (filesToDelete.length > 1) {
// More than just the instance folder
await this._deleteFiles(filesToDelete);
}
}

/**
* delete WWW pages, objects and meta files
*
Expand All @@ -1387,17 +1495,7 @@ export class Install {
...metaFilesToDelete.map(id => ({ id })),
];

for (const file of filesToDelete) {
const id = typeof file === 'object' ? file.id : file;
try {
await this.objects.unlinkAsync(id, file.name ?? '');
console.log(`host.${hostname} file ${id + (file.name ? `/${file.name}` : '')} deleted`);
} catch (err) {
err !== tools.ERRORS.ERROR_NOT_FOUND &&
err.message !== tools.ERRORS.ERROR_NOT_FOUND &&
console.error(`host.${hostname} Cannot delete ${id} files folder: ${err.message}`);
}
}
await this._deleteFiles(filesToDelete);

for (const objId of [adapter, `${adapter}.admin`]) {
try {
Expand Down Expand Up @@ -1593,8 +1691,13 @@ export class Install {
*
* @param adapter adapter name like hm-rpc
* @param instance e.g. 1, if undefined deletes all instances
* @param withMeta if true, also delete meta files without asking for confirmation
*/
async deleteInstance(adapter: string, instance?: number): Promise<void | EXIT_CODES.CANNOT_DELETE_DEPENDENCY> {
async deleteInstance(
adapter: string,
instance?: number,
withMeta?: boolean,
): Promise<void | EXIT_CODES.CANNOT_DELETE_DEPENDENCY> {
const knownObjectIDs: string[] = [];
const knownStateIDs: string[] = [];

Expand All @@ -1617,6 +1720,39 @@ export class Install {
await this._enumerateAdapterStates(knownStateIDs, adapter, instance);
await this._enumerateAdapterDocs(knownObjectIDs, adapter, instance);

// Delete files for this specific instance (before deleting objects, since enumeration needs them)
if (instance !== undefined) {
// Check if there are meta files that would be deleted
const hasMetaFiles = await this._hasInstanceMetaFiles(adapter, instance);

if (hasMetaFiles) {
// Check if adapter allows deletion of meta files without confirmation
const allowedByAdapter = await this._isMetaFileDeletionAllowed(adapter);

let shouldDeleteMeta = false;

if (allowedByAdapter) {
// Adapter allows deletion, proceed without asking
shouldDeleteMeta = true;
} else if (withMeta) {
// User provided --with-meta flag
shouldDeleteMeta = true;
} else {
// Ask user interactively (will return false if not in TTY)
shouldDeleteMeta = await this._askUserToDeleteMetaFiles();
}

if (shouldDeleteMeta) {
await this._deleteInstanceFiles(adapter, instance);
} else {
console.log(`host.${hostname} Meta files for ${adapter}.${instance} were not deleted`);
}
} else {
// No meta files to worry about, proceed with standard deletion
await this._deleteInstanceFiles(adapter, instance);
}
}

await this._deleteAdapterObjects(knownObjectIDs);
await this._deleteAdapterStates(knownStateIDs);
if (this.params.custom) {
Expand Down
Loading
Loading