From 3973ed48f33890589f3d6d2f3656119cb436bd9b Mon Sep 17 00:00:00 2001 From: Julian lechner Date: Wed, 30 Sep 2026 09:22:55 +0200 Subject: [PATCH] fix(update): preserve unmanaged client settings --- scripts/lib/install-options.py | 91 ++++++++++++++++++++++++++++++++-- scripts/lib/manifest.ps1 | 32 +++--------- scripts/lib/manifest.sh | 44 +++------------- 3 files changed, 103 insertions(+), 64 deletions(-) diff --git a/scripts/lib/install-options.py b/scripts/lib/install-options.py index 4d10d4e..4e6e5cf 100644 --- a/scripts/lib/install-options.py +++ b/scripts/lib/install-options.py @@ -60,16 +60,99 @@ def filter_flashbang(path): return filter_options(path, flashbang_enabled=False) +def merge_json(current, managed): + if not isinstance(current, dict) or not isinstance(managed, dict): + return managed + result = dict(current) + if 'statusLine' not in managed: + result.pop('statusLine', None) + for key, value in managed.items(): + if key == 'hooks' and isinstance(value, dict) and isinstance(result.get(key), dict): + hooks = dict(result[key]) + for event in set(value) | {'Stop'}: + groups = value.get(event, []) + old_groups = hooks.get(event, []) + if isinstance(groups, list) and isinstance(old_groups, list): + retained = [group for group in old_groups if not any( + re.search(r'(?:flashbang|statusline|record-compact|session-state-pointer)\.(?:ps1|sh)', + str(hook.get('command', '')) + str(hook.get('command_windows', '')), re.I) + for hook in group.get('hooks', []) if isinstance(hook, dict) + )] + merged = groups + [group for group in retained if group not in groups] + if merged: + hooks[event] = merged + else: + hooks.pop(event, None) + else: + hooks[event] = value + result[key] = hooks + elif isinstance(value, dict) and isinstance(result.get(key), dict): + result[key] = merge_json(result[key], value) + else: + result[key] = value + return result + + +def merge_toml(current_text, managed_text): + tomllib.loads(current_text) + tomllib.loads(managed_text) + managed_root_keys = set(tomllib.loads(managed_text)) + lines = current_text.splitlines(keepends=True) + root = [] + blocks = [] + active = root + for line in lines: + header = re.match(r'^\s*(\[\[?.+?\]\]?)\s*(?:#.*)?$', line.rstrip('\r\n')) + if header: + active = [] + blocks.append((header.group(1), active)) + if active is root: + assignment = re.match(r'^\s*([A-Za-z0-9_-]+)\s*=', line) + if assignment and assignment.group(1) in managed_root_keys: + continue + active.append(line) + + managed_headers = set(re.findall(r'(?m)^\s*(\[\[?.+?\]\]?)\s*(?:#.*)?$', managed_text)) + extras = [] + for header, block in blocks: + plugin_table = re.match(r'^\[(?:plugins|marketplaces)(?:\.|\])', header) + array_table = header.startswith('[[') + owned_hook = any(re.search(r'(?:flashbang|statusline|record-compact|session-state-pointer)\.(?:ps1|sh)', line, re.I) for line in block) + if plugin_table and header in managed_headers: + raise ValueError('Cannot merge conflicting managed plugin tables.') + if header not in managed_headers or (array_table and not owned_hook): + extras.extend(block) + + result = managed_text + if root and ''.join(root).strip(): + managed_lines = managed_text.splitlines(keepends=True) + split = next((i for i, line in enumerate(managed_lines) if re.match(r'^\s*\[', line)), len(managed_lines)) + result = ''.join(managed_lines[:split]).rstrip() + '\n' + ''.join(root).strip() + '\n' + ''.join(managed_lines[split:]) + if extras: + result = result.rstrip() + '\n\n' + ''.join(extras).lstrip() + tomllib.loads(result) + return result + + if __name__ == '__main__': parser = argparse.ArgumentParser() - parser.add_argument('action', choices=('filter',)) - parser.add_argument('--path', type=Path, required=True) - parser.add_argument('--flashbang', choices=('true', 'false'), required=True) + parser.add_argument('action', choices=('filter', 'merge-json', 'merge-toml')) + parser.add_argument('--path', type=Path) + parser.add_argument('--current', type=Path) + parser.add_argument('--flashbang', choices=('true', 'false')) parser.add_argument('--statusline', choices=('true', 'false'), default='true') args = parser.parse_args() try: sys.stdout.reconfigure(encoding='utf-8') - print(filter_options(args.path, args.flashbang == 'true', args.statusline == 'true'), end='') + if args.action == 'filter': + print(filter_options(args.path, args.flashbang == 'true', args.statusline == 'true'), end='') + else: + if args.action == 'merge-json': + current = json.loads(args.current.read_text(encoding='utf-8-sig')) + managed = json.load(sys.stdin) + print(json.dumps(merge_json(current, managed), indent=2, ensure_ascii=False) + '\n', end='') + else: + print(merge_toml(args.current.read_text(encoding='utf-8-sig'), sys.stdin.read()), end='') except (OSError, ValueError, KeyError) as error: print(f'Install options: {error}', file=sys.stderr) sys.exit(1) diff --git a/scripts/lib/manifest.ps1 b/scripts/lib/manifest.ps1 index b49bff5..2b6d1a1 100644 --- a/scripts/lib/manifest.ps1 +++ b/scripts/lib/manifest.ps1 @@ -112,30 +112,14 @@ function Sync-ManagedDestination { $newBytes = [System.IO.File]::ReadAllBytes($_.FullName) } if ($relative -eq 'config.toml' -and (Test-Path -LiteralPath $target)) { - $localConfig = [System.IO.File]::ReadAllText($target) - $pluginTables = [regex]::Matches($localConfig, '(?m)^[ \t]*\[(?:plugins|marketplaces)(?:\.|\])[\s\S]*?(?=^[ \t]*\[|\z)') - $rootConfig = ([regex]::Split($localConfig, '(?m)^[ \t]*\[', 2)[0]).TrimStart([char]0xFEFF) - $localSettings = [regex]::Matches($rootConfig, '(?m)^[ \t]*(?:model|model_reasoning_effort)[ \t]*=.*$') - if ($localSettings.Count -gt 0) { - $content = [System.Text.Encoding]::UTF8.GetString($newBytes).TrimEnd([char[]]"`r`n") - foreach ($setting in $localSettings) { - $name = ([regex]::Match($setting.Value, '^[ \t]*([^ \t=]+)')).Groups[1].Value - $content = [regex]::Replace($content, "(?m)^[ \t]*$([regex]::Escape($name))[ \t]*=.*(?:\r?\n|$)", '') - } - $preservedSettings = ($localSettings | ForEach-Object { $_.Value.TrimEnd([char[]]"`r`n") }) -join "`n" - $firstTable = [regex]::Match($content, '(?m)^[ \t]*\[') - if ($firstTable.Success) { $content = $content.Insert($firstTable.Index, "$preservedSettings`n") } - else { $content = "$content`n$preservedSettings" } - $newBytes = [System.Text.Encoding]::UTF8.GetBytes("$content`n") - } - if ($pluginTables.Count -gt 0) { - $content = [System.Text.Encoding]::UTF8.GetString($newBytes).TrimEnd([char[]]"`r`n") - if ($content -match '(?m)^[ \t]*\[(?:plugins|marketplaces)(?:\.|\])') { - throw 'Cannot overwrite local plugin configuration with generated plugin tables.' - } - $preserved = ($pluginTables | ForEach-Object { $_.Value.TrimEnd([char[]]"`r`n") }) -join "`n" - $newBytes = [System.Text.Encoding]::UTF8.GetBytes("$content`n`n$preserved`n") - } + $content = [System.Text.Encoding]::UTF8.GetString($newBytes) | & python (Join-Path $PSScriptRoot 'install-options.py') merge-toml --current $target | Out-String + if ($LASTEXITCODE -ne 0) { throw 'Could not merge managed config.toml keys.' } + $newBytes = [System.Text.Encoding]::UTF8.GetBytes($content) + } + if ($relative -eq 'settings.json' -and (Test-Path -LiteralPath $target)) { + $finalContent = [System.Text.Encoding]::UTF8.GetString($newBytes) | & python (Join-Path $PSScriptRoot 'install-options.py') merge-json --current $target | Out-String + if ($LASTEXITCODE -ne 0) { throw 'Could not merge managed settings.json keys.' } + $newBytes = [System.Text.Encoding]::UTF8.GetBytes($finalContent) } $newHash = Get-Sha256HashOfBytes $newBytes $newManifest[$relative] = $newHash diff --git a/scripts/lib/manifest.sh b/scripts/lib/manifest.sh index 04e4dbd..4cd9893 100755 --- a/scripts/lib/manifest.sh +++ b/scripts/lib/manifest.sh @@ -111,42 +111,14 @@ sync_managed_destination() { new_hash=$(sha256_of_file "$source_file") fi if [ "$relative" = config.toml ] && [ -f "$target" ]; then - local plugin_tables local_settings setting_name - local_settings=$(awk ' - NR == 1 { sub(/^\357\273\277/, "") } - /^[ \t]*\[/ { exit } - /^[ \t]*(model|model_reasoning_effort)[ \t]*=/ { print } - ' "$target") - if [ -n "$local_settings" ]; then - while IFS= read -r setting; do - setting_name=${setting%%=*} - setting_name=${setting_name//[[:space:]]/} - content=$(printf '%s\n' "$content" | sed "/^[[:space:]]*${setting_name}[[:space:]]*=/d") - done <<< "$local_settings" - if printf '%s\n' "$content" | grep -qE '^[[:blank:]]*\['; then - content=$(printf '%s\n' "$content" | awk -v settings="$local_settings" ' - !inserted && /^[ \t]*\[/ { print settings; inserted = 1 } - { print } - ') - else - content="$content"$'\n'"$local_settings" - fi - needs_sub=true - new_hash=$(sha256_of_string "$content") - fi - plugin_tables=$(awk ' - /^[ \t]*\[/ { preserve = ($0 ~ /^[ \t]*\[(plugins|marketplaces)(\.|\])/) } - preserve { print } - ' "$target") - if [ -n "$plugin_tables" ]; then - if printf '%s\n' "$content" | grep -Eq '^[[:blank:]]*\[(plugins|marketplaces)(\.|\])'; then - printf 'Cannot overwrite local plugin configuration with generated plugin tables.\n' >&2 - return 1 - fi - content="$content"$'\n\n'"$plugin_tables"$'\n' - needs_sub=true - new_hash=$(sha256_of_string "$content") - fi + content=$(printf '%s' "$content" | python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" merge-toml --current "$target") || return + needs_sub=true + new_hash=$(sha256_of_string "$content") + fi + if [ "$relative" = settings.json ] && [ -f "$target" ]; then + content=$(printf '%s' "$content" | python3 "$(dirname -- "${BASH_SOURCE[0]}")/install-options.py" merge-json --current "$target") || return + needs_sub=true + new_hash=$(sha256_of_string "$content") fi new_manifest[$relative]=$new_hash