Initial shared Copilot resources scaffold
This commit is contained in:
248
install/merge-vscode-settings.mjs
Normal file
248
install/merge-vscode-settings.mjs
Normal file
@@ -0,0 +1,248 @@
|
||||
#!/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();
|
||||
Reference in New Issue
Block a user