#!/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 --template [--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();