diff --git a/plugins/github-desktop/plugin.yaml b/plugins/github-desktop/plugin.yaml new file mode 100644 index 00000000..38e4a946 --- /dev/null +++ b/plugins/github-desktop/plugin.yaml @@ -0,0 +1,7 @@ +name: github-desktop +version: 0.1.0 +type: python +main: src/plugin.py + +capabilities: + - config provider diff --git a/plugins/github-desktop/src/plugin.py b/plugins/github-desktop/src/plugin.py new file mode 100644 index 00000000..8349cf09 --- /dev/null +++ b/plugins/github-desktop/src/plugin.py @@ -0,0 +1,73 @@ +import sys +import os +import json +import tempfile + +def deep_merge(base, update): + if not isinstance(base, dict) or not isinstance(update, dict): + return update + for key, val in update.items(): + if key in base and isinstance(base[key], dict) and isinstance(val, dict): + base[key] = deep_merge(base[key], val) + else: + base[key] = val + return base + +def main(): + raw_input = sys.stdin.read().strip() + if not raw_input: + print(json.dumps({"error": "Empty stdin context payload received"}), file=sys.stderr) + sys.exit(1) + + try: + args = json.loads(raw_input) + except json.JSONDecodeError: + print(json.dumps({"error": "Invalid JSON format payload structure"}), file=sys.stderr) + sys.exit(1) + + request_id = args.get("requestId", "") + + if args.get("check_installed", False): + appdata = os.environ.get("APPDATA", "") + config_path = os.path.join(appdata, "GitHub Desktop", "config.json") if appdata else "" + installed = bool(config_path and os.path.exists(config_path)) + print(json.dumps({"requestId": request_id, "installed": installed})) + sys.exit(0) + + settings = args.get("settings", {}) + dry_run = args.get("dryRun", False) + + appdata = os.environ.get("APPDATA", "") + if not appdata: + print(json.dumps({"requestId": request_id, "error": "APPDATA environment variable missing"}), file=sys.stderr) + sys.exit(1) + + config_dir = os.path.join(appdata, "GitHub Desktop") + config_path = os.path.join(config_dir, "config.json") + + current_config = {} + if os.path.exists(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + current_config = json.load(f) + except Exception: + current_config = {} + + updated_config = deep_merge(current_config, settings) + + if not dry_run: + if not os.path.exists(config_dir): + os.makedirs(config_dir, exist_ok=True) + try: + fd, temp_path = tempfile.mkstemp(dir=config_dir, prefix="config_", suffix=".json") + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(updated_config, f, indent=2) + os.replace(temp_path, config_path) + except Exception as e: + print(json.dumps({"requestId": request_id, "error": f"Atomic write operation exception: {str(e)}"}), file=sys.stderr) + sys.exit(1) + + print(json.dumps({"requestId": request_id})) + +if __name__ == "__main__": + main() diff --git a/plugins/github-desktop/test/test_github_desktop.py b/plugins/github-desktop/test/test_github_desktop.py new file mode 100644 index 00000000..8cf25e33 --- /dev/null +++ b/plugins/github-desktop/test/test_github_desktop.py @@ -0,0 +1,64 @@ +import unittest +import json +import sys +import os +from unittest.mock import patch, mock_open + +# Compliance constraint: Leveraging sys.path.append instead of sys.path.insert(0) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) + +import plugin + +class TestGitHubDesktopPlugin(unittest.TestCase): + + @patch("sys.stdin") + @patch("sys.stderr") + def test_empty_stdin_throws_json_error(self, mock_stderr, mock_stdin): + mock_stdin.read.return_value = " " + with self.assertRaises(SystemExit) as cm: + plugin.main() + self.assertEqual(cm.exception.code, 1) + + @patch("sys.stdin") + @patch("os.environ", {"APPDATA": "C:\\MockAppData"}) + @patch("os.path.exists") + def test_check_installed_protocol_parity(self, mock_exists, mock_stdin): + mock_stdin.read.return_value = json.dumps({"requestId": "test-req-123", "check_installed": True}) + mock_exists.return_value = True + + with patch("sys.stdout") as mock_stdout: + with self.assertRaises(SystemExit) as cm: + plugin.main() + self.assertEqual(cm.exception.code, 0) + output = json.loads(mock_stdout.write.call_args) + self.assertEqual(output["requestId"], "test-req-123") + self.assertTrue(output["installed"]) + + @patch("sys.stdin") + @patch("os.environ", {"APPDATA": "C:\\MockAppData"}) + @patch("os.path.exists") + @patch("builtins.open", new_callable=mock_open, read_data='{"theme": "dark"}') + @patch("tempfile.mkstemp") + @patch("os.fdopen", new_callable=mock_open) + @patch("os.replace") + def test_settings_deep_merge_atomic_write(self, mock_replace, mock_fdopen, mock_mkstemp, mock_file, mock_exists, mock_stdin): + mock_stdin.read.return_value = json.dumps({ + "requestId": "test-req-456", + "settings": {"defaultBranchName": "main", "confirmRemovedFiles": True}, + "dryRun": False + }) + mock_exists.return_value = True + mock_mkstemp.return_value = (10, "C:\\MockAppData\\GitHub Desktop\\config_tmp.json") + + with patch("sys.stdout") as mock_stdout: + with self.assertRaises(SystemExit) as cm: + plugin.main() + self.assertEqual(cm.exception.code, 0) + output = json.loads(mock_stdout.write.call_args) + self.assertEqual(output["requestId"], "test-req-456") + self.assertNotIn("success", output) + self.assertNotIn("data", output) + +if __name__ == "__main__": + unittest.main() + diff --git a/src/plugin.py b/src/plugin.py new file mode 100644 index 00000000..8349cf09 --- /dev/null +++ b/src/plugin.py @@ -0,0 +1,73 @@ +import sys +import os +import json +import tempfile + +def deep_merge(base, update): + if not isinstance(base, dict) or not isinstance(update, dict): + return update + for key, val in update.items(): + if key in base and isinstance(base[key], dict) and isinstance(val, dict): + base[key] = deep_merge(base[key], val) + else: + base[key] = val + return base + +def main(): + raw_input = sys.stdin.read().strip() + if not raw_input: + print(json.dumps({"error": "Empty stdin context payload received"}), file=sys.stderr) + sys.exit(1) + + try: + args = json.loads(raw_input) + except json.JSONDecodeError: + print(json.dumps({"error": "Invalid JSON format payload structure"}), file=sys.stderr) + sys.exit(1) + + request_id = args.get("requestId", "") + + if args.get("check_installed", False): + appdata = os.environ.get("APPDATA", "") + config_path = os.path.join(appdata, "GitHub Desktop", "config.json") if appdata else "" + installed = bool(config_path and os.path.exists(config_path)) + print(json.dumps({"requestId": request_id, "installed": installed})) + sys.exit(0) + + settings = args.get("settings", {}) + dry_run = args.get("dryRun", False) + + appdata = os.environ.get("APPDATA", "") + if not appdata: + print(json.dumps({"requestId": request_id, "error": "APPDATA environment variable missing"}), file=sys.stderr) + sys.exit(1) + + config_dir = os.path.join(appdata, "GitHub Desktop") + config_path = os.path.join(config_dir, "config.json") + + current_config = {} + if os.path.exists(config_path): + try: + with open(config_path, "r", encoding="utf-8") as f: + current_config = json.load(f) + except Exception: + current_config = {} + + updated_config = deep_merge(current_config, settings) + + if not dry_run: + if not os.path.exists(config_dir): + os.makedirs(config_dir, exist_ok=True) + try: + fd, temp_path = tempfile.mkstemp(dir=config_dir, prefix="config_", suffix=".json") + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(updated_config, f, indent=2) + os.replace(temp_path, config_path) + except Exception as e: + print(json.dumps({"requestId": request_id, "error": f"Atomic write operation exception: {str(e)}"}), file=sys.stderr) + sys.exit(1) + + print(json.dumps({"requestId": request_id})) + +if __name__ == "__main__": + main() diff --git a/test/test_github_desktop.py b/test/test_github_desktop.py new file mode 100644 index 00000000..36005004 --- /dev/null +++ b/test/test_github_desktop.py @@ -0,0 +1,64 @@ +import unittest +import json +import sys +import os +from unittest.mock import patch, mock_open + +# Compliance constraint: Leveraging sys.path.append instead of sys.path.insert(0) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "src"))) + +import plugin + +class TestGitHubDesktopPlugin(unittest.TestCase): + + @patch("sys.stdin") + @patch("sys.stderr") + def test_empty_stdin_throws_json_error(self, mock_stderr, mock_stdin): + mock_stdin.read.return_value = " " + with self.assertRaises(SystemExit) as cm: + plugin.main() + self.assertEqual(cm.exception.code, 1) + + @patch("sys.stdin") + @patch("os.environ", {"APPDATA": "C:\\MockAppData"}) + @patch("os.path.exists") + def test_check_installed_protocol_parity(self, mock_exists, mock_stdin): + mock_stdin.read.return_value = json.dumps({"requestId": "test-req-123", "check_installed": True}) + mock_exists.return_value = True + + with patch("sys.stdout") as mock_stdout: + with self.assertRaises(SystemExit) as cm: + plugin.main() + self.assertEqual(cm.exception.code, 0) + output = json.loads(mock_stdout.write.call_args[0][0]) + self.assertEqual(output["requestId"], "test-req-123") + self.assertTrue(output["installed"]) + + @patch("sys.stdin") + @patch("os.environ", {"APPDATA": "C:\\MockAppData"}) + @patch("os.path.exists") + @patch("builtins.open", new_callable=mock_open, read_data='{"theme": "dark"}') + @patch("tempfile.mkstemp") + @patch("os.fdopen", new_callable=mock_open) + @patch("os.replace") + def test_settings_deep_merge_atomic_write(self, mock_replace, mock_fdopen, mock_mkstemp, mock_file, mock_exists, mock_stdin): + mock_stdin.read.return_value = json.dumps({ + "requestId": "test-req-456", + "settings": {"defaultBranchName": "main", "confirmRemovedFiles": True}, + "dryRun": False + }) + mock_exists.return_value = True + mock_mkstemp.return_value = (10, "C:\\MockAppData\\GitHub Desktop\\config_tmp.json") + + with patch("sys.stdout") as mock_stdout: + with self.assertRaises(SystemExit) as cm: + plugin.main() + self.assertEqual(cm.exception.code, 0) + output = json.loads(mock_stdout.write.call_args[0][0]) + self.assertEqual(output["requestId"], "test-req-456") + self.assertNotIn("success", output) + self.assertNotIn("data", output) + +if __name__ == "__main__": + unittest.main() +