Files
bw-copilot-resources/install/merge-vscode-settings.mjs

248 lines
5.6 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
function usage() {
console.error(
'Usage: node install/merge-vscode-settings.mjs --target <settings.json> --template <settings.template.jsonc> [--set NAME=value]'
);
}
function parseArgs(argv) {
const options = {
replacements: {},
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--target') {
options.target = argv[index + 1];
index += 1;
continue;
}
if (arg === '--template') {
options.template = argv[index + 1];
index += 1;
continue;
}
if (arg === '--set') {
const assignment = argv[index + 1] ?? '';
const equalsIndex = assignment.indexOf('=');
if (equalsIndex <= 0) {
throw new Error(`Invalid --set assignment: ${assignment}`);
}
const key = assignment.slice(0, equalsIndex);
const value = assignment.slice(equalsIndex + 1);
options.replacements[key] = value;
index += 1;
continue;
}
throw new Error(`Unknown argument: ${arg}`);
}
if (!options.target || !options.template) {
usage();
throw new Error('Both --target and --template are required.');
}
return options;
}
function stripJsonComments(input) {
let output = '';
let inString = false;
let escaping = false;
let inLineComment = false;
let inBlockComment = false;
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
const next = input[index + 1];
if (inLineComment) {
if (char === '\n') {
inLineComment = false;
output += char;
}
continue;
}
if (inBlockComment) {
if (char === '*' && next === '/') {
inBlockComment = false;
index += 1;
} else if (char === '\n' || char === '\r') {
output += char;
}
continue;
}
if (inString) {
output += char;
if (escaping) {
escaping = false;
} else if (char === '\\') {
escaping = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
output += char;
continue;
}
if (char === '/' && next === '/') {
inLineComment = true;
index += 1;
continue;
}
if (char === '/' && next === '*') {
inBlockComment = true;
index += 1;
continue;
}
output += char;
}
return output;
}
function stripTrailingCommas(input) {
let output = '';
let inString = false;
let escaping = false;
for (let index = 0; index < input.length; index += 1) {
const char = input[index];
if (inString) {
output += char;
if (escaping) {
escaping = false;
} else if (char === '\\') {
escaping = true;
} else if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
output += char;
continue;
}
if (char === ',') {
let lookahead = index + 1;
while (lookahead < input.length && /\s/.test(input[lookahead])) {
lookahead += 1;
}
if (input[lookahead] === '}' || input[lookahead] === ']') {
continue;
}
}
output += char;
}
return output;
}
function renderTemplate(input, replacements) {
return input.replace(/\{\{([A-Z0-9_]+)\}\}/g, (match, key) => {
if (!(key in replacements)) {
return match;
}
return replacements[key]
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
});
}
function parseJsonc(input, label) {
const sanitized = stripTrailingCommas(stripJsonComments(input)).trim();
if (!sanitized) {
return {};
}
let parsed;
try {
parsed = JSON.parse(sanitized);
} catch (error) {
throw new Error(`Failed to parse ${label}: ${error.message}`);
}
if (!isPlainObject(parsed)) {
throw new Error(`${label} must contain a JSON object at the root.`);
}
return parsed;
}
function isPlainObject(value) {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function cloneValue(value) {
if (Array.isArray(value)) {
return value.map((item) => cloneValue(item));
}
if (isPlainObject(value)) {
return Object.fromEntries(
Object.entries(value).map(([key, nested]) => [key, cloneValue(nested)])
);
}
return value;
}
function deepMerge(target, source) {
const merged = { ...target };
for (const [key, value] of Object.entries(source)) {
if (isPlainObject(value) && isPlainObject(merged[key])) {
merged[key] = deepMerge(merged[key], value);
continue;
}
merged[key] = cloneValue(value);
}
return merged;
}
function main() {
const options = parseArgs(process.argv.slice(2));
const templateText = fs.readFileSync(options.template, 'utf8');
const renderedTemplate = renderTemplate(templateText, options.replacements);
const managedSettings = parseJsonc(renderedTemplate, options.template);
const targetText = fs.existsSync(options.target)
? fs.readFileSync(options.target, 'utf8')
: '{}\n';
const currentSettings = parseJsonc(targetText, options.target);
const mergedSettings = deepMerge(currentSettings, managedSettings);
const output = `${JSON.stringify(mergedSettings, null, 2)}\n`;
fs.mkdirSync(path.dirname(options.target), { recursive: true });
if (output === targetText) {
console.log(`VS Code settings already up to date: ${options.target}`);
return;
}
fs.writeFileSync(options.target, output, 'utf8');
console.log(`Merged managed VS Code settings into: ${options.target}`);
}
main();