Repository navigation
feat(vscode-desktop): pre-apply machine settings (#207) #1220
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -167,4 +167,44 @@ describe("vscode-desktop", async () => { | |||
| await removeContainer(id); | ||||
| } | ||||
| }); | ||||
|
|
||||
| it("does not create settings script with default inputs", async () => { | ||||
| const state = await runTerraformApply(import.meta.dir, { | ||||
| agent_id: "foo", | ||||
| }); | ||||
| const settingsScripts = state.resources.filter( | ||||
| (resource) => | ||||
| resource.type === "coder_script" && | ||||
| resource.name === "apply_settings", | ||||
| ); | ||||
| expect(settingsScripts).toHaveLength(0); | ||||
| }); | ||||
|
|
||||
| it("creates login-blocking settings script when settings are configured", async () => { | ||||
| const settings = { | ||||
| "editor.fontSize": 14, | ||||
| "editor.formatOnSave": true, | ||||
| }; | ||||
| const state = await runTerraformApply(import.meta.dir, { | ||||
| agent_id: "foo", | ||||
| settings: JSON.stringify(settings), | ||||
| }); | ||||
| const settingsScript = findResourceInstance( | ||||
| state, | ||||
| "coder_script", | ||||
| "apply_settings", | ||||
| ); | ||||
|
|
||||
| expect(settingsScript.start_blocks_login).toBe(true); | ||||
| expect(settingsScript.timeout).toBe(300); | ||||
| expect(settingsScript.script).toContain( | ||||
| Buffer.from(JSON.stringify(settings)).toString("base64"), | ||||
| ); | ||||
| expect(settingsScript.script).toContain( | ||||
| Buffer.from( | ||||
| "$HOME/.vscode-server/data/Machine/settings.json", | ||||
| ).toString("base64"), | ||||
| ); | ||||
| }); | ||||
| }); | ||||
|
|
||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Trailing blank line at EOF, Prettier flags this file (CI check failure). Empty suggestion removes the line:
Suggested change
|
||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -49,9 +49,40 @@ variable "extensions" { | |||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| variable "settings" { | ||||||||||||||||||||||||||||||||
| type = any | ||||||||||||||||||||||||||||||||
| description = "A map of settings to apply to VS Code Desktop's Machine settings. These settings are merged with any existing machine settings on startup." | ||||||||||||||||||||||||||||||||
| default = {} | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
Comment on lines
+52
to
+56
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Note |
||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| variable "settings_file" { | ||||||||||||||||||||||||||||||||
| type = string | ||||||||||||||||||||||||||||||||
| description = "Path to the settings.json file on the remote workspace host." | ||||||||||||||||||||||||||||||||
| default = "$HOME/.vscode-server/data/Machine/settings.json" | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| locals { | ||||||||||||||||||||||||||||||||
| module_directory = "$HOME/.coder-modules/coder/vscode-desktop" | ||||||||||||||||||||||||||||||||
| server_directory = "${local.module_directory}/server" | ||||||||||||||||||||||||||||||||
| has_settings = var.settings != null && var.settings != {} | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| resource "coder_script" "apply_settings" { | ||||||||||||||||||||||||||||||||
| count = local.has_settings ? 1 : 0 | ||||||||||||||||||||||||||||||||
| agent_id = var.agent_id | ||||||||||||||||||||||||||||||||
| display_name = "VS Code Desktop Settings" | ||||||||||||||||||||||||||||||||
| icon = "/icon/code.svg" | ||||||||||||||||||||||||||||||||
| run_on_start = true | ||||||||||||||||||||||||||||||||
| start_blocks_login = true | ||||||||||||||||||||||||||||||||
| timeout = 300 | ||||||||||||||||||||||||||||||||
| script = templatefile( | ||||||||||||||||||||||||||||||||
| "${path.module}/scripts/apply-settings.sh.tftpl", | ||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||
| SETTINGS_B64 = base64encode(jsonencode(var.settings)) | ||||||||||||||||||||||||||||||||
| SETTINGS_FILE_B64 = base64encode(var.settings_file) | ||||||||||||||||||||||||||||||||
| SCRIPTS_DIR = "${path.module}/scripts" | ||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| module "vscode-desktop-core" { | ||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| #!/usr/bin/env bash | ||
|
|
||
| set -euo pipefail | ||
|
|
||
| expand_home() { | ||
| case "$1" in | ||
| '$HOME') | ||
| printf '%s\n' "$HOME" | ||
| ;; | ||
| '$HOME/'*) | ||
| printf '%s/%s\n' "$HOME" "$${1#\$HOME/}" | ||
| ;; | ||
| *) | ||
| printf '%s\n' "$1" | ||
| ;; | ||
| esac | ||
| } | ||
|
|
||
| decode_path() { | ||
| local encoded_path="$1" | ||
| local decoded_path | ||
|
|
||
| decoded_path="$(printf '%s' "$encoded_path" | base64 -d)" | ||
| expand_home "$decoded_path" | ||
| } | ||
|
|
||
| SETTINGS_FILE="$(decode_path '${SETTINGS_FILE_B64}')" | ||
|
|
||
| if [ -z "$SETTINGS_FILE" ]; then | ||
| printf 'settings_file is required when settings are configured.\n' >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| if [ -L "$SETTINGS_FILE" ]; then | ||
| if ! command -v python3 > /dev/null 2>&1; then | ||
| printf 'Resolving a symlinked IDE settings file requires python3.\n' >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| SETTINGS_FILE="$(python3 - "$SETTINGS_FILE" << 'PY' | ||
| ${file("${SCRIPTS_DIR}/resolve-settings-path.py")} | ||
| PY | ||
| )" | ||
| fi | ||
|
|
||
| settings_directory="$(dirname "$SETTINGS_FILE")" | ||
| mkdir -p "$settings_directory" | ||
|
|
||
| desired_settings="$(mktemp "$settings_directory/.coder-settings.desired.XXXXXX")" | ||
| merged_settings="$(mktemp "$settings_directory/.coder-settings.merged.XXXXXX")" | ||
| trap 'rm -f "$desired_settings" "$merged_settings"' EXIT | ||
|
|
||
| printf '%s' '${SETTINGS_B64}' | base64 -d > "$desired_settings" | ||
| chmod 600 "$desired_settings" | ||
|
|
||
| if [ ! -e "$SETTINGS_FILE" ]; then | ||
| mv "$desired_settings" "$SETTINGS_FILE" | ||
| printf 'Created IDE settings: %s\n' "$SETTINGS_FILE" | ||
| exit 0 | ||
| fi | ||
|
|
||
| if [ ! -f "$SETTINGS_FILE" ]; then | ||
| printf 'IDE settings path is not a regular file: %s\n' "$SETTINGS_FILE" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| if ! command -v python3 > /dev/null 2>&1; then | ||
| printf 'Merging an existing IDE settings file requires python3.\n' >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| python3 - "$SETTINGS_FILE" "$desired_settings" "$merged_settings" << 'PY' | ||
| ${file("${SCRIPTS_DIR}/merge-settings.py")} | ||
| PY | ||
|
|
||
| chmod 600 "$merged_settings" | ||
| mv "$merged_settings" "$SETTINGS_FILE" | ||
| printf 'Merged IDE settings: %s\n' "$SETTINGS_FILE" |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,119 @@ | ||||||||||||||
| import json | ||||||||||||||
| import sys | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def strip_jsonc_comments(source): | ||||||||||||||
| result = [] | ||||||||||||||
| index = 0 | ||||||||||||||
| in_string = False | ||||||||||||||
| escaped = False | ||||||||||||||
|
|
||||||||||||||
| while index < len(source): | ||||||||||||||
| character = source[index] | ||||||||||||||
|
|
||||||||||||||
| if in_string: | ||||||||||||||
| result.append(character) | ||||||||||||||
| if escaped: | ||||||||||||||
| escaped = False | ||||||||||||||
| elif character == "\\": | ||||||||||||||
| escaped = True | ||||||||||||||
| elif character == '"': | ||||||||||||||
| in_string = False | ||||||||||||||
| index += 1 | ||||||||||||||
| continue | ||||||||||||||
|
|
||||||||||||||
| if character == '"': | ||||||||||||||
| in_string = True | ||||||||||||||
| result.append(character) | ||||||||||||||
| index += 1 | ||||||||||||||
| continue | ||||||||||||||
|
|
||||||||||||||
| if character == "/" and index + 1 < len(source): | ||||||||||||||
| next_character = source[index + 1] | ||||||||||||||
| if next_character == "/": | ||||||||||||||
| index += 2 | ||||||||||||||
| while index < len(source) and source[index] not in "\r\n": | ||||||||||||||
| index += 1 | ||||||||||||||
| continue | ||||||||||||||
| if next_character == "*": | ||||||||||||||
| result.append(" ") | ||||||||||||||
| index += 2 | ||||||||||||||
| while index + 1 < len(source): | ||||||||||||||
| if source[index : index + 2] == "*/": | ||||||||||||||
| index += 2 | ||||||||||||||
| break | ||||||||||||||
| if source[index] in "\r\n": | ||||||||||||||
| result.append(source[index]) | ||||||||||||||
| index += 1 | ||||||||||||||
| else: | ||||||||||||||
| raise ValueError("unterminated block comment in IDE settings") | ||||||||||||||
| continue | ||||||||||||||
|
|
||||||||||||||
| result.append(character) | ||||||||||||||
| index += 1 | ||||||||||||||
|
|
||||||||||||||
| return "".join(result) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def strip_trailing_commas(source): | ||||||||||||||
| result = [] | ||||||||||||||
| index = 0 | ||||||||||||||
| in_string = False | ||||||||||||||
| escaped = False | ||||||||||||||
|
|
||||||||||||||
| while index < len(source): | ||||||||||||||
| character = source[index] | ||||||||||||||
|
|
||||||||||||||
| if in_string: | ||||||||||||||
| result.append(character) | ||||||||||||||
| if escaped: | ||||||||||||||
| escaped = False | ||||||||||||||
| elif character == "\\": | ||||||||||||||
| escaped = True | ||||||||||||||
| elif character == '"': | ||||||||||||||
| in_string = False | ||||||||||||||
| index += 1 | ||||||||||||||
| continue | ||||||||||||||
|
|
||||||||||||||
| if character == '"': | ||||||||||||||
| in_string = True | ||||||||||||||
| elif character == ",": | ||||||||||||||
| lookahead = index + 1 | ||||||||||||||
| while lookahead < len(source) and source[lookahead].isspace(): | ||||||||||||||
| lookahead += 1 | ||||||||||||||
| if lookahead < len(source) and source[lookahead] in "}]": | ||||||||||||||
| index += 1 | ||||||||||||||
| continue | ||||||||||||||
|
|
||||||||||||||
| result.append(character) | ||||||||||||||
| index += 1 | ||||||||||||||
|
|
||||||||||||||
| return "".join(result) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def load_jsonc(path): | ||||||||||||||
| with open(path, encoding="utf-8-sig") as source_file: | ||||||||||||||
| source = source_file.read() | ||||||||||||||
| return json.loads(strip_trailing_commas(strip_jsonc_comments(source))) | ||||||||||||||
|
Comment on lines
+96
to
+97
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A 0-byte
Suggested change
|
||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def merge(existing, configured): | ||||||||||||||
| result = dict(existing) | ||||||||||||||
| for key, value in configured.items(): | ||||||||||||||
| if isinstance(result.get(key), dict) and isinstance(value, dict): | ||||||||||||||
| result[key] = merge(result[key], value) | ||||||||||||||
| else: | ||||||||||||||
| result[key] = value | ||||||||||||||
| return result | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| existing = load_jsonc(sys.argv[1]) | ||||||||||||||
| with open(sys.argv[2], encoding="utf-8") as configured_file: | ||||||||||||||
| configured = json.load(configured_file) | ||||||||||||||
|
|
||||||||||||||
| if not isinstance(existing, dict): | ||||||||||||||
| raise ValueError("the existing IDE settings file must contain a JSON object") | ||||||||||||||
|
|
||||||||||||||
| with open(sys.argv[3], "w", encoding="utf-8") as merged_file: | ||||||||||||||
| json.dump(merge(existing, configured), merged_file, indent=2) | ||||||||||||||
| merged_file.write("\n") | ||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,9 @@ | ||||||
| from pathlib import Path | ||||||
| import sys | ||||||
|
|
||||||
|
|
||||||
| try: | ||||||
| print(Path(sys.argv[1]).resolve(strict=True)) | ||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A dangling symlink (target not created yet) makes
Suggested change
|
||||||
| except (OSError, RuntimeError) as error: | ||||||
| print(f"Cannot resolve IDE settings symlink: {error}", file=sys.stderr) | ||||||
| raise SystemExit(1) | ||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Verified: comments are not preserved. The pipeline strips comments and trailing commas and rewrites the file with
json.dump, so the output is plain JSON, and users lose comments they may have in a dotfiles-managed settings.json. Suggested reword so the docs match behavior: