Files
SillyTavern/post-install.js
wrvsrx bf97686dfc Allow read-only installation
Fix #3453.

Thanks to #3499, #3500 and #3521, most of the obstacles to read-only installation have been resolved. This PR addresses the final piece, ensuring that SillyTavern no longer changes directories to `serverDirectory` and outputs files there. Instead, it outputs or copies necessary files to the directory where it is being run. Now, `serverDirectory` is read-only for SillyTavern (i.e., SillyTavern will not attempt to modify `serverDirectory`). Additionally, this PR sets the permissions for copied `default-user` files to be writable, so even if SillyTavern is installed as read-only, the copied `default-user` folder can still be modified.
2025-04-16 09:52:08 +08:00

295 lines
9.1 KiB
JavaScript

/**
* Scripts to be done before starting the server for the first time.
*/
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import yaml from 'yaml';
import _ from 'lodash';
import chalk from 'chalk';
import { createRequire } from 'node:module';
/**
* Colorizes console output.
*/
const color = chalk;
const keyMigrationMap = [
{
oldKey: 'disableThumbnails',
newKey: 'thumbnails.enabled',
migrate: (value) => !value,
},
{
oldKey: 'thumbnailsQuality',
newKey: 'thumbnails.quality',
migrate: (value) => value,
},
{
oldKey: 'avatarThumbnailsPng',
newKey: 'thumbnails.format',
migrate: (value) => (value ? 'png' : 'jpg'),
},
{
oldKey: 'disableChatBackup',
newKey: 'backups.chat.enabled',
migrate: (value) => !value,
},
{
oldKey: 'numberOfBackups',
newKey: 'backups.common.numberOfBackups',
migrate: (value) => value,
},
{
oldKey: 'maxTotalChatBackups',
newKey: 'backups.chat.maxTotalBackups',
migrate: (value) => value,
},
{
oldKey: 'chatBackupThrottleInterval',
newKey: 'backups.chat.throttleInterval',
migrate: (value) => value,
},
{
oldKey: 'enableExtensions',
newKey: 'extensions.enabled',
migrate: (value) => value,
},
{
oldKey: 'enableExtensionsAutoUpdate',
newKey: 'extensions.autoUpdate',
migrate: (value) => value,
},
{
oldKey: 'extras.disableAutoDownload',
newKey: 'extensions.models.autoDownload',
migrate: (value) => !value,
},
{
oldKey: 'extras.classificationModel',
newKey: 'extensions.models.classification',
migrate: (value) => value,
},
{
oldKey: 'extras.captioningModel',
newKey: 'extensions.models.captioning',
migrate: (value) => value,
},
{
oldKey: 'extras.embeddingModel',
newKey: 'extensions.models.embedding',
migrate: (value) => value,
},
{
oldKey: 'extras.speechToTextModel',
newKey: 'extensions.models.speechToText',
migrate: (value) => value,
},
{
oldKey: 'extras.textToSpeechModel',
newKey: 'extensions.models.textToSpeech',
migrate: (value) => value,
},
{
oldKey: 'minLogLevel',
newKey: 'logging.minLogLevel',
migrate: (value) => value,
},
{
oldKey: 'cardsCacheCapacity',
newKey: 'performance.memoryCacheCapacity',
migrate: (value) => `${value}mb`,
},
{
oldKey: 'cookieSecret',
newKey: 'cookieSecret',
migrate: () => void 0,
remove: true,
},
];
/**
* Gets all keys from an object recursively.
* @param {object} obj Object to get all keys from
* @param {string} prefix Prefix to prepend to all keys
* @returns {string[]} Array of all keys in the object
*/
function getAllKeys(obj, prefix = '') {
if (typeof obj !== 'object' || Array.isArray(obj) || obj === null) {
return [];
}
return _.flatMap(Object.keys(obj), key => {
const newPrefix = prefix ? `${prefix}.${key}` : key;
if (typeof obj[key] === 'object' && !Array.isArray(obj[key])) {
return getAllKeys(obj[key], newPrefix);
} else {
return [newPrefix];
}
});
}
/**
* Converts the old config.conf file to the new config.yaml format.
*/
function convertConfig() {
if (fs.existsSync('./config.conf')) {
if (fs.existsSync('./config.yaml')) {
console.log(color.yellow('Both config.conf and config.yaml exist. Please delete config.conf manually.'));
return;
}
try {
console.log(color.blue('Converting config.conf to config.yaml. Your old config.conf will be renamed to config.conf.bak'));
fs.renameSync('./config.conf', './config.conf.cjs'); // Force loading as CommonJS
const require = createRequire(import.meta.url);
const config = require(path.join(process.cwd(), './config.conf.cjs'));
fs.copyFileSync('./config.conf.cjs', './config.conf.bak');
fs.rmSync('./config.conf.cjs');
fs.writeFileSync('./config.yaml', yaml.stringify(config));
console.log(color.green('Conversion successful. Please check your config.yaml and fix it if necessary.'));
} catch (error) {
console.error(color.red('FATAL: Config conversion failed. Please check your config.conf file and try again.'), error);
return;
}
}
}
/**
* Compares the current config.yaml with the default config.yaml and adds any missing values.
*/
function addMissingConfigValues() {
try {
const defaultConfig = yaml.parse(fs.readFileSync(path.join(process.cwd(), './default/config.yaml'), 'utf8'));
let config = yaml.parse(fs.readFileSync(path.join(process.cwd(), './config.yaml'), 'utf8'));
// Migrate old keys to new keys
const migratedKeys = [];
for (const { oldKey, newKey, migrate, remove } of keyMigrationMap) {
if (_.has(config, oldKey)) {
if (remove) {
_.unset(config, oldKey);
migratedKeys.push({
oldKey,
newValue: void 0,
});
continue;
}
const oldValue = _.get(config, oldKey);
const newValue = migrate(oldValue);
_.set(config, newKey, newValue);
_.unset(config, oldKey);
migratedKeys.push({
oldKey,
newKey,
oldValue,
newValue,
});
}
}
// Get all keys from the original config
const originalKeys = getAllKeys(config);
// Use lodash's defaultsDeep function to recursively apply default properties
config = _.defaultsDeep(config, defaultConfig);
// Get all keys from the updated config
const updatedKeys = getAllKeys(config);
// Find the keys that were added
const addedKeys = _.difference(updatedKeys, originalKeys);
if (addedKeys.length === 0 && migratedKeys.length === 0) {
return;
}
if (addedKeys.length > 0) {
console.log('Adding missing config values to config.yaml:', addedKeys);
}
if (migratedKeys.length > 0) {
console.log('Migrating config values in config.yaml:', migratedKeys);
}
fs.writeFileSync('./config.yaml', yaml.stringify(config));
} catch (error) {
console.error(color.red('FATAL: Could not add missing config values to config.yaml'), error);
}
}
/**
* Creates the default config files if they don't exist yet.
*/
function createDefaultFiles() {
/**
* @typedef DefaultItem
* @type {object}
* @property {'file' | 'directory'} type - Whether the item should be copied as a single file or merged into a directory structure.
* @property {string} defaultPath - The path to the default item (typically in `default/`).
* @property {string} productionPath - The path to the copied item for production use.
*/
/** @type {DefaultItem[]} */
const defaultItems = [
{
type: 'file',
defaultPath: './default/config.yaml',
productionPath: './config.yaml',
},
{
type: 'directory',
defaultPath: './default/public/',
productionPath: './public/',
},
];
for (const defaultItem of defaultItems) {
try {
if (defaultItem.type === 'file') {
if (!fs.existsSync(defaultItem.productionPath)) {
fs.copyFileSync(
defaultItem.defaultPath,
defaultItem.productionPath,
);
console.log(
color.green(`Created default file: ${defaultItem.productionPath}`),
);
}
} else if (defaultItem.type === 'directory') {
fs.cpSync(defaultItem.defaultPath, defaultItem.productionPath, {
force: false, // Don't overwrite existing files!
recursive: true,
});
console.log(
color.green(`Synchronized missing files: ${defaultItem.productionPath}`),
);
} else {
throw new Error(
'FATAL: Unexpected default file format in `post-install.js#createDefaultFiles()`.',
);
}
} catch (error) {
console.error(
color.red(
`FATAL: Could not write default ${defaultItem.type}: ${defaultItem.productionPath}`,
),
error,
);
}
}
}
try {
// 0. Convert config.conf to config.yaml
convertConfig();
// 1. Create default config files
createDefaultFiles();
// 2. Add missing config values
addMissingConfigValues();
} catch (error) {
console.error(error);
}