SaylorTwift HF Staff commited on
Commit
9332ccf
·
verified ·
1 Parent(s): 84aa3bf

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. packages/cli/src/commands/extensions/examples/custom-commands/.gitignore +26 -0
  2. packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml +6 -0
  3. packages/cli/src/commands/extensions/examples/custom-commands/gemini-extension.json +4 -0
  4. packages/cli/src/commands/extensions/examples/hooks/.gitignore +26 -0
  5. packages/cli/src/commands/extensions/examples/hooks/gemini-extension.json +4 -0
  6. packages/cli/src/commands/extensions/examples/hooks/hooks/hooks.json +14 -0
  7. packages/cli/src/commands/extensions/examples/hooks/scripts/on-start.js +8 -0
  8. packages/cli/src/commands/extensions/examples/mcp-server/example.js +60 -0
  9. packages/cli/src/commands/extensions/examples/mcp-server/package.json +11 -0
  10. packages/cli/src/commands/extensions/examples/policies/README.md +41 -0
  11. packages/cli/src/commands/extensions/examples/policies/gemini-extension.json +5 -0
  12. packages/cli/src/commands/extensions/examples/policies/policies/policies.toml +28 -0
  13. packages/cli/src/commands/extensions/examples/skills/.gitignore +26 -0
  14. packages/cli/src/commands/extensions/examples/skills/gemini-extension.json +4 -0
  15. packages/cli/src/commands/extensions/examples/skills/skills/greeter/SKILL.md +7 -0
  16. packages/cli/src/commands/extensions/examples/themes-example/README.md +31 -0
  17. packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json +29 -0
  18. packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-generate-a-consent-string-with-all-fields.snap.svg +20 -0
  19. packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-include-warning-when-hooks-are-present.snap.svg +14 -0
  20. packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg +13 -0
  21. packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg +34 -0
  22. packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg +24 -0
  23. packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg +19 -0
  24. packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap +106 -0
  25. packages/cli/src/config/extensions/consent.test.ts +563 -0
  26. packages/cli/src/config/extensions/consent.ts +399 -0
  27. packages/cli/src/config/extensions/extensionEnablement.test.ts +534 -0
  28. packages/cli/src/config/extensions/extensionEnablement.ts +251 -0
  29. packages/cli/src/config/extensions/extensionSettings.test.ts +937 -0
  30. packages/cli/src/config/extensions/extensionSettings.ts +389 -0
  31. packages/cli/src/config/extensions/extensionUpdates.test.ts +308 -0
  32. packages/cli/src/config/extensions/github.test.ts +654 -0
  33. packages/cli/src/config/extensions/github.ts +576 -0
  34. packages/cli/src/config/extensions/github_fetch.test.ts +201 -0
  35. packages/cli/src/config/extensions/github_fetch.ts +58 -0
  36. packages/cli/src/config/extensions/storage.test.ts +101 -0
  37. packages/cli/src/config/extensions/storage.ts +45 -0
  38. packages/cli/src/config/extensions/update.test.ts +480 -0
  39. packages/cli/src/config/extensions/update.ts +225 -0
  40. packages/cli/src/config/extensions/variableSchema.ts +34 -0
  41. packages/cli/src/config/extensions/variables.test.ts +153 -0
  42. packages/cli/src/config/extensions/variables.ts +93 -0
  43. packages/cli/src/config/mcp/index.ts +17 -0
  44. packages/cli/src/config/mcp/mcpServerEnablement.test.ts +189 -0
  45. packages/cli/src/config/mcp/mcpServerEnablement.ts +387 -0
  46. packages/cli/src/test-utils/fixtures/simple.responses +1 -0
  47. packages/cli/src/test-utils/fixtures/steering.responses +3 -0
  48. packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +174 -0
  49. packages/cli/src/ui/auth/ApiAuthDialog.tsx +150 -0
  50. packages/cli/src/ui/auth/AuthDialog.test.tsx +458 -0
packages/cli/src/commands/extensions/examples/custom-commands/.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-error.log
5
+ yarn-debug.log
6
+
7
+ # Build output
8
+ dist/
9
+
10
+ # OS metadata
11
+ .DS_Store
12
+ Thumbs.db
13
+
14
+ # TypeScript
15
+ *.tsbuildinfo
16
+
17
+ # Environment variables
18
+ .env
19
+ .env.local
20
+ .env.development.local
21
+ .env.test.local
22
+ .env.production.local
23
+
24
+ # IDEs
25
+ .vscode/
26
+ .idea/
packages/cli/src/commands/extensions/examples/custom-commands/commands/fs/grep-code.toml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ prompt = """
2
+ Please summarize the findings for the pattern `{{args}}`.
3
+
4
+ Search Results:
5
+ !{grep -r {{args}} .}
6
+ """
packages/cli/src/commands/extensions/examples/custom-commands/gemini-extension.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "name": "custom-commands",
3
+ "version": "1.0.0"
4
+ }
packages/cli/src/commands/extensions/examples/hooks/.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-error.log
5
+ yarn-debug.log
6
+
7
+ # Build output
8
+ dist/
9
+
10
+ # OS metadata
11
+ .DS_Store
12
+ Thumbs.db
13
+
14
+ # TypeScript
15
+ *.tsbuildinfo
16
+
17
+ # Environment variables
18
+ .env
19
+ .env.local
20
+ .env.development.local
21
+ .env.test.local
22
+ .env.production.local
23
+
24
+ # IDEs
25
+ .vscode/
26
+ .idea/
packages/cli/src/commands/extensions/examples/hooks/gemini-extension.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "name": "hooks-example",
3
+ "version": "1.0.0"
4
+ }
packages/cli/src/commands/extensions/examples/hooks/hooks/hooks.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "hooks": {
3
+ "SessionStart": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node ${extensionPath}/scripts/on-start.js"
9
+ }
10
+ ]
11
+ }
12
+ ]
13
+ }
14
+ }
packages/cli/src/commands/extensions/examples/hooks/scripts/on-start.js ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+ console.log(
7
+ 'Session Started! This is running from a script in the hooks-example extension.',
8
+ );
packages/cli/src/commands/extensions/examples/mcp-server/example.js ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
9
+ import { z } from 'zod';
10
+
11
+ const server = new McpServer({
12
+ name: 'prompt-server',
13
+ version: '1.0.0',
14
+ });
15
+
16
+ server.registerTool(
17
+ 'fetch_posts',
18
+ {
19
+ description: 'Fetches a list of posts from a public API.',
20
+ inputSchema: z.object({}).shape,
21
+ },
22
+ async () => {
23
+ const apiResponse = await fetch(
24
+ 'https://jsonplaceholder.typicode.com/posts',
25
+ );
26
+ const posts = await apiResponse.json();
27
+ const response = { posts: posts.slice(0, 5) };
28
+ return {
29
+ content: [
30
+ {
31
+ type: 'text',
32
+ text: JSON.stringify(response),
33
+ },
34
+ ],
35
+ };
36
+ },
37
+ );
38
+
39
+ server.registerPrompt(
40
+ 'poem-writer',
41
+ {
42
+ title: 'Poem Writer',
43
+ description: 'Write a nice haiku',
44
+ argsSchema: { title: z.string(), mood: z.string().optional() },
45
+ },
46
+ ({ title, mood }) => ({
47
+ messages: [
48
+ {
49
+ role: 'user',
50
+ content: {
51
+ type: 'text',
52
+ text: `Write a haiku${mood ? ` with the mood ${mood}` : ''} called ${title}. Note that a haiku is 5 syllables followed by 7 syllables followed by 5 syllables `,
53
+ },
54
+ },
55
+ ],
56
+ }),
57
+ );
58
+
59
+ const transport = new StdioServerTransport();
60
+ await server.connect(transport);
packages/cli/src/commands/extensions/examples/mcp-server/package.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "mcp-server-example",
3
+ "version": "1.0.0",
4
+ "description": "Example MCP Server for Gemini CLI Extension",
5
+ "type": "module",
6
+ "main": "example.js",
7
+ "dependencies": {
8
+ "@modelcontextprotocol/sdk": "1.23.0",
9
+ "zod": "3.22.4"
10
+ }
11
+ }
packages/cli/src/commands/extensions/examples/policies/README.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Policy engine example extension
2
+
3
+ This extension demonstrates how to contribute security rules and safety checkers
4
+ to the Gemini CLI Policy Engine.
5
+
6
+ ## Description
7
+
8
+ The extension uses a `policies/` directory containing `.toml` files to define:
9
+
10
+ - A rule that requires user confirmation for `rm -rf` commands.
11
+ - A rule that denies searching for sensitive files (like `.env`) using `grep`.
12
+ - A safety checker that validates file paths for all write operations.
13
+
14
+ ## Structure
15
+
16
+ - `gemini-extension.json`: The manifest file.
17
+ - `policies/`: Contains the `.toml` policy files.
18
+
19
+ ## How to use
20
+
21
+ 1. Link this extension to your local Gemini CLI installation:
22
+
23
+ ```bash
24
+ gemini extensions link packages/cli/src/commands/extensions/examples/policies
25
+ ```
26
+
27
+ 2. Restart your Gemini CLI session.
28
+
29
+ 3. **Observe the policies:**
30
+ - Try asking the model to delete a directory: The policy engine will prompt
31
+ you for confirmation due to the `rm -rf` rule.
32
+ - Try asking the model to search for secrets: The `grep` rule will deny the
33
+ request and display the custom deny message.
34
+ - Any file write operation will now be processed through the `allowed-path`
35
+ safety checker.
36
+
37
+ ## Security note
38
+
39
+ For security, Gemini CLI ignores any `allow` decisions or `yolo` mode
40
+ configurations contributed by extensions. This ensures that extensions can
41
+ strengthen security but cannot bypass user confirmation.
packages/cli/src/commands/extensions/examples/policies/gemini-extension.json ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ {
2
+ "name": "policy-example",
3
+ "version": "1.0.0",
4
+ "description": "An example extension demonstrating Policy Engine support."
5
+ }
packages/cli/src/commands/extensions/examples/policies/policies/policies.toml ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Example Policy Rules for Gemini CLI Extension
2
+ #
3
+ # Extensions run in Tier 2 (Extension Tier).
4
+ # Security Note: 'allow' decisions and 'yolo' mode configurations are ignored.
5
+
6
+ # Rule: Always ask the user before running a specific dangerous shell command.
7
+ [[rule]]
8
+ toolName = "run_shell_command"
9
+ commandPrefix = "rm -rf"
10
+ decision = "ask_user"
11
+ priority = 100
12
+
13
+ # Rule: Deny access to sensitive files using the grep tool.
14
+ [[rule]]
15
+ toolName = "grep_search"
16
+ argsPattern = "(\.env|id_rsa|passwd)"
17
+ decision = "deny"
18
+ priority = 200
19
+ denyMessage = "Access to sensitive credentials or system files is restricted by the policy-example extension."
20
+
21
+ # Safety Checker: Apply path validation to all write operations.
22
+ [[safety_checker]]
23
+ toolName = ["write_file", "replace"]
24
+ priority = 300
25
+ [safety_checker.checker]
26
+ type = "in-process"
27
+ name = "allowed-path"
28
+ required_context = ["environment"]
packages/cli/src/commands/extensions/examples/skills/.gitignore ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+ npm-debug.log*
4
+ yarn-error.log
5
+ yarn-debug.log
6
+
7
+ # Build output
8
+ dist/
9
+
10
+ # OS metadata
11
+ .DS_Store
12
+ Thumbs.db
13
+
14
+ # TypeScript
15
+ *.tsbuildinfo
16
+
17
+ # Environment variables
18
+ .env
19
+ .env.local
20
+ .env.development.local
21
+ .env.test.local
22
+ .env.production.local
23
+
24
+ # IDEs
25
+ .vscode/
26
+ .idea/
packages/cli/src/commands/extensions/examples/skills/gemini-extension.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "name": "skills-example",
3
+ "version": "1.0.0"
4
+ }
packages/cli/src/commands/extensions/examples/skills/skills/greeter/SKILL.md ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: greeter
3
+ description: A friendly greeter skill
4
+ ---
5
+
6
+ You are a friendly greeter. When the user says "hello" or asks for a greeting,
7
+ you should reply with: "Greetings from the skills-example extension! 👋"
packages/cli/src/commands/extensions/examples/themes-example/README.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Themes Example
2
+
3
+ This is an example of a Gemini CLI extension that adds a custom theme.
4
+
5
+ ## How to use
6
+
7
+ 1. Link this extension:
8
+
9
+ ```bash
10
+ gemini extensions link packages/cli/src/commands/extensions/examples/themes-example
11
+ ```
12
+
13
+ 2. Set the theme in your settings file (`~/.gemini/settings.json`):
14
+
15
+ ```json
16
+ {
17
+ "ui": {
18
+ "theme": "shades-of-green (themes-example)"
19
+ }
20
+ }
21
+ ```
22
+
23
+ Alternatively, you can set it through the UI by running `gemini` and then
24
+ typing `/theme` and pressing Enter.
25
+
26
+ 3. **Observe the Changes:**
27
+
28
+ After setting the theme, you should see the changes reflected in the Gemini
29
+ CLI's UI. The background will be a dark green, the primary text a lighter
30
+ green, and various other UI elements will display different shades of green,
31
+ as defined in this extension's `gemini-extension.json` file.
packages/cli/src/commands/extensions/examples/themes-example/gemini-extension.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "themes-example",
3
+ "version": "1.0.0",
4
+ "themes": [
5
+ {
6
+ "name": "shades-of-green",
7
+ "type": "custom",
8
+ "background": {
9
+ "primary": "#1a362a"
10
+ },
11
+ "text": {
12
+ "primary": "#a6e3a1",
13
+ "secondary": "#6e8e7a",
14
+ "link": "#89e689"
15
+ },
16
+ "status": {
17
+ "success": "#76c076",
18
+ "warning": "#d9e689",
19
+ "error": "#b34e4e"
20
+ },
21
+ "border": {
22
+ "default": "#4a6c5a"
23
+ },
24
+ "ui": {
25
+ "comment": "#6e8e7a"
26
+ }
27
+ }
28
+ ]
29
+ }
packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-generate-a-consent-string-with-all-fields.snap.svg ADDED
packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-include-warning-when-hooks-are-present.snap.svg ADDED
packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg ADDED
packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg ADDED
packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg ADDED
packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg ADDED
packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`consent > maybeRequestConsentOrFail > consent string generation > should generate a consent string with all fields 1`] = `
4
+ "Installing extension "test-ext".
5
+ This extension will run the following MCP servers:
6
+ * server1 (local): npm start
7
+ Config signature: 4fec6e8152e1eb7aa1a25665dde7964a218c1b60f801587e3c800f0d7fe5941a
8
+ * server2 (remote): https://remote.com
9
+ Config signature: 69391bfd3f62b97f9c7e944e9597ec5ba075838f8e29738f8adf0ba3eed35961
10
+ This extension will append info to your gemini.md context using my-context.md
11
+ This extension will exclude the following core tools: tool1,tool2
12
+
13
+ The extension you are about to install may have been created by a third-party developer and sourced
14
+ from a public repository. Google does not vet, endorse, or guarantee the functionality or security
15
+ of extensions. Please carefully inspect any extension and its source code before installing to
16
+ understand the permissions it requires and the actions it may perform."
17
+ `;
18
+
19
+ exports[`consent > maybeRequestConsentOrFail > consent string generation > should include warning when hooks are present 1`] = `
20
+ "Installing extension "test-ext".
21
+ ⚠️ This extension contains Hooks which can automatically execute commands.
22
+
23
+ The extension you are about to install may have been created by a third-party developer and sourced
24
+ from a public repository. Google does not vet, endorse, or guarantee the functionality or security
25
+ of extensions. Please carefully inspect any extension and its source code before installing to
26
+ understand the permissions it requires and the actions it may perform."
27
+ `;
28
+
29
+ exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = `
30
+ "Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates.
31
+
32
+ The extension you are about to install may have been created by a third-party developer and sourced
33
+ from a public repository. Google does not vet, endorse, or guarantee the functionality or security
34
+ of extensions. Please carefully inspect any extension and its source code before installing to
35
+ understand the permissions it requires and the actions it may perform."
36
+ `;
37
+
38
+ exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = `
39
+ "Installing extension "test-ext".
40
+ This extension will run the following MCP servers:
41
+ * server1 (local): npm start
42
+ Config signature: 4fec6e8152e1eb7aa1a25665dde7964a218c1b60f801587e3c800f0d7fe5941a
43
+ * server2 (remote): https://remote.com
44
+ Config signature: 69391bfd3f62b97f9c7e944e9597ec5ba075838f8e29738f8adf0ba3eed35961
45
+ This extension will append info to your gemini.md context using my-context.md
46
+ This extension will exclude the following core tools: tool1,tool2
47
+
48
+ Agent Skills:
49
+
50
+ This extension will install the following agent skills:
51
+
52
+ * skill1: desc1
53
+ (Source: /mock/temp/dir/skill1/SKILL.md) (2 items in directory)
54
+
55
+ * skill2: desc2
56
+ (Source: /mock/temp/dir/skill2/SKILL.md) (1 items in directory)
57
+
58
+
59
+ The extension you are about to install may have been created by a third-party developer and sourced
60
+ from a public repository. Google does not vet, endorse, or guarantee the functionality or security
61
+ of extensions. Please carefully inspect any extension and its source code before installing to
62
+ understand the permissions it requires and the actions it may perform.
63
+
64
+ Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
65
+ prompt. This can change how the agent interprets your requests and interacts with your environment.
66
+ Review the skill definitions at the location(s) provided below to ensure they meet your security
67
+ standards."
68
+ `;
69
+
70
+ exports[`consent > maybeRequestConsentOrFail > consent string generation > should show a warning if the skill directory cannot be read 1`] = `
71
+ "Installing extension "test-ext".
72
+
73
+ Agent Skills:
74
+
75
+ This extension will install the following agent skills:
76
+
77
+ * locked-skill: A skill in a locked dir
78
+ (Source: /mock/temp/dir/locked/SKILL.md) ⚠️ (Could not count items in directory)
79
+
80
+
81
+ The extension you are about to install may have been created by a third-party developer and sourced
82
+ from a public repository. Google does not vet, endorse, or guarantee the functionality or security
83
+ of extensions. Please carefully inspect any extension and its source code before installing to
84
+ understand the permissions it requires and the actions it may perform.
85
+
86
+ Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
87
+ prompt. This can change how the agent interprets your requests and interacts with your environment.
88
+ Review the skill definitions at the location(s) provided below to ensure they meet your security
89
+ standards."
90
+ `;
91
+
92
+ exports[`consent > skillsConsentString > should generate a consent string for skills 1`] = `
93
+ "Installing agent skill(s) from "https://example.com/repo.git".
94
+
95
+ The following agent skill(s) will be installing:
96
+
97
+ * skill1: desc1
98
+ (Source: /mock/temp/dir/skill1/SKILL.md) (1 items in directory)
99
+
100
+ Install Destination: /mock/target/dir
101
+
102
+ Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
103
+ prompt. This can change how the agent interprets your requests and interacts with your environment.
104
+ Review the skill definitions at the location(s) provided below to ensure they meet your security
105
+ standards."
106
+ `;
packages/cli/src/config/extensions/consent.test.ts ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import React from 'react';
8
+ import { Text } from 'ink';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import * as fs from 'node:fs/promises';
11
+ import * as path from 'node:path';
12
+ import * as os from 'node:os';
13
+ import { render, cleanup } from '../../test-utils/render.js';
14
+ import {
15
+ requestConsentNonInteractive,
16
+ requestConsentInteractive,
17
+ maybeRequestConsentOrFail,
18
+ } from './consent.js';
19
+ import type { ConfirmationRequest } from '../../ui/types.js';
20
+ import type { ExtensionConfig } from '../extension.js';
21
+ import { debugLogger, type SkillDefinition } from '@google/gemini-cli-core';
22
+
23
+ const mockReadline = vi.hoisted(() => ({
24
+ createInterface: vi.fn().mockReturnValue({
25
+ question: vi.fn(),
26
+ close: vi.fn(),
27
+ }),
28
+ }));
29
+
30
+ const mockReaddir = vi.hoisted(() => vi.fn());
31
+ const originalReaddir = vi.hoisted(() => ({
32
+ current: null as typeof fs.readdir | null,
33
+ }));
34
+
35
+ // Mocking readline for non-interactive prompts
36
+ vi.mock('node:readline', () => ({
37
+ default: mockReadline,
38
+ createInterface: mockReadline.createInterface,
39
+ }));
40
+
41
+ vi.mock('node:fs/promises', async (importOriginal) => {
42
+ const actual = await importOriginal<typeof import('node:fs/promises')>();
43
+ originalReaddir.current = actual.readdir;
44
+ return {
45
+ ...actual,
46
+ readdir: mockReaddir,
47
+ };
48
+ });
49
+
50
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
51
+ const actual =
52
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
53
+ return {
54
+ ...actual,
55
+ debugLogger: {
56
+ log: vi.fn(),
57
+ },
58
+ };
59
+ });
60
+
61
+ async function expectConsentSnapshot(consentString: string) {
62
+ const renderResult = await render(
63
+ React.createElement(Text, null, consentString),
64
+ );
65
+ await expect(renderResult).toMatchSvgSnapshot();
66
+ }
67
+
68
+ /**
69
+ * Normalizes a consent string for snapshot testing by:
70
+ * 1. Replacing the dynamic temp directory path with a static placeholder.
71
+ * 2. Converting Windows backslashes to forward slashes for platform-agnosticism.
72
+ */
73
+ function normalizePathsForSnapshot(str: string, tempDir: string): string {
74
+ return str.replaceAll(tempDir, '/mock/temp/dir').replaceAll('\\', '/');
75
+ }
76
+
77
+ describe('consent', () => {
78
+ let tempDir: string;
79
+
80
+ beforeEach(async () => {
81
+ vi.clearAllMocks();
82
+ if (originalReaddir.current) {
83
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
84
+ mockReaddir.mockImplementation(originalReaddir.current as any);
85
+ }
86
+ tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'consent-test-'));
87
+ });
88
+
89
+ afterEach(async () => {
90
+ vi.restoreAllMocks();
91
+ if (tempDir) {
92
+ await fs.rm(tempDir, { recursive: true, force: true });
93
+ }
94
+ cleanup();
95
+ });
96
+
97
+ describe('requestConsentNonInteractive', () => {
98
+ it.each([
99
+ { input: 'y', expected: true },
100
+ { input: 'Y', expected: true },
101
+ { input: '', expected: true },
102
+ { input: 'n', expected: false },
103
+ { input: 'N', expected: false },
104
+ { input: 'yes', expected: true },
105
+ ])(
106
+ 'should return $expected for input "$input"',
107
+ async ({ input, expected }) => {
108
+ const questionMock = vi.fn().mockImplementation((_, callback) => {
109
+ callback(input);
110
+ });
111
+ mockReadline.createInterface.mockReturnValue({
112
+ question: questionMock,
113
+ close: vi.fn(),
114
+ });
115
+
116
+ const consent = await requestConsentNonInteractive('Test consent');
117
+ expect(debugLogger.log).toHaveBeenCalledWith('Test consent');
118
+ expect(questionMock).toHaveBeenCalledWith(
119
+ 'Do you want to continue? [Y/n]: ',
120
+ expect.any(Function),
121
+ );
122
+ expect(consent).toBe(expected);
123
+ },
124
+ );
125
+ });
126
+
127
+ describe('requestConsentInteractive', () => {
128
+ it.each([
129
+ { confirmed: true, expected: true },
130
+ { confirmed: false, expected: false },
131
+ ])(
132
+ 'should resolve with $expected when user confirms with $confirmed',
133
+ async ({ confirmed, expected }) => {
134
+ const addExtensionUpdateConfirmationRequest = vi
135
+ .fn()
136
+ .mockImplementation((request: ConfirmationRequest) => {
137
+ request.onConfirm(confirmed);
138
+ });
139
+
140
+ const consent = await requestConsentInteractive(
141
+ 'Test consent',
142
+ addExtensionUpdateConfirmationRequest,
143
+ );
144
+
145
+ expect(addExtensionUpdateConfirmationRequest).toHaveBeenCalledWith({
146
+ prompt: 'Test consent\n\nDo you want to continue?',
147
+ onConfirm: expect.any(Function),
148
+ });
149
+ expect(consent).toBe(expected);
150
+ },
151
+ );
152
+
153
+ it('should clear the active confirmation request before resolving', async () => {
154
+ const clearConfirmationRequest = vi.fn();
155
+ const steps: string[] = [];
156
+ const addExtensionUpdateConfirmationRequest = vi
157
+ .fn()
158
+ .mockImplementation((request: ConfirmationRequest) => {
159
+ steps.push('prompted');
160
+ request.onConfirm(true);
161
+ steps.push('confirmed');
162
+ });
163
+
164
+ const consentPromise = requestConsentInteractive(
165
+ 'Test consent',
166
+ addExtensionUpdateConfirmationRequest,
167
+ () => {
168
+ steps.push('cleared');
169
+ clearConfirmationRequest();
170
+ },
171
+ ).then((consent) => {
172
+ steps.push('resolved');
173
+ return consent;
174
+ });
175
+
176
+ expect(clearConfirmationRequest).toHaveBeenCalledTimes(1);
177
+ expect(steps).toEqual(['prompted', 'cleared', 'confirmed']);
178
+ await expect(consentPromise).resolves.toBe(true);
179
+ expect(steps).toEqual(['prompted', 'cleared', 'confirmed', 'resolved']);
180
+ });
181
+ });
182
+
183
+ describe('maybeRequestConsentOrFail', () => {
184
+ const baseConfig: ExtensionConfig = {
185
+ name: 'test-ext',
186
+ version: '1.0.0',
187
+ };
188
+
189
+ it('should request consent if there is no previous config', async () => {
190
+ const requestConsent = vi.fn().mockResolvedValue(true);
191
+ await maybeRequestConsentOrFail(
192
+ baseConfig,
193
+ requestConsent,
194
+ false,
195
+ undefined,
196
+ );
197
+ expect(requestConsent).toHaveBeenCalledTimes(1);
198
+ });
199
+
200
+ it('should not request consent if configs are identical', async () => {
201
+ const requestConsent = vi.fn().mockResolvedValue(true);
202
+ await maybeRequestConsentOrFail(
203
+ baseConfig,
204
+ requestConsent,
205
+ false,
206
+ baseConfig,
207
+ false,
208
+ );
209
+ expect(requestConsent).not.toHaveBeenCalled();
210
+ });
211
+
212
+ it('should throw an error if consent is denied', async () => {
213
+ const requestConsent = vi.fn().mockResolvedValue(false);
214
+ await expect(
215
+ maybeRequestConsentOrFail(baseConfig, requestConsent, false, undefined),
216
+ ).rejects.toThrow('Installation cancelled for "test-ext".');
217
+ });
218
+
219
+ describe('consent string generation', () => {
220
+ it('should generate a consent string with all fields', async () => {
221
+ const config: ExtensionConfig = {
222
+ ...baseConfig,
223
+ mcpServers: {
224
+ server1: { command: 'npm', args: ['start'] },
225
+ server2: { httpUrl: 'https://remote.com' },
226
+ },
227
+ contextFileName: 'my-context.md',
228
+ excludeTools: ['tool1', 'tool2'],
229
+ };
230
+ const requestConsent = vi.fn().mockResolvedValue(true);
231
+ await maybeRequestConsentOrFail(
232
+ config,
233
+ requestConsent,
234
+ false,
235
+ undefined,
236
+ );
237
+
238
+ expect(requestConsent).toHaveBeenCalledTimes(1);
239
+ const consentString = requestConsent.mock.calls[0][0] as string;
240
+ await expectConsentSnapshot(consentString);
241
+ });
242
+
243
+ it('should sort environment variables alphabetically and handle undefined envValue', async () => {
244
+ const config: ExtensionConfig = {
245
+ ...baseConfig,
246
+ mcpServers: {
247
+ server1: {
248
+ command: 'node',
249
+ args: ['server.js'],
250
+ env: {
251
+ VAR_B: 'b',
252
+ VAR_A: 'a',
253
+ VAR_C: undefined as unknown as string,
254
+ },
255
+ },
256
+ },
257
+ };
258
+ const requestConsent = vi.fn().mockResolvedValue(true);
259
+ await maybeRequestConsentOrFail(
260
+ config,
261
+ requestConsent,
262
+ false,
263
+ undefined,
264
+ );
265
+
266
+ expect(requestConsent).toHaveBeenCalledTimes(1);
267
+ const consentString = requestConsent.mock.calls[0][0] as string;
268
+ expect(consentString).toContain(' - VAR_A: a');
269
+ expect(consentString).toContain(' - VAR_B: b');
270
+ expect(consentString).toContain(' - VAR_C: ');
271
+
272
+ const posA = consentString.indexOf(' - VAR_A: a');
273
+ const posB = consentString.indexOf(' - VAR_B: b');
274
+ const posC = consentString.indexOf(' - VAR_C: ');
275
+ expect(posA).toBeLessThan(posB);
276
+ expect(posB).toBeLessThan(posC);
277
+ });
278
+
279
+ it('should mask sensitive environment variables, headers, and OAuth parameters', async () => {
280
+ const config: ExtensionConfig = {
281
+ ...baseConfig,
282
+ mcpServers: {
283
+ server1: {
284
+ command: 'node',
285
+ args: ['server.js'],
286
+ env: {
287
+ SAFE_VAR: 'safe-value',
288
+ SUPER_TOKEN: 'secret-token-123',
289
+ DB_PASSWORD: 'my-password',
290
+ },
291
+ headers: {
292
+ 'X-API-Key': 'some-api-key',
293
+ Authorization: 'Bearer 12345',
294
+ 'Content-Type': 'application/json',
295
+ },
296
+ oauth: {
297
+ clientId: 'my-client-id',
298
+ clientSecret: 'secret-oauth-value',
299
+ },
300
+ },
301
+ },
302
+ };
303
+ const requestConsent = vi.fn().mockResolvedValue(true);
304
+ await maybeRequestConsentOrFail(
305
+ config,
306
+ requestConsent,
307
+ false,
308
+ undefined,
309
+ );
310
+
311
+ expect(requestConsent).toHaveBeenCalledTimes(1);
312
+ const consentString = requestConsent.mock.calls[0][0] as string;
313
+
314
+ // Assert sensitive env values are masked
315
+ expect(consentString).toContain(' - SAFE_VAR: safe-value');
316
+ expect(consentString).toContain(' - SUPER_TOKEN: ********');
317
+ expect(consentString).toContain(' - DB_PASSWORD: ********');
318
+
319
+ // Assert sensitive headers are masked
320
+ expect(consentString).toContain(
321
+ ' - Content-Type: application/json',
322
+ );
323
+ expect(consentString).toContain(' - Authorization: ********');
324
+ expect(consentString).toContain(' - X-API-Key: ********');
325
+
326
+ // Assert sensitive OAuth parameters are masked
327
+ expect(consentString).toContain(' - clientId: my-client-id');
328
+ expect(consentString).toContain(' - clientSecret: ********');
329
+ });
330
+
331
+ it('should request consent if mcpServers change', async () => {
332
+ const prevConfig: ExtensionConfig = { ...baseConfig };
333
+ const newConfig: ExtensionConfig = {
334
+ ...baseConfig,
335
+ mcpServers: { server1: { command: 'npm', args: ['start'] } },
336
+ };
337
+ const requestConsent = vi.fn().mockResolvedValue(true);
338
+ await maybeRequestConsentOrFail(
339
+ newConfig,
340
+ requestConsent,
341
+ false,
342
+ prevConfig,
343
+ false,
344
+ );
345
+ expect(requestConsent).toHaveBeenCalledTimes(1);
346
+ });
347
+
348
+ it('should request consent if mcpServer env changes', async () => {
349
+ const prevConfig: ExtensionConfig = {
350
+ ...baseConfig,
351
+ mcpServers: {
352
+ server1: { command: 'node', args: ['server.js'] },
353
+ },
354
+ };
355
+ const newConfig: ExtensionConfig = {
356
+ ...baseConfig,
357
+ mcpServers: {
358
+ server1: {
359
+ command: 'node',
360
+ args: ['server.js'],
361
+ env: { NODE_OPTIONS: '--require=./payload.js' },
362
+ },
363
+ },
364
+ };
365
+ const requestConsent = vi.fn().mockResolvedValue(true);
366
+ await maybeRequestConsentOrFail(
367
+ newConfig,
368
+ requestConsent,
369
+ false,
370
+ prevConfig,
371
+ false,
372
+ );
373
+ expect(requestConsent).toHaveBeenCalledTimes(1);
374
+ });
375
+
376
+ it('should request consent if contextFileName changes', async () => {
377
+ const prevConfig: ExtensionConfig = { ...baseConfig };
378
+ const newConfig: ExtensionConfig = {
379
+ ...baseConfig,
380
+ contextFileName: 'new-context.md',
381
+ };
382
+ const requestConsent = vi.fn().mockResolvedValue(true);
383
+ await maybeRequestConsentOrFail(
384
+ newConfig,
385
+ requestConsent,
386
+ false,
387
+ prevConfig,
388
+ false,
389
+ );
390
+ expect(requestConsent).toHaveBeenCalledTimes(1);
391
+ });
392
+
393
+ it('should request consent if excludeTools changes', async () => {
394
+ const prevConfig: ExtensionConfig = { ...baseConfig };
395
+ const newConfig: ExtensionConfig = {
396
+ ...baseConfig,
397
+ excludeTools: ['new-tool'],
398
+ };
399
+ const requestConsent = vi.fn().mockResolvedValue(true);
400
+ await maybeRequestConsentOrFail(
401
+ newConfig,
402
+ requestConsent,
403
+ false,
404
+ prevConfig,
405
+ false,
406
+ );
407
+ expect(requestConsent).toHaveBeenCalledTimes(1);
408
+ });
409
+
410
+ it('should include warning when hooks are present', async () => {
411
+ const requestConsent = vi.fn().mockResolvedValue(true);
412
+ await maybeRequestConsentOrFail(
413
+ baseConfig,
414
+ requestConsent,
415
+ true,
416
+ undefined,
417
+ );
418
+
419
+ expect(requestConsent).toHaveBeenCalledTimes(1);
420
+ const consentString = requestConsent.mock.calls[0][0] as string;
421
+ await expectConsentSnapshot(consentString);
422
+ });
423
+
424
+ it('should request consent if hooks status changes', async () => {
425
+ const requestConsent = vi.fn().mockResolvedValue(true);
426
+ await maybeRequestConsentOrFail(
427
+ baseConfig,
428
+ requestConsent,
429
+ true,
430
+ baseConfig,
431
+ false,
432
+ );
433
+ expect(requestConsent).toHaveBeenCalledTimes(1);
434
+ });
435
+
436
+ it('should request consent if extension is migrated', async () => {
437
+ const requestConsent = vi.fn().mockResolvedValue(true);
438
+ await maybeRequestConsentOrFail(
439
+ baseConfig,
440
+ requestConsent,
441
+ false,
442
+ { ...baseConfig, name: 'old-ext' },
443
+ false,
444
+ [],
445
+ [],
446
+ true,
447
+ );
448
+
449
+ expect(requestConsent).toHaveBeenCalledTimes(1);
450
+ let consentString = requestConsent.mock.calls[0][0] as string;
451
+ consentString = normalizePathsForSnapshot(consentString, tempDir);
452
+ await expectConsentSnapshot(consentString);
453
+ });
454
+
455
+ it('should request consent if skills change', async () => {
456
+ const skill1Dir = path.join(tempDir, 'skill1');
457
+ const skill2Dir = path.join(tempDir, 'skill2');
458
+ await fs.mkdir(skill1Dir, { recursive: true });
459
+ await fs.mkdir(skill2Dir, { recursive: true });
460
+ await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'body1');
461
+ await fs.writeFile(path.join(skill1Dir, 'extra.txt'), 'extra');
462
+ await fs.writeFile(path.join(skill2Dir, 'SKILL.md'), 'body2');
463
+
464
+ const skill1: SkillDefinition = {
465
+ name: 'skill1',
466
+ description: 'desc1',
467
+ location: path.join(skill1Dir, 'SKILL.md'),
468
+ body: 'body1',
469
+ };
470
+ const skill2: SkillDefinition = {
471
+ name: 'skill2',
472
+ description: 'desc2',
473
+ location: path.join(skill2Dir, 'SKILL.md'),
474
+ body: 'body2',
475
+ };
476
+
477
+ const config: ExtensionConfig = {
478
+ ...baseConfig,
479
+ mcpServers: {
480
+ server1: { command: 'npm', args: ['start'] },
481
+ server2: { httpUrl: 'https://remote.com' },
482
+ },
483
+ contextFileName: 'my-context.md',
484
+ excludeTools: ['tool1', 'tool2'],
485
+ };
486
+ const requestConsent = vi.fn().mockResolvedValue(true);
487
+ await maybeRequestConsentOrFail(
488
+ config,
489
+ requestConsent,
490
+ false,
491
+ undefined,
492
+ false,
493
+ [skill1, skill2],
494
+ );
495
+
496
+ expect(requestConsent).toHaveBeenCalledTimes(1);
497
+ let consentString = requestConsent.mock.calls[0][0] as string;
498
+ consentString = normalizePathsForSnapshot(consentString, tempDir);
499
+ await expectConsentSnapshot(consentString);
500
+ });
501
+
502
+ it('should show a warning if the skill directory cannot be read', async () => {
503
+ const lockedDir = path.join(tempDir, 'locked');
504
+ await fs.mkdir(lockedDir, { recursive: true });
505
+
506
+ const skill: SkillDefinition = {
507
+ name: 'locked-skill',
508
+ description: 'A skill in a locked dir',
509
+ location: path.join(lockedDir, 'SKILL.md'),
510
+ body: 'body',
511
+ };
512
+
513
+ // Mock readdir to simulate a permission error.
514
+ // We do this instead of using fs.mkdir(..., { mode: 0o000 }) because
515
+ // directory permissions work differently on Windows and 0o000 doesn't
516
+ // effectively block access there, leading to test failures in Windows CI.
517
+ mockReaddir.mockRejectedValueOnce(
518
+ new Error('EACCES: permission denied, scandir'),
519
+ );
520
+
521
+ const requestConsent = vi.fn().mockResolvedValue(true);
522
+ await maybeRequestConsentOrFail(
523
+ baseConfig,
524
+ requestConsent,
525
+ false,
526
+ undefined,
527
+ false,
528
+ [skill],
529
+ );
530
+
531
+ expect(requestConsent).toHaveBeenCalledTimes(1);
532
+ let consentString = requestConsent.mock.calls[0][0] as string;
533
+ consentString = normalizePathsForSnapshot(consentString, tempDir);
534
+ await expectConsentSnapshot(consentString);
535
+ });
536
+ });
537
+ });
538
+
539
+ describe('skillsConsentString', () => {
540
+ it('should generate a consent string for skills', async () => {
541
+ const skill1Dir = path.join(tempDir, 'skill1');
542
+ await fs.mkdir(skill1Dir, { recursive: true });
543
+ await fs.writeFile(path.join(skill1Dir, 'SKILL.md'), 'body1');
544
+
545
+ const skill1: SkillDefinition = {
546
+ name: 'skill1',
547
+ description: 'desc1',
548
+ location: path.join(skill1Dir, 'SKILL.md'),
549
+ body: 'body1',
550
+ };
551
+
552
+ const { skillsConsentString } = await import('./consent.js');
553
+ let consentString = await skillsConsentString(
554
+ [skill1],
555
+ 'https://example.com/repo.git',
556
+ '/mock/target/dir',
557
+ );
558
+
559
+ consentString = normalizePathsForSnapshot(consentString, tempDir);
560
+ await expectConsentSnapshot(consentString);
561
+ });
562
+ });
563
+ });
packages/cli/src/config/extensions/consent.ts ADDED
@@ -0,0 +1,399 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as crypto from 'node:crypto';
8
+ import * as fs from 'node:fs/promises';
9
+ import * as path from 'node:path';
10
+ import {
11
+ debugLogger,
12
+ type SkillDefinition,
13
+ type MCPServerConfig,
14
+ } from '@google/gemini-cli-core';
15
+ import chalk from 'chalk';
16
+
17
+ import type { ConfirmationRequest } from '../../ui/types.js';
18
+ import { escapeAnsiCtrlCodes } from '../../ui/utils/textUtils.js';
19
+ import type { ExtensionConfig } from '../extension.js';
20
+
21
+ export const INSTALL_WARNING_MESSAGE = chalk.yellow(
22
+ 'The extension you are about to install may have been created by a third-party developer and sourced from a public repository. Google does not vet, endorse, or guarantee the functionality or security of extensions. Please carefully inspect any extension and its source code before installing to understand the permissions it requires and the actions it may perform.',
23
+ );
24
+
25
+ export const SKILLS_WARNING_MESSAGE = chalk.yellow(
26
+ "Agent skills inject specialized instructions and domain-specific knowledge into the agent's system prompt. This can change how the agent interprets your requests and interacts with your environment. Review the skill definitions at the location(s) provided below to ensure they meet your security standards.",
27
+ );
28
+
29
+ /**
30
+ * Builds a consent string for installing agent skills.
31
+ */
32
+ export async function skillsConsentString(
33
+ skills: SkillDefinition[],
34
+ source: string,
35
+ targetDir?: string,
36
+ isLink = false,
37
+ ): Promise<string> {
38
+ const action = isLink ? 'Linking' : 'Installing';
39
+ const output: string[] = [];
40
+ output.push(`${action} agent skill(s) from "${source}".`);
41
+ output.push(
42
+ `\nThe following agent skill(s) will be ${action.toLowerCase()}:\n`,
43
+ );
44
+ output.push(...(await renderSkillsList(skills)));
45
+
46
+ if (targetDir) {
47
+ const destLabel = isLink ? 'Link' : 'Install';
48
+ output.push(`${destLabel} Destination: ${targetDir}`);
49
+ }
50
+ output.push('\n' + SKILLS_WARNING_MESSAGE);
51
+
52
+ return output.join('\n');
53
+ }
54
+
55
+ /**
56
+ * Requests consent from the user to perform an action, by reading a Y/n
57
+ * character from stdin.
58
+ *
59
+ * This should not be called from interactive mode as it will break the CLI.
60
+ *
61
+ * @param consentDescription The description of the thing they will be consenting to.
62
+ * @returns boolean, whether they consented or not.
63
+ */
64
+ export async function requestConsentNonInteractive(
65
+ consentDescription: string,
66
+ ): Promise<boolean> {
67
+ debugLogger.log(consentDescription);
68
+ const result = await promptForConsentNonInteractive(
69
+ 'Do you want to continue? [Y/n]: ',
70
+ );
71
+ return result;
72
+ }
73
+
74
+ /**
75
+ * Requests consent from the user to perform an action, in interactive mode.
76
+ *
77
+ * This should not be called from non-interactive mode as it will not work.
78
+ *
79
+ * @param consentDescription The description of the thing they will be consenting to.
80
+ * @param addExtensionUpdateConfirmationRequest A function to actually add a prompt to the UI.
81
+ * @returns boolean, whether they consented or not.
82
+ */
83
+ export async function requestConsentInteractive(
84
+ consentDescription: string,
85
+ addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
86
+ clearConfirmationRequest?: () => void,
87
+ ): Promise<boolean> {
88
+ return promptForConsentInteractive(
89
+ consentDescription + '\n\nDo you want to continue?',
90
+ addExtensionUpdateConfirmationRequest,
91
+ clearConfirmationRequest,
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Asks users a prompt and awaits for a y/n response on stdin.
97
+ *
98
+ * This should not be called from interactive mode as it will break the CLI.
99
+ *
100
+ * @param prompt A yes/no prompt to ask the user
101
+ * @param defaultValue Whether to resolve as true or false on enter.
102
+ * @returns Whether or not the user answers 'y' (yes).
103
+ */
104
+ export async function promptForConsentNonInteractive(
105
+ prompt: string,
106
+ defaultValue = true,
107
+ ): Promise<boolean> {
108
+ const readline = await import('node:readline');
109
+ const rl = readline.createInterface({
110
+ input: process.stdin,
111
+ output: process.stdout,
112
+ });
113
+
114
+ return new Promise((resolve) => {
115
+ rl.question(prompt, (answer) => {
116
+ rl.close();
117
+ const trimmedAnswer = answer.trim().toLowerCase();
118
+ if (trimmedAnswer === '') {
119
+ resolve(defaultValue);
120
+ } else {
121
+ resolve(['y', 'yes'].includes(trimmedAnswer));
122
+ }
123
+ });
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Asks users an interactive yes/no prompt.
129
+ *
130
+ * This should not be called from non-interactive mode as it will break the CLI.
131
+ *
132
+ * @param prompt A markdown prompt to ask the user
133
+ * @param addExtensionUpdateConfirmationRequest Function to update the UI state with the confirmation request.
134
+ * @returns Whether or not the user answers yes.
135
+ */
136
+ async function promptForConsentInteractive(
137
+ prompt: string,
138
+ addExtensionUpdateConfirmationRequest: (value: ConfirmationRequest) => void,
139
+ clearConfirmationRequest?: () => void,
140
+ ): Promise<boolean> {
141
+ return new Promise<boolean>((resolve) => {
142
+ addExtensionUpdateConfirmationRequest({
143
+ prompt,
144
+ onConfirm: (resolvedConfirmed) => {
145
+ clearConfirmationRequest?.();
146
+ setImmediate(() => resolve(resolvedConfirmed));
147
+ },
148
+ });
149
+ });
150
+ }
151
+
152
+ function isRecord(value: unknown): value is Record<string, unknown> {
153
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
154
+ }
155
+
156
+ /**
157
+ * Helper to generate a deterministic canonical hash of an MCPServerConfig object.
158
+ * Excludes circular or complex fields like extension.
159
+ */
160
+ function getMcpServerConfigHash(mcpServer: MCPServerConfig): string {
161
+ const plainMcpServer: Record<string, unknown> = isRecord(mcpServer)
162
+ ? mcpServer
163
+ : {};
164
+ const copy = { ...plainMcpServer };
165
+ delete copy['extension'];
166
+
167
+ const canonicalString = JSON.stringify(copy, (key, value) => {
168
+ if (value instanceof RegExp) {
169
+ return value.toString();
170
+ }
171
+ if (isRecord(value)) {
172
+ return Object.keys(value)
173
+ .sort()
174
+ .reduce((result: Record<string, unknown>, k) => {
175
+ result[k] = value[k];
176
+ return result;
177
+ }, {});
178
+ }
179
+ return value as unknown;
180
+ });
181
+
182
+ return crypto.createHash('sha256').update(canonicalString).digest('hex');
183
+ }
184
+
185
+ /**
186
+ * Builds a consent string for installing an extension based on it's
187
+ * extensionConfig.
188
+ */
189
+ async function extensionConsentString(
190
+ extensionConfig: ExtensionConfig,
191
+ hasHooks: boolean,
192
+ skills: SkillDefinition[] = [],
193
+ previousName?: string,
194
+ wasMigrated?: boolean,
195
+ ): Promise<string> {
196
+ const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
197
+ const output: string[] = [];
198
+ const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
199
+
200
+ if (wasMigrated) {
201
+ if (previousName && previousName !== sanitizedConfig.name) {
202
+ output.push(
203
+ `Migrating extension "${previousName}" to a new repository, renaming to "${sanitizedConfig.name}", and installing updates.`,
204
+ );
205
+ } else {
206
+ output.push(
207
+ `Migrating extension "${sanitizedConfig.name}" to a new repository and installing updates.`,
208
+ );
209
+ }
210
+ } else if (previousName && previousName !== sanitizedConfig.name) {
211
+ output.push(
212
+ `Renaming extension "${previousName}" to "${sanitizedConfig.name}" and installing updates.`,
213
+ );
214
+ } else {
215
+ output.push(`Installing extension "${sanitizedConfig.name}".`);
216
+ }
217
+
218
+ if (mcpServerEntries.length) {
219
+ output.push('This extension will run the following MCP servers:');
220
+ for (const [key, mcpServer] of mcpServerEntries) {
221
+ const isLocal = !!mcpServer.command;
222
+ const source =
223
+ mcpServer.httpUrl ??
224
+ `${mcpServer.command || ''}${mcpServer.args ? ' ' + mcpServer.args.join(' ') : ''}`;
225
+ output.push(` * ${key} (${isLocal ? 'local' : 'remote'}): ${source}`);
226
+ if (mcpServer.cwd) {
227
+ output.push(` Working directory: ${mcpServer.cwd}`);
228
+ }
229
+ if (mcpServer.env && Object.keys(mcpServer.env).length > 0) {
230
+ output.push(' Environment variables:');
231
+ const sortedEnv = Object.entries(mcpServer.env).sort(([a], [b]) =>
232
+ a.localeCompare(b),
233
+ );
234
+ for (const [envKey, envValue] of sortedEnv) {
235
+ const isSensitive =
236
+ /token|secret|password|passwd|key|auth|credential|creds|private|cert/i.test(
237
+ envKey,
238
+ );
239
+ const displayValue =
240
+ isSensitive && envValue ? '********' : (envValue ?? '');
241
+ output.push(` - ${envKey}: ${displayValue}`);
242
+ }
243
+ }
244
+ if (mcpServer.headers && Object.keys(mcpServer.headers).length > 0) {
245
+ output.push(' Headers:');
246
+ const sortedHeaders = Object.entries(mcpServer.headers).sort(
247
+ ([a], [b]) => a.localeCompare(b),
248
+ );
249
+ for (const [headerKey, headerValue] of sortedHeaders) {
250
+ const isSensitive = /authorization|key|cookie|token|secret/i.test(
251
+ headerKey,
252
+ );
253
+ const displayValue =
254
+ isSensitive && headerValue ? '********' : (headerValue ?? '');
255
+ output.push(` - ${headerKey}: ${displayValue}`);
256
+ }
257
+ }
258
+ if (mcpServer.url) {
259
+ output.push(` URL: ${mcpServer.url}`);
260
+ }
261
+ if (mcpServer.tcp) {
262
+ output.push(` TCP: ${mcpServer.tcp}`);
263
+ }
264
+ if (mcpServer.type) {
265
+ output.push(` Type: ${mcpServer.type}`);
266
+ }
267
+ if (mcpServer.timeout !== undefined) {
268
+ output.push(` Timeout: ${mcpServer.timeout}`);
269
+ }
270
+ if (mcpServer.trust !== undefined) {
271
+ output.push(` Trust: ${mcpServer.trust}`);
272
+ }
273
+ if (mcpServer.description) {
274
+ output.push(` Description: ${mcpServer.description}`);
275
+ }
276
+ if (mcpServer.includeTools && mcpServer.includeTools.length > 0) {
277
+ output.push(` Include tools: ${mcpServer.includeTools.join(', ')}`);
278
+ }
279
+ if (mcpServer.excludeTools && mcpServer.excludeTools.length > 0) {
280
+ output.push(` Exclude tools: ${mcpServer.excludeTools.join(', ')}`);
281
+ }
282
+ if (mcpServer.authProviderType) {
283
+ output.push(` Auth Provider Type: ${mcpServer.authProviderType}`);
284
+ }
285
+ if (mcpServer.targetAudience) {
286
+ output.push(` Target Audience: ${mcpServer.targetAudience}`);
287
+ }
288
+ if (mcpServer.targetServiceAccount) {
289
+ output.push(
290
+ ` Target Service Account: ${mcpServer.targetServiceAccount}`,
291
+ );
292
+ }
293
+ if (mcpServer.oauth) {
294
+ output.push(' OAuth Configuration:');
295
+ const sortedOauth = Object.entries(mcpServer.oauth).sort(([a], [b]) =>
296
+ a.localeCompare(b),
297
+ );
298
+ for (const [oKey, oValue] of sortedOauth) {
299
+ const isSensitive = /secret|token|password/i.test(oKey);
300
+ const valStr = Array.isArray(oValue)
301
+ ? oValue.join(', ')
302
+ : String(oValue ?? '');
303
+ const displayValue = isSensitive && valStr ? '********' : valStr;
304
+ output.push(` - ${oKey}: ${displayValue}`);
305
+ }
306
+ }
307
+ output.push(` Config signature: ${getMcpServerConfigHash(mcpServer)}`);
308
+ }
309
+ }
310
+ if (sanitizedConfig.contextFileName) {
311
+ output.push(
312
+ `This extension will append info to your gemini.md context using ${sanitizedConfig.contextFileName}`,
313
+ );
314
+ }
315
+ if (sanitizedConfig.excludeTools) {
316
+ output.push(
317
+ `This extension will exclude the following core tools: ${sanitizedConfig.excludeTools}`,
318
+ );
319
+ }
320
+ if (hasHooks) {
321
+ output.push(
322
+ '⚠️ This extension contains Hooks which can automatically execute commands.',
323
+ );
324
+ }
325
+ if (skills.length > 0) {
326
+ output.push(`\n${chalk.bold('Agent Skills:')}`);
327
+ output.push('\nThis extension will install the following agent skills:\n');
328
+ output.push(...(await renderSkillsList(skills)));
329
+ }
330
+
331
+ output.push('\n' + INSTALL_WARNING_MESSAGE);
332
+ if (skills.length > 0) {
333
+ output.push('\n' + SKILLS_WARNING_MESSAGE);
334
+ }
335
+
336
+ return output.join('\n');
337
+ }
338
+
339
+ /**
340
+ * Shared logic for formatting a list of agent skills for a consent prompt.
341
+ */
342
+ async function renderSkillsList(skills: SkillDefinition[]): Promise<string[]> {
343
+ const output: string[] = [];
344
+ for (const skill of skills) {
345
+ output.push(` * ${chalk.bold(skill.name)}: ${skill.description}`);
346
+ const skillDir = path.dirname(skill.location);
347
+ let fileCountStr = '';
348
+ try {
349
+ const skillDirItems = await fs.readdir(skillDir);
350
+ fileCountStr = ` (${skillDirItems.length} items in directory)`;
351
+ } catch {
352
+ fileCountStr = ` ${chalk.red('⚠️ (Could not count items in directory)')}`;
353
+ }
354
+ output.push(chalk.dim(` (Source: ${skill.location})${fileCountStr}`));
355
+ output.push('');
356
+ }
357
+ return output;
358
+ }
359
+
360
+ /**
361
+ * Requests consent from the user to install an extension (extensionConfig), if
362
+ * there is any difference between the consent string for `extensionConfig` and
363
+ * `previousExtensionConfig`.
364
+ *
365
+ * Always requests consent if previousExtensionConfig is null.
366
+ *
367
+ * Throws if the user does not consent.
368
+ */
369
+ export async function maybeRequestConsentOrFail(
370
+ extensionConfig: ExtensionConfig,
371
+ requestConsent: (consent: string) => Promise<boolean>,
372
+ hasHooks: boolean,
373
+ previousExtensionConfig?: ExtensionConfig,
374
+ previousHasHooks?: boolean,
375
+ skills: SkillDefinition[] = [],
376
+ previousSkills: SkillDefinition[] = [],
377
+ isMigrating: boolean = false,
378
+ ) {
379
+ const extensionConsent = await extensionConsentString(
380
+ extensionConfig,
381
+ hasHooks,
382
+ skills,
383
+ previousExtensionConfig?.name,
384
+ isMigrating,
385
+ );
386
+ if (previousExtensionConfig) {
387
+ const previousExtensionConsent = await extensionConsentString(
388
+ previousExtensionConfig,
389
+ previousHasHooks ?? false,
390
+ previousSkills,
391
+ );
392
+ if (previousExtensionConsent === extensionConsent) {
393
+ return;
394
+ }
395
+ }
396
+ if (!(await requestConsent(extensionConsent))) {
397
+ throw new Error(`Installation cancelled for "${extensionConfig.name}".`);
398
+ }
399
+ }
packages/cli/src/config/extensions/extensionEnablement.test.ts ADDED
@@ -0,0 +1,534 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as path from 'node:path';
8
+ import fs from 'node:fs';
9
+
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import { ExtensionEnablementManager, Override } from './extensionEnablement.js';
12
+
13
+ import { ExtensionStorage } from './storage.js';
14
+
15
+ vi.mock('./storage.js');
16
+
17
+ import {
18
+ coreEvents,
19
+ GEMINI_DIR,
20
+ type GeminiCLIExtension,
21
+ } from '@google/gemini-cli-core';
22
+
23
+ vi.mock('node:os', () => ({
24
+ homedir: vi.fn().mockReturnValue('/virtual-home'),
25
+ tmpdir: vi.fn().mockReturnValue('/virtual-tmp'),
26
+ }));
27
+
28
+ const inMemoryFs: { [key: string]: string } = {};
29
+
30
+ // Helper to create a temporary directory for testing
31
+ function createTestDir() {
32
+ const dirPath = `/virtual-tmp/gemini-test-${Math.random().toString(36).substring(2, 15)}`;
33
+ inMemoryFs[dirPath] = ''; // Simulate directory existence
34
+ return {
35
+ path: dirPath,
36
+ cleanup: () => {
37
+ for (const key in inMemoryFs) {
38
+ if (key.startsWith(dirPath)) {
39
+ delete inMemoryFs[key];
40
+ }
41
+ }
42
+ },
43
+ };
44
+ }
45
+
46
+ let testDir: { path: string; cleanup: () => void };
47
+ let manager: ExtensionEnablementManager;
48
+
49
+ describe('ExtensionEnablementManager', () => {
50
+ beforeEach(() => {
51
+ // Clear the in-memory file system before each test
52
+ for (const key in inMemoryFs) {
53
+ delete inMemoryFs[key];
54
+ }
55
+ expect(Object.keys(inMemoryFs).length).toBe(0); // Add this assertion
56
+
57
+ // Mock fs functions
58
+ vi.spyOn(fs, 'readFileSync').mockImplementation(
59
+ (path: fs.PathOrFileDescriptor) => {
60
+ const content = inMemoryFs[path.toString()];
61
+ if (content === undefined) {
62
+ const error = new Error(
63
+ `ENOENT: no such file or directory, open '${path}'`,
64
+ );
65
+ (error as NodeJS.ErrnoException).code = 'ENOENT';
66
+ throw error;
67
+ }
68
+ return content;
69
+ },
70
+ );
71
+ vi.spyOn(fs, 'writeFileSync').mockImplementation(
72
+ (
73
+ path: fs.PathOrFileDescriptor,
74
+ data: string | NodeJS.ArrayBufferView,
75
+ ) => {
76
+ inMemoryFs[path.toString()] = data.toString(); // Convert ArrayBufferView to string for inMemoryFs
77
+ },
78
+ );
79
+ vi.spyOn(fs, 'mkdirSync').mockImplementation(
80
+ (
81
+ _path: fs.PathLike,
82
+ _options?: fs.MakeDirectoryOptions | fs.Mode | null,
83
+ ) => undefined,
84
+ );
85
+ vi.spyOn(fs, 'mkdtempSync').mockImplementation((prefix: string) => {
86
+ const virtualPath = `/virtual-tmp/${prefix.replace(/[^a-zA-Z0-9]/g, '')}`;
87
+ return virtualPath;
88
+ });
89
+ vi.spyOn(fs, 'rmSync').mockImplementation(() => {});
90
+
91
+ testDir = createTestDir();
92
+ vi.mocked(ExtensionStorage.getUserExtensionsDir).mockReturnValue(
93
+ path.join(testDir.path, GEMINI_DIR),
94
+ );
95
+ manager = new ExtensionEnablementManager();
96
+ });
97
+
98
+ afterEach(() => {
99
+ vi.restoreAllMocks();
100
+ // Reset the singleton instance for test isolation
101
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
+ (ExtensionEnablementManager as any).instance = undefined;
103
+ });
104
+
105
+ describe('isEnabled', () => {
106
+ it('should return true if extension is not configured', () => {
107
+ expect(manager.isEnabled('ext-test', '/any/path')).toBe(true);
108
+ });
109
+
110
+ it('should return true if no overrides match', () => {
111
+ manager.disable('ext-test', false, '/another/path');
112
+ expect(manager.isEnabled('ext-test', '/any/path')).toBe(true);
113
+ });
114
+
115
+ it('should enable a path based on an override rule', () => {
116
+ manager.disable('ext-test', true, '/');
117
+ manager.enable('ext-test', true, '/home/user/projects/');
118
+ expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
119
+ true,
120
+ );
121
+ });
122
+
123
+ it('should disable a path based on a disable override rule', () => {
124
+ manager.enable('ext-test', true, '/');
125
+ manager.disable('ext-test', true, '/home/user/projects/');
126
+ expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
127
+ false,
128
+ );
129
+ });
130
+
131
+ it('should respect the last matching rule (enable wins)', () => {
132
+ manager.disable('ext-test', true, '/home/user/projects/');
133
+ manager.enable('ext-test', false, '/home/user/projects/my-app');
134
+ expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
135
+ true,
136
+ );
137
+ });
138
+
139
+ it('should respect the last matching rule (disable wins)', () => {
140
+ manager.enable('ext-test', true, '/home/user/projects/');
141
+ manager.disable('ext-test', false, '/home/user/projects/my-app');
142
+ expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
143
+ false,
144
+ );
145
+ });
146
+
147
+ it('should handle overlapping rules correctly', () => {
148
+ manager.enable('ext-test', true, '/home/user/projects');
149
+ manager.disable('ext-test', false, '/home/user/projects/my-app');
150
+ expect(manager.isEnabled('ext-test', '/home/user/projects/my-app')).toBe(
151
+ false,
152
+ );
153
+ expect(
154
+ manager.isEnabled('ext-test', '/home/user/projects/something-else'),
155
+ ).toBe(true);
156
+ });
157
+ });
158
+
159
+ describe('remove', () => {
160
+ it('should remove an extension from the config', () => {
161
+ manager.enable('ext-test', true, '/path/to/dir');
162
+ const config = manager.readConfig();
163
+ expect(config['ext-test']).toBeDefined();
164
+
165
+ manager.remove('ext-test');
166
+ const newConfig = manager.readConfig();
167
+ expect(newConfig['ext-test']).toBeUndefined();
168
+ });
169
+
170
+ it('should not throw when removing a non-existent extension', () => {
171
+ const config = manager.readConfig();
172
+ expect(config['ext-test']).toBeUndefined();
173
+ expect(() => manager.remove('ext-test')).not.toThrow();
174
+ });
175
+ });
176
+
177
+ describe('readConfig', () => {
178
+ it('should return an empty object if the config file is corrupted', () => {
179
+ const configPath = path.join(
180
+ testDir.path,
181
+ GEMINI_DIR,
182
+ 'extension-enablement.json',
183
+ );
184
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
185
+ fs.writeFileSync(configPath, 'not a json');
186
+ const config = manager.readConfig();
187
+ expect(config).toEqual({});
188
+ });
189
+
190
+ it('should return an empty object on generic read error', () => {
191
+ vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
192
+ throw new Error('Read error');
193
+ });
194
+ const config = manager.readConfig();
195
+ expect(config).toEqual({});
196
+ });
197
+ });
198
+
199
+ describe('includeSubdirs', () => {
200
+ it('should add a glob when enabling with includeSubdirs', () => {
201
+ manager.enable('ext-test', true, '/path/to/dir');
202
+ const config = manager.readConfig();
203
+ expect(config['ext-test'].overrides).toContain('/path/to/dir/*');
204
+ });
205
+
206
+ it('should not add a glob when enabling without includeSubdirs', () => {
207
+ manager.enable('ext-test', false, '/path/to/dir');
208
+ const config = manager.readConfig();
209
+ expect(config['ext-test'].overrides).toContain('/path/to/dir/');
210
+ expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*');
211
+ });
212
+
213
+ it('should add a glob when disabling with includeSubdirs', () => {
214
+ manager.disable('ext-test', true, '/path/to/dir');
215
+ const config = manager.readConfig();
216
+ expect(config['ext-test'].overrides).toContain('!/path/to/dir/*');
217
+ });
218
+
219
+ it('should remove conflicting glob rule when enabling without subdirs', () => {
220
+ manager.enable('ext-test', true, '/path/to/dir'); // Adds /path/to/dir*
221
+ manager.enable('ext-test', false, '/path/to/dir'); // Should remove the glob
222
+ const config = manager.readConfig();
223
+ expect(config['ext-test'].overrides).toContain('/path/to/dir/');
224
+ expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*');
225
+ });
226
+
227
+ it('should remove conflicting non-glob rule when enabling with subdirs', () => {
228
+ manager.enable('ext-test', false, '/path/to/dir'); // Adds /path/to/dir
229
+ manager.enable('ext-test', true, '/path/to/dir'); // Should remove the non-glob
230
+ const config = manager.readConfig();
231
+ expect(config['ext-test'].overrides).toContain('/path/to/dir/*');
232
+ expect(config['ext-test'].overrides).not.toContain('/path/to/dir/');
233
+ });
234
+
235
+ it('should remove conflicting rules when disabling', () => {
236
+ manager.enable('ext-test', true, '/path/to/dir'); // enabled with glob
237
+ manager.disable('ext-test', false, '/path/to/dir'); // disabled without
238
+ const config = manager.readConfig();
239
+ expect(config['ext-test'].overrides).toContain('!/path/to/dir/');
240
+ expect(config['ext-test'].overrides).not.toContain('/path/to/dir/*');
241
+ });
242
+
243
+ it('should correctly evaluate isEnabled with subdirs', () => {
244
+ manager.disable('ext-test', true, '/');
245
+ manager.enable('ext-test', true, '/path/to/dir');
246
+ expect(manager.isEnabled('ext-test', '/path/to/dir/')).toBe(true);
247
+ expect(manager.isEnabled('ext-test', '/path/to/dir/sub/')).toBe(true);
248
+ expect(manager.isEnabled('ext-test', '/path/to/another/')).toBe(false);
249
+ });
250
+
251
+ it('should correctly evaluate isEnabled without subdirs', () => {
252
+ manager.disable('ext-test', true, '/*');
253
+ manager.enable('ext-test', false, '/path/to/dir');
254
+ expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(true);
255
+ expect(manager.isEnabled('ext-test', '/path/to/dir/sub')).toBe(false);
256
+ });
257
+ });
258
+
259
+ describe('pruning child rules', () => {
260
+ it('should remove child rules when enabling a parent with subdirs', () => {
261
+ // Pre-existing rules for children
262
+ manager.enable('ext-test', false, '/path/to/dir/subdir1');
263
+ manager.disable('ext-test', true, '/path/to/dir/subdir2');
264
+ manager.enable('ext-test', false, '/path/to/another/dir');
265
+
266
+ // Enable the parent directory
267
+ manager.enable('ext-test', true, '/path/to/dir');
268
+
269
+ const config = manager.readConfig();
270
+ const overrides = config['ext-test'].overrides;
271
+
272
+ // The new parent rule should be present
273
+ expect(overrides).toContain(`/path/to/dir/*`);
274
+
275
+ // Child rules should be removed
276
+ expect(overrides).not.toContain('/path/to/dir/subdir1/');
277
+ expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`);
278
+
279
+ // Unrelated rules should remain
280
+ expect(overrides).toContain('/path/to/another/dir/');
281
+ });
282
+
283
+ it('should remove child rules when disabling a parent with subdirs', () => {
284
+ // Pre-existing rules for children
285
+ manager.enable('ext-test', false, '/path/to/dir/subdir1');
286
+ manager.disable('ext-test', true, '/path/to/dir/subdir2');
287
+ manager.enable('ext-test', false, '/path/to/another/dir');
288
+
289
+ // Disable the parent directory
290
+ manager.disable('ext-test', true, '/path/to/dir');
291
+
292
+ const config = manager.readConfig();
293
+ const overrides = config['ext-test'].overrides;
294
+
295
+ // The new parent rule should be present
296
+ expect(overrides).toContain(`!/path/to/dir/*`);
297
+
298
+ // Child rules should be removed
299
+ expect(overrides).not.toContain('/path/to/dir/subdir1/');
300
+ expect(overrides).not.toContain(`!/path/to/dir/subdir2/*`);
301
+
302
+ // Unrelated rules should remain
303
+ expect(overrides).toContain('/path/to/another/dir/');
304
+ });
305
+
306
+ it('should not remove child rules if includeSubdirs is false', () => {
307
+ manager.enable('ext-test', false, '/path/to/dir/subdir1');
308
+ manager.enable('ext-test', false, '/path/to/dir'); // Not including subdirs
309
+
310
+ const config = manager.readConfig();
311
+ const overrides = config['ext-test'].overrides;
312
+
313
+ expect(overrides).toContain('/path/to/dir/subdir1/');
314
+ expect(overrides).toContain('/path/to/dir/');
315
+ });
316
+ });
317
+
318
+ it('should correctly prioritize more specific enable rules', () => {
319
+ manager.disable('ext-test', true, '/Users/chrstn');
320
+ manager.enable('ext-test', true, '/Users/chrstn/gemini-cli');
321
+
322
+ expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe(
323
+ true,
324
+ );
325
+ });
326
+
327
+ it('should not disable subdirectories if includeSubdirs is false', () => {
328
+ manager.disable('ext-test', false, '/Users/chrstn');
329
+ expect(manager.isEnabled('ext-test', '/Users/chrstn/gemini-cli')).toBe(
330
+ true,
331
+ );
332
+ });
333
+
334
+ describe('extension overrides (-e <name>)', () => {
335
+ beforeEach(() => {
336
+ manager = new ExtensionEnablementManager(['ext-test']);
337
+ });
338
+
339
+ it('can enable extensions, case-insensitive', () => {
340
+ manager.disable('ext-test', true, '/');
341
+ expect(manager.isEnabled('ext-test', '/')).toBe(true);
342
+ expect(manager.isEnabled('Ext-Test', '/')).toBe(true);
343
+ // Double check that it would have been disabled otherwise
344
+ expect(new ExtensionEnablementManager().isEnabled('ext-test', '/')).toBe(
345
+ false,
346
+ );
347
+ });
348
+
349
+ it('disable all other extensions', () => {
350
+ manager = new ExtensionEnablementManager(['ext-test']);
351
+ manager.enable('ext-test-2', true, '/');
352
+ expect(manager.isEnabled('ext-test-2', '/')).toBe(false);
353
+ // Double check that it would have been enabled otherwise
354
+ expect(
355
+ new ExtensionEnablementManager().isEnabled('ext-test-2', '/'),
356
+ ).toBe(true);
357
+ });
358
+
359
+ it('none disables all extensions', () => {
360
+ manager = new ExtensionEnablementManager(['none']);
361
+ manager.enable('ext-test', true, '/');
362
+ expect(manager.isEnabled('ext-test', '/path/to/dir')).toBe(false);
363
+ // Double check that it would have been enabled otherwise
364
+ expect(new ExtensionEnablementManager().isEnabled('ext-test', '/')).toBe(
365
+ true,
366
+ );
367
+ });
368
+ });
369
+
370
+ describe('validateExtensionOverrides', () => {
371
+ let coreEventsEmitSpy: ReturnType<typeof vi.spyOn>;
372
+
373
+ beforeEach(() => {
374
+ coreEventsEmitSpy = vi.spyOn(coreEvents, 'emitFeedback');
375
+ });
376
+
377
+ afterEach(() => {
378
+ coreEventsEmitSpy.mockRestore();
379
+ });
380
+
381
+ it('should not log an error if enabledExtensionNamesOverride is empty', () => {
382
+ const manager = new ExtensionEnablementManager([]);
383
+ manager.validateExtensionOverrides([]);
384
+ expect(coreEventsEmitSpy).not.toHaveBeenCalled();
385
+ });
386
+
387
+ it('should not log an error if all enabledExtensionNamesOverride are valid', () => {
388
+ const manager = new ExtensionEnablementManager(['ext-one', 'ext-two']);
389
+ const extensions = [
390
+ { name: 'ext-one' },
391
+ { name: 'ext-two' },
392
+ ] as GeminiCLIExtension[];
393
+ manager.validateExtensionOverrides(extensions);
394
+ expect(coreEventsEmitSpy).not.toHaveBeenCalled();
395
+ });
396
+
397
+ it('should log an error for each invalid extension name in enabledExtensionNamesOverride', () => {
398
+ const manager = new ExtensionEnablementManager([
399
+ 'ext-one',
400
+ 'ext-invalid',
401
+ 'ext-another-invalid',
402
+ ]);
403
+ const extensions = [
404
+ { name: 'ext-one' },
405
+ { name: 'ext-two' },
406
+ ] as GeminiCLIExtension[];
407
+ manager.validateExtensionOverrides(extensions);
408
+ expect(coreEventsEmitSpy).toHaveBeenCalledTimes(2);
409
+ expect(coreEventsEmitSpy).toHaveBeenCalledWith(
410
+ 'error',
411
+ 'Extension not found: ext-invalid',
412
+ );
413
+ expect(coreEventsEmitSpy).toHaveBeenCalledWith(
414
+ 'error',
415
+ 'Extension not found: ext-another-invalid',
416
+ );
417
+ });
418
+
419
+ it('should not log an error if "none" is in enabledExtensionNamesOverride', () => {
420
+ const manager = new ExtensionEnablementManager(['none']);
421
+ manager.validateExtensionOverrides([]);
422
+ expect(coreEventsEmitSpy).not.toHaveBeenCalled();
423
+ });
424
+ });
425
+ });
426
+
427
+ describe('Override', () => {
428
+ it('should create an override from input', () => {
429
+ const override = Override.fromInput('/path/to/dir', true);
430
+ expect(override.baseRule).toBe(`/path/to/dir/`);
431
+ expect(override.isDisable).toBe(false);
432
+ expect(override.includeSubdirs).toBe(true);
433
+ });
434
+
435
+ it('should create a disable override from input', () => {
436
+ const override = Override.fromInput('!/path/to/dir', false);
437
+ expect(override.baseRule).toBe(`/path/to/dir/`);
438
+ expect(override.isDisable).toBe(true);
439
+ expect(override.includeSubdirs).toBe(false);
440
+ });
441
+
442
+ it('should create an override from a file rule', () => {
443
+ const override = Override.fromFileRule('/path/to/dir/');
444
+ expect(override.baseRule).toBe('/path/to/dir/');
445
+ expect(override.isDisable).toBe(false);
446
+ expect(override.includeSubdirs).toBe(false);
447
+ });
448
+
449
+ it('should create an override from a file rule without a trailing slash', () => {
450
+ const override = Override.fromFileRule('/path/to/dir');
451
+ expect(override.baseRule).toBe('/path/to/dir');
452
+ expect(override.isDisable).toBe(false);
453
+ expect(override.includeSubdirs).toBe(false);
454
+ });
455
+
456
+ it('should create a disable override from a file rule', () => {
457
+ const override = Override.fromFileRule('!/path/to/dir/');
458
+ expect(override.isDisable).toBe(true);
459
+ expect(override.baseRule).toBe('/path/to/dir/');
460
+ expect(override.includeSubdirs).toBe(false);
461
+ });
462
+
463
+ it('should create an override with subdirs from a file rule', () => {
464
+ const override = Override.fromFileRule('/path/to/dir/*');
465
+ expect(override.baseRule).toBe('/path/to/dir/');
466
+ expect(override.isDisable).toBe(false);
467
+ expect(override.includeSubdirs).toBe(true);
468
+ });
469
+
470
+ it('should correctly identify conflicting overrides', () => {
471
+ const override1 = Override.fromInput('/path/to/dir', true);
472
+ const override2 = Override.fromInput('/path/to/dir', false);
473
+ expect(override1.conflictsWith(override2)).toBe(true);
474
+ });
475
+
476
+ it('should correctly identify non-conflicting overrides', () => {
477
+ const override1 = Override.fromInput('/path/to/dir', true);
478
+ const override2 = Override.fromInput('/path/to/another/dir', true);
479
+ expect(override1.conflictsWith(override2)).toBe(false);
480
+ });
481
+
482
+ it('should correctly identify equal overrides', () => {
483
+ const override1 = Override.fromInput('/path/to/dir', true);
484
+ const override2 = Override.fromInput('/path/to/dir', true);
485
+ expect(override1.isEqualTo(override2)).toBe(true);
486
+ });
487
+
488
+ it('should correctly identify unequal overrides', () => {
489
+ const override1 = Override.fromInput('/path/to/dir', true);
490
+ const override2 = Override.fromInput('!/path/to/dir', true);
491
+ expect(override1.isEqualTo(override2)).toBe(false);
492
+ });
493
+
494
+ it('should generate the correct regex', () => {
495
+ const override = Override.fromInput('/path/to/dir', true);
496
+ const regex = override.asRegex();
497
+ expect(regex.test('/path/to/dir/')).toBe(true);
498
+ expect(regex.test('/path/to/dir/subdir')).toBe(true);
499
+ expect(regex.test('/path/to/another/dir')).toBe(false);
500
+ });
501
+
502
+ it('should correctly identify child overrides', () => {
503
+ const parent = Override.fromInput('/path/to/dir', true);
504
+ const child = Override.fromInput('/path/to/dir/subdir', false);
505
+ expect(child.isChildOf(parent)).toBe(true);
506
+ });
507
+
508
+ it('should correctly identify child overrides with glob', () => {
509
+ const parent = Override.fromInput('/path/to/dir/*', true);
510
+ const child = Override.fromInput('/path/to/dir/subdir', false);
511
+ expect(child.isChildOf(parent)).toBe(true);
512
+ });
513
+
514
+ it('should correctly identify non-child overrides', () => {
515
+ const parent = Override.fromInput('/path/to/dir', true);
516
+ const other = Override.fromInput('/path/to/another/dir', false);
517
+ expect(other.isChildOf(parent)).toBe(false);
518
+ });
519
+
520
+ it('should generate the correct output string', () => {
521
+ const override = Override.fromInput('/path/to/dir', true);
522
+ expect(override.output()).toBe(`/path/to/dir/*`);
523
+ });
524
+
525
+ it('should generate the correct output string for a disable override', () => {
526
+ const override = Override.fromInput('!/path/to/dir', false);
527
+ expect(override.output()).toBe(`!/path/to/dir/`);
528
+ });
529
+
530
+ it('should disable a path based on a disable override rule', () => {
531
+ const override = Override.fromInput('!/path/to/dir', false);
532
+ expect(override.output()).toBe(`!/path/to/dir/`);
533
+ });
534
+ });
packages/cli/src/config/extensions/extensionEnablement.ts ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { coreEvents, type GeminiCLIExtension } from '@google/gemini-cli-core';
10
+ import { ExtensionStorage } from './storage.js';
11
+ import { z } from 'zod';
12
+
13
+ export interface ExtensionEnablementConfig {
14
+ overrides: string[];
15
+ }
16
+
17
+ export interface AllExtensionsEnablementConfig {
18
+ [extensionName: string]: ExtensionEnablementConfig;
19
+ }
20
+
21
+ export class Override {
22
+ constructor(
23
+ public baseRule: string,
24
+ public isDisable: boolean,
25
+ public includeSubdirs: boolean,
26
+ ) {}
27
+
28
+ static fromInput(inputRule: string, includeSubdirs: boolean): Override {
29
+ const isDisable = inputRule.startsWith('!');
30
+ let baseRule = isDisable ? inputRule.substring(1) : inputRule;
31
+ baseRule = ensureLeadingAndTrailingSlash(baseRule);
32
+ return new Override(baseRule, isDisable, includeSubdirs);
33
+ }
34
+
35
+ static fromFileRule(fileRule: string): Override {
36
+ const isDisable = fileRule.startsWith('!');
37
+ let baseRule = isDisable ? fileRule.substring(1) : fileRule;
38
+ const includeSubdirs = baseRule.endsWith('*');
39
+ baseRule = includeSubdirs
40
+ ? baseRule.substring(0, baseRule.length - 1)
41
+ : baseRule;
42
+ return new Override(baseRule, isDisable, includeSubdirs);
43
+ }
44
+
45
+ conflictsWith(other: Override): boolean {
46
+ if (this.baseRule === other.baseRule) {
47
+ return (
48
+ this.includeSubdirs !== other.includeSubdirs ||
49
+ this.isDisable !== other.isDisable
50
+ );
51
+ }
52
+ return false;
53
+ }
54
+
55
+ isEqualTo(other: Override): boolean {
56
+ return (
57
+ this.baseRule === other.baseRule &&
58
+ this.includeSubdirs === other.includeSubdirs &&
59
+ this.isDisable === other.isDisable
60
+ );
61
+ }
62
+
63
+ asRegex(): RegExp {
64
+ return globToRegex(`${this.baseRule}${this.includeSubdirs ? '*' : ''}`);
65
+ }
66
+
67
+ isChildOf(parent: Override) {
68
+ if (!parent.includeSubdirs) {
69
+ return false;
70
+ }
71
+ return parent.asRegex().test(this.baseRule);
72
+ }
73
+
74
+ output(): string {
75
+ return `${this.isDisable ? '!' : ''}${this.baseRule}${this.includeSubdirs ? '*' : ''}`;
76
+ }
77
+
78
+ matchesPath(path: string) {
79
+ return this.asRegex().test(path);
80
+ }
81
+ }
82
+
83
+ const ensureLeadingAndTrailingSlash = function (dirPath: string): string {
84
+ // Normalize separators to forward slashes for consistent matching across platforms.
85
+ let result = dirPath.replace(/\\/g, '/');
86
+ if (result.charAt(0) !== '/') {
87
+ result = '/' + result;
88
+ }
89
+ if (result.charAt(result.length - 1) !== '/') {
90
+ result = result + '/';
91
+ }
92
+ return result;
93
+ };
94
+
95
+ /**
96
+ * Converts a glob pattern to a RegExp object.
97
+ * This is a simplified implementation that supports `*`.
98
+ *
99
+ * @param glob The glob pattern to convert.
100
+ * @returns A RegExp object.
101
+ */
102
+ function globToRegex(glob: string): RegExp {
103
+ const regexString = glob
104
+ .replace(/[.+?^${}()|[\]\\]/g, '\\$&') // Escape special regex characters
105
+ .replace(/(\/?)\*/g, '($1.*)?'); // Convert * to optional group
106
+
107
+ return new RegExp(`^${regexString}$`);
108
+ }
109
+
110
+ export class ExtensionEnablementManager {
111
+ private configFilePath: string;
112
+ private configDir: string;
113
+ // If non-empty, this overrides all other extension configuration and enables
114
+ // only the ones in this list.
115
+ private enabledExtensionNamesOverride: string[];
116
+
117
+ constructor(enabledExtensionNames?: string[]) {
118
+ this.configDir = ExtensionStorage.getUserExtensionsDir();
119
+ this.configFilePath = path.join(
120
+ this.configDir,
121
+ 'extension-enablement.json',
122
+ );
123
+ this.enabledExtensionNamesOverride =
124
+ enabledExtensionNames?.map((name) => name.toLowerCase()) ?? [];
125
+ }
126
+
127
+ validateExtensionOverrides(extensions: GeminiCLIExtension[]) {
128
+ for (const name of this.enabledExtensionNamesOverride) {
129
+ if (name === 'none') continue;
130
+ if (
131
+ !extensions.some((ext) => ext.name.toLowerCase() === name.toLowerCase())
132
+ ) {
133
+ coreEvents.emitFeedback('error', `Extension not found: ${name}`);
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Determines if an extension is enabled based on its name and the current
140
+ * path. The last matching rule in the overrides list wins.
141
+ *
142
+ * @param extensionName The name of the extension.
143
+ * @param currentPath The absolute path of the current working directory.
144
+ * @returns True if the extension is enabled, false otherwise.
145
+ */
146
+ isEnabled(extensionName: string, currentPath: string): boolean {
147
+ // If we have a single override called 'none', this disables all extensions.
148
+ // Typically, this comes from the user passing `-e none`.
149
+ if (
150
+ this.enabledExtensionNamesOverride.length === 1 &&
151
+ this.enabledExtensionNamesOverride[0] === 'none'
152
+ ) {
153
+ return false;
154
+ }
155
+
156
+ // If we have explicit overrides, only enable those extensions.
157
+ if (this.enabledExtensionNamesOverride.length > 0) {
158
+ // When checking against overrides ONLY, we use a case insensitive match.
159
+ // The override names are already lowercased in the constructor.
160
+ return this.enabledExtensionNamesOverride.includes(
161
+ extensionName.toLocaleLowerCase(),
162
+ );
163
+ }
164
+
165
+ // Otherwise, we use the configuration settings
166
+ const config = this.readConfig();
167
+ const extensionConfig = config[extensionName];
168
+ // Extensions are enabled by default.
169
+ let enabled = true;
170
+ const allOverrides = extensionConfig?.overrides ?? [];
171
+ for (const rule of allOverrides) {
172
+ const override = Override.fromFileRule(rule);
173
+ if (override.matchesPath(ensureLeadingAndTrailingSlash(currentPath))) {
174
+ enabled = !override.isDisable;
175
+ }
176
+ }
177
+ return enabled;
178
+ }
179
+
180
+ readConfig(): AllExtensionsEnablementConfig {
181
+ try {
182
+ const content = fs.readFileSync(this.configFilePath, 'utf-8');
183
+ const parsed: unknown = JSON.parse(content);
184
+ const schema = z.record(
185
+ z.string(),
186
+ z.object({ overrides: z.array(z.string()) }),
187
+ );
188
+ return schema.parse(parsed);
189
+ } catch (error) {
190
+ if (
191
+ error instanceof Error &&
192
+ 'code' in error &&
193
+ error.code === 'ENOENT'
194
+ ) {
195
+ return {};
196
+ }
197
+ coreEvents.emitFeedback(
198
+ 'error',
199
+ 'Failed to read extension enablement config.',
200
+ error,
201
+ );
202
+ return {};
203
+ }
204
+ }
205
+
206
+ writeConfig(config: AllExtensionsEnablementConfig): void {
207
+ fs.mkdirSync(this.configDir, { recursive: true });
208
+ fs.writeFileSync(this.configFilePath, JSON.stringify(config, null, 2));
209
+ }
210
+
211
+ enable(
212
+ extensionName: string,
213
+ includeSubdirs: boolean,
214
+ scopePath: string,
215
+ ): void {
216
+ const config = this.readConfig();
217
+ if (!config[extensionName]) {
218
+ config[extensionName] = { overrides: [] };
219
+ }
220
+ const override = Override.fromInput(scopePath, includeSubdirs);
221
+ const overrides = config[extensionName].overrides.filter((rule) => {
222
+ const fileOverride = Override.fromFileRule(rule);
223
+ if (
224
+ fileOverride.conflictsWith(override) ||
225
+ fileOverride.isEqualTo(override)
226
+ ) {
227
+ return false; // Remove conflicts and equivalent values.
228
+ }
229
+ return !fileOverride.isChildOf(override);
230
+ });
231
+ overrides.push(override.output());
232
+ config[extensionName].overrides = overrides;
233
+ this.writeConfig(config);
234
+ }
235
+
236
+ disable(
237
+ extensionName: string,
238
+ includeSubdirs: boolean,
239
+ scopePath: string,
240
+ ): void {
241
+ this.enable(extensionName, includeSubdirs, `!${scopePath}`);
242
+ }
243
+
244
+ remove(extensionName: string): void {
245
+ const config = this.readConfig();
246
+ if (config[extensionName]) {
247
+ delete config[extensionName];
248
+ this.writeConfig(config);
249
+ }
250
+ }
251
+ }
packages/cli/src/config/extensions/extensionSettings.test.ts ADDED
@@ -0,0 +1,937 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import * as path from 'node:path';
9
+ import * as os from 'node:os';
10
+ import {
11
+ getEnvContents,
12
+ maybePromptForSettings,
13
+ promptForSetting,
14
+ type ExtensionSetting,
15
+ updateSetting,
16
+ ExtensionSettingScope,
17
+ getScopedEnvContents,
18
+ } from './extensionSettings.js';
19
+ import type { ExtensionConfig } from '../extension.js';
20
+ import { ExtensionStorage } from './storage.js';
21
+ import prompts from 'prompts';
22
+ import * as fsPromises from 'node:fs/promises';
23
+ import * as fs from 'node:fs';
24
+ import { KeychainTokenStorage } from '@google/gemini-cli-core';
25
+ import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
26
+
27
+ vi.mock('prompts');
28
+ vi.mock('os', async (importOriginal) => {
29
+ const mockedOs = await importOriginal<typeof os>();
30
+ return {
31
+ ...mockedOs,
32
+ homedir: vi.fn(),
33
+ };
34
+ });
35
+
36
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
37
+ const actual =
38
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
39
+ return {
40
+ ...actual,
41
+ KeychainTokenStorage: vi.fn(),
42
+ };
43
+ });
44
+
45
+ describe('extensionSettings', () => {
46
+ let tempHomeDir: string;
47
+ let tempWorkspaceDir: string;
48
+ let extensionDir: string;
49
+ let mockKeychainData: Record<string, Record<string, string>>;
50
+
51
+ beforeEach(() => {
52
+ vi.clearAllMocks();
53
+ mockKeychainData = {};
54
+ vi.mocked(KeychainTokenStorage).mockImplementation(
55
+ (serviceName: string) => {
56
+ if (!mockKeychainData[serviceName]) {
57
+ mockKeychainData[serviceName] = {};
58
+ }
59
+ const keychainData = mockKeychainData[serviceName];
60
+ return {
61
+ getSecret: vi
62
+ .fn()
63
+ .mockImplementation(
64
+ async (key: string) => keychainData[key] || null,
65
+ ),
66
+ setSecret: vi
67
+ .fn()
68
+ .mockImplementation(async (key: string, value: string) => {
69
+ keychainData[key] = value;
70
+ }),
71
+ deleteSecret: vi.fn().mockImplementation(async (key: string) => {
72
+ delete keychainData[key];
73
+ }),
74
+ listSecrets: vi
75
+ .fn()
76
+ .mockImplementation(async () => Object.keys(keychainData)),
77
+ isAvailable: vi.fn().mockResolvedValue(true),
78
+ } as unknown as KeychainTokenStorage;
79
+ },
80
+ );
81
+ tempHomeDir = os.tmpdir() + path.sep + `gemini-cli-test-home-${Date.now()}`;
82
+ tempWorkspaceDir = path.join(
83
+ os.tmpdir(),
84
+ `gemini-cli-test-workspace-${Date.now()}`,
85
+ );
86
+ extensionDir = path.join(tempHomeDir, '.gemini', 'extensions', 'test-ext');
87
+ // Spy and mock the method, but also create the directory so we can write to it.
88
+ vi.spyOn(ExtensionStorage.prototype, 'getExtensionDir').mockReturnValue(
89
+ extensionDir,
90
+ );
91
+ fs.mkdirSync(extensionDir, { recursive: true });
92
+ fs.mkdirSync(tempWorkspaceDir, { recursive: true });
93
+ vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
94
+ vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir);
95
+ vi.mocked(prompts).mockClear();
96
+ });
97
+
98
+ afterEach(() => {
99
+ fs.rmSync(tempHomeDir, { recursive: true, force: true });
100
+ fs.rmSync(tempWorkspaceDir, { recursive: true, force: true });
101
+ vi.restoreAllMocks();
102
+ });
103
+
104
+ describe('maybePromptForSettings', () => {
105
+ const mockRequestSetting = vi.fn(
106
+ async (setting: ExtensionSetting) => `mock-${setting.envVar}`,
107
+ );
108
+
109
+ beforeEach(() => {
110
+ mockRequestSetting.mockClear();
111
+ });
112
+
113
+ it('should do nothing if settings are undefined', async () => {
114
+ const config: ExtensionConfig = { name: 'test-ext', version: '1.0.0' };
115
+ await maybePromptForSettings(
116
+ config,
117
+ '12345',
118
+ mockRequestSetting,
119
+ undefined,
120
+ undefined,
121
+ );
122
+ expect(mockRequestSetting).not.toHaveBeenCalled();
123
+ });
124
+
125
+ it('should do nothing if settings are empty', async () => {
126
+ const config: ExtensionConfig = {
127
+ name: 'test-ext',
128
+ version: '1.0.0',
129
+ settings: [],
130
+ };
131
+ await maybePromptForSettings(
132
+ config,
133
+ '12345',
134
+ mockRequestSetting,
135
+ undefined,
136
+ undefined,
137
+ );
138
+ expect(mockRequestSetting).not.toHaveBeenCalled();
139
+ });
140
+
141
+ it('should prompt for all settings if there is no previous config', async () => {
142
+ const config: ExtensionConfig = {
143
+ name: 'test-ext',
144
+ version: '1.0.0',
145
+ settings: [
146
+ { name: 's1', description: 'd1', envVar: 'VAR1' },
147
+ { name: 's2', description: 'd2', envVar: 'VAR2' },
148
+ ],
149
+ };
150
+ await maybePromptForSettings(
151
+ config,
152
+ '12345',
153
+ mockRequestSetting,
154
+ undefined,
155
+ undefined,
156
+ );
157
+ expect(mockRequestSetting).toHaveBeenCalledTimes(2);
158
+ expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![0]);
159
+ expect(mockRequestSetting).toHaveBeenCalledWith(config.settings![1]);
160
+ });
161
+
162
+ it('should only prompt for new settings', async () => {
163
+ const previousConfig: ExtensionConfig = {
164
+ name: 'test-ext',
165
+ version: '1.0.0',
166
+ settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
167
+ };
168
+ const newConfig: ExtensionConfig = {
169
+ name: 'test-ext',
170
+ version: '1.0.0',
171
+ settings: [
172
+ { name: 's1', description: 'd1', envVar: 'VAR1' },
173
+ { name: 's2', description: 'd2', envVar: 'VAR2' },
174
+ ],
175
+ };
176
+ const previousSettings = { VAR1: 'previous-VAR1' };
177
+
178
+ await maybePromptForSettings(
179
+ newConfig,
180
+ '12345',
181
+ mockRequestSetting,
182
+ previousConfig,
183
+ previousSettings,
184
+ );
185
+
186
+ expect(mockRequestSetting).toHaveBeenCalledTimes(1);
187
+ expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![1]);
188
+
189
+ const expectedEnvPath = path.join(extensionDir, '.env');
190
+ const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
191
+ const expectedContent = 'VAR1=previous-VAR1\nVAR2=mock-VAR2\n';
192
+ expect(actualContent).toBe(expectedContent);
193
+ });
194
+
195
+ it('should clear settings if new config has no settings', async () => {
196
+ const previousConfig: ExtensionConfig = {
197
+ name: 'test-ext',
198
+ version: '1.0.0',
199
+ settings: [
200
+ { name: 's1', description: 'd1', envVar: 'VAR1' },
201
+ {
202
+ name: 's2',
203
+ description: 'd2',
204
+ envVar: 'SENSITIVE_VAR',
205
+ sensitive: true,
206
+ },
207
+ ],
208
+ };
209
+ const newConfig: ExtensionConfig = {
210
+ name: 'test-ext',
211
+ version: '1.0.0',
212
+ settings: [],
213
+ };
214
+ const previousSettings = {
215
+ VAR1: 'previous-VAR1',
216
+ SENSITIVE_VAR: 'secret',
217
+ };
218
+ const userKeychain = new KeychainTokenStorage(
219
+ `Gemini CLI Extensions test-ext 12345`,
220
+ );
221
+ await userKeychain.setSecret('SENSITIVE_VAR', 'secret');
222
+ const envPath = path.join(extensionDir, '.env');
223
+ await fsPromises.writeFile(envPath, 'VAR1=previous-VAR1');
224
+
225
+ await maybePromptForSettings(
226
+ newConfig,
227
+ '12345',
228
+ mockRequestSetting,
229
+ previousConfig,
230
+ previousSettings,
231
+ );
232
+
233
+ expect(mockRequestSetting).not.toHaveBeenCalled();
234
+ const actualContent = await fsPromises.readFile(envPath, 'utf-8');
235
+ expect(actualContent).toBe('');
236
+ expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
237
+ });
238
+
239
+ it('should remove sensitive settings from keychain', async () => {
240
+ const previousConfig: ExtensionConfig = {
241
+ name: 'test-ext',
242
+ version: '1.0.0',
243
+ settings: [
244
+ {
245
+ name: 's1',
246
+ description: 'd1',
247
+ envVar: 'SENSITIVE_VAR',
248
+ sensitive: true,
249
+ },
250
+ ],
251
+ };
252
+ const newConfig: ExtensionConfig = {
253
+ name: 'test-ext',
254
+ version: '1.0.0',
255
+ settings: [],
256
+ };
257
+ const previousSettings = { SENSITIVE_VAR: 'secret' };
258
+ const userKeychain = new KeychainTokenStorage(
259
+ `Gemini CLI Extensions test-ext 12345`,
260
+ );
261
+ await userKeychain.setSecret('SENSITIVE_VAR', 'secret');
262
+
263
+ await maybePromptForSettings(
264
+ newConfig,
265
+ '12345',
266
+ mockRequestSetting,
267
+ previousConfig,
268
+ previousSettings,
269
+ );
270
+
271
+ expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
272
+ });
273
+
274
+ it('should remove settings that are no longer in the config', async () => {
275
+ const previousConfig: ExtensionConfig = {
276
+ name: 'test-ext',
277
+ version: '1.0.0',
278
+ settings: [
279
+ { name: 's1', description: 'd1', envVar: 'VAR1' },
280
+ { name: 's2', description: 'd2', envVar: 'VAR2' },
281
+ ],
282
+ };
283
+ const newConfig: ExtensionConfig = {
284
+ name: 'test-ext',
285
+ version: '1.0.0',
286
+ settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
287
+ };
288
+ const previousSettings = {
289
+ VAR1: 'previous-VAR1',
290
+ VAR2: 'previous-VAR2',
291
+ };
292
+
293
+ await maybePromptForSettings(
294
+ newConfig,
295
+ '12345',
296
+ mockRequestSetting,
297
+ previousConfig,
298
+ previousSettings,
299
+ );
300
+
301
+ expect(mockRequestSetting).not.toHaveBeenCalled();
302
+
303
+ const expectedEnvPath = path.join(extensionDir, '.env');
304
+ const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
305
+ const expectedContent = 'VAR1=previous-VAR1\n';
306
+ expect(actualContent).toBe(expectedContent);
307
+ });
308
+
309
+ it('should reprompt if a setting changes sensitivity', async () => {
310
+ const previousConfig: ExtensionConfig = {
311
+ name: 'test-ext',
312
+ version: '1.0.0',
313
+ settings: [
314
+ { name: 's1', description: 'd1', envVar: 'VAR1', sensitive: false },
315
+ ],
316
+ };
317
+ const newConfig: ExtensionConfig = {
318
+ name: 'test-ext',
319
+ version: '1.0.0',
320
+ settings: [
321
+ { name: 's1', description: 'd1', envVar: 'VAR1', sensitive: true },
322
+ ],
323
+ };
324
+ const previousSettings = { VAR1: 'previous-VAR1' };
325
+
326
+ await maybePromptForSettings(
327
+ newConfig,
328
+ '12345',
329
+ mockRequestSetting,
330
+ previousConfig,
331
+ previousSettings,
332
+ );
333
+
334
+ expect(mockRequestSetting).toHaveBeenCalledTimes(1);
335
+ expect(mockRequestSetting).toHaveBeenCalledWith(newConfig.settings![0]);
336
+
337
+ // The value should now be in keychain, not the .env file.
338
+ const expectedEnvPath = path.join(extensionDir, '.env');
339
+ const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
340
+ expect(actualContent).toBe('');
341
+ });
342
+
343
+ it('should not prompt if settings are identical', async () => {
344
+ const previousConfig: ExtensionConfig = {
345
+ name: 'test-ext',
346
+ version: '1.0.0',
347
+ settings: [
348
+ { name: 's1', description: 'd1', envVar: 'VAR1' },
349
+ { name: 's2', description: 'd2', envVar: 'VAR2' },
350
+ ],
351
+ };
352
+ const newConfig: ExtensionConfig = {
353
+ name: 'test-ext',
354
+ version: '1.0.0',
355
+ settings: [
356
+ { name: 's1', description: 'd1', envVar: 'VAR1' },
357
+ { name: 's2', description: 'd2', envVar: 'VAR2' },
358
+ ],
359
+ };
360
+ const previousSettings = {
361
+ VAR1: 'previous-VAR1',
362
+ VAR2: 'previous-VAR2',
363
+ };
364
+
365
+ await maybePromptForSettings(
366
+ newConfig,
367
+ '12345',
368
+ mockRequestSetting,
369
+ previousConfig,
370
+ previousSettings,
371
+ );
372
+
373
+ expect(mockRequestSetting).not.toHaveBeenCalled();
374
+ const expectedEnvPath = path.join(extensionDir, '.env');
375
+ const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
376
+ const expectedContent = 'VAR1=previous-VAR1\nVAR2=previous-VAR2\n';
377
+ expect(actualContent).toBe(expectedContent);
378
+ });
379
+
380
+ it('should wrap values with spaces in quotes', async () => {
381
+ const config: ExtensionConfig = {
382
+ name: 'test-ext',
383
+ version: '1.0.0',
384
+ settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
385
+ };
386
+ mockRequestSetting.mockResolvedValue('a value with spaces');
387
+
388
+ await maybePromptForSettings(
389
+ config,
390
+ '12345',
391
+ mockRequestSetting,
392
+ undefined,
393
+ undefined,
394
+ );
395
+
396
+ const expectedEnvPath = path.join(extensionDir, '.env');
397
+ const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
398
+ expect(actualContent).toBe('VAR1="a value with spaces"\n');
399
+ });
400
+
401
+ it('should not set sensitive settings if the value is empty during initial setup', async () => {
402
+ const config: ExtensionConfig = {
403
+ name: 'test-ext',
404
+ version: '1.0.0',
405
+ settings: [
406
+ {
407
+ name: 's1',
408
+ description: 'd1',
409
+ envVar: 'SENSITIVE_VAR',
410
+ sensitive: true,
411
+ },
412
+ ],
413
+ };
414
+ mockRequestSetting.mockResolvedValue('');
415
+
416
+ await maybePromptForSettings(
417
+ config,
418
+ '12345',
419
+ mockRequestSetting,
420
+ undefined,
421
+ undefined,
422
+ );
423
+
424
+ const userKeychain = new KeychainTokenStorage(
425
+ `Gemini CLI Extensions test-ext 12345`,
426
+ );
427
+ expect(await userKeychain.getSecret('SENSITIVE_VAR')).toBeNull();
428
+ });
429
+
430
+ it('should not attempt to clear secrets if keychain is unavailable', async () => {
431
+ // Arrange
432
+ const mockIsAvailable = vi.fn().mockResolvedValue(false);
433
+ const mockListSecrets = vi.fn();
434
+
435
+ vi.mocked(KeychainTokenStorage).mockImplementation(
436
+ () =>
437
+ ({
438
+ isAvailable: mockIsAvailable,
439
+ listSecrets: mockListSecrets,
440
+ deleteSecret: vi.fn(),
441
+ getSecret: vi.fn(),
442
+ setSecret: vi.fn(),
443
+ }) as unknown as KeychainTokenStorage,
444
+ );
445
+
446
+ const config: ExtensionConfig = {
447
+ name: 'test-ext',
448
+ version: '1.0.0',
449
+ settings: [], // Empty settings triggers clearSettings
450
+ };
451
+
452
+ const previousConfig: ExtensionConfig = {
453
+ name: 'test-ext',
454
+ version: '1.0.0',
455
+ settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
456
+ };
457
+
458
+ // Act
459
+ await maybePromptForSettings(
460
+ config,
461
+ '12345',
462
+ mockRequestSetting,
463
+ previousConfig,
464
+ undefined,
465
+ );
466
+
467
+ // Assert
468
+ expect(mockIsAvailable).toHaveBeenCalled();
469
+ expect(mockListSecrets).not.toHaveBeenCalled();
470
+ });
471
+ });
472
+
473
+ describe('promptForSetting', () => {
474
+ it.each([
475
+ {
476
+ description:
477
+ 'should use prompts with type "password" for sensitive settings',
478
+ setting: {
479
+ name: 'API Key',
480
+ description: 'Your secret key',
481
+ envVar: 'API_KEY',
482
+ sensitive: true,
483
+ },
484
+ expectedType: 'password',
485
+ promptValue: 'secret-key',
486
+ },
487
+ {
488
+ description:
489
+ 'should use prompts with type "text" for non-sensitive settings',
490
+ setting: {
491
+ name: 'Username',
492
+ description: 'Your public username',
493
+ envVar: 'USERNAME',
494
+ sensitive: false,
495
+ },
496
+ expectedType: 'text',
497
+ promptValue: 'test-user',
498
+ },
499
+ {
500
+ description: 'should default to "text" if sensitive is undefined',
501
+ setting: {
502
+ name: 'Username',
503
+ description: 'Your public username',
504
+ envVar: 'USERNAME',
505
+ },
506
+ expectedType: 'text',
507
+ promptValue: 'test-user',
508
+ },
509
+ ])('$description', async ({ setting, expectedType, promptValue }) => {
510
+ vi.mocked(prompts).mockResolvedValue({ value: promptValue });
511
+
512
+ const result = await promptForSetting(setting as ExtensionSetting);
513
+
514
+ expect(prompts).toHaveBeenCalledWith({
515
+ type: expectedType,
516
+ name: 'value',
517
+ message: `${setting.name}\n${setting.description}`,
518
+ });
519
+ expect(result).toBe(promptValue);
520
+ });
521
+
522
+ it('should return undefined if the user cancels the prompt', async () => {
523
+ vi.mocked(prompts).mockResolvedValue({ value: undefined });
524
+ const result = await promptForSetting({
525
+ name: 'Test',
526
+ description: 'Test desc',
527
+ envVar: 'TEST_VAR',
528
+ });
529
+ expect(result).toBeUndefined();
530
+ });
531
+ });
532
+
533
+ describe('getScopedEnvContents', () => {
534
+ const config: ExtensionConfig = {
535
+ name: 'test-ext',
536
+ version: '1.0.0',
537
+ settings: [
538
+ { name: 's1', description: 'd1', envVar: 'VAR1' },
539
+ {
540
+ name: 's2',
541
+ description: 'd2',
542
+ envVar: 'SENSITIVE_VAR',
543
+ sensitive: true,
544
+ },
545
+ ],
546
+ };
547
+ const extensionId = '12345';
548
+
549
+ it('should return combined contents from user .env and keychain for USER scope', async () => {
550
+ const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
551
+ await fsPromises.writeFile(userEnvPath, 'VAR1=user-value1');
552
+ const userKeychain = new KeychainTokenStorage(
553
+ `Gemini CLI Extensions test-ext 12345`,
554
+ );
555
+ await userKeychain.setSecret('SENSITIVE_VAR', 'user-secret');
556
+
557
+ const contents = await getScopedEnvContents(
558
+ config,
559
+ extensionId,
560
+ ExtensionSettingScope.USER,
561
+ tempWorkspaceDir,
562
+ );
563
+
564
+ expect(contents).toEqual({
565
+ VAR1: 'user-value1',
566
+ SENSITIVE_VAR: 'user-secret',
567
+ });
568
+ });
569
+
570
+ it('should return combined contents from workspace .env and keychain for WORKSPACE scope', async () => {
571
+ const workspaceEnvPath = path.join(
572
+ tempWorkspaceDir,
573
+ EXTENSION_SETTINGS_FILENAME,
574
+ );
575
+ await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
576
+ const workspaceKeychain = new KeychainTokenStorage(
577
+ `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
578
+ );
579
+ await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
580
+
581
+ const contents = await getScopedEnvContents(
582
+ config,
583
+ extensionId,
584
+ ExtensionSettingScope.WORKSPACE,
585
+ tempWorkspaceDir,
586
+ );
587
+
588
+ expect(contents).toEqual({
589
+ VAR1: 'workspace-value1',
590
+ SENSITIVE_VAR: 'workspace-secret',
591
+ });
592
+ });
593
+
594
+ it('should ignore .env if it is a directory', async () => {
595
+ const workspaceEnvPath = path.join(
596
+ tempWorkspaceDir,
597
+ EXTENSION_SETTINGS_FILENAME,
598
+ );
599
+ fs.mkdirSync(workspaceEnvPath);
600
+ const workspaceKeychain = new KeychainTokenStorage(
601
+ `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
602
+ );
603
+ await workspaceKeychain.setSecret('SENSITIVE_VAR', 'workspace-secret');
604
+
605
+ const contents = await getScopedEnvContents(
606
+ config,
607
+ extensionId,
608
+ ExtensionSettingScope.WORKSPACE,
609
+ tempWorkspaceDir,
610
+ );
611
+
612
+ expect(contents).toEqual({
613
+ SENSITIVE_VAR: 'workspace-secret',
614
+ });
615
+ });
616
+ });
617
+
618
+ describe('getEnvContents (merged)', () => {
619
+ const config: ExtensionConfig = {
620
+ name: 'test-ext',
621
+ version: '1.0.0',
622
+ settings: [
623
+ { name: 's1', description: 'd1', envVar: 'VAR1' },
624
+ { name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
625
+ { name: 's3', description: 'd3', envVar: 'VAR3' },
626
+ ],
627
+ };
628
+ const extensionId = '12345';
629
+
630
+ it('should merge user and workspace settings, with workspace taking precedence', async () => {
631
+ // User settings
632
+ const userEnvPath = path.join(extensionDir, EXTENSION_SETTINGS_FILENAME);
633
+ await fsPromises.writeFile(
634
+ userEnvPath,
635
+ 'VAR1=user-value1\nVAR3=user-value3',
636
+ );
637
+ const userKeychain = new KeychainTokenStorage(
638
+ `Gemini CLI Extensions test-ext ${extensionId}`,
639
+ );
640
+ await userKeychain.setSecret('VAR2', 'user-secret2');
641
+
642
+ // Workspace settings
643
+ const workspaceEnvPath = path.join(
644
+ tempWorkspaceDir,
645
+ EXTENSION_SETTINGS_FILENAME,
646
+ );
647
+ await fsPromises.writeFile(workspaceEnvPath, 'VAR1=workspace-value1');
648
+ const workspaceKeychain = new KeychainTokenStorage(
649
+ `Gemini CLI Extensions test-ext ${extensionId} ${tempWorkspaceDir}`,
650
+ );
651
+ await workspaceKeychain.setSecret('VAR2', 'workspace-secret2');
652
+
653
+ const contents = await getEnvContents(
654
+ config,
655
+ extensionId,
656
+ tempWorkspaceDir,
657
+ );
658
+
659
+ expect(contents).toEqual({
660
+ VAR1: 'workspace-value1',
661
+ VAR2: 'workspace-secret2',
662
+ VAR3: 'user-value3',
663
+ });
664
+ });
665
+ });
666
+
667
+ describe('updateSetting', () => {
668
+ const config: ExtensionConfig = {
669
+ name: 'test-ext',
670
+ version: '1.0.0',
671
+ settings: [
672
+ { name: 's1', description: 'd1', envVar: 'VAR1' },
673
+ { name: 's2', description: 'd2', envVar: 'VAR2', sensitive: true },
674
+ ],
675
+ };
676
+ const mockRequestSetting = vi.fn();
677
+
678
+ beforeEach(async () => {
679
+ const userEnvPath = path.join(extensionDir, '.env');
680
+ await fsPromises.writeFile(userEnvPath, 'VAR1=value1\n');
681
+ const userKeychain = new KeychainTokenStorage(
682
+ `Gemini CLI Extensions test-ext 12345`,
683
+ );
684
+ await userKeychain.setSecret('VAR2', 'value2');
685
+ mockRequestSetting.mockClear();
686
+ });
687
+
688
+ it('should update a non-sensitive setting in USER scope', async () => {
689
+ mockRequestSetting.mockResolvedValue('new-value1');
690
+
691
+ await updateSetting(
692
+ config,
693
+ '12345',
694
+ 'VAR1',
695
+ mockRequestSetting,
696
+ ExtensionSettingScope.USER,
697
+ tempWorkspaceDir,
698
+ );
699
+
700
+ const expectedEnvPath = path.join(extensionDir, '.env');
701
+ const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
702
+ expect(actualContent).toContain('VAR1=new-value1');
703
+ });
704
+
705
+ it('should update a non-sensitive setting in WORKSPACE scope', async () => {
706
+ mockRequestSetting.mockResolvedValue('new-workspace-value');
707
+
708
+ await updateSetting(
709
+ config,
710
+ '12345',
711
+ 'VAR1',
712
+ mockRequestSetting,
713
+ ExtensionSettingScope.WORKSPACE,
714
+ tempWorkspaceDir,
715
+ );
716
+
717
+ const expectedEnvPath = path.join(tempWorkspaceDir, '.env');
718
+ const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
719
+ expect(actualContent).toContain('VAR1=new-workspace-value');
720
+ });
721
+
722
+ it('should throw an error when trying to write to a workspace with a .env directory', async () => {
723
+ const workspaceEnvPath = path.join(tempWorkspaceDir, '.env');
724
+ fs.mkdirSync(workspaceEnvPath);
725
+
726
+ mockRequestSetting.mockResolvedValue('new-workspace-value');
727
+
728
+ await expect(
729
+ updateSetting(
730
+ config,
731
+ '12345',
732
+ 'VAR1',
733
+ mockRequestSetting,
734
+ ExtensionSettingScope.WORKSPACE,
735
+ tempWorkspaceDir,
736
+ ),
737
+ ).rejects.toThrow(
738
+ /Cannot write extension settings to .* because it is a directory./,
739
+ );
740
+ });
741
+
742
+ it('should update a sensitive setting in USER scope', async () => {
743
+ mockRequestSetting.mockResolvedValue('new-value2');
744
+
745
+ await updateSetting(
746
+ config,
747
+ '12345',
748
+ 'VAR2',
749
+ mockRequestSetting,
750
+ ExtensionSettingScope.USER,
751
+ tempWorkspaceDir,
752
+ );
753
+
754
+ const userKeychain = new KeychainTokenStorage(
755
+ `Gemini CLI Extensions test-ext 12345`,
756
+ );
757
+ expect(await userKeychain.getSecret('VAR2')).toBe('new-value2');
758
+ });
759
+
760
+ it('should update a sensitive setting in WORKSPACE scope', async () => {
761
+ mockRequestSetting.mockResolvedValue('new-workspace-secret');
762
+
763
+ await updateSetting(
764
+ config,
765
+ '12345',
766
+ 'VAR2',
767
+ mockRequestSetting,
768
+ ExtensionSettingScope.WORKSPACE,
769
+ tempWorkspaceDir,
770
+ );
771
+
772
+ const workspaceKeychain = new KeychainTokenStorage(
773
+ `Gemini CLI Extensions test-ext 12345 ${tempWorkspaceDir}`,
774
+ );
775
+ expect(await workspaceKeychain.getSecret('VAR2')).toBe(
776
+ 'new-workspace-secret',
777
+ );
778
+ });
779
+
780
+ it('should leave existing, unmanaged .env variables intact when updating in WORKSPACE scope', async () => {
781
+ // Setup a pre-existing .env file in the workspace with unmanaged variables
782
+ const workspaceEnvPath = path.join(tempWorkspaceDir, '.env');
783
+ const originalEnvContent =
784
+ 'PROJECT_VAR_1=value_1\nPROJECT_VAR_2=value_2\nVAR1=original-value'; // VAR1 is managed by extension
785
+ await fsPromises.writeFile(workspaceEnvPath, originalEnvContent);
786
+
787
+ // Simulate updating an extension-managed non-sensitive setting
788
+ mockRequestSetting.mockResolvedValue('updated-value');
789
+ await updateSetting(
790
+ config,
791
+ '12345',
792
+ 'VAR1',
793
+ mockRequestSetting,
794
+ ExtensionSettingScope.WORKSPACE,
795
+ tempWorkspaceDir,
796
+ );
797
+
798
+ // Read the .env file after update
799
+ const actualContent = await fsPromises.readFile(
800
+ workspaceEnvPath,
801
+ 'utf-8',
802
+ );
803
+
804
+ // Assert that original variables are intact and extension variable is updated
805
+ expect(actualContent).toContain('PROJECT_VAR_1=value_1');
806
+ expect(actualContent).toContain('PROJECT_VAR_2=value_2');
807
+ expect(actualContent).toContain('VAR1=updated-value');
808
+
809
+ // Ensure no other unexpected changes or deletions
810
+ const lines = actualContent.split('\n').filter((line) => line.length > 0);
811
+ expect(lines).toHaveLength(3); // Should only have the three variables
812
+ });
813
+
814
+ it('should delete a sensitive setting if the new value is empty', async () => {
815
+ mockRequestSetting.mockResolvedValue('');
816
+
817
+ await updateSetting(
818
+ config,
819
+ '12345',
820
+ 'VAR2',
821
+ mockRequestSetting,
822
+ ExtensionSettingScope.USER,
823
+ tempWorkspaceDir,
824
+ );
825
+
826
+ const userKeychain = new KeychainTokenStorage(
827
+ `Gemini CLI Extensions test-ext 12345`,
828
+ );
829
+ expect(await userKeychain.getSecret('VAR2')).toBeNull();
830
+ });
831
+
832
+ it('should delete a non-sensitive setting if the new value is empty', async () => {
833
+ mockRequestSetting.mockResolvedValue('');
834
+
835
+ await updateSetting(
836
+ config,
837
+ '12345',
838
+ 'VAR1',
839
+ mockRequestSetting,
840
+ ExtensionSettingScope.USER,
841
+ tempWorkspaceDir,
842
+ );
843
+
844
+ const expectedEnvPath = path.join(extensionDir, '.env');
845
+ const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
846
+ expect(actualContent).not.toContain('VAR1=');
847
+ });
848
+
849
+ it('should not throw if deleting a non-existent sensitive setting with empty value', async () => {
850
+ mockRequestSetting.mockResolvedValue('');
851
+ // Ensure it doesn't exist first
852
+ const userKeychain = new KeychainTokenStorage(
853
+ `Gemini CLI Extensions test-ext 12345`,
854
+ );
855
+ await userKeychain.deleteSecret('VAR2');
856
+
857
+ await updateSetting(
858
+ config,
859
+ '12345',
860
+ 'VAR2',
861
+ mockRequestSetting,
862
+ ExtensionSettingScope.USER,
863
+ tempWorkspaceDir,
864
+ );
865
+ // Should complete without error
866
+ });
867
+
868
+ it('should throw error if env var name contains invalid characters', async () => {
869
+ const securityConfig: ExtensionConfig = {
870
+ name: 'test-ext',
871
+ version: '1.0.0',
872
+ settings: [{ name: 's2', description: 'd2', envVar: 'VAR-BAD' }],
873
+ };
874
+ mockRequestSetting.mockResolvedValue('value');
875
+
876
+ await expect(
877
+ updateSetting(
878
+ securityConfig,
879
+ '12345',
880
+ 'VAR-BAD',
881
+ mockRequestSetting,
882
+ ExtensionSettingScope.USER,
883
+ tempWorkspaceDir,
884
+ ),
885
+ ).rejects.toThrow(/Invalid environment variable name/);
886
+ });
887
+
888
+ it('should throw error if env var value contains newlines', async () => {
889
+ mockRequestSetting.mockResolvedValue('value\nwith\nnewlines');
890
+
891
+ await expect(
892
+ updateSetting(
893
+ config,
894
+ '12345',
895
+ 'VAR1',
896
+ mockRequestSetting,
897
+ ExtensionSettingScope.USER,
898
+ tempWorkspaceDir,
899
+ ),
900
+ ).rejects.toThrow(/Invalid environment variable value/);
901
+ });
902
+
903
+ it('should quote values with spaces', async () => {
904
+ mockRequestSetting.mockResolvedValue('value with spaces');
905
+
906
+ await updateSetting(
907
+ config,
908
+ '12345',
909
+ 'VAR1',
910
+ mockRequestSetting,
911
+ ExtensionSettingScope.USER,
912
+ tempWorkspaceDir,
913
+ );
914
+
915
+ const expectedEnvPath = path.join(extensionDir, '.env');
916
+ const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
917
+ expect(actualContent).toContain('VAR1="value with spaces"');
918
+ });
919
+
920
+ it('should escape quotes in values', async () => {
921
+ mockRequestSetting.mockResolvedValue('value with "quotes"');
922
+
923
+ await updateSetting(
924
+ config,
925
+ '12345',
926
+ 'VAR1',
927
+ mockRequestSetting,
928
+ ExtensionSettingScope.USER,
929
+ tempWorkspaceDir,
930
+ );
931
+
932
+ const expectedEnvPath = path.join(extensionDir, '.env');
933
+ const actualContent = await fsPromises.readFile(expectedEnvPath, 'utf-8');
934
+ expect(actualContent).toContain('VAR1="value with \\"quotes\\""');
935
+ });
936
+ });
937
+ });
packages/cli/src/config/extensions/extensionSettings.ts ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as fs from 'node:fs/promises';
8
+ import * as fsSync from 'node:fs';
9
+ import * as dotenv from 'dotenv';
10
+ import * as path from 'node:path';
11
+
12
+ import { ExtensionStorage } from './storage.js';
13
+ import type { ExtensionConfig } from '../extension.js';
14
+
15
+ import prompts from 'prompts';
16
+ import { debugLogger, KeychainTokenStorage } from '@google/gemini-cli-core';
17
+ import { EXTENSION_SETTINGS_FILENAME } from './variables.js';
18
+
19
+ export enum ExtensionSettingScope {
20
+ USER = 'user',
21
+ WORKSPACE = 'workspace',
22
+ }
23
+
24
+ export interface ExtensionSetting {
25
+ name: string;
26
+ description: string;
27
+ envVar: string;
28
+ // NOTE: If no value is set, this setting will be considered NOT sensitive.
29
+ sensitive?: boolean;
30
+ }
31
+
32
+ const getKeychainStorageName = (
33
+ extensionName: string,
34
+ extensionId: string,
35
+ scope: ExtensionSettingScope,
36
+ workspaceDir?: string,
37
+ ): string => {
38
+ const base = `Gemini CLI Extensions ${extensionName} ${extensionId}`;
39
+ if (scope === ExtensionSettingScope.WORKSPACE) {
40
+ if (!workspaceDir) {
41
+ throw new Error('Workspace directory is required for workspace scope');
42
+ }
43
+ return `${base} ${workspaceDir}`;
44
+ }
45
+ return base;
46
+ };
47
+
48
+ export const getEnvFilePath = (
49
+ extensionName: string,
50
+ scope: ExtensionSettingScope,
51
+ workspaceDir?: string,
52
+ ): string => {
53
+ if (scope === ExtensionSettingScope.WORKSPACE) {
54
+ if (!workspaceDir) {
55
+ throw new Error('Workspace directory is required for workspace scope');
56
+ }
57
+ return path.join(workspaceDir, EXTENSION_SETTINGS_FILENAME);
58
+ }
59
+ return new ExtensionStorage(extensionName).getEnvFilePath();
60
+ };
61
+
62
+ export async function maybePromptForSettings(
63
+ extensionConfig: ExtensionConfig,
64
+ extensionId: string,
65
+ requestSetting: (setting: ExtensionSetting) => Promise<string | undefined>,
66
+ previousExtensionConfig?: ExtensionConfig,
67
+ previousSettings?: Record<string, string>,
68
+ ): Promise<void> {
69
+ const { name: extensionName, settings } = extensionConfig;
70
+ if (
71
+ (!settings || settings.length === 0) &&
72
+ (!previousExtensionConfig?.settings ||
73
+ previousExtensionConfig.settings.length === 0)
74
+ ) {
75
+ return;
76
+ }
77
+ // We assume user scope here because we don't have a way to ask the user for scope during the initial setup.
78
+ // The user can change the scope later using the `settings set` command.
79
+ const scope = ExtensionSettingScope.USER;
80
+ const envFilePath = getEnvFilePath(extensionName, scope);
81
+ const keychain = new KeychainTokenStorage(
82
+ getKeychainStorageName(extensionName, extensionId, scope),
83
+ );
84
+
85
+ if (!settings || settings.length === 0) {
86
+ await clearSettings(envFilePath, keychain);
87
+ return;
88
+ }
89
+
90
+ const settingsChanges = getSettingsChanges(
91
+ settings,
92
+ previousExtensionConfig?.settings ?? [],
93
+ );
94
+
95
+ const allSettings: Record<string, string> = { ...previousSettings };
96
+
97
+ for (const removedEnvSetting of settingsChanges.removeEnv) {
98
+ delete allSettings[removedEnvSetting.envVar];
99
+ }
100
+
101
+ for (const removedSensitiveSetting of settingsChanges.removeSensitive) {
102
+ await keychain.deleteSecret(removedSensitiveSetting.envVar);
103
+ }
104
+
105
+ for (const setting of settingsChanges.promptForSensitive.concat(
106
+ settingsChanges.promptForEnv,
107
+ )) {
108
+ const answer = await requestSetting(setting);
109
+ if (answer !== undefined) {
110
+ allSettings[setting.envVar] = answer;
111
+ }
112
+ }
113
+
114
+ const nonSensitiveSettings: Record<string, string> = {};
115
+ for (const setting of settings) {
116
+ const value = allSettings[setting.envVar];
117
+ if (value === undefined || value === '') {
118
+ continue;
119
+ }
120
+ if (setting.sensitive) {
121
+ await keychain.setSecret(setting.envVar, value);
122
+ } else {
123
+ nonSensitiveSettings[setting.envVar] = value;
124
+ }
125
+ }
126
+
127
+ const envContent = formatEnvContent(nonSensitiveSettings);
128
+
129
+ if (fsSync.existsSync(envFilePath)) {
130
+ const stat = fsSync.statSync(envFilePath);
131
+ if (stat.isDirectory()) {
132
+ throw new Error(
133
+ `Cannot write extension settings to ${envFilePath} because it is a directory.`,
134
+ );
135
+ }
136
+ }
137
+
138
+ await fs.writeFile(envFilePath, envContent);
139
+ }
140
+
141
+ function formatEnvContent(settings: Record<string, string>): string {
142
+ let envContent = '';
143
+ for (const [key, value] of Object.entries(settings)) {
144
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
145
+ throw new Error(
146
+ `Invalid environment variable name: "${key}". Must contain only alphanumeric characters and underscores.`,
147
+ );
148
+ }
149
+ if (value.includes('\n') || value.includes('\r')) {
150
+ throw new Error(
151
+ `Invalid environment variable value for "${key}". Values cannot contain newlines.`,
152
+ );
153
+ }
154
+ const formattedValue = value.includes(' ')
155
+ ? `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
156
+ : value;
157
+ envContent += `${key}=${formattedValue}\n`;
158
+ }
159
+ return envContent;
160
+ }
161
+
162
+ export async function promptForSetting(
163
+ setting: ExtensionSetting,
164
+ ): Promise<string | undefined> {
165
+ const response = await prompts({
166
+ type: setting.sensitive ? 'password' : 'text',
167
+ name: 'value',
168
+ message: `${setting.name}\n${setting.description}`,
169
+ });
170
+ return typeof response.value === 'string' ? response.value : undefined;
171
+ }
172
+
173
+ export async function getScopedEnvContents(
174
+ extensionConfig: ExtensionConfig,
175
+ extensionId: string,
176
+ scope: ExtensionSettingScope,
177
+ workspaceDir?: string,
178
+ ): Promise<Record<string, string>> {
179
+ const { name: extensionName } = extensionConfig;
180
+ const keychain = new KeychainTokenStorage(
181
+ getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
182
+ );
183
+ const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
184
+ let customEnv: Record<string, string> = {};
185
+ if (fsSync.existsSync(envFilePath)) {
186
+ const stat = fsSync.statSync(envFilePath);
187
+ if (!stat.isDirectory()) {
188
+ const envFile = fsSync.readFileSync(envFilePath, 'utf-8');
189
+ customEnv = dotenv.parse(envFile);
190
+ }
191
+ }
192
+
193
+ if (extensionConfig.settings) {
194
+ for (const setting of extensionConfig.settings) {
195
+ if (setting.sensitive) {
196
+ const secret = await keychain.getSecret(setting.envVar);
197
+ if (secret) {
198
+ customEnv[setting.envVar] = secret;
199
+ }
200
+ }
201
+ }
202
+ }
203
+ return customEnv;
204
+ }
205
+
206
+ export async function getEnvContents(
207
+ extensionConfig: ExtensionConfig,
208
+ extensionId: string,
209
+ workspaceDir: string,
210
+ ): Promise<Record<string, string>> {
211
+ if (!extensionConfig.settings || extensionConfig.settings.length === 0) {
212
+ return Promise.resolve({});
213
+ }
214
+
215
+ const userSettings = await getScopedEnvContents(
216
+ extensionConfig,
217
+ extensionId,
218
+ ExtensionSettingScope.USER,
219
+ );
220
+ const workspaceSettings = await getScopedEnvContents(
221
+ extensionConfig,
222
+ extensionId,
223
+ ExtensionSettingScope.WORKSPACE,
224
+ workspaceDir,
225
+ );
226
+
227
+ return { ...userSettings, ...workspaceSettings };
228
+ }
229
+
230
+ export async function updateSetting(
231
+ extensionConfig: ExtensionConfig,
232
+ extensionId: string,
233
+ settingKey: string,
234
+ requestSetting: (setting: ExtensionSetting) => Promise<string | undefined>,
235
+ scope: ExtensionSettingScope,
236
+ workspaceDir: string,
237
+ ): Promise<void> {
238
+ const { name: extensionName, settings } = extensionConfig;
239
+ if (!settings || settings.length === 0) {
240
+ debugLogger.log('This extension does not have any settings.');
241
+ return;
242
+ }
243
+
244
+ const settingToUpdate = settings.find(
245
+ (s) => s.name === settingKey || s.envVar === settingKey,
246
+ );
247
+
248
+ if (!settingToUpdate) {
249
+ debugLogger.log(`Setting ${settingKey} not found.`);
250
+ return;
251
+ }
252
+
253
+ const newValue = await requestSetting(settingToUpdate);
254
+ if (newValue === undefined) {
255
+ return;
256
+ }
257
+
258
+ const keychain = new KeychainTokenStorage(
259
+ getKeychainStorageName(extensionName, extensionId, scope, workspaceDir),
260
+ );
261
+
262
+ if (settingToUpdate.sensitive) {
263
+ if (newValue) {
264
+ await keychain.setSecret(settingToUpdate.envVar, newValue);
265
+ } else {
266
+ try {
267
+ await keychain.deleteSecret(settingToUpdate.envVar);
268
+ } catch {
269
+ // Ignore if secret does not exist
270
+ }
271
+ }
272
+ return;
273
+ }
274
+
275
+ // For non-sensitive settings, we need to read the existing .env file,
276
+ // update the value, and write it back, preserving any other values.
277
+ const envFilePath = getEnvFilePath(extensionName, scope, workspaceDir);
278
+ let envContent = '';
279
+ if (fsSync.existsSync(envFilePath)) {
280
+ const stat = fsSync.statSync(envFilePath);
281
+ if (stat.isDirectory()) {
282
+ throw new Error(
283
+ `Cannot write extension settings to ${envFilePath} because it is a directory.`,
284
+ );
285
+ }
286
+ envContent = await fs.readFile(envFilePath, 'utf-8');
287
+ }
288
+
289
+ const parsedEnv = dotenv.parse(envContent);
290
+ if (!newValue) {
291
+ delete parsedEnv[settingToUpdate.envVar];
292
+ } else {
293
+ parsedEnv[settingToUpdate.envVar] = newValue;
294
+ }
295
+
296
+ // We only want to write back the variables that are not sensitive.
297
+ const nonSensitiveSettings: Record<string, string> = {};
298
+ const sensitiveEnvVars = new Set(
299
+ settings.filter((s) => s.sensitive).map((s) => s.envVar),
300
+ );
301
+ for (const [key, value] of Object.entries(parsedEnv)) {
302
+ if (!sensitiveEnvVars.has(key)) {
303
+ nonSensitiveSettings[key] = value;
304
+ }
305
+ }
306
+
307
+ const newEnvContent = formatEnvContent(nonSensitiveSettings);
308
+ await fs.writeFile(envFilePath, newEnvContent);
309
+ }
310
+
311
+ interface settingsChanges {
312
+ promptForSensitive: ExtensionSetting[];
313
+ removeSensitive: ExtensionSetting[];
314
+ promptForEnv: ExtensionSetting[];
315
+ removeEnv: ExtensionSetting[];
316
+ }
317
+ function getSettingsChanges(
318
+ settings: ExtensionSetting[],
319
+ oldSettings: ExtensionSetting[],
320
+ ): settingsChanges {
321
+ const isSameSetting = (a: ExtensionSetting, b: ExtensionSetting) =>
322
+ a.envVar === b.envVar && (a.sensitive ?? false) === (b.sensitive ?? false);
323
+
324
+ const sensitiveOld = oldSettings.filter((s) => s.sensitive ?? false);
325
+ const sensitiveNew = settings.filter((s) => s.sensitive ?? false);
326
+ const envOld = oldSettings.filter((s) => !(s.sensitive ?? false));
327
+ const envNew = settings.filter((s) => !(s.sensitive ?? false));
328
+
329
+ return {
330
+ promptForSensitive: sensitiveNew.filter(
331
+ (s) => !sensitiveOld.some((old) => isSameSetting(s, old)),
332
+ ),
333
+ removeSensitive: sensitiveOld.filter(
334
+ (s) => !sensitiveNew.some((neu) => isSameSetting(s, neu)),
335
+ ),
336
+ promptForEnv: envNew.filter(
337
+ (s) => !envOld.some((old) => isSameSetting(s, old)),
338
+ ),
339
+ removeEnv: envOld.filter(
340
+ (s) => !envNew.some((neu) => isSameSetting(s, neu)),
341
+ ),
342
+ };
343
+ }
344
+
345
+ async function clearSettings(
346
+ envFilePath: string,
347
+ keychain: KeychainTokenStorage,
348
+ ) {
349
+ if (fsSync.existsSync(envFilePath)) {
350
+ const stat = fsSync.statSync(envFilePath);
351
+ if (!stat.isDirectory()) {
352
+ await fs.writeFile(envFilePath, '');
353
+ }
354
+ }
355
+ if (!(await keychain.isAvailable())) {
356
+ return;
357
+ }
358
+ const secrets = await keychain.listSecrets();
359
+ for (const secret of secrets) {
360
+ await keychain.deleteSecret(secret);
361
+ }
362
+ return;
363
+ }
364
+
365
+ export async function getMissingSettings(
366
+ extensionConfig: ExtensionConfig,
367
+ extensionId: string,
368
+ workspaceDir: string,
369
+ ): Promise<ExtensionSetting[]> {
370
+ const { settings } = extensionConfig;
371
+ if (!settings || settings.length === 0) {
372
+ return [];
373
+ }
374
+
375
+ const existingSettings = await getEnvContents(
376
+ extensionConfig,
377
+ extensionId,
378
+ workspaceDir,
379
+ );
380
+ const missingSettings: ExtensionSetting[] = [];
381
+
382
+ for (const setting of settings) {
383
+ if (existingSettings[setting.envVar] === undefined) {
384
+ missingSettings.push(setting);
385
+ }
386
+ }
387
+
388
+ return missingSettings;
389
+ }
packages/cli/src/config/extensions/extensionUpdates.test.ts ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
+ import * as fs from 'node:fs';
9
+ import { getMissingSettings } from './extensionSettings.js';
10
+ import type { ExtensionConfig } from '../extension.js';
11
+ import {
12
+ debugLogger,
13
+ type ExtensionInstallMetadata,
14
+ type GeminiCLIExtension,
15
+ coreEvents,
16
+ } from '@google/gemini-cli-core';
17
+ import { ExtensionManager } from '../extension-manager.js';
18
+ import { createTestMergedSettings } from '../settings.js';
19
+ import { isWorkspaceTrusted } from '../trustedFolders.js';
20
+
21
+ // --- Mocks ---
22
+
23
+ vi.mock('node:fs', async (importOriginal) => {
24
+ const actual = await importOriginal<typeof import('node:fs')>();
25
+ return {
26
+ ...actual,
27
+ existsSync: vi.fn(),
28
+ statSync: vi.fn(),
29
+ lstatSync: vi.fn(),
30
+ realpathSync: vi.fn((p) => p),
31
+ promises: {
32
+ ...actual.promises,
33
+ mkdir: vi.fn(),
34
+ readdir: vi.fn(),
35
+ writeFile: vi.fn(),
36
+ rm: vi.fn(),
37
+ cp: vi.fn(),
38
+ readFile: vi.fn(),
39
+ lstat: vi.fn(),
40
+ chmod: vi.fn(),
41
+ },
42
+ };
43
+ });
44
+
45
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
46
+ const actual =
47
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
48
+ return {
49
+ ...actual,
50
+ KeychainTokenStorage: vi.fn(),
51
+ debugLogger: {
52
+ warn: vi.fn(),
53
+ error: vi.fn(),
54
+ log: vi.fn(),
55
+ },
56
+ coreEvents: {
57
+ emitFeedback: vi.fn(),
58
+ on: vi.fn(),
59
+ off: vi.fn(),
60
+ emitConsoleLog: vi.fn(),
61
+ },
62
+ loadSkillsFromDir: vi.fn().mockResolvedValue([]),
63
+ loadAgentsFromDirectory: vi
64
+ .fn()
65
+ .mockResolvedValue({ agents: [], errors: [] }),
66
+ logExtensionInstallEvent: vi.fn().mockResolvedValue(undefined),
67
+ logExtensionUpdateEvent: vi.fn().mockResolvedValue(undefined),
68
+ logExtensionUninstall: vi.fn().mockResolvedValue(undefined),
69
+ logExtensionEnable: vi.fn().mockResolvedValue(undefined),
70
+ logExtensionDisable: vi.fn().mockResolvedValue(undefined),
71
+ Config: vi.fn().mockImplementation(() => ({
72
+ getEnableExtensionReloading: vi.fn().mockReturnValue(true),
73
+ })),
74
+ KeychainService: class {
75
+ isAvailable = vi.fn().mockResolvedValue(true);
76
+ getPassword = vi.fn().mockResolvedValue('test-key');
77
+ setPassword = vi.fn().mockResolvedValue(undefined);
78
+ },
79
+ ExtensionIntegrityManager: class {
80
+ verify = vi.fn().mockResolvedValue('verified');
81
+ store = vi.fn().mockResolvedValue(undefined);
82
+ },
83
+ IntegrityDataStatus: {
84
+ VERIFIED: 'verified',
85
+ MISSING: 'missing',
86
+ INVALID: 'invalid',
87
+ },
88
+ };
89
+ });
90
+
91
+ vi.mock('./consent.js', () => ({
92
+ maybeRequestConsentOrFail: vi.fn().mockResolvedValue(undefined),
93
+ }));
94
+
95
+ vi.mock('./extensionSettings.js', async (importOriginal) => {
96
+ const actual =
97
+ await importOriginal<typeof import('./extensionSettings.js')>();
98
+ return {
99
+ ...actual,
100
+ getEnvContents: vi.fn().mockResolvedValue({}),
101
+ getMissingSettings: vi.fn(), // We will mock this implementation per test
102
+ };
103
+ });
104
+
105
+ vi.mock('../trustedFolders.js', () => ({
106
+ isWorkspaceTrusted: vi.fn().mockReturnValue({ isTrusted: true }), // Default to trusted to simplify flow
107
+ loadTrustedFolders: vi.fn().mockReturnValue({
108
+ setValue: vi.fn().mockResolvedValue(undefined),
109
+ }),
110
+ TrustLevel: { TRUST_FOLDER: 'TRUST_FOLDER' },
111
+ }));
112
+
113
+ // Mock ExtensionStorage to avoid real FS paths
114
+ vi.mock('./storage.js', () => ({
115
+ ExtensionStorage: class {
116
+ constructor(public name: string) {}
117
+ getExtensionDir() {
118
+ return `/mock/extensions/${this.name}`;
119
+ }
120
+ static getUserExtensionsDir() {
121
+ return '/mock/extensions';
122
+ }
123
+ static createTmpDir() {
124
+ return Promise.resolve('/mock/tmp');
125
+ }
126
+ },
127
+ }));
128
+
129
+ vi.mock('os', async (importOriginal) => {
130
+ const mockedOs = await importOriginal<typeof import('node:os')>();
131
+ return {
132
+ ...mockedOs,
133
+ homedir: vi.fn().mockReturnValue('/mock/home'),
134
+ };
135
+ });
136
+
137
+ describe('extensionUpdates', () => {
138
+ let tempWorkspaceDir: string;
139
+
140
+ beforeEach(() => {
141
+ vi.clearAllMocks();
142
+ // Default fs mocks
143
+ vi.mocked(fs.promises.mkdir).mockResolvedValue(undefined);
144
+ vi.mocked(fs.promises.writeFile).mockResolvedValue(undefined);
145
+ vi.mocked(fs.promises.rm).mockResolvedValue(undefined);
146
+ vi.mocked(fs.promises.cp).mockResolvedValue(undefined);
147
+ vi.mocked(fs.promises.readdir).mockResolvedValue([]);
148
+ vi.mocked(fs.promises.lstat).mockResolvedValue({
149
+ isDirectory: () => true,
150
+ mode: 0o755,
151
+ } as unknown as fs.Stats);
152
+ vi.mocked(fs.promises.chmod).mockResolvedValue(undefined);
153
+ vi.mocked(isWorkspaceTrusted).mockReturnValue({
154
+ isTrusted: true,
155
+ source: 'file',
156
+ });
157
+ vi.mocked(getMissingSettings).mockResolvedValue([]);
158
+
159
+ // Allow directories to exist by default to satisfy Config/WorkspaceContext checks
160
+ vi.mocked(fs.existsSync).mockReturnValue(true);
161
+ vi.mocked(fs.statSync).mockReturnValue({
162
+ isDirectory: () => true,
163
+ } as unknown as fs.Stats);
164
+ vi.mocked(fs.lstatSync).mockReturnValue({
165
+ isDirectory: () => true,
166
+ } as unknown as fs.Stats);
167
+ vi.mocked(fs.realpathSync).mockImplementation((p) => p as string);
168
+
169
+ tempWorkspaceDir = '/mock/workspace';
170
+ });
171
+
172
+ afterEach(() => {
173
+ vi.restoreAllMocks();
174
+ });
175
+
176
+ describe('ExtensionManager integration', () => {
177
+ it('should warn about missing settings after update', async () => {
178
+ // 1. Setup Data
179
+ const newConfig: ExtensionConfig = {
180
+ name: 'test-ext',
181
+ version: '1.1.0',
182
+ settings: [{ name: 's1', description: 'd1', envVar: 'VAR1' }],
183
+ };
184
+
185
+ const previousConfig: ExtensionConfig = {
186
+ name: 'test-ext',
187
+ version: '1.0.0',
188
+ settings: [],
189
+ };
190
+
191
+ const installMetadata: ExtensionInstallMetadata = {
192
+ source: '/mock/source',
193
+ type: 'local',
194
+ autoUpdate: true,
195
+ };
196
+
197
+ // 2. Setup Manager
198
+ const manager = new ExtensionManager({
199
+ workspaceDir: tempWorkspaceDir,
200
+ settings: createTestMergedSettings({
201
+ telemetry: { enabled: false },
202
+ experimental: { extensionConfig: true },
203
+ }),
204
+ requestConsent: vi.fn().mockResolvedValue(true),
205
+ requestSetting: null,
206
+ });
207
+
208
+ // 3. Mock Internal Manager Methods
209
+ vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
210
+ vi.spyOn(manager, 'getExtensions').mockReturnValue([
211
+ {
212
+ name: 'test-ext',
213
+ version: '1.0.0',
214
+ installMetadata,
215
+ path: '/mock/extensions/test-ext',
216
+ contextFiles: [],
217
+ mcpServers: {},
218
+ hooks: undefined,
219
+ isActive: true,
220
+ id: 'test-id',
221
+ settings: [],
222
+ resolvedSettings: [],
223
+ skills: [],
224
+ } as unknown as GeminiCLIExtension,
225
+ ]);
226
+ vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
227
+ // Mock loadExtension to return something so the method doesn't crash at the end
228
+ vi.spyOn(manager, 'loadExtension').mockResolvedValue({
229
+ name: 'test-ext',
230
+ version: '1.1.0',
231
+ } as unknown as GeminiCLIExtension);
232
+
233
+ // 4. Mock External Helpers
234
+ // This is the key fix: we explicitly mock `getMissingSettings` to return
235
+ // the result we expect, avoiding any real FS or logic execution during the update.
236
+ vi.mocked(getMissingSettings).mockResolvedValue([
237
+ {
238
+ name: 's1',
239
+ description: 'd1',
240
+ envVar: 'VAR1',
241
+ },
242
+ ]);
243
+
244
+ // 5. Execute
245
+ await manager.installOrUpdateExtension(installMetadata, previousConfig);
246
+
247
+ // 6. Assert
248
+ expect(debugLogger.warn).toHaveBeenCalledWith(
249
+ expect.stringContaining(
250
+ 'Extension "test-ext" has missing settings: s1',
251
+ ),
252
+ );
253
+ expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
254
+ 'warning',
255
+ expect.stringContaining(
256
+ 'Please run "gemini extensions config test-ext [setting-name]"',
257
+ ),
258
+ );
259
+ });
260
+
261
+ it('should store integrity data after update', async () => {
262
+ const newConfig: ExtensionConfig = {
263
+ name: 'test-ext',
264
+ version: '1.1.0',
265
+ };
266
+
267
+ const previousConfig: ExtensionConfig = {
268
+ name: 'test-ext',
269
+ version: '1.0.0',
270
+ };
271
+
272
+ const installMetadata: ExtensionInstallMetadata = {
273
+ source: '/mock/source',
274
+ type: 'local',
275
+ };
276
+
277
+ const manager = new ExtensionManager({
278
+ workspaceDir: tempWorkspaceDir,
279
+ settings: createTestMergedSettings(),
280
+ requestConsent: vi.fn().mockResolvedValue(true),
281
+ requestSetting: null,
282
+ });
283
+
284
+ await manager.loadExtensions();
285
+ vi.spyOn(manager, 'loadExtensionConfig').mockResolvedValue(newConfig);
286
+ vi.spyOn(manager, 'getExtensions').mockReturnValue([
287
+ {
288
+ name: 'test-ext',
289
+ version: '1.0.0',
290
+ installMetadata,
291
+ path: '/mock/extensions/test-ext',
292
+ isActive: true,
293
+ } as unknown as GeminiCLIExtension,
294
+ ]);
295
+ vi.spyOn(manager, 'uninstallExtension').mockResolvedValue(undefined);
296
+ vi.spyOn(manager, 'loadExtension').mockResolvedValue({
297
+ name: 'test-ext',
298
+ version: '1.1.0',
299
+ } as unknown as GeminiCLIExtension);
300
+
301
+ const storeSpy = vi.spyOn(manager, 'storeExtensionIntegrity');
302
+
303
+ await manager.installOrUpdateExtension(installMetadata, previousConfig);
304
+
305
+ expect(storeSpy).toHaveBeenCalledWith('test-ext', installMetadata);
306
+ });
307
+ });
308
+ });
packages/cli/src/config/extensions/github.test.ts ADDED
@@ -0,0 +1,654 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import {
9
+ cloneFromGit,
10
+ tryParseGithubUrl,
11
+ fetchReleaseFromGithub,
12
+ checkForExtensionUpdate,
13
+ downloadFromGitHubRelease,
14
+ findReleaseAsset,
15
+ downloadFile,
16
+ extractFile,
17
+ } from './github.js';
18
+ import { simpleGit, type SimpleGit } from 'simple-git';
19
+ import { ExtensionUpdateState } from '../../ui/state/extensions.js';
20
+ import * as os from 'node:os';
21
+ import * as fs from 'node:fs';
22
+ import * as https from 'node:https';
23
+ import * as tar from 'tar';
24
+ import * as extract from 'extract-zip';
25
+ import type { ExtensionManager } from '../extension-manager.js';
26
+ import { fetchJson } from './github_fetch.js';
27
+ import { EventEmitter } from 'node:events';
28
+ import type {
29
+ GeminiCLIExtension,
30
+ ExtensionInstallMetadata,
31
+ } from '@google/gemini-cli-core';
32
+ import type { ExtensionConfig } from '../extension.js';
33
+
34
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
35
+ const actual =
36
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
37
+ return {
38
+ ...actual,
39
+ Storage: {
40
+ getGlobalSettingsPath: vi.fn().mockReturnValue('/mock/settings.json'),
41
+ getGlobalGeminiDir: vi.fn().mockReturnValue('/mock/.gemini'),
42
+ },
43
+ debugLogger: {
44
+ error: vi.fn(),
45
+ log: vi.fn(),
46
+ warn: vi.fn(),
47
+ },
48
+ };
49
+ });
50
+
51
+ vi.mock('simple-git');
52
+ vi.mock('node:os');
53
+ vi.mock('node:fs');
54
+ vi.mock('node:https');
55
+ vi.mock('tar');
56
+ vi.mock('extract-zip');
57
+ vi.mock('./github_fetch.js');
58
+ vi.mock('../extension-manager.js');
59
+ // Mock settings.ts to avoid top-level side effects if possible, or just rely on Storage mock
60
+ vi.mock('../settings.js', () => ({
61
+ loadSettings: vi.fn(),
62
+ USER_SETTINGS_PATH: '/mock/settings.json',
63
+ }));
64
+
65
+ describe('github.ts', () => {
66
+ beforeEach(() => {
67
+ vi.resetAllMocks();
68
+ });
69
+
70
+ describe('cloneFromGit', () => {
71
+ let mockGit: {
72
+ clone: ReturnType<typeof vi.fn>;
73
+ getRemotes: ReturnType<typeof vi.fn>;
74
+ fetch: ReturnType<typeof vi.fn>;
75
+ checkout: ReturnType<typeof vi.fn>;
76
+ listRemote: ReturnType<typeof vi.fn>;
77
+ revparse: ReturnType<typeof vi.fn>;
78
+ env: ReturnType<typeof vi.fn>;
79
+ };
80
+
81
+ beforeEach(() => {
82
+ mockGit = {
83
+ clone: vi.fn(),
84
+ getRemotes: vi.fn(),
85
+ fetch: vi.fn(),
86
+ checkout: vi.fn(),
87
+ listRemote: vi.fn(),
88
+ revparse: vi.fn(),
89
+ env: vi.fn().mockReturnThis(),
90
+ };
91
+ vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
92
+ });
93
+
94
+ it('should clone, fetch and checkout a repo', async () => {
95
+ mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
96
+
97
+ await cloneFromGit(
98
+ {
99
+ type: 'git',
100
+ source: 'https://github.com/owner/repo.git',
101
+ ref: 'v1.0.0',
102
+ },
103
+ '/dest',
104
+ );
105
+
106
+ expect(mockGit.clone).toHaveBeenCalledWith(
107
+ 'https://github.com/owner/repo.git',
108
+ './',
109
+ ['--depth', '1'],
110
+ );
111
+ expect(mockGit.fetch).toHaveBeenCalledWith('origin', 'v1.0.0');
112
+ expect(mockGit.checkout).toHaveBeenCalledWith('FETCH_HEAD');
113
+ });
114
+
115
+ it('should throw if no remotes found', async () => {
116
+ mockGit.getRemotes.mockResolvedValue([]);
117
+
118
+ await expect(
119
+ cloneFromGit({ type: 'git', source: 'src' }, '/dest'),
120
+ ).rejects.toThrow('Unable to find any remotes');
121
+ });
122
+
123
+ it('should throw on clone error', async () => {
124
+ mockGit.clone.mockRejectedValue(new Error('Clone failed'));
125
+
126
+ await expect(
127
+ cloneFromGit({ type: 'git', source: 'src' }, '/dest'),
128
+ ).rejects.toThrow('Failed to clone Git repository');
129
+ });
130
+ });
131
+
132
+ describe('tryParseGithubUrl', () => {
133
+ it.each([
134
+ ['https://github.com/owner/repo', 'owner', 'repo'],
135
+ ['https://github.com/owner/repo.git', 'owner', 'repo'],
136
+ ['git@github.com:owner/repo.git', 'owner', 'repo'],
137
+ ['owner/repo', 'owner', 'repo'],
138
+ ])('should parse %s to %s/%s', (url, owner, repo) => {
139
+ expect(tryParseGithubUrl(url)).toEqual({ owner, repo });
140
+ });
141
+
142
+ it.each([
143
+ 'https://gitlab.com/owner/repo',
144
+ 'https://my-git-host.com/owner/group/repo',
145
+ 'git@gitlab.com:some-group/some-project/some-repo.git',
146
+ ])('should return null for non-GitHub URLs', (url) => {
147
+ expect(tryParseGithubUrl(url)).toBeNull();
148
+ });
149
+
150
+ it('should throw for invalid formats', () => {
151
+ expect(() => tryParseGithubUrl('invalid')).toThrow(
152
+ 'Invalid GitHub repository source',
153
+ );
154
+ });
155
+ });
156
+
157
+ describe('fetchReleaseFromGithub', () => {
158
+ it('should fetch latest release if no ref provided', async () => {
159
+ vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
160
+
161
+ await fetchReleaseFromGithub('owner', 'repo');
162
+
163
+ expect(fetchJson).toHaveBeenCalledWith(
164
+ 'https://api.github.com/repos/owner/repo/releases/latest',
165
+ );
166
+ });
167
+
168
+ it('should fetch specific ref if provided', async () => {
169
+ vi.mocked(fetchJson).mockResolvedValue({ tag_name: 'v1.0.0' });
170
+
171
+ await fetchReleaseFromGithub('owner', 'repo', 'v1.0.0');
172
+
173
+ expect(fetchJson).toHaveBeenCalledWith(
174
+ 'https://api.github.com/repos/owner/repo/releases/tags/v1.0.0',
175
+ );
176
+ });
177
+
178
+ it('should handle pre-releases if allowed', async () => {
179
+ vi.mocked(fetchJson).mockResolvedValueOnce([{ tag_name: 'v1.0.0-beta' }]);
180
+
181
+ const result = await fetchReleaseFromGithub(
182
+ 'owner',
183
+ 'repo',
184
+ undefined,
185
+ true,
186
+ );
187
+
188
+ expect(result).toEqual({ tag_name: 'v1.0.0-beta' });
189
+ });
190
+
191
+ it('should return null if no releases found', async () => {
192
+ vi.mocked(fetchJson).mockResolvedValueOnce([]);
193
+
194
+ const result = await fetchReleaseFromGithub(
195
+ 'owner',
196
+ 'repo',
197
+ undefined,
198
+ true,
199
+ );
200
+
201
+ expect(result).toBeNull();
202
+ });
203
+ });
204
+
205
+ describe('checkForExtensionUpdate', () => {
206
+ let mockExtensionManager: ExtensionManager;
207
+ let mockGit: {
208
+ getRemotes: ReturnType<typeof vi.fn>;
209
+ listRemote: ReturnType<typeof vi.fn>;
210
+ revparse: ReturnType<typeof vi.fn>;
211
+ env: ReturnType<typeof vi.fn>;
212
+ };
213
+
214
+ beforeEach(() => {
215
+ mockExtensionManager = {
216
+ loadExtensionConfig: vi.fn(),
217
+ } as unknown as ExtensionManager;
218
+ mockGit = {
219
+ getRemotes: vi.fn(),
220
+ listRemote: vi.fn(),
221
+ revparse: vi.fn(),
222
+ env: vi.fn().mockReturnThis(),
223
+ };
224
+ vi.mocked(simpleGit).mockReturnValue(mockGit as unknown as SimpleGit);
225
+ });
226
+
227
+ it('should return NOT_UPDATABLE for non-git/non-release extensions', async () => {
228
+ vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
229
+ Promise.resolve({
230
+ version: '1.0.0',
231
+ } as unknown as ExtensionConfig),
232
+ );
233
+
234
+ const linkExt = {
235
+ installMetadata: { type: 'link' },
236
+ } as unknown as GeminiCLIExtension;
237
+ expect(await checkForExtensionUpdate(linkExt, mockExtensionManager)).toBe(
238
+ ExtensionUpdateState.NOT_UPDATABLE,
239
+ );
240
+ });
241
+
242
+ it('should return UPDATE_AVAILABLE if git remote hash differs', async () => {
243
+ mockGit.getRemotes.mockResolvedValue([
244
+ { name: 'origin', refs: { fetch: 'url' } },
245
+ ]);
246
+ mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
247
+ mockGit.revparse.mockResolvedValue('local-hash');
248
+
249
+ const ext = {
250
+ path: '/path',
251
+ installMetadata: { type: 'git', source: 'url' },
252
+ } as unknown as GeminiCLIExtension;
253
+ expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
254
+ ExtensionUpdateState.UPDATE_AVAILABLE,
255
+ );
256
+ });
257
+
258
+ it('should return UP_TO_DATE if git remote hash matches', async () => {
259
+ mockGit.getRemotes.mockResolvedValue([
260
+ { name: 'origin', refs: { fetch: 'url' } },
261
+ ]);
262
+ mockGit.listRemote.mockResolvedValue('hash\tHEAD');
263
+ mockGit.revparse.mockResolvedValue('hash');
264
+
265
+ const ext = {
266
+ path: '/path',
267
+ installMetadata: { type: 'git', source: 'url' },
268
+ } as unknown as GeminiCLIExtension;
269
+ expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
270
+ ExtensionUpdateState.UP_TO_DATE,
271
+ );
272
+ });
273
+
274
+ it('should return NOT_UPDATABLE if local extension config cannot be loaded', async () => {
275
+ vi.mocked(mockExtensionManager.loadExtensionConfig).mockImplementation(
276
+ async () => {
277
+ throw new Error('Config not found');
278
+ },
279
+ );
280
+
281
+ const ext = {
282
+ name: 'local-ext',
283
+ version: '1.0.0',
284
+ path: '/path/to/installed/ext',
285
+ installMetadata: { type: 'local', source: '/path/to/source/ext' },
286
+ } as unknown as GeminiCLIExtension;
287
+
288
+ expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
289
+ ExtensionUpdateState.NOT_UPDATABLE,
290
+ );
291
+ });
292
+
293
+ it('should check migratedTo source if present and return UPDATE_AVAILABLE', async () => {
294
+ mockGit.getRemotes.mockResolvedValue([
295
+ { name: 'origin', refs: { fetch: 'new-url' } },
296
+ ]);
297
+ mockGit.listRemote.mockResolvedValue('hash\tHEAD');
298
+ mockGit.revparse.mockResolvedValue('hash');
299
+
300
+ const ext = {
301
+ path: '/path',
302
+ migratedTo: 'new-url',
303
+ installMetadata: { type: 'git', source: 'old-url' },
304
+ } as unknown as GeminiCLIExtension;
305
+ expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
306
+ ExtensionUpdateState.UPDATE_AVAILABLE,
307
+ );
308
+ });
309
+ });
310
+
311
+ describe('downloadFromGitHubRelease', () => {
312
+ it('should fail if no release data found', async () => {
313
+ // Mock fetchJson to throw for latest release check
314
+ vi.mocked(fetchJson).mockRejectedValue(new Error('Not found'));
315
+
316
+ const result = await downloadFromGitHubRelease(
317
+ {
318
+ type: 'github-release',
319
+ source: 'owner/repo',
320
+ ref: 'v1',
321
+ } as unknown as ExtensionInstallMetadata,
322
+ '/dest',
323
+ { owner: 'owner', repo: 'repo' },
324
+ );
325
+
326
+ expect(result.success).toBe(false);
327
+ if (!result.success) {
328
+ expect(result.failureReason).toBe('failed to fetch release data');
329
+ }
330
+ });
331
+
332
+ it('should use correct headers for release assets', async () => {
333
+ vi.mocked(fetchJson).mockResolvedValue({
334
+ tag_name: 'v1.0.0',
335
+ assets: [{ name: 'asset.tar.gz', url: 'http://asset.url' }],
336
+ });
337
+ vi.mocked(os.platform).mockReturnValue('linux');
338
+ vi.mocked(os.arch).mockReturnValue('x64');
339
+
340
+ // Mock https.get and fs.createWriteStream for downloadFile
341
+ const mockReq = new EventEmitter();
342
+ const mockRes =
343
+ new EventEmitter() as unknown as import('node:http').IncomingMessage;
344
+ Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
345
+
346
+ vi.mocked(https.get).mockImplementation((url, options, cb) => {
347
+ if (typeof options === 'function') {
348
+ cb = options;
349
+ }
350
+ if (cb) cb(mockRes);
351
+ return mockReq as unknown as import('node:http').ClientRequest;
352
+ });
353
+
354
+ const mockStream = new EventEmitter() as unknown as fs.WriteStream;
355
+ Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
356
+ vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
357
+
358
+ // Mock fs.promises.readdir to return empty array (no cleanup needed)
359
+ vi.mocked(fs.promises.readdir).mockResolvedValue([]);
360
+ // Mock fs.promises.unlink
361
+ vi.mocked(fs.promises.unlink).mockResolvedValue(undefined);
362
+
363
+ const promise = downloadFromGitHubRelease(
364
+ {
365
+ type: 'github-release',
366
+ source: 'owner/repo',
367
+ ref: 'v1.0.0',
368
+ } as unknown as ExtensionInstallMetadata,
369
+ '/dest',
370
+ { owner: 'owner', repo: 'repo' },
371
+ );
372
+
373
+ // Wait for downloadFile to be called and stream to be created
374
+ await vi.waitUntil(
375
+ () => vi.mocked(fs.createWriteStream).mock.calls.length > 0,
376
+ );
377
+
378
+ // Trigger stream events to complete download
379
+ mockRes.emit('end');
380
+ mockStream.emit('finish');
381
+
382
+ await promise;
383
+
384
+ expect(https.get).toHaveBeenCalledWith(
385
+ 'http://asset.url',
386
+ expect.objectContaining({
387
+ headers: expect.objectContaining({
388
+ Accept: 'application/octet-stream',
389
+ }),
390
+ }),
391
+ expect.anything(),
392
+ );
393
+ });
394
+
395
+ it('should use correct headers for source tarballs', async () => {
396
+ vi.mocked(fetchJson).mockResolvedValue({
397
+ tag_name: 'v1.0.0',
398
+ assets: [],
399
+ tarball_url: 'http://tarball.url',
400
+ });
401
+
402
+ // Mock https.get and fs.createWriteStream for downloadFile
403
+ const mockReq = new EventEmitter();
404
+ const mockRes =
405
+ new EventEmitter() as unknown as import('node:http').IncomingMessage;
406
+ Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
407
+
408
+ vi.mocked(https.get).mockImplementation((url, options, cb) => {
409
+ if (typeof options === 'function') {
410
+ cb = options;
411
+ }
412
+ if (cb) cb(mockRes);
413
+ return mockReq as unknown as import('node:http').ClientRequest;
414
+ });
415
+
416
+ const mockStream = new EventEmitter() as unknown as fs.WriteStream;
417
+ Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
418
+ vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
419
+
420
+ // Mock fs.promises.readdir to return empty array
421
+ vi.mocked(fs.promises.readdir).mockResolvedValue([]);
422
+ // Mock fs.promises.unlink
423
+ vi.mocked(fs.promises.unlink).mockResolvedValue(undefined);
424
+
425
+ const promise = downloadFromGitHubRelease(
426
+ {
427
+ type: 'github-release',
428
+ source: 'owner/repo',
429
+ ref: 'v1.0.0',
430
+ } as unknown as ExtensionInstallMetadata,
431
+ '/dest',
432
+ { owner: 'owner', repo: 'repo' },
433
+ );
434
+
435
+ // Wait for downloadFile to be called and stream to be created
436
+ await vi.waitUntil(
437
+ () => vi.mocked(fs.createWriteStream).mock.calls.length > 0,
438
+ );
439
+
440
+ // Trigger stream events to complete download
441
+ mockRes.emit('end');
442
+ mockStream.emit('finish');
443
+
444
+ await promise;
445
+
446
+ expect(https.get).toHaveBeenCalledWith(
447
+ 'http://tarball.url',
448
+ expect.objectContaining({
449
+ headers: expect.objectContaining({
450
+ Accept: 'application/vnd.github+json',
451
+ }),
452
+ }),
453
+ expect.anything(),
454
+ );
455
+ });
456
+ });
457
+
458
+ describe('findReleaseAsset', () => {
459
+ it('should find platform/arch specific asset', () => {
460
+ vi.mocked(os.platform).mockReturnValue('darwin');
461
+ vi.mocked(os.arch).mockReturnValue('arm64');
462
+ const assets = [
463
+ { name: 'darwin.arm64.tar.gz', url: 'url1' },
464
+ { name: 'linux.x64.tar.gz', url: 'url2' },
465
+ ];
466
+ expect(findReleaseAsset(assets)).toEqual(assets[0]);
467
+ });
468
+
469
+ it('should find generic asset', () => {
470
+ vi.mocked(os.platform).mockReturnValue('darwin');
471
+ const assets = [{ name: 'generic.tar.gz', url: 'url' }];
472
+ expect(findReleaseAsset(assets)).toEqual(assets[0]);
473
+ });
474
+ });
475
+
476
+ describe('downloadFile', () => {
477
+ it('should download file successfully', async () => {
478
+ const mockReq = new EventEmitter();
479
+ const mockRes =
480
+ new EventEmitter() as unknown as import('node:http').IncomingMessage;
481
+ Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
482
+
483
+ vi.mocked(https.get).mockImplementation((url, options, cb) => {
484
+ if (typeof options === 'function') {
485
+ cb = options;
486
+ }
487
+ if (cb) cb(mockRes);
488
+ return mockReq as unknown as import('node:http').ClientRequest;
489
+ });
490
+
491
+ const mockStream = new EventEmitter() as unknown as fs.WriteStream;
492
+ Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
493
+ vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
494
+
495
+ const promise = downloadFile('url', '/dest');
496
+ mockRes.emit('end');
497
+ mockStream.emit('finish');
498
+
499
+ await expect(promise).resolves.toBeUndefined();
500
+ });
501
+
502
+ it('should fail on non-200 status', async () => {
503
+ const mockReq = new EventEmitter();
504
+ const mockRes =
505
+ new EventEmitter() as unknown as import('node:http').IncomingMessage;
506
+ Object.assign(mockRes, { statusCode: 404 });
507
+
508
+ vi.mocked(https.get).mockImplementation((url, options, cb) => {
509
+ if (typeof options === 'function') {
510
+ cb = options;
511
+ }
512
+ if (cb) cb(mockRes);
513
+ return mockReq as unknown as import('node:http').ClientRequest;
514
+ });
515
+
516
+ await expect(downloadFile('url', '/dest')).rejects.toThrow(
517
+ 'Request failed with status code 404',
518
+ );
519
+ });
520
+
521
+ it('should follow redirects', async () => {
522
+ const mockReq = new EventEmitter();
523
+ const mockResRedirect =
524
+ new EventEmitter() as unknown as import('node:http').IncomingMessage;
525
+ Object.assign(mockResRedirect, {
526
+ statusCode: 302,
527
+ headers: { location: 'new-url' },
528
+ });
529
+
530
+ const mockResSuccess =
531
+ new EventEmitter() as unknown as import('node:http').IncomingMessage;
532
+ Object.assign(mockResSuccess, { statusCode: 200, pipe: vi.fn() });
533
+
534
+ vi.mocked(https.get)
535
+ .mockImplementationOnce((url, options, cb) => {
536
+ if (typeof options === 'function') cb = options;
537
+ if (cb) cb(mockResRedirect);
538
+ return mockReq as unknown as import('node:http').ClientRequest;
539
+ })
540
+ .mockImplementationOnce((url, options, cb) => {
541
+ if (typeof options === 'function') cb = options;
542
+ if (cb) cb(mockResSuccess);
543
+ return mockReq as unknown as import('node:http').ClientRequest;
544
+ });
545
+
546
+ const mockStream = new EventEmitter() as unknown as fs.WriteStream;
547
+ Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
548
+ vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
549
+
550
+ const promise = downloadFile('url', '/dest');
551
+ mockResSuccess.emit('end');
552
+ mockStream.emit('finish');
553
+
554
+ await expect(promise).resolves.toBeUndefined();
555
+ expect(https.get).toHaveBeenCalledTimes(2);
556
+ expect(https.get).toHaveBeenLastCalledWith(
557
+ 'new-url',
558
+ expect.anything(),
559
+ expect.anything(),
560
+ );
561
+ });
562
+
563
+ it('should fail after too many redirects', async () => {
564
+ const mockReq = new EventEmitter();
565
+ const mockResRedirect =
566
+ new EventEmitter() as unknown as import('node:http').IncomingMessage;
567
+ Object.assign(mockResRedirect, {
568
+ statusCode: 302,
569
+ headers: { location: 'new-url' },
570
+ });
571
+
572
+ vi.mocked(https.get).mockImplementation((url, options, cb) => {
573
+ if (typeof options === 'function') cb = options;
574
+ if (cb) cb(mockResRedirect);
575
+ return mockReq as unknown as import('node:http').ClientRequest;
576
+ });
577
+
578
+ await expect(downloadFile('url', '/dest')).rejects.toThrow(
579
+ 'Too many redirects',
580
+ );
581
+ }, 10000); // Increase timeout for this test if needed, though with mocks it should be fast
582
+
583
+ it('should fail if redirect location is missing', async () => {
584
+ const mockReq = new EventEmitter();
585
+ const mockResRedirect =
586
+ new EventEmitter() as unknown as import('node:http').IncomingMessage;
587
+ Object.assign(mockResRedirect, {
588
+ statusCode: 302,
589
+ headers: {}, // No location
590
+ });
591
+
592
+ vi.mocked(https.get).mockImplementation((url, options, cb) => {
593
+ if (typeof options === 'function') cb = options;
594
+ if (cb) cb(mockResRedirect);
595
+ return mockReq as unknown as import('node:http').ClientRequest;
596
+ });
597
+
598
+ await expect(downloadFile('url', '/dest')).rejects.toThrow(
599
+ 'Redirect response missing Location header',
600
+ );
601
+ });
602
+
603
+ it('should pass custom headers', async () => {
604
+ const mockReq = new EventEmitter();
605
+ const mockRes =
606
+ new EventEmitter() as unknown as import('node:http').IncomingMessage;
607
+ Object.assign(mockRes, { statusCode: 200, pipe: vi.fn() });
608
+
609
+ vi.mocked(https.get).mockImplementation((url, options, cb) => {
610
+ if (typeof options === 'function') cb = options;
611
+ if (cb) cb(mockRes);
612
+ return mockReq as unknown as import('node:http').ClientRequest;
613
+ });
614
+
615
+ const mockStream = new EventEmitter() as unknown as fs.WriteStream;
616
+ Object.assign(mockStream, { close: vi.fn((cb) => cb && cb()) });
617
+ vi.mocked(fs.createWriteStream).mockReturnValue(mockStream);
618
+
619
+ const promise = downloadFile('url', '/dest', {
620
+ headers: { 'X-Custom': 'value' },
621
+ });
622
+ mockRes.emit('end');
623
+ mockStream.emit('finish');
624
+
625
+ await expect(promise).resolves.toBeUndefined();
626
+ expect(https.get).toHaveBeenCalledWith(
627
+ 'url',
628
+ expect.objectContaining({
629
+ headers: expect.objectContaining({ 'X-Custom': 'value' }),
630
+ }),
631
+ expect.anything(),
632
+ );
633
+ });
634
+ });
635
+
636
+ describe('extractFile', () => {
637
+ it('should extract tar.gz using tar', async () => {
638
+ await extractFile('file.tar.gz', '/dest');
639
+ expect(tar.x).toHaveBeenCalled();
640
+ });
641
+
642
+ it('should extract zip using extract-zip', async () => {
643
+ vi.mocked(extract.default || extract).mockResolvedValue(undefined);
644
+ await extractFile('file.zip', '/dest');
645
+ // Check if extract was called. Note: extract-zip export might be default or named depending on mock
646
+ });
647
+
648
+ it('should throw for unsupported extensions', async () => {
649
+ await expect(extractFile('file.txt', '/dest')).rejects.toThrow(
650
+ 'Unsupported file extension',
651
+ );
652
+ });
653
+ });
654
+ });
packages/cli/src/config/extensions/github.ts ADDED
@@ -0,0 +1,576 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { simpleGit } from 'simple-git';
8
+ import {
9
+ debugLogger,
10
+ getErrorMessage,
11
+ getSafeGitEnv,
12
+ type ExtensionInstallMetadata,
13
+ type GeminiCLIExtension,
14
+ } from '@google/gemini-cli-core';
15
+ import { ExtensionUpdateState } from '../../ui/state/extensions.js';
16
+ import * as os from 'node:os';
17
+ import * as https from 'node:https';
18
+ import * as fs from 'node:fs';
19
+ import * as path from 'node:path';
20
+ import * as tar from 'tar';
21
+ import extract from 'extract-zip';
22
+ import { fetchJson, getGitHubToken } from './github_fetch.js';
23
+ import type { ExtensionConfig } from '../extension.js';
24
+ import type { ExtensionManager } from '../extension-manager.js';
25
+ import { EXTENSIONS_CONFIG_FILENAME } from './variables.js';
26
+
27
+ /**
28
+ * Clones a Git repository to a specified local path.
29
+ * @param installMetadata The metadata for the extension to install.
30
+ * @param destination The destination path to clone the repository to.
31
+ */
32
+ export async function cloneFromGit(
33
+ installMetadata: ExtensionInstallMetadata,
34
+ destination: string,
35
+ ): Promise<void> {
36
+ try {
37
+ const git = simpleGit(destination).env(getSafeGitEnv());
38
+ let sourceUrl = installMetadata.source;
39
+ const token = getGitHubToken();
40
+ if (token) {
41
+ try {
42
+ const parsedUrl = new URL(sourceUrl);
43
+ if (
44
+ parsedUrl.protocol === 'https:' &&
45
+ parsedUrl.hostname === 'github.com'
46
+ ) {
47
+ if (!parsedUrl.username) {
48
+ parsedUrl.username = token;
49
+ }
50
+ sourceUrl = parsedUrl.toString();
51
+ }
52
+ } catch {
53
+ // If source is not a valid URL, we don't inject the token.
54
+ // We let git handle the source as is.
55
+ }
56
+ }
57
+ await git.clone(sourceUrl, './', ['--depth', '1']);
58
+
59
+ const remotes = await git.getRemotes(true);
60
+ if (remotes.length === 0) {
61
+ throw new Error(
62
+ `Unable to find any remotes for repo ${installMetadata.source}`,
63
+ );
64
+ }
65
+
66
+ const refToFetch = installMetadata.ref || 'HEAD';
67
+
68
+ await git.fetch(remotes[0].name, refToFetch);
69
+
70
+ // After fetching, checkout FETCH_HEAD to get the content of the fetched ref.
71
+ // This results in a detached HEAD state, which is fine for this purpose.
72
+ await git.checkout('FETCH_HEAD');
73
+ } catch (error) {
74
+ throw new Error(
75
+ `Failed to clone Git repository from ${installMetadata.source} ${getErrorMessage(error)}`,
76
+ {
77
+ cause: error,
78
+ },
79
+ );
80
+ }
81
+ }
82
+
83
+ export interface GithubRepoInfo {
84
+ owner: string;
85
+ repo: string;
86
+ }
87
+
88
+ export function tryParseGithubUrl(source: string): GithubRepoInfo | null {
89
+ // Handle SCP-style SSH URLs.
90
+ if (source.startsWith('git@')) {
91
+ if (source.startsWith('git@github.com:')) {
92
+ // It's a GitHub SSH URL, so normalize it for the URL parser.
93
+ source = source.replace('git@github.com:', '');
94
+ } else {
95
+ // It's another provider's SSH URL (e.g., gitlab), so not a GitHub repo.
96
+ return null;
97
+ }
98
+ }
99
+ // Default to a github repo path, so `source` can be just an org/repo
100
+ let parsedUrl: URL;
101
+ try {
102
+ // Use the standard URL constructor for backward compatibility.
103
+ parsedUrl = new URL(source, 'https://github.com');
104
+ } catch (e) {
105
+ // Throw a TypeError to maintain a consistent error contract for invalid URLs.
106
+ // This avoids a breaking change for consumers who might expect a TypeError.
107
+ throw new TypeError(`Invalid repo URL: ${source}`, { cause: e });
108
+ }
109
+
110
+ if (!parsedUrl) {
111
+ throw new Error(`Invalid repo URL: ${source}`);
112
+ }
113
+ if (parsedUrl?.host !== 'github.com') {
114
+ return null;
115
+ }
116
+ // The pathname should be "/owner/repo".
117
+ const parts = parsedUrl?.pathname
118
+ .split('/')
119
+ // Remove the empty segments, fixes trailing and leading slashes
120
+ .filter((part) => part !== '');
121
+
122
+ if (parts?.length !== 2) {
123
+ throw new Error(
124
+ `Invalid GitHub repository source: ${source}. Expected "owner/repo" or a github repo uri.`,
125
+ );
126
+ }
127
+ const owner = parts[0];
128
+ const repo = parts[1].replace('.git', '');
129
+
130
+ return {
131
+ owner,
132
+ repo,
133
+ };
134
+ }
135
+
136
+ export async function fetchReleaseFromGithub(
137
+ owner: string,
138
+ repo: string,
139
+ ref?: string,
140
+ allowPreRelease?: boolean,
141
+ ): Promise<GithubReleaseData | null> {
142
+ if (ref) {
143
+ return fetchJson(
144
+ `https://api.github.com/repos/${owner}/${repo}/releases/tags/${ref}`,
145
+ );
146
+ }
147
+
148
+ if (!allowPreRelease) {
149
+ // Grab the release that is tagged as the "latest", github does not allow
150
+ // this to be a pre-release so we can blindly grab it.
151
+ try {
152
+ return await fetchJson(
153
+ `https://api.github.com/repos/${owner}/${repo}/releases/latest`,
154
+ );
155
+ } catch {
156
+ // This can fail if there is no release marked latest. In that case
157
+ // we want to just try the pre-release logic below.
158
+ }
159
+ }
160
+
161
+ // If pre-releases are allowed, we just grab the most recent release.
162
+ const releases = await fetchJson<GithubReleaseData[]>(
163
+ `https://api.github.com/repos/${owner}/${repo}/releases?per_page=1`,
164
+ );
165
+ if (releases.length === 0) {
166
+ return null;
167
+ }
168
+ return releases[0];
169
+ }
170
+
171
+ export async function checkForExtensionUpdate(
172
+ extension: GeminiCLIExtension,
173
+ extensionManager: ExtensionManager,
174
+ ): Promise<ExtensionUpdateState> {
175
+ const installMetadata = extension.installMetadata;
176
+ if (installMetadata?.type === 'local') {
177
+ let latestConfig: ExtensionConfig | undefined;
178
+ try {
179
+ latestConfig = await extensionManager.loadExtensionConfig(
180
+ installMetadata.source,
181
+ );
182
+ } catch (e) {
183
+ debugLogger.warn(
184
+ `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}. Error: ${getErrorMessage(e)}`,
185
+ );
186
+ return ExtensionUpdateState.NOT_UPDATABLE;
187
+ }
188
+
189
+ if (!latestConfig) {
190
+ debugLogger.warn(
191
+ `Failed to check for update for local extension "${extension.name}". Could not load extension from source path: ${installMetadata.source}`,
192
+ );
193
+ return ExtensionUpdateState.NOT_UPDATABLE;
194
+ }
195
+ if (latestConfig.version !== extension.version) {
196
+ return ExtensionUpdateState.UPDATE_AVAILABLE;
197
+ }
198
+ return ExtensionUpdateState.UP_TO_DATE;
199
+ }
200
+ if (
201
+ !installMetadata ||
202
+ (installMetadata.type !== 'git' &&
203
+ installMetadata.type !== 'github-release')
204
+ ) {
205
+ return ExtensionUpdateState.NOT_UPDATABLE;
206
+ }
207
+
208
+ if (extension.migratedTo) {
209
+ const migratedState = await checkForExtensionUpdate(
210
+ {
211
+ ...extension,
212
+ installMetadata: { ...installMetadata, source: extension.migratedTo },
213
+ migratedTo: undefined,
214
+ },
215
+ extensionManager,
216
+ );
217
+ if (
218
+ migratedState === ExtensionUpdateState.UPDATE_AVAILABLE ||
219
+ migratedState === ExtensionUpdateState.UP_TO_DATE
220
+ ) {
221
+ return ExtensionUpdateState.UPDATE_AVAILABLE;
222
+ }
223
+ }
224
+
225
+ try {
226
+ if (installMetadata.type === 'git') {
227
+ const git = simpleGit(extension.path).env(getSafeGitEnv());
228
+ const remotes = await git.getRemotes(true);
229
+ if (remotes.length === 0) {
230
+ debugLogger.error('No git remotes found.');
231
+ return ExtensionUpdateState.ERROR;
232
+ }
233
+ const remoteUrl = remotes[0].refs.fetch;
234
+ if (!remoteUrl) {
235
+ debugLogger.error(
236
+ `No fetch URL found for git remote ${remotes[0].name}.`,
237
+ );
238
+ return ExtensionUpdateState.ERROR;
239
+ }
240
+
241
+ // Determine the ref to check on the remote.
242
+ const refToCheck = installMetadata.ref || 'HEAD';
243
+
244
+ const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]);
245
+
246
+ if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') {
247
+ debugLogger.error(`Git ref ${refToCheck} not found.`);
248
+ return ExtensionUpdateState.ERROR;
249
+ }
250
+
251
+ const remoteHash = lsRemoteOutput.split('\t')[0];
252
+ const localHash = await git.revparse(['HEAD']);
253
+
254
+ if (!remoteHash) {
255
+ debugLogger.error(
256
+ `Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`,
257
+ );
258
+ return ExtensionUpdateState.ERROR;
259
+ }
260
+ if (remoteHash === localHash) {
261
+ return ExtensionUpdateState.UP_TO_DATE;
262
+ }
263
+ return ExtensionUpdateState.UPDATE_AVAILABLE;
264
+ } else {
265
+ const { source, releaseTag } = installMetadata;
266
+ if (!source) {
267
+ debugLogger.error(`No "source" provided for extension.`);
268
+ return ExtensionUpdateState.ERROR;
269
+ }
270
+ const repoInfo = tryParseGithubUrl(source);
271
+ if (!repoInfo) {
272
+ debugLogger.error(
273
+ `Source is not a valid GitHub repository for release checks: ${source}`,
274
+ );
275
+ return ExtensionUpdateState.ERROR;
276
+ }
277
+ const { owner, repo } = repoInfo;
278
+
279
+ const releaseData = await fetchReleaseFromGithub(
280
+ owner,
281
+ repo,
282
+ installMetadata.ref,
283
+ installMetadata.allowPreRelease,
284
+ );
285
+ if (!releaseData) {
286
+ return ExtensionUpdateState.ERROR;
287
+ }
288
+ if (releaseData.tag_name !== releaseTag) {
289
+ return ExtensionUpdateState.UPDATE_AVAILABLE;
290
+ }
291
+ return ExtensionUpdateState.UP_TO_DATE;
292
+ }
293
+ } catch (error) {
294
+ debugLogger.error(
295
+ `Failed to check for updates for extension "${installMetadata.source}": ${getErrorMessage(error)}`,
296
+ );
297
+ return ExtensionUpdateState.ERROR;
298
+ }
299
+ }
300
+
301
+ export type GitHubDownloadResult =
302
+ | {
303
+ tagName?: string;
304
+ type: 'git' | 'github-release';
305
+ success: false;
306
+ failureReason:
307
+ | 'failed to fetch release data'
308
+ | 'no release data'
309
+ | 'no release asset found'
310
+ | 'failed to download asset'
311
+ | 'failed to extract asset'
312
+ | 'unknown';
313
+ errorMessage: string;
314
+ }
315
+ | {
316
+ tagName?: string;
317
+ type: 'git' | 'github-release';
318
+ success: true;
319
+ };
320
+ export async function downloadFromGitHubRelease(
321
+ installMetadata: ExtensionInstallMetadata,
322
+ destination: string,
323
+ githubRepoInfo: GithubRepoInfo,
324
+ ): Promise<GitHubDownloadResult> {
325
+ const { ref, allowPreRelease: preRelease } = installMetadata;
326
+ const { owner, repo } = githubRepoInfo;
327
+ let releaseData: GithubReleaseData | null = null;
328
+
329
+ try {
330
+ try {
331
+ releaseData = await fetchReleaseFromGithub(owner, repo, ref, preRelease);
332
+ if (!releaseData) {
333
+ return {
334
+ failureReason: 'no release data',
335
+ success: false,
336
+ type: 'github-release',
337
+ errorMessage: `No release data found for ${owner}/${repo} at tag ${ref}`,
338
+ };
339
+ }
340
+ } catch (error) {
341
+ return {
342
+ failureReason: 'failed to fetch release data',
343
+ success: false,
344
+ type: 'github-release',
345
+ errorMessage: `Failed to fetch release data for ${owner}/${repo} at tag ${ref}: ${getErrorMessage(error)}`,
346
+ };
347
+ }
348
+
349
+ const asset = findReleaseAsset(releaseData.assets);
350
+ let archiveUrl: string | undefined;
351
+ let isTar = false;
352
+ let isZip = false;
353
+ let fileName: string | undefined;
354
+
355
+ if (asset) {
356
+ archiveUrl = asset.url;
357
+ fileName = asset.name;
358
+ } else {
359
+ if (releaseData.tarball_url) {
360
+ archiveUrl = releaseData.tarball_url;
361
+ isTar = true;
362
+ } else if (releaseData.zipball_url) {
363
+ archiveUrl = releaseData.zipball_url;
364
+ isZip = true;
365
+ }
366
+ }
367
+ if (!archiveUrl) {
368
+ return {
369
+ failureReason: 'no release asset found',
370
+ success: false,
371
+ type: 'github-release',
372
+ tagName: releaseData.tag_name,
373
+ errorMessage: `No assets found for release with tag ${releaseData.tag_name}`,
374
+ };
375
+ }
376
+ if (!fileName) {
377
+ fileName = path.basename(new URL(archiveUrl).pathname);
378
+ }
379
+ let downloadedAssetPath = path.join(destination, fileName);
380
+ if (isTar && !downloadedAssetPath.endsWith('.tar.gz')) {
381
+ downloadedAssetPath += '.tar.gz';
382
+ } else if (isZip && !downloadedAssetPath.endsWith('.zip')) {
383
+ downloadedAssetPath += '.zip';
384
+ }
385
+
386
+ try {
387
+ // GitHub API requires different Accept headers for different types of downloads:
388
+ // 1. Binary Assets (e.g. release artifacts): Require 'application/octet-stream' to return the raw content.
389
+ // 2. Source Tarballs (e.g. /tarball/{ref}): Require 'application/vnd.github+json' (or similar) to return
390
+ // a 302 Redirect to the actual download location (codeload.github.com).
391
+ // Sending 'application/octet-stream' for tarballs results in a 415 Unsupported Media Type error.
392
+ const headers = {
393
+ ...(asset
394
+ ? { Accept: 'application/octet-stream' }
395
+ : { Accept: 'application/vnd.github+json' }),
396
+ };
397
+ await downloadFile(archiveUrl, downloadedAssetPath, { headers });
398
+ } catch (error) {
399
+ return {
400
+ failureReason: 'failed to download asset',
401
+ success: false,
402
+ type: 'github-release',
403
+ tagName: releaseData.tag_name,
404
+ errorMessage: `Failed to download asset from ${archiveUrl}: ${getErrorMessage(error)}`,
405
+ };
406
+ }
407
+
408
+ try {
409
+ await extractFile(downloadedAssetPath, destination);
410
+ } catch (error) {
411
+ return {
412
+ failureReason: 'failed to extract asset',
413
+ success: false,
414
+ type: 'github-release',
415
+ tagName: releaseData.tag_name,
416
+ errorMessage: `Failed to extract asset from ${downloadedAssetPath}: ${getErrorMessage(error)}`,
417
+ };
418
+ }
419
+
420
+ // For regular github releases, the repository is put inside of a top level
421
+ // directory. In this case we should see exactly two file in the destination
422
+ // dir, the archive and the directory. If we see that, validate that the
423
+ // dir has a gemini extension configuration file and then move all files
424
+ // from the directory up one level into the destination directory.
425
+ const entries = await fs.promises.readdir(destination, {
426
+ withFileTypes: true,
427
+ });
428
+ if (entries.length === 2) {
429
+ const lonelyDir = entries.find((entry) => entry.isDirectory());
430
+ if (
431
+ lonelyDir &&
432
+ fs.existsSync(
433
+ path.join(destination, lonelyDir.name, EXTENSIONS_CONFIG_FILENAME),
434
+ )
435
+ ) {
436
+ const dirPathToExtract = path.join(destination, lonelyDir.name);
437
+ const extractedDirFiles = await fs.promises.readdir(dirPathToExtract);
438
+ for (const file of extractedDirFiles) {
439
+ await fs.promises.rename(
440
+ path.join(dirPathToExtract, file),
441
+ path.join(destination, file),
442
+ );
443
+ }
444
+ await fs.promises.rmdir(dirPathToExtract);
445
+ }
446
+ }
447
+
448
+ await fs.promises.unlink(downloadedAssetPath);
449
+ return {
450
+ tagName: releaseData.tag_name,
451
+ type: 'github-release',
452
+ success: true,
453
+ };
454
+ } catch (error) {
455
+ return {
456
+ failureReason: 'unknown',
457
+ success: false,
458
+ type: 'github-release',
459
+ tagName: releaseData?.tag_name,
460
+ errorMessage: `Failed to download release from ${installMetadata.source}: ${getErrorMessage(error)}`,
461
+ };
462
+ }
463
+ }
464
+
465
+ interface GithubReleaseData {
466
+ assets: Asset[];
467
+ tag_name: string;
468
+ tarball_url?: string;
469
+ zipball_url?: string;
470
+ }
471
+
472
+ interface Asset {
473
+ name: string;
474
+ url: string;
475
+ }
476
+
477
+ export function findReleaseAsset(assets: Asset[]): Asset | undefined {
478
+ const platform = os.platform();
479
+ const arch = os.arch();
480
+
481
+ const platformArchPrefix = `${platform}.${arch}.`;
482
+ const platformPrefix = `${platform}.`;
483
+
484
+ // Check for platform + architecture specific asset
485
+ const platformArchAsset = assets.find((asset) =>
486
+ asset.name.toLowerCase().startsWith(platformArchPrefix),
487
+ );
488
+ if (platformArchAsset) {
489
+ return platformArchAsset;
490
+ }
491
+
492
+ // Check for platform specific asset
493
+ const platformAsset = assets.find((asset) =>
494
+ asset.name.toLowerCase().startsWith(platformPrefix),
495
+ );
496
+ if (platformAsset) {
497
+ return platformAsset;
498
+ }
499
+
500
+ // Check for generic asset if only one is available
501
+ const genericAsset = assets.find(
502
+ (asset) =>
503
+ !asset.name.toLowerCase().includes('darwin') &&
504
+ !asset.name.toLowerCase().includes('linux') &&
505
+ !asset.name.toLowerCase().includes('win32'),
506
+ );
507
+ if (assets.length === 1) {
508
+ return genericAsset;
509
+ }
510
+
511
+ return undefined;
512
+ }
513
+
514
+ export interface DownloadOptions {
515
+ headers?: Record<string, string>;
516
+ }
517
+
518
+ export async function downloadFile(
519
+ url: string,
520
+ dest: string,
521
+ options?: DownloadOptions,
522
+ redirectCount: number = 0,
523
+ ): Promise<void> {
524
+ const headers: Record<string, string> = {
525
+ 'User-agent': 'gemini-cli',
526
+ Accept: 'application/octet-stream',
527
+ ...options?.headers,
528
+ };
529
+ const token = getGitHubToken();
530
+ if (token) {
531
+ headers['Authorization'] = `token ${token}`;
532
+ }
533
+
534
+ return new Promise((resolve, reject) => {
535
+ https
536
+ .get(url, { headers }, (res) => {
537
+ if (res.statusCode === 302 || res.statusCode === 301) {
538
+ if (redirectCount >= 10) {
539
+ return reject(new Error('Too many redirects'));
540
+ }
541
+
542
+ if (!res.headers.location) {
543
+ return reject(
544
+ new Error('Redirect response missing Location header'),
545
+ );
546
+ }
547
+ downloadFile(res.headers.location, dest, options, redirectCount + 1)
548
+ .then(resolve)
549
+ .catch(reject);
550
+ return;
551
+ }
552
+ if (res.statusCode !== 200) {
553
+ return reject(
554
+ new Error(`Request failed with status code ${res.statusCode}`),
555
+ );
556
+ }
557
+ const file = fs.createWriteStream(dest);
558
+ res.pipe(file);
559
+ file.on('finish', () => file.close(resolve as () => void));
560
+ })
561
+ .on('error', reject);
562
+ });
563
+ }
564
+
565
+ export async function extractFile(file: string, dest: string): Promise<void> {
566
+ if (file.endsWith('.tar.gz')) {
567
+ await tar.x({
568
+ file,
569
+ cwd: dest,
570
+ });
571
+ } else if (file.endsWith('.zip')) {
572
+ await extract(file, { dir: dest });
573
+ } else {
574
+ throw new Error(`Unsupported file extension for extraction: ${file}`);
575
+ }
576
+ }
packages/cli/src/config/extensions/github_fetch.test.ts ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
8
+ import * as https from 'node:https';
9
+ import { EventEmitter } from 'node:events';
10
+ import { fetchJson, getGitHubToken } from './github_fetch.js';
11
+ import type { ClientRequest, IncomingMessage } from 'node:http';
12
+
13
+ vi.mock('node:https');
14
+
15
+ describe('getGitHubToken', () => {
16
+ const originalToken = process.env['GITHUB_TOKEN'];
17
+
18
+ afterEach(() => {
19
+ if (originalToken) {
20
+ process.env['GITHUB_TOKEN'] = originalToken;
21
+ } else {
22
+ delete process.env['GITHUB_TOKEN'];
23
+ }
24
+ });
25
+
26
+ it('should return the token if GITHUB_TOKEN is set', () => {
27
+ process.env['GITHUB_TOKEN'] = 'test-token';
28
+ expect(getGitHubToken()).toBe('test-token');
29
+ });
30
+
31
+ it('should return undefined if GITHUB_TOKEN is not set', () => {
32
+ delete process.env['GITHUB_TOKEN'];
33
+ expect(getGitHubToken()).toBeUndefined();
34
+ });
35
+ });
36
+
37
+ describe('fetchJson', () => {
38
+ const getMock = vi.mocked(https.get);
39
+
40
+ afterEach(() => {
41
+ vi.resetAllMocks();
42
+ });
43
+
44
+ it('should fetch and parse JSON successfully', async () => {
45
+ getMock.mockImplementationOnce((_url, _options, callback) => {
46
+ const res = new EventEmitter() as IncomingMessage;
47
+ res.statusCode = 200;
48
+ (callback as (res: IncomingMessage) => void)(res);
49
+ res.emit('data', Buffer.from('{"foo":'));
50
+ res.emit('data', Buffer.from('"bar"}'));
51
+ res.emit('end');
52
+ return new EventEmitter() as ClientRequest;
53
+ });
54
+ await expect(fetchJson('https://example.com/data.json')).resolves.toEqual({
55
+ foo: 'bar',
56
+ });
57
+ });
58
+
59
+ it('should handle redirects (301 and 302)', async () => {
60
+ // Test 302
61
+ getMock.mockImplementationOnce((_url, _options, callback) => {
62
+ const res = new EventEmitter() as IncomingMessage;
63
+ res.statusCode = 302;
64
+ res.headers = { location: 'https://example.com/final' };
65
+ res.resume = vi.fn();
66
+ (callback as (res: IncomingMessage) => void)(res);
67
+ res.emit('end');
68
+ return new EventEmitter() as ClientRequest;
69
+ });
70
+ getMock.mockImplementationOnce((url, _options, callback) => {
71
+ expect(url).toBe('https://example.com/final');
72
+ const res = new EventEmitter() as IncomingMessage;
73
+ res.statusCode = 200;
74
+ (callback as (res: IncomingMessage) => void)(res);
75
+ res.emit('data', Buffer.from('{"success": true}'));
76
+ res.emit('end');
77
+ return new EventEmitter() as ClientRequest;
78
+ });
79
+
80
+ await expect(fetchJson('https://example.com/redirect')).resolves.toEqual({
81
+ success: true,
82
+ });
83
+
84
+ // Test 301
85
+ getMock.mockImplementationOnce((_url, _options, callback) => {
86
+ const res = new EventEmitter() as IncomingMessage;
87
+ res.statusCode = 301;
88
+ res.headers = { location: 'https://example.com/final-permanent' };
89
+ res.resume = vi.fn();
90
+ (callback as (res: IncomingMessage) => void)(res);
91
+ res.emit('end');
92
+ return new EventEmitter() as ClientRequest;
93
+ });
94
+ getMock.mockImplementationOnce((url, _options, callback) => {
95
+ expect(url).toBe('https://example.com/final-permanent');
96
+ const res = new EventEmitter() as IncomingMessage;
97
+ res.statusCode = 200;
98
+ (callback as (res: IncomingMessage) => void)(res);
99
+ res.emit('data', Buffer.from('{"permanent": true}'));
100
+ res.emit('end');
101
+ return new EventEmitter() as ClientRequest;
102
+ });
103
+
104
+ await expect(
105
+ fetchJson('https://example.com/redirect-perm'),
106
+ ).resolves.toEqual({ permanent: true });
107
+ });
108
+
109
+ it('should reject on non-200/30x status code', async () => {
110
+ getMock.mockImplementationOnce((_url, _options, callback) => {
111
+ const res = new EventEmitter() as IncomingMessage;
112
+ res.statusCode = 404;
113
+ (callback as (res: IncomingMessage) => void)(res);
114
+ res.emit('end');
115
+ return new EventEmitter() as ClientRequest;
116
+ });
117
+
118
+ await expect(fetchJson('https://example.com/error')).rejects.toThrow(
119
+ 'Request failed with status code 404',
120
+ );
121
+ });
122
+
123
+ it('should reject on request error', async () => {
124
+ const error = new Error('Network error');
125
+ getMock.mockImplementationOnce(() => {
126
+ const req = new EventEmitter() as ClientRequest;
127
+ req.emit('error', error);
128
+ return req;
129
+ });
130
+
131
+ await expect(fetchJson('https://example.com/error')).rejects.toThrow(
132
+ 'Network error',
133
+ );
134
+ });
135
+
136
+ describe('with GITHUB_TOKEN', () => {
137
+ const originalToken = process.env['GITHUB_TOKEN'];
138
+
139
+ beforeEach(() => {
140
+ process.env['GITHUB_TOKEN'] = 'my-secret-token';
141
+ });
142
+
143
+ afterEach(() => {
144
+ if (originalToken) {
145
+ process.env['GITHUB_TOKEN'] = originalToken;
146
+ } else {
147
+ delete process.env['GITHUB_TOKEN'];
148
+ }
149
+ });
150
+
151
+ it('should include Authorization header if token is present', async () => {
152
+ getMock.mockImplementationOnce((_url, options, callback) => {
153
+ expect(options.headers).toEqual({
154
+ 'User-Agent': 'gemini-cli',
155
+ Authorization: 'token my-secret-token',
156
+ });
157
+ const res = new EventEmitter() as IncomingMessage;
158
+ res.statusCode = 200;
159
+ (callback as (res: IncomingMessage) => void)(res);
160
+ res.emit('data', Buffer.from('{"foo": "bar"}'));
161
+ res.emit('end');
162
+ return new EventEmitter() as ClientRequest;
163
+ });
164
+ await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({
165
+ foo: 'bar',
166
+ });
167
+ });
168
+ });
169
+
170
+ describe('without GITHUB_TOKEN', () => {
171
+ const originalToken = process.env['GITHUB_TOKEN'];
172
+
173
+ beforeEach(() => {
174
+ delete process.env['GITHUB_TOKEN'];
175
+ });
176
+
177
+ afterEach(() => {
178
+ if (originalToken) {
179
+ process.env['GITHUB_TOKEN'] = originalToken;
180
+ }
181
+ });
182
+
183
+ it('should not include Authorization header if token is not present', async () => {
184
+ getMock.mockImplementationOnce((_url, options, callback) => {
185
+ expect(options.headers).toEqual({
186
+ 'User-Agent': 'gemini-cli',
187
+ });
188
+ const res = new EventEmitter() as IncomingMessage;
189
+ res.statusCode = 200;
190
+ (callback as (res: IncomingMessage) => void)(res);
191
+ res.emit('data', Buffer.from('{"foo": "bar"}'));
192
+ res.emit('end');
193
+ return new EventEmitter() as ClientRequest;
194
+ });
195
+
196
+ await expect(fetchJson('https://api.github.com/user')).resolves.toEqual({
197
+ foo: 'bar',
198
+ });
199
+ });
200
+ });
201
+ });
packages/cli/src/config/extensions/github_fetch.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as https from 'node:https';
8
+
9
+ export function getGitHubToken(): string | undefined {
10
+ return process.env['GITHUB_TOKEN'];
11
+ }
12
+
13
+ export async function fetchJson<T>(
14
+ url: string,
15
+ redirectCount: number = 0,
16
+ ): Promise<T> {
17
+ const headers: { 'User-Agent': string; Authorization?: string } = {
18
+ 'User-Agent': 'gemini-cli',
19
+ };
20
+ const token = getGitHubToken();
21
+ if (token) {
22
+ headers.Authorization = `token ${token}`;
23
+ }
24
+ return new Promise((resolve, reject) => {
25
+ https
26
+ .get(url, { headers }, (res) => {
27
+ if (res.statusCode === 302 || res.statusCode === 301) {
28
+ if (redirectCount >= 10) {
29
+ return reject(new Error('Too many redirects'));
30
+ }
31
+ if (!res.headers.location) {
32
+ return reject(new Error('No location header in redirect response'));
33
+ }
34
+ res.resume();
35
+ fetchJson<T>(
36
+ new URL(res.headers.location, url).toString(),
37
+ redirectCount + 1,
38
+ )
39
+ .then(resolve)
40
+ .catch(reject);
41
+ return;
42
+ }
43
+ if (res.statusCode !== 200) {
44
+ return reject(
45
+ new Error(`Request failed with status code ${res.statusCode}`),
46
+ );
47
+ }
48
+ const chunks: Buffer[] = [];
49
+ res.on('data', (chunk) => chunks.push(chunk));
50
+ res.on('end', () => {
51
+ const data = Buffer.concat(chunks).toString();
52
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
53
+ resolve(JSON.parse(data) as T);
54
+ });
55
+ })
56
+ .on('error', reject);
57
+ });
58
+ }
packages/cli/src/config/extensions/storage.test.ts ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8
+ import { ExtensionStorage } from './storage.js';
9
+ import * as os from 'node:os';
10
+ import * as path from 'node:path';
11
+ import * as fs from 'node:fs';
12
+ import {
13
+ EXTENSION_SETTINGS_FILENAME,
14
+ EXTENSIONS_CONFIG_FILENAME,
15
+ } from './variables.js';
16
+ import { Storage } from '@google/gemini-cli-core';
17
+
18
+ vi.mock('node:os');
19
+ vi.mock('node:fs', async (importOriginal) => {
20
+ const actual = await importOriginal<typeof fs>();
21
+ return {
22
+ ...actual,
23
+ promises: {
24
+ ...actual.promises,
25
+ mkdtemp: vi.fn(),
26
+ },
27
+ };
28
+ });
29
+ vi.mock('@google/gemini-cli-core');
30
+
31
+ describe('ExtensionStorage', () => {
32
+ const mockHomeDir = '/mock/home';
33
+ const extensionName = 'test-extension';
34
+ let storage: ExtensionStorage;
35
+
36
+ beforeEach(() => {
37
+ vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
38
+ vi.mocked(Storage).mockImplementation(
39
+ () =>
40
+ ({
41
+ getExtensionsDir: () =>
42
+ path.join(mockHomeDir, '.gemini', 'extensions'),
43
+ }) as any, // eslint-disable-line @typescript-eslint/no-explicit-any
44
+ );
45
+ storage = new ExtensionStorage(extensionName);
46
+ });
47
+
48
+ afterEach(() => {
49
+ vi.restoreAllMocks();
50
+ });
51
+
52
+ it('should return the correct extension directory', () => {
53
+ const expectedDir = path.join(
54
+ mockHomeDir,
55
+ '.gemini',
56
+ 'extensions',
57
+ extensionName,
58
+ );
59
+ expect(storage.getExtensionDir()).toBe(expectedDir);
60
+ });
61
+
62
+ it('should return the correct config path', () => {
63
+ const expectedPath = path.join(
64
+ mockHomeDir,
65
+ '.gemini',
66
+ 'extensions',
67
+ extensionName,
68
+ EXTENSIONS_CONFIG_FILENAME, // EXTENSIONS_CONFIG_FILENAME
69
+ );
70
+ expect(storage.getConfigPath()).toBe(expectedPath);
71
+ });
72
+
73
+ it('should return the correct env file path', () => {
74
+ const expectedPath = path.join(
75
+ mockHomeDir,
76
+ '.gemini',
77
+ 'extensions',
78
+ extensionName,
79
+ EXTENSION_SETTINGS_FILENAME, // EXTENSION_SETTINGS_FILENAME
80
+ );
81
+ expect(storage.getEnvFilePath()).toBe(expectedPath);
82
+ });
83
+
84
+ it('should return the correct user extensions directory', () => {
85
+ const expectedDir = path.join(mockHomeDir, '.gemini', 'extensions');
86
+ expect(ExtensionStorage.getUserExtensionsDir()).toBe(expectedDir);
87
+ });
88
+
89
+ it('should create a temporary directory', async () => {
90
+ const mockTmpDir = '/tmp/gemini-extension-123';
91
+ vi.mocked(fs.promises.mkdtemp).mockResolvedValue(mockTmpDir);
92
+ vi.mocked(os.tmpdir).mockReturnValue('/tmp');
93
+
94
+ const result = await ExtensionStorage.createTmpDir();
95
+
96
+ expect(fs.promises.mkdtemp).toHaveBeenCalledWith(
97
+ path.join('/tmp', 'gemini-extension'),
98
+ );
99
+ expect(result).toBe(mockTmpDir);
100
+ });
101
+ });
packages/cli/src/config/extensions/storage.ts ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as path from 'node:path';
8
+ import * as fs from 'node:fs';
9
+ import * as os from 'node:os';
10
+ import {
11
+ EXTENSION_SETTINGS_FILENAME,
12
+ EXTENSIONS_CONFIG_FILENAME,
13
+ } from './variables.js';
14
+ import { Storage, homedir } from '@google/gemini-cli-core';
15
+
16
+ export class ExtensionStorage {
17
+ private readonly extensionName: string;
18
+
19
+ constructor(extensionName: string) {
20
+ this.extensionName = extensionName;
21
+ }
22
+
23
+ getExtensionDir(): string {
24
+ return path.join(
25
+ ExtensionStorage.getUserExtensionsDir(),
26
+ this.extensionName,
27
+ );
28
+ }
29
+
30
+ getConfigPath(): string {
31
+ return path.join(this.getExtensionDir(), EXTENSIONS_CONFIG_FILENAME);
32
+ }
33
+
34
+ getEnvFilePath(): string {
35
+ return path.join(this.getExtensionDir(), EXTENSION_SETTINGS_FILENAME);
36
+ }
37
+
38
+ static getUserExtensionsDir(): string {
39
+ return new Storage(homedir()).getExtensionsDir();
40
+ }
41
+
42
+ static async createTmpDir(): Promise<string> {
43
+ return fs.promises.mkdtemp(path.join(os.tmpdir(), 'gemini-extension'));
44
+ }
45
+ }
packages/cli/src/config/extensions/update.test.ts ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
8
+ import {
9
+ updateExtension,
10
+ updateAllUpdatableExtensions,
11
+ checkForAllExtensionUpdates,
12
+ } from './update.js';
13
+ import {
14
+ ExtensionUpdateState,
15
+ type ExtensionUpdateStatus,
16
+ } from '../../ui/state/extensions.js';
17
+ import { ExtensionStorage } from './storage.js';
18
+ import { type ExtensionManager, copyExtension } from '../extension-manager.js';
19
+ import { checkForExtensionUpdate } from './github.js';
20
+ import { loadInstallMetadata } from '../extension.js';
21
+ import * as fs from 'node:fs';
22
+ import {
23
+ type GeminiCLIExtension,
24
+ type ExtensionInstallMetadata,
25
+ IntegrityDataStatus,
26
+ } from '@google/gemini-cli-core';
27
+
28
+ vi.mock('./storage.js', () => ({
29
+ ExtensionStorage: {
30
+ createTmpDir: vi.fn(),
31
+ },
32
+ }));
33
+
34
+ vi.mock('../extension-manager.js', () => ({
35
+ copyExtension: vi.fn(),
36
+ // We don't need to mock the class implementation if we pass a mock instance
37
+ }));
38
+
39
+ vi.mock('./github.js', () => ({
40
+ checkForExtensionUpdate: vi.fn(),
41
+ }));
42
+
43
+ vi.mock('../extension.js', () => ({
44
+ loadInstallMetadata: vi.fn(),
45
+ }));
46
+
47
+ vi.mock('node:fs', async (importOriginal) => {
48
+ const actual = await importOriginal<typeof import('node:fs')>();
49
+ return {
50
+ ...actual,
51
+ promises: {
52
+ ...actual.promises,
53
+ rm: vi.fn(),
54
+ },
55
+ };
56
+ });
57
+
58
+ describe('Extension Update Logic', () => {
59
+ let mockExtensionManager: ExtensionManager;
60
+ let mockDispatch: ReturnType<typeof vi.fn>;
61
+ const mockExtension: GeminiCLIExtension = {
62
+ name: 'test-extension',
63
+ version: '1.0.0',
64
+ path: '/path/to/extension',
65
+ } as GeminiCLIExtension;
66
+
67
+ beforeEach(() => {
68
+ vi.clearAllMocks();
69
+ mockExtensionManager = {
70
+ loadExtensionConfig: vi.fn().mockResolvedValue({
71
+ name: 'test-extension',
72
+ version: '1.0.0',
73
+ }),
74
+ installOrUpdateExtension: vi.fn().mockResolvedValue({
75
+ ...mockExtension,
76
+ version: '1.1.0',
77
+ }),
78
+ verifyExtensionIntegrity: vi
79
+ .fn()
80
+ .mockResolvedValue(IntegrityDataStatus.VERIFIED),
81
+ storeExtensionIntegrity: vi.fn().mockResolvedValue(undefined),
82
+ } as unknown as ExtensionManager;
83
+ mockDispatch = vi.fn();
84
+
85
+ // Default mock behaviors
86
+ vi.mocked(ExtensionStorage.createTmpDir).mockResolvedValue('/tmp/mock-dir');
87
+ vi.mocked(loadInstallMetadata).mockReturnValue({
88
+ source: 'https://example.com/repo.git',
89
+ type: 'git',
90
+ });
91
+ });
92
+
93
+ describe('updateExtension', () => {
94
+ it('should return undefined if state is already UPDATING', async () => {
95
+ const result = await updateExtension(
96
+ mockExtension,
97
+ mockExtensionManager,
98
+ ExtensionUpdateState.UPDATING,
99
+ mockDispatch,
100
+ );
101
+ expect(result).toBeUndefined();
102
+ expect(mockDispatch).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it('should throw error and set state to ERROR if install metadata type is unknown', async () => {
106
+ vi.mocked(loadInstallMetadata).mockReturnValue({
107
+ type: undefined,
108
+ } as unknown as ExtensionInstallMetadata);
109
+
110
+ await expect(
111
+ updateExtension(
112
+ mockExtension,
113
+ mockExtensionManager,
114
+ ExtensionUpdateState.UPDATE_AVAILABLE,
115
+ mockDispatch,
116
+ ),
117
+ ).rejects.toThrow('type is unknown');
118
+
119
+ expect(mockDispatch).toHaveBeenCalledWith({
120
+ type: 'SET_STATE',
121
+ payload: {
122
+ name: mockExtension.name,
123
+ state: ExtensionUpdateState.UPDATING,
124
+ },
125
+ });
126
+ expect(mockDispatch).toHaveBeenCalledWith({
127
+ type: 'SET_STATE',
128
+ payload: {
129
+ name: mockExtension.name,
130
+ state: ExtensionUpdateState.ERROR,
131
+ },
132
+ });
133
+ });
134
+
135
+ it('should throw error and set state to UP_TO_DATE if extension is linked', async () => {
136
+ vi.mocked(loadInstallMetadata).mockReturnValue({
137
+ type: 'link',
138
+ source: '',
139
+ });
140
+
141
+ await expect(
142
+ updateExtension(
143
+ mockExtension,
144
+ mockExtensionManager,
145
+ ExtensionUpdateState.UPDATE_AVAILABLE,
146
+ mockDispatch,
147
+ ),
148
+ ).rejects.toThrow('Extension is linked');
149
+
150
+ expect(mockDispatch).toHaveBeenCalledWith({
151
+ type: 'SET_STATE',
152
+ payload: {
153
+ name: mockExtension.name,
154
+ state: ExtensionUpdateState.UP_TO_DATE,
155
+ },
156
+ });
157
+ });
158
+
159
+ it('should successfully update extension and set state to UPDATED_NEEDS_RESTART by default', async () => {
160
+ vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
161
+ Promise.resolve({
162
+ name: 'test-extension',
163
+ version: '1.0.0',
164
+ }),
165
+ );
166
+ vi.mocked(
167
+ mockExtensionManager.installOrUpdateExtension,
168
+ ).mockResolvedValue({
169
+ ...mockExtension,
170
+ version: '1.1.0',
171
+ });
172
+
173
+ const result = await updateExtension(
174
+ mockExtension,
175
+ mockExtensionManager,
176
+ ExtensionUpdateState.UPDATE_AVAILABLE,
177
+ mockDispatch,
178
+ );
179
+
180
+ expect(mockExtensionManager.installOrUpdateExtension).toHaveBeenCalled();
181
+ expect(mockDispatch).toHaveBeenCalledWith({
182
+ type: 'SET_STATE',
183
+ payload: {
184
+ name: mockExtension.name,
185
+ state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
186
+ },
187
+ });
188
+ expect(result).toEqual({
189
+ name: 'test-extension',
190
+ originalVersion: '1.0.0',
191
+ updatedVersion: '1.1.0',
192
+ });
193
+ expect(fs.promises.rm).toHaveBeenCalledWith('/tmp/mock-dir', {
194
+ recursive: true,
195
+ force: true,
196
+ });
197
+ });
198
+
199
+ it('should migrate source if migratedTo is set and an update is available', async () => {
200
+ vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
201
+ Promise.resolve({
202
+ name: 'test-extension',
203
+ version: '1.0.0',
204
+ }),
205
+ );
206
+ vi.mocked(
207
+ mockExtensionManager.installOrUpdateExtension,
208
+ ).mockResolvedValue({
209
+ ...mockExtension,
210
+ version: '1.1.0',
211
+ });
212
+ vi.mocked(checkForExtensionUpdate).mockResolvedValue(
213
+ ExtensionUpdateState.UPDATE_AVAILABLE,
214
+ );
215
+
216
+ const extensionWithMigratedTo = {
217
+ ...mockExtension,
218
+ migratedTo: 'https://new-source.com/repo.git',
219
+ };
220
+
221
+ await updateExtension(
222
+ extensionWithMigratedTo,
223
+ mockExtensionManager,
224
+ ExtensionUpdateState.UPDATE_AVAILABLE,
225
+ mockDispatch,
226
+ );
227
+
228
+ expect(checkForExtensionUpdate).toHaveBeenCalledWith(
229
+ expect.objectContaining({
230
+ installMetadata: expect.objectContaining({
231
+ source: 'https://new-source.com/repo.git',
232
+ }),
233
+ }),
234
+ mockExtensionManager,
235
+ );
236
+
237
+ expect(
238
+ mockExtensionManager.installOrUpdateExtension,
239
+ ).toHaveBeenCalledWith(
240
+ expect.objectContaining({
241
+ source: 'https://new-source.com/repo.git',
242
+ }),
243
+ expect.anything(),
244
+ );
245
+ });
246
+
247
+ it('should set state to UPDATED if enableExtensionReloading is true', async () => {
248
+ vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
249
+ Promise.resolve({
250
+ name: 'test-extension',
251
+ version: '1.0.0',
252
+ }),
253
+ );
254
+ vi.mocked(
255
+ mockExtensionManager.installOrUpdateExtension,
256
+ ).mockResolvedValue({
257
+ ...mockExtension,
258
+ version: '1.1.0',
259
+ });
260
+
261
+ await updateExtension(
262
+ mockExtension,
263
+ mockExtensionManager,
264
+ ExtensionUpdateState.UPDATE_AVAILABLE,
265
+ mockDispatch,
266
+ true, // enableExtensionReloading
267
+ );
268
+
269
+ expect(mockDispatch).toHaveBeenCalledWith({
270
+ type: 'SET_STATE',
271
+ payload: {
272
+ name: mockExtension.name,
273
+ state: ExtensionUpdateState.UPDATED,
274
+ },
275
+ });
276
+ });
277
+
278
+ it('should rollback and set state to ERROR if installation fails', async () => {
279
+ vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
280
+ Promise.resolve({
281
+ name: 'test-extension',
282
+ version: '1.0.0',
283
+ }),
284
+ );
285
+ vi.mocked(
286
+ mockExtensionManager.installOrUpdateExtension,
287
+ ).mockRejectedValue(new Error('Install failed'));
288
+
289
+ await expect(
290
+ updateExtension(
291
+ mockExtension,
292
+ mockExtensionManager,
293
+ ExtensionUpdateState.UPDATE_AVAILABLE,
294
+ mockDispatch,
295
+ ),
296
+ ).rejects.toThrow('Updated extension not found after installation');
297
+
298
+ expect(copyExtension).toHaveBeenCalledWith(
299
+ '/tmp/mock-dir',
300
+ mockExtension.path,
301
+ );
302
+ expect(mockDispatch).toHaveBeenCalledWith({
303
+ type: 'SET_STATE',
304
+ payload: {
305
+ name: mockExtension.name,
306
+ state: ExtensionUpdateState.ERROR,
307
+ },
308
+ });
309
+ expect(fs.promises.rm).toHaveBeenCalled();
310
+ });
311
+
312
+ describe('Integrity Verification', () => {
313
+ it('should fail update with security alert if integrity is invalid', async () => {
314
+ vi.mocked(
315
+ mockExtensionManager.verifyExtensionIntegrity,
316
+ ).mockResolvedValue(IntegrityDataStatus.INVALID);
317
+
318
+ await expect(
319
+ updateExtension(
320
+ mockExtension,
321
+ mockExtensionManager,
322
+ ExtensionUpdateState.UPDATE_AVAILABLE,
323
+ mockDispatch,
324
+ ),
325
+ ).rejects.toThrow(
326
+ 'Extension test-extension cannot be updated. Extension integrity cannot be verified.',
327
+ );
328
+
329
+ expect(mockDispatch).toHaveBeenCalledWith({
330
+ type: 'SET_STATE',
331
+ payload: {
332
+ name: mockExtension.name,
333
+ state: ExtensionUpdateState.ERROR,
334
+ },
335
+ });
336
+ });
337
+
338
+ it('should establish trust on first update if integrity data is missing', async () => {
339
+ vi.mocked(
340
+ mockExtensionManager.verifyExtensionIntegrity,
341
+ ).mockResolvedValue(IntegrityDataStatus.MISSING);
342
+
343
+ await updateExtension(
344
+ mockExtension,
345
+ mockExtensionManager,
346
+ ExtensionUpdateState.UPDATE_AVAILABLE,
347
+ mockDispatch,
348
+ );
349
+
350
+ // Verify updateExtension delegates to installOrUpdateExtension,
351
+ // which is responsible for establishing trust internally.
352
+ expect(
353
+ mockExtensionManager.installOrUpdateExtension,
354
+ ).toHaveBeenCalled();
355
+
356
+ expect(mockDispatch).toHaveBeenCalledWith({
357
+ type: 'SET_STATE',
358
+ payload: {
359
+ name: mockExtension.name,
360
+ state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
361
+ },
362
+ });
363
+ });
364
+
365
+ it('should throw if integrity manager throws', async () => {
366
+ vi.mocked(
367
+ mockExtensionManager.verifyExtensionIntegrity,
368
+ ).mockRejectedValue(new Error('Verification failed'));
369
+
370
+ await expect(
371
+ updateExtension(
372
+ mockExtension,
373
+ mockExtensionManager,
374
+ ExtensionUpdateState.UPDATE_AVAILABLE,
375
+ mockDispatch,
376
+ ),
377
+ ).rejects.toThrow(
378
+ 'Extension test-extension cannot be updated. Verification failed',
379
+ );
380
+ });
381
+ });
382
+ });
383
+
384
+ describe('updateAllUpdatableExtensions', () => {
385
+ it('should update all extensions with UPDATE_AVAILABLE status', async () => {
386
+ const extensions: GeminiCLIExtension[] = [
387
+ { ...mockExtension, name: 'ext1' },
388
+ { ...mockExtension, name: 'ext2' },
389
+ { ...mockExtension, name: 'ext3' },
390
+ ];
391
+ const extensionsState = new Map([
392
+ ['ext1', { status: ExtensionUpdateState.UPDATE_AVAILABLE }],
393
+ ['ext2', { status: ExtensionUpdateState.UP_TO_DATE }],
394
+ ['ext3', { status: ExtensionUpdateState.UPDATE_AVAILABLE }],
395
+ ]);
396
+
397
+ vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
398
+ Promise.resolve({
399
+ name: 'ext',
400
+ version: '1.0.0',
401
+ }),
402
+ );
403
+ vi.mocked(
404
+ mockExtensionManager.installOrUpdateExtension,
405
+ ).mockResolvedValue({ ...mockExtension, version: '1.1.0' });
406
+
407
+ const results = await updateAllUpdatableExtensions(
408
+ extensions,
409
+ extensionsState as Map<string, ExtensionUpdateStatus>,
410
+ mockExtensionManager,
411
+ mockDispatch,
412
+ );
413
+
414
+ expect(results).toHaveLength(2);
415
+ expect(results.map((r) => r.name)).toEqual(['ext1', 'ext3']);
416
+ expect(
417
+ mockExtensionManager.installOrUpdateExtension,
418
+ ).toHaveBeenCalledTimes(2);
419
+ });
420
+ });
421
+
422
+ describe('checkForAllExtensionUpdates', () => {
423
+ it('should dispatch BATCH_CHECK_START and BATCH_CHECK_END', async () => {
424
+ await checkForAllExtensionUpdates([], mockExtensionManager, mockDispatch);
425
+
426
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'BATCH_CHECK_START' });
427
+ expect(mockDispatch).toHaveBeenCalledWith({ type: 'BATCH_CHECK_END' });
428
+ });
429
+
430
+ it('should set state to NOT_UPDATABLE if no install metadata', async () => {
431
+ const extensions: GeminiCLIExtension[] = [
432
+ { ...mockExtension, installMetadata: undefined },
433
+ ];
434
+
435
+ await checkForAllExtensionUpdates(
436
+ extensions,
437
+ mockExtensionManager,
438
+ mockDispatch,
439
+ );
440
+
441
+ expect(mockDispatch).toHaveBeenCalledWith({
442
+ type: 'SET_STATE',
443
+ payload: {
444
+ name: mockExtension.name,
445
+ state: ExtensionUpdateState.NOT_UPDATABLE,
446
+ },
447
+ });
448
+ });
449
+
450
+ it('should check for updates and update state', async () => {
451
+ const extensions: GeminiCLIExtension[] = [
452
+ { ...mockExtension, installMetadata: { type: 'git', source: '...' } },
453
+ ];
454
+ vi.mocked(checkForExtensionUpdate).mockResolvedValue(
455
+ ExtensionUpdateState.UPDATE_AVAILABLE,
456
+ );
457
+
458
+ await checkForAllExtensionUpdates(
459
+ extensions,
460
+ mockExtensionManager,
461
+ mockDispatch,
462
+ );
463
+
464
+ expect(mockDispatch).toHaveBeenCalledWith({
465
+ type: 'SET_STATE',
466
+ payload: {
467
+ name: mockExtension.name,
468
+ state: ExtensionUpdateState.CHECKING_FOR_UPDATES,
469
+ },
470
+ });
471
+ expect(mockDispatch).toHaveBeenCalledWith({
472
+ type: 'SET_STATE',
473
+ payload: {
474
+ name: mockExtension.name,
475
+ state: ExtensionUpdateState.UPDATE_AVAILABLE,
476
+ },
477
+ });
478
+ });
479
+ });
480
+ });
packages/cli/src/config/extensions/update.ts ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import {
8
+ type ExtensionUpdateAction,
9
+ ExtensionUpdateState,
10
+ type ExtensionUpdateStatus,
11
+ } from '../../ui/state/extensions.js';
12
+ import { loadInstallMetadata } from '../extension.js';
13
+ import { checkForExtensionUpdate } from './github.js';
14
+ import {
15
+ debugLogger,
16
+ getErrorMessage,
17
+ type GeminiCLIExtension,
18
+ IntegrityDataStatus,
19
+ } from '@google/gemini-cli-core';
20
+ import * as fs from 'node:fs';
21
+ import { copyExtension, type ExtensionManager } from '../extension-manager.js';
22
+ import { ExtensionStorage } from './storage.js';
23
+
24
+ export interface ExtensionUpdateInfo {
25
+ name: string;
26
+ originalVersion: string;
27
+ updatedVersion: string;
28
+ }
29
+
30
+ export async function updateExtension(
31
+ extension: GeminiCLIExtension,
32
+ extensionManager: ExtensionManager,
33
+ currentState: ExtensionUpdateState,
34
+ dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void,
35
+ enableExtensionReloading?: boolean,
36
+ ): Promise<ExtensionUpdateInfo | undefined> {
37
+ if (currentState === ExtensionUpdateState.UPDATING) {
38
+ return undefined;
39
+ }
40
+ dispatchExtensionStateUpdate({
41
+ type: 'SET_STATE',
42
+ payload: { name: extension.name, state: ExtensionUpdateState.UPDATING },
43
+ });
44
+ const installMetadata = loadInstallMetadata(extension.path);
45
+
46
+ if (!installMetadata?.type) {
47
+ dispatchExtensionStateUpdate({
48
+ type: 'SET_STATE',
49
+ payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
50
+ });
51
+ throw new Error(
52
+ `Extension ${extension.name} cannot be updated, type is unknown.`,
53
+ );
54
+ }
55
+
56
+ try {
57
+ const status = await extensionManager.verifyExtensionIntegrity(
58
+ extension.name,
59
+ installMetadata,
60
+ );
61
+
62
+ if (status === IntegrityDataStatus.INVALID) {
63
+ throw new Error('Extension integrity cannot be verified');
64
+ }
65
+ } catch (e) {
66
+ dispatchExtensionStateUpdate({
67
+ type: 'SET_STATE',
68
+ payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
69
+ });
70
+ throw new Error(
71
+ `Extension ${extension.name} cannot be updated. ${getErrorMessage(e)}. To fix this, reinstall the extension.`,
72
+ );
73
+ }
74
+
75
+ if (installMetadata?.type === 'link') {
76
+ dispatchExtensionStateUpdate({
77
+ type: 'SET_STATE',
78
+ payload: { name: extension.name, state: ExtensionUpdateState.UP_TO_DATE },
79
+ });
80
+ throw new Error(`Extension is linked so does not need to be updated`);
81
+ }
82
+
83
+ if (extension.migratedTo) {
84
+ const migratedState = await checkForExtensionUpdate(
85
+ {
86
+ ...extension,
87
+ installMetadata: { ...installMetadata, source: extension.migratedTo },
88
+ migratedTo: undefined,
89
+ },
90
+ extensionManager,
91
+ );
92
+ if (
93
+ migratedState === ExtensionUpdateState.UPDATE_AVAILABLE ||
94
+ migratedState === ExtensionUpdateState.UP_TO_DATE
95
+ ) {
96
+ installMetadata.source = extension.migratedTo;
97
+ }
98
+ }
99
+
100
+ const originalVersion = extension.version;
101
+
102
+ const tempDir = await ExtensionStorage.createTmpDir();
103
+ try {
104
+ const previousExtensionConfig = await extensionManager.loadExtensionConfig(
105
+ extension.path,
106
+ );
107
+ let updatedExtension: GeminiCLIExtension;
108
+ try {
109
+ updatedExtension = await extensionManager.installOrUpdateExtension(
110
+ installMetadata,
111
+ previousExtensionConfig,
112
+ );
113
+ } catch (e) {
114
+ dispatchExtensionStateUpdate({
115
+ type: 'SET_STATE',
116
+ payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
117
+ });
118
+ throw new Error(
119
+ `Updated extension not found after installation, got error:\n${e}`,
120
+ );
121
+ }
122
+ const updatedVersion = updatedExtension.version;
123
+ dispatchExtensionStateUpdate({
124
+ type: 'SET_STATE',
125
+ payload: {
126
+ name: extension.name,
127
+ state: enableExtensionReloading
128
+ ? ExtensionUpdateState.UPDATED
129
+ : ExtensionUpdateState.UPDATED_NEEDS_RESTART,
130
+ },
131
+ });
132
+ return {
133
+ name: extension.name,
134
+ originalVersion,
135
+ updatedVersion,
136
+ };
137
+ } catch (e) {
138
+ debugLogger.error(
139
+ `Error updating extension, rolling back. ${getErrorMessage(e)}`,
140
+ );
141
+ dispatchExtensionStateUpdate({
142
+ type: 'SET_STATE',
143
+ payload: { name: extension.name, state: ExtensionUpdateState.ERROR },
144
+ });
145
+ await copyExtension(tempDir, extension.path);
146
+ throw e;
147
+ } finally {
148
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
149
+ }
150
+ }
151
+
152
+ export async function updateAllUpdatableExtensions(
153
+ extensions: GeminiCLIExtension[],
154
+ extensionsState: Map<string, ExtensionUpdateStatus>,
155
+ extensionManager: ExtensionManager,
156
+ dispatch: (action: ExtensionUpdateAction) => void,
157
+ enableExtensionReloading?: boolean,
158
+ ): Promise<ExtensionUpdateInfo[]> {
159
+ const results = await Promise.all(
160
+ extensions
161
+ .filter(
162
+ (extension) =>
163
+ extensionsState.get(extension.name)?.status ===
164
+ ExtensionUpdateState.UPDATE_AVAILABLE,
165
+ )
166
+ .map((extension) =>
167
+ updateExtension(
168
+ extension,
169
+ extensionManager,
170
+ extensionsState.get(extension.name)!.status,
171
+ dispatch,
172
+ enableExtensionReloading,
173
+ ),
174
+ ),
175
+ );
176
+ return results.filter(
177
+ (updateInfo): updateInfo is ExtensionUpdateInfo => !!updateInfo,
178
+ );
179
+ }
180
+
181
+ export interface ExtensionUpdateCheckResult {
182
+ state: ExtensionUpdateState;
183
+ error?: string;
184
+ }
185
+
186
+ export async function checkForAllExtensionUpdates(
187
+ extensions: GeminiCLIExtension[],
188
+ extensionManager: ExtensionManager,
189
+ dispatch: (action: ExtensionUpdateAction) => void,
190
+ ): Promise<void> {
191
+ dispatch({ type: 'BATCH_CHECK_START' });
192
+ try {
193
+ const promises: Array<Promise<void>> = [];
194
+ for (const extension of extensions) {
195
+ if (!extension.installMetadata) {
196
+ dispatch({
197
+ type: 'SET_STATE',
198
+ payload: {
199
+ name: extension.name,
200
+ state: ExtensionUpdateState.NOT_UPDATABLE,
201
+ },
202
+ });
203
+ continue;
204
+ }
205
+ dispatch({
206
+ type: 'SET_STATE',
207
+ payload: {
208
+ name: extension.name,
209
+ state: ExtensionUpdateState.CHECKING_FOR_UPDATES,
210
+ },
211
+ });
212
+ promises.push(
213
+ checkForExtensionUpdate(extension, extensionManager).then((state) =>
214
+ dispatch({
215
+ type: 'SET_STATE',
216
+ payload: { name: extension.name, state },
217
+ }),
218
+ ),
219
+ );
220
+ }
221
+ await Promise.all(promises);
222
+ } finally {
223
+ dispatch({ type: 'BATCH_CHECK_END' });
224
+ }
225
+ }
packages/cli/src/config/extensions/variableSchema.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ export interface VariableDefinition {
8
+ type: 'string';
9
+ description: string;
10
+ default?: string;
11
+ required?: boolean;
12
+ }
13
+
14
+ export interface VariableSchema {
15
+ [key: string]: VariableDefinition;
16
+ }
17
+
18
+ const PATH_SEPARATOR_DEFINITION = {
19
+ type: 'string',
20
+ description: 'The path separator.',
21
+ } as const;
22
+
23
+ export const VARIABLE_SCHEMA = {
24
+ extensionPath: {
25
+ type: 'string',
26
+ description: 'The path of the extension in the filesystem.',
27
+ },
28
+ workspacePath: {
29
+ type: 'string',
30
+ description: 'The absolute path of the current workspace.',
31
+ },
32
+ '/': PATH_SEPARATOR_DEFINITION,
33
+ pathSeparator: PATH_SEPARATOR_DEFINITION,
34
+ } as const;
packages/cli/src/config/extensions/variables.test.ts ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { expect, describe, it } from 'vitest';
8
+ import {
9
+ hydrateString,
10
+ recursivelyHydrateStrings,
11
+ validateVariables,
12
+ type VariableContext,
13
+ } from './variables.js';
14
+
15
+ describe('validateVariables', () => {
16
+ it('should not throw if all required variables are present', () => {
17
+ const schema = {
18
+ extensionPath: { type: 'string', description: 'test', required: true },
19
+ } as const;
20
+ const context = { extensionPath: 'value' };
21
+ expect(() => validateVariables(context, schema)).not.toThrow();
22
+ });
23
+
24
+ it('should throw if a required variable is missing', () => {
25
+ const schema = {
26
+ extensionPath: { type: 'string', description: 'test', required: true },
27
+ } as const;
28
+ const context = {};
29
+ expect(() => validateVariables(context, schema)).toThrow(
30
+ 'Missing required variable: extensionPath',
31
+ );
32
+ });
33
+ });
34
+
35
+ describe('hydrateString', () => {
36
+ it('should replace a single variable', () => {
37
+ const context = {
38
+ extensionPath: 'path/my-extension',
39
+ };
40
+ const result = hydrateString('Hello, ${extensionPath}!', context);
41
+ expect(result).toBe('Hello, path/my-extension!');
42
+ });
43
+
44
+ it('should replace multiple variables', () => {
45
+ const context = {
46
+ extensionPath: 'path/my-extension',
47
+ workspacePath: '/ws',
48
+ };
49
+ const result = hydrateString(
50
+ 'Ext: ${extensionPath}, WS: ${workspacePath}',
51
+ context,
52
+ );
53
+ expect(result).toBe('Ext: path/my-extension, WS: /ws');
54
+ });
55
+
56
+ it('should ignore unknown variables', () => {
57
+ const context = {
58
+ extensionPath: 'path/my-extension',
59
+ };
60
+ const result = hydrateString('Hello, ${unknown}!', context);
61
+ expect(result).toBe('Hello, ${unknown}!');
62
+ });
63
+
64
+ it('should handle null and undefined context values', () => {
65
+ const context: VariableContext = {
66
+ extensionPath: undefined,
67
+ };
68
+ const result = hydrateString(
69
+ 'Ext: ${extensionPath}, WS: ${workspacePath}',
70
+ context,
71
+ );
72
+ expect(result).toBe('Ext: ${extensionPath}, WS: ${workspacePath}');
73
+ });
74
+ });
75
+
76
+ describe('recursivelyHydrateStrings', () => {
77
+ const context = {
78
+ extensionPath: 'path/my-extension',
79
+ workspacePath: '/ws',
80
+ };
81
+
82
+ it('should hydrate strings in a flat object', () => {
83
+ const obj = {
84
+ a: 'Hello, ${workspacePath}',
85
+ b: 'Hi, ${extensionPath}',
86
+ };
87
+ const result = recursivelyHydrateStrings(obj, context);
88
+ expect(result).toEqual({
89
+ a: 'Hello, /ws',
90
+ b: 'Hi, path/my-extension',
91
+ });
92
+ });
93
+
94
+ it('should hydrate strings in an array', () => {
95
+ const arr = ['${workspacePath}', '${extensionPath}'];
96
+ const result = recursivelyHydrateStrings(arr, context);
97
+ expect(result).toEqual(['/ws', 'path/my-extension']);
98
+ });
99
+
100
+ it('should hydrate strings in a nested object', () => {
101
+ const obj = {
102
+ a: 'Hello, ${workspacePath}',
103
+ b: {
104
+ c: 'Hi, ${extensionPath}',
105
+ d: ['${workspacePath}/foo'],
106
+ },
107
+ };
108
+ const result = recursivelyHydrateStrings(obj, context);
109
+ expect(result).toEqual({
110
+ a: 'Hello, /ws',
111
+ b: {
112
+ c: 'Hi, path/my-extension',
113
+ d: ['/ws/foo'],
114
+ },
115
+ });
116
+ });
117
+
118
+ it('should not modify non-string values', () => {
119
+ const obj = {
120
+ a: 123,
121
+ b: true,
122
+ c: null,
123
+ };
124
+ const result = recursivelyHydrateStrings(obj, context);
125
+ expect(result).toEqual(obj);
126
+ });
127
+
128
+ it('should not allow prototype pollution via __proto__', () => {
129
+ const payload = JSON.parse('{"__proto__": {"polluted": "yes"}}');
130
+ const result = recursivelyHydrateStrings(payload, context);
131
+
132
+ expect(result.polluted).toBeUndefined();
133
+ expect(Object.prototype.hasOwnProperty.call(result, 'polluted')).toBe(
134
+ false,
135
+ );
136
+ });
137
+
138
+ it('should not allow prototype pollution via constructor', () => {
139
+ const payload = JSON.parse(
140
+ '{"constructor": {"prototype": {"polluted": "yes"}}}',
141
+ );
142
+ const result = recursivelyHydrateStrings(payload, context);
143
+
144
+ expect(result.polluted).toBeUndefined();
145
+ });
146
+
147
+ it('should not allow prototype pollution via prototype', () => {
148
+ const payload = JSON.parse('{"prototype": {"polluted": "yes"}}');
149
+ const result = recursivelyHydrateStrings(payload, context);
150
+
151
+ expect(result.polluted).toBeUndefined();
152
+ });
153
+ });
packages/cli/src/config/extensions/variables.ts ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import * as path from 'node:path';
8
+ import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js';
9
+ import { GEMINI_DIR } from '@google/gemini-cli-core';
10
+
11
+ /**
12
+ * Represents a set of keys that will be considered invalid while unmarshalling
13
+ * JSON in recursivelyHydrateStrings.
14
+ */
15
+ const UNMARSHALL_KEY_IGNORE_LIST: Set<string> = new Set<string>([
16
+ '__proto__',
17
+ 'constructor',
18
+ 'prototype',
19
+ ]);
20
+
21
+ export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions');
22
+ export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json';
23
+ export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json';
24
+ export const EXTENSION_SETTINGS_FILENAME = '.env';
25
+
26
+ export type JsonObject = { [key: string]: JsonValue };
27
+ export type JsonArray = JsonValue[];
28
+ export type JsonValue =
29
+ | string
30
+ | number
31
+ | boolean
32
+ | null
33
+ | JsonObject
34
+ | JsonArray;
35
+
36
+ export type VariableContext = {
37
+ [key: string]: string | undefined;
38
+ };
39
+
40
+ export function validateVariables(
41
+ variables: VariableContext,
42
+ schema: VariableSchema,
43
+ ) {
44
+ for (const key in schema) {
45
+ const definition = schema[key];
46
+ if (definition.required && !variables[key]) {
47
+ throw new Error(`Missing required variable: ${key}`);
48
+ }
49
+ }
50
+ }
51
+
52
+ export function hydrateString(str: string, context: VariableContext): string {
53
+ validateVariables(context, VARIABLE_SCHEMA);
54
+ const regex = /\${(.*?)}/g;
55
+ return str.replace(regex, (match, key) => {
56
+ const val = context[key];
57
+ return val == null ? match : String(val);
58
+ });
59
+ }
60
+
61
+ export function recursivelyHydrateStrings<T>(
62
+ obj: T,
63
+ values: VariableContext,
64
+ ): T {
65
+ if (typeof obj === 'string') {
66
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
67
+ return hydrateString(obj, values) as unknown as T;
68
+ }
69
+ if (Array.isArray(obj)) {
70
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
71
+ return (obj as unknown[]).map((item) =>
72
+ recursivelyHydrateStrings(item, values),
73
+ ) as unknown as T;
74
+ }
75
+ if (typeof obj === 'object' && obj !== null) {
76
+ const newObj: Record<string, unknown> = {};
77
+ for (const key in obj) {
78
+ if (
79
+ !UNMARSHALL_KEY_IGNORE_LIST.has(key) &&
80
+ Object.prototype.hasOwnProperty.call(obj, key)
81
+ ) {
82
+ newObj[key] = recursivelyHydrateStrings(
83
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
84
+ (obj as Record<string, unknown>)[key],
85
+ values,
86
+ );
87
+ }
88
+ }
89
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
90
+ return newObj as T;
91
+ }
92
+ return obj;
93
+ }
packages/cli/src/config/mcp/index.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ export {
8
+ McpServerEnablementManager,
9
+ canLoadServer,
10
+ normalizeServerId,
11
+ isInSettingsList,
12
+ type McpServerEnablementState,
13
+ type McpServerEnablementConfig,
14
+ type McpServerDisplayState,
15
+ type EnablementCallbacks,
16
+ type ServerLoadResult,
17
+ } from './mcpServerEnablement.js';
packages/cli/src/config/mcp/mcpServerEnablement.test.ts ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import fs from 'node:fs/promises';
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
9
+
10
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
11
+ const actual =
12
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
13
+ return {
14
+ ...actual,
15
+ Storage: {
16
+ // eslint-disable-next-line @typescript-eslint/no-misused-spread
17
+ ...actual.Storage,
18
+ getGlobalGeminiDir: () => '/virtual-home/.gemini',
19
+ },
20
+ };
21
+ });
22
+
23
+ import {
24
+ McpServerEnablementManager,
25
+ canLoadServer,
26
+ normalizeServerId,
27
+ isInSettingsList,
28
+ type EnablementCallbacks,
29
+ } from './mcpServerEnablement.js';
30
+
31
+ let inMemoryFs: Record<string, string> = {};
32
+
33
+ function createMockEnablement(
34
+ sessionDisabled: boolean,
35
+ fileEnabled: boolean,
36
+ ): EnablementCallbacks {
37
+ return {
38
+ isSessionDisabled: () => sessionDisabled,
39
+ isFileEnabled: () => Promise.resolve(fileEnabled),
40
+ };
41
+ }
42
+
43
+ function setupFsMocks(): void {
44
+ vi.spyOn(fs, 'readFile').mockImplementation(async (filePath) => {
45
+ const content = inMemoryFs[filePath.toString()];
46
+ if (content === undefined) {
47
+ const error = new Error(`ENOENT: ${filePath}`);
48
+ (error as NodeJS.ErrnoException).code = 'ENOENT';
49
+ throw error;
50
+ }
51
+ return content;
52
+ });
53
+ vi.spyOn(fs, 'writeFile').mockImplementation(async (filePath, data) => {
54
+ inMemoryFs[filePath.toString()] = data.toString();
55
+ });
56
+ vi.spyOn(fs, 'mkdir').mockImplementation(async () => undefined);
57
+ }
58
+
59
+ describe('McpServerEnablementManager', () => {
60
+ let manager: McpServerEnablementManager;
61
+
62
+ beforeEach(() => {
63
+ inMemoryFs = {};
64
+ setupFsMocks();
65
+ McpServerEnablementManager.resetInstance();
66
+ manager = McpServerEnablementManager.getInstance();
67
+ });
68
+
69
+ afterEach(() => {
70
+ vi.restoreAllMocks();
71
+ McpServerEnablementManager.resetInstance();
72
+ });
73
+
74
+ it('should enable/disable servers with persistence', async () => {
75
+ expect(await manager.isFileEnabled('server')).toBe(true);
76
+ await manager.disable('server');
77
+ expect(await manager.isFileEnabled('server')).toBe(false);
78
+ await manager.enable('server');
79
+ expect(await manager.isFileEnabled('server')).toBe(true);
80
+ });
81
+
82
+ it('should handle session disable separately', async () => {
83
+ manager.disableForSession('server');
84
+ expect(manager.isSessionDisabled('server')).toBe(true);
85
+ expect(await manager.isFileEnabled('server')).toBe(true);
86
+ expect(await manager.isEffectivelyEnabled('server')).toBe(false);
87
+ manager.clearSessionDisable('server');
88
+ expect(await manager.isEffectivelyEnabled('server')).toBe(true);
89
+ });
90
+
91
+ it('should be case-insensitive', async () => {
92
+ await manager.disable('PlayWright');
93
+ expect(await manager.isFileEnabled('playwright')).toBe(false);
94
+ });
95
+
96
+ it('should return correct display state', async () => {
97
+ await manager.disable('file-disabled');
98
+ manager.disableForSession('session-disabled');
99
+
100
+ expect(await manager.getDisplayState('enabled')).toEqual({
101
+ enabled: true,
102
+ isSessionDisabled: false,
103
+ isPersistentDisabled: false,
104
+ });
105
+ expect(
106
+ (await manager.getDisplayState('file-disabled')).isPersistentDisabled,
107
+ ).toBe(true);
108
+ expect(
109
+ (await manager.getDisplayState('session-disabled')).isSessionDisabled,
110
+ ).toBe(true);
111
+ });
112
+
113
+ it('should share session state across getInstance calls', () => {
114
+ const instance1 = McpServerEnablementManager.getInstance();
115
+ const instance2 = McpServerEnablementManager.getInstance();
116
+
117
+ instance1.disableForSession('test-server');
118
+
119
+ expect(instance2.isSessionDisabled('test-server')).toBe(true);
120
+ expect(instance1).toBe(instance2);
121
+ });
122
+ });
123
+
124
+ describe('canLoadServer', () => {
125
+ it('blocks when admin has disabled MCP', async () => {
126
+ const result = await canLoadServer('s', { adminMcpEnabled: false });
127
+ expect(result.blockType).toBe('admin');
128
+ });
129
+
130
+ it('blocks when server is not in allowlist', async () => {
131
+ const result = await canLoadServer('s', {
132
+ adminMcpEnabled: true,
133
+ allowedList: ['other'],
134
+ });
135
+ expect(result.blockType).toBe('allowlist');
136
+ });
137
+
138
+ it('blocks when server is in excludelist', async () => {
139
+ const result = await canLoadServer('s', {
140
+ adminMcpEnabled: true,
141
+ excludedList: ['s'],
142
+ });
143
+ expect(result.blockType).toBe('excludelist');
144
+ });
145
+
146
+ it('blocks when server is session-disabled', async () => {
147
+ const result = await canLoadServer('s', {
148
+ adminMcpEnabled: true,
149
+ enablement: createMockEnablement(true, true),
150
+ });
151
+ expect(result.blockType).toBe('session');
152
+ });
153
+
154
+ it('blocks when server is file-disabled', async () => {
155
+ const result = await canLoadServer('s', {
156
+ adminMcpEnabled: true,
157
+ enablement: createMockEnablement(false, false),
158
+ });
159
+ expect(result.blockType).toBe('enablement');
160
+ });
161
+
162
+ it('allows when admin MCP is enabled and no restrictions', async () => {
163
+ const result = await canLoadServer('s', { adminMcpEnabled: true });
164
+ expect(result.allowed).toBe(true);
165
+ });
166
+
167
+ it('allows when server passes all checks', async () => {
168
+ const result = await canLoadServer('s', {
169
+ adminMcpEnabled: true,
170
+ allowedList: ['s'],
171
+ enablement: createMockEnablement(false, true),
172
+ });
173
+ expect(result.allowed).toBe(true);
174
+ });
175
+ });
176
+
177
+ describe('helper functions', () => {
178
+ it('normalizeServerId lowercases and trims', () => {
179
+ expect(normalizeServerId(' PlayWright ')).toBe('playwright');
180
+ });
181
+
182
+ it('isInSettingsList supports ext: backward compat', () => {
183
+ expect(isInSettingsList('playwright', ['playwright']).found).toBe(true);
184
+ expect(isInSettingsList('ext:github:mcp', ['mcp']).found).toBe(true);
185
+ expect(
186
+ isInSettingsList('ext:github:mcp', ['mcp']).deprecationWarning,
187
+ ).toBeTruthy();
188
+ });
189
+ });
packages/cli/src/config/mcp/mcpServerEnablement.ts ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ import { Storage, coreEvents } from '@google/gemini-cli-core';
10
+
11
+ /**
12
+ * Stored in JSON file - represents persistent enablement state.
13
+ */
14
+ export interface McpServerEnablementState {
15
+ enabled: boolean;
16
+ }
17
+
18
+ /**
19
+ * File config format - map of server ID to enablement state.
20
+ */
21
+ export interface McpServerEnablementConfig {
22
+ [serverId: string]: McpServerEnablementState;
23
+ }
24
+
25
+ /**
26
+ * For UI display - combines file and session state.
27
+ */
28
+ export interface McpServerDisplayState {
29
+ /** Effective state (considering session override) */
30
+ enabled: boolean;
31
+ /** True if disabled via --session flag */
32
+ isSessionDisabled: boolean;
33
+ /** True if disabled in file */
34
+ isPersistentDisabled: boolean;
35
+ }
36
+
37
+ /**
38
+ * Callback types for enablement checks (passed from CLI to core).
39
+ */
40
+ export interface EnablementCallbacks {
41
+ isSessionDisabled: (serverId: string) => boolean;
42
+ isFileEnabled: (serverId: string) => Promise<boolean>;
43
+ }
44
+
45
+ /**
46
+ * Result of canLoadServer check.
47
+ */
48
+ export interface ServerLoadResult {
49
+ allowed: boolean;
50
+ reason?: string;
51
+ blockType?: 'admin' | 'allowlist' | 'excludelist' | 'session' | 'enablement';
52
+ }
53
+
54
+ /**
55
+ * Normalize a server ID to canonical lowercase form.
56
+ */
57
+ export function normalizeServerId(serverId: string): string {
58
+ return serverId.toLowerCase().trim();
59
+ }
60
+
61
+ /**
62
+ * Check if a server ID is in a settings list (with backward compatibility).
63
+ * Handles case-insensitive matching and plain name fallback for ext: servers.
64
+ */
65
+ export function isInSettingsList(
66
+ serverId: string,
67
+ list: string[],
68
+ ): { found: boolean; deprecationWarning?: string } {
69
+ const normalizedId = normalizeServerId(serverId);
70
+ const normalizedList = list.map(normalizeServerId);
71
+
72
+ // Exact canonical match
73
+ if (normalizedList.includes(normalizedId)) {
74
+ return { found: true };
75
+ }
76
+
77
+ // Backward compat: for ext: servers, check if plain name matches
78
+ if (normalizedId.startsWith('ext:')) {
79
+ const plainName = normalizedId.split(':').pop();
80
+ if (plainName && normalizedList.includes(plainName)) {
81
+ return {
82
+ found: true,
83
+ deprecationWarning:
84
+ `Settings reference '${plainName}' matches extension server '${serverId}'. ` +
85
+ `Update your settings to use the full identifier '${serverId}' instead.`,
86
+ };
87
+ }
88
+ }
89
+
90
+ return { found: false };
91
+ }
92
+
93
+ /**
94
+ * Single source of truth for whether a server can be loaded.
95
+ * Used by: isAllowedMcpServer(), connectServer(), CLI handlers, slash handlers.
96
+ *
97
+ * Uses callbacks instead of direct enablementManager reference to keep
98
+ * packages/core independent of packages/cli.
99
+ */
100
+ export async function canLoadServer(
101
+ serverId: string,
102
+ config: {
103
+ adminMcpEnabled: boolean;
104
+ allowedList?: string[];
105
+ excludedList?: string[];
106
+ enablement?: EnablementCallbacks;
107
+ },
108
+ ): Promise<ServerLoadResult> {
109
+ const normalizedId = normalizeServerId(serverId);
110
+
111
+ // 1. Admin kill switch
112
+ if (!config.adminMcpEnabled) {
113
+ return {
114
+ allowed: false,
115
+ reason:
116
+ 'MCP servers are disabled by administrator. Check admin settings or contact your admin.',
117
+ blockType: 'admin',
118
+ };
119
+ }
120
+
121
+ // 2. Allowlist check
122
+ if (config.allowedList !== undefined) {
123
+ const { found, deprecationWarning } = isInSettingsList(
124
+ normalizedId,
125
+ config.allowedList,
126
+ );
127
+ if (deprecationWarning) {
128
+ coreEvents.emitFeedback('warning', deprecationWarning);
129
+ }
130
+ if (!found) {
131
+ return {
132
+ allowed: false,
133
+ reason: `Server '${serverId}' is not in mcp.allowed list. Add it to settings.json mcp.allowed array to enable.`,
134
+ blockType: 'allowlist',
135
+ };
136
+ }
137
+ }
138
+
139
+ // 3. Excludelist check
140
+ if (config.excludedList) {
141
+ const { found, deprecationWarning } = isInSettingsList(
142
+ normalizedId,
143
+ config.excludedList,
144
+ );
145
+ if (deprecationWarning) {
146
+ coreEvents.emitFeedback('warning', deprecationWarning);
147
+ }
148
+ if (found) {
149
+ return {
150
+ allowed: false,
151
+ reason: `Server '${serverId}' is blocked by mcp.excluded. Remove it from settings.json mcp.excluded array to enable.`,
152
+ blockType: 'excludelist',
153
+ };
154
+ }
155
+ }
156
+
157
+ // 4. Session disable check (before file-based enablement)
158
+ if (config.enablement?.isSessionDisabled(normalizedId)) {
159
+ return {
160
+ allowed: false,
161
+ reason: `Server '${serverId}' is disabled for this session. Run 'gemini mcp enable ${serverId} --session' to clear.`,
162
+ blockType: 'session',
163
+ };
164
+ }
165
+
166
+ // 5. File-based enablement check
167
+ if (
168
+ config.enablement &&
169
+ !(await config.enablement.isFileEnabled(normalizedId))
170
+ ) {
171
+ return {
172
+ allowed: false,
173
+ reason: `Server '${serverId}' is disabled. Run 'gemini mcp enable ${serverId}' to enable.`,
174
+ blockType: 'enablement',
175
+ };
176
+ }
177
+
178
+ return { allowed: true };
179
+ }
180
+
181
+ const MCP_ENABLEMENT_FILENAME = 'mcp-server-enablement.json';
182
+
183
+ /**
184
+ * McpServerEnablementManager
185
+ *
186
+ * Manages the enabled/disabled state of MCP servers.
187
+ * Uses a simplified format compared to ExtensionEnablementManager.
188
+ * Supports both persistent (file) and session-only (in-memory) states.
189
+ *
190
+ * NOTE: Use getInstance() to get the singleton instance. This ensures
191
+ * session state (sessionDisabled Set) is shared across all code paths.
192
+ */
193
+ export class McpServerEnablementManager {
194
+ private static instance: McpServerEnablementManager | null = null;
195
+
196
+ private readonly configFilePath: string;
197
+ private readonly configDir: string;
198
+ private readonly sessionDisabled = new Set<string>();
199
+
200
+ /**
201
+ * Get the singleton instance.
202
+ */
203
+ static getInstance(): McpServerEnablementManager {
204
+ if (!McpServerEnablementManager.instance) {
205
+ McpServerEnablementManager.instance = new McpServerEnablementManager();
206
+ }
207
+ return McpServerEnablementManager.instance;
208
+ }
209
+
210
+ /**
211
+ * Reset the singleton instance (for testing only).
212
+ */
213
+ static resetInstance(): void {
214
+ McpServerEnablementManager.instance = null;
215
+ }
216
+
217
+ constructor() {
218
+ this.configDir = Storage.getGlobalGeminiDir();
219
+ this.configFilePath = path.join(this.configDir, MCP_ENABLEMENT_FILENAME);
220
+ }
221
+
222
+ /**
223
+ * Check if server is enabled in FILE (persistent config only).
224
+ * Does NOT include session state.
225
+ */
226
+ async isFileEnabled(serverName: string): Promise<boolean> {
227
+ const config = await this.readConfig();
228
+ const state = config[normalizeServerId(serverName)];
229
+ return state?.enabled ?? true;
230
+ }
231
+
232
+ /**
233
+ * Check if server is session-disabled.
234
+ */
235
+ isSessionDisabled(serverName: string): boolean {
236
+ return this.sessionDisabled.has(normalizeServerId(serverName));
237
+ }
238
+
239
+ /**
240
+ * Check effective enabled state (combines file + session).
241
+ * Convenience method; canLoadServer() uses separate callbacks for granular blockType.
242
+ */
243
+ async isEffectivelyEnabled(serverName: string): Promise<boolean> {
244
+ if (this.isSessionDisabled(serverName)) {
245
+ return false;
246
+ }
247
+ return this.isFileEnabled(serverName);
248
+ }
249
+
250
+ /**
251
+ * Enable a server persistently.
252
+ * Removes the server from config file (defaults to enabled).
253
+ */
254
+ async enable(serverName: string): Promise<void> {
255
+ const normalizedName = normalizeServerId(serverName);
256
+ const config = await this.readConfig();
257
+
258
+ if (normalizedName in config) {
259
+ delete config[normalizedName];
260
+ await this.writeConfig(config);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Disable a server persistently.
266
+ * Adds server to config file with enabled: false.
267
+ */
268
+ async disable(serverName: string): Promise<void> {
269
+ const config = await this.readConfig();
270
+ config[normalizeServerId(serverName)] = { enabled: false };
271
+ await this.writeConfig(config);
272
+ }
273
+
274
+ /**
275
+ * Disable a server for current session only (in-memory).
276
+ */
277
+ disableForSession(serverName: string): void {
278
+ this.sessionDisabled.add(normalizeServerId(serverName));
279
+ }
280
+
281
+ /**
282
+ * Clear session disable for a server.
283
+ */
284
+ clearSessionDisable(serverName: string): void {
285
+ this.sessionDisabled.delete(normalizeServerId(serverName));
286
+ }
287
+
288
+ /**
289
+ * Get display state for a specific server (for UI).
290
+ */
291
+ async getDisplayState(serverName: string): Promise<McpServerDisplayState> {
292
+ const isSessionDisabled = this.isSessionDisabled(serverName);
293
+ const isPersistentDisabled = !(await this.isFileEnabled(serverName));
294
+
295
+ return {
296
+ enabled: !isSessionDisabled && !isPersistentDisabled,
297
+ isSessionDisabled,
298
+ isPersistentDisabled,
299
+ };
300
+ }
301
+
302
+ /**
303
+ * Get all display states (for UI listing).
304
+ */
305
+ async getAllDisplayStates(
306
+ serverIds: string[],
307
+ ): Promise<Record<string, McpServerDisplayState>> {
308
+ const result: Record<string, McpServerDisplayState> = {};
309
+ for (const serverId of serverIds) {
310
+ result[normalizeServerId(serverId)] =
311
+ await this.getDisplayState(serverId);
312
+ }
313
+ return result;
314
+ }
315
+
316
+ /**
317
+ * Get enablement callbacks for passing to core.
318
+ */
319
+ getEnablementCallbacks(): EnablementCallbacks {
320
+ return {
321
+ isSessionDisabled: (id) => this.isSessionDisabled(id),
322
+ isFileEnabled: (id) => this.isFileEnabled(id),
323
+ };
324
+ }
325
+
326
+ /**
327
+ * Auto-enable any disabled MCP servers by name.
328
+ * Returns server names that were actually re-enabled.
329
+ */
330
+ async autoEnableServers(serverNames: string[]): Promise<string[]> {
331
+ const enabledServers: string[] = [];
332
+
333
+ for (const serverName of serverNames) {
334
+ const normalizedName = normalizeServerId(serverName);
335
+ const state = await this.getDisplayState(normalizedName);
336
+
337
+ let wasDisabled = false;
338
+ if (state.isPersistentDisabled) {
339
+ await this.enable(normalizedName);
340
+ wasDisabled = true;
341
+ }
342
+ if (state.isSessionDisabled) {
343
+ this.clearSessionDisable(normalizedName);
344
+ wasDisabled = true;
345
+ }
346
+
347
+ if (wasDisabled) {
348
+ enabledServers.push(serverName);
349
+ }
350
+ }
351
+
352
+ return enabledServers;
353
+ }
354
+
355
+ /**
356
+ * Read config from file asynchronously.
357
+ */
358
+ private async readConfig(): Promise<McpServerEnablementConfig> {
359
+ try {
360
+ const content = await fs.readFile(this.configFilePath, 'utf-8');
361
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
362
+ return JSON.parse(content) as McpServerEnablementConfig;
363
+ } catch (error) {
364
+ if (
365
+ error instanceof Error &&
366
+ 'code' in error &&
367
+ error.code === 'ENOENT'
368
+ ) {
369
+ return {};
370
+ }
371
+ coreEvents.emitFeedback(
372
+ 'error',
373
+ 'Failed to read MCP server enablement config.',
374
+ error,
375
+ );
376
+ return {};
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Write config to file asynchronously.
382
+ */
383
+ private async writeConfig(config: McpServerEnablementConfig): Promise<void> {
384
+ await fs.mkdir(this.configDir, { recursive: true });
385
+ await fs.writeFile(this.configFilePath, JSON.stringify(config, null, 2));
386
+ }
387
+ }
packages/cli/src/test-utils/fixtures/simple.responses ADDED
@@ -0,0 +1 @@
 
 
1
+ {"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! How can I help you today?"}],"role":"model"},"finishReason":"STOP"}]}]}
packages/cli/src/test-utils/fixtures/steering.responses ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"Starting a long task. First, I'll list the files."},{"functionCall":{"name":"list_directory","args":{"dir_path":"."}}}]},"finishReason":"STOP"}]}]}
2
+ {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I see the files. Since you want me to focus on .txt files, I will read file1.txt."},{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}}]},"finishReason":"STOP"}]}]}
3
+ {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I have read file1.txt. Task complete."}]},"finishReason":"STOP"}]}]}
packages/cli/src/ui/auth/ApiAuthDialog.test.tsx ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { renderWithProviders } from '../../test-utils/render.js';
8
+ import { waitFor } from '../../test-utils/async.js';
9
+ import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
10
+ import { ApiAuthDialog } from './ApiAuthDialog.js';
11
+ import { useKeypress } from '../hooks/useKeypress.js';
12
+ import {
13
+ useTextBuffer,
14
+ type TextBuffer,
15
+ } from '../components/shared/text-buffer.js';
16
+ import { clearApiKey } from '@google/gemini-cli-core';
17
+
18
+ // Mocks
19
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
20
+ const actual =
21
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
22
+ return {
23
+ ...actual,
24
+ clearApiKey: vi.fn().mockResolvedValue(undefined),
25
+ };
26
+ });
27
+
28
+ vi.mock('../hooks/useKeypress.js', () => ({
29
+ useKeypress: vi.fn(),
30
+ }));
31
+
32
+ vi.mock('../components/shared/text-buffer.js', async (importOriginal) => {
33
+ const actual =
34
+ await importOriginal<
35
+ typeof import('../components/shared/text-buffer.js')
36
+ >();
37
+ return {
38
+ ...actual,
39
+ useTextBuffer: vi.fn(),
40
+ };
41
+ });
42
+
43
+ vi.mock('../contexts/UIStateContext.js', async (importOriginal) => {
44
+ const actual =
45
+ await importOriginal<typeof import('../contexts/UIStateContext.js')>();
46
+ return {
47
+ ...actual,
48
+ useUIState: vi.fn(() => ({
49
+ terminalWidth: 80,
50
+ })),
51
+ };
52
+ });
53
+
54
+ const mockedUseKeypress = useKeypress as Mock;
55
+ const mockedUseTextBuffer = useTextBuffer as Mock;
56
+
57
+ describe('ApiAuthDialog', () => {
58
+ const onSubmit = vi.fn();
59
+ const onCancel = vi.fn();
60
+ let mockBuffer: TextBuffer;
61
+
62
+ beforeEach(() => {
63
+ vi.clearAllMocks();
64
+ vi.stubEnv('GEMINI_API_KEY', '');
65
+ mockBuffer = {
66
+ text: '',
67
+ lines: [''],
68
+ cursor: [0, 0],
69
+ visualCursor: [0, 0],
70
+ viewportVisualLines: [''],
71
+ handleInput: vi.fn(),
72
+ setText: vi.fn((newText) => {
73
+ mockBuffer.text = newText;
74
+ mockBuffer.viewportVisualLines = [newText];
75
+ }),
76
+ } as unknown as TextBuffer;
77
+ mockedUseTextBuffer.mockReturnValue(mockBuffer);
78
+ });
79
+
80
+ it('renders correctly', async () => {
81
+ const { lastFrame, unmount } = await renderWithProviders(
82
+ <ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
83
+ );
84
+ expect(lastFrame()).toMatchSnapshot();
85
+ unmount();
86
+ });
87
+
88
+ it('renders with a defaultValue', async () => {
89
+ const { unmount } = await renderWithProviders(
90
+ <ApiAuthDialog
91
+ onSubmit={onSubmit}
92
+ onCancel={onCancel}
93
+ defaultValue="test-key"
94
+ />,
95
+ );
96
+ expect(mockedUseTextBuffer).toHaveBeenCalledWith(
97
+ expect.objectContaining({
98
+ initialText: 'test-key',
99
+ viewport: expect.objectContaining({
100
+ height: 4,
101
+ }),
102
+ }),
103
+ );
104
+ unmount();
105
+ });
106
+
107
+ it.each([
108
+ {
109
+ keyName: 'enter',
110
+ sequence: '\r',
111
+ expectedCall: onSubmit,
112
+ args: ['submitted-key'],
113
+ },
114
+ { keyName: 'escape', sequence: '\u001b', expectedCall: onCancel, args: [] },
115
+ ])(
116
+ 'calls $expectedCall.name when $keyName is pressed',
117
+ async ({ keyName, sequence, expectedCall, args }) => {
118
+ mockBuffer.text = 'submitted-key'; // Set for the onSubmit case
119
+ const { unmount } = await renderWithProviders(
120
+ <ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
121
+ );
122
+ // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler)
123
+ // calls[1] is the TextInput's useKeypress (typing handler)
124
+ const keypressHandler = mockedUseKeypress.mock.calls[1][0];
125
+
126
+ keypressHandler({
127
+ name: keyName,
128
+ shift: false,
129
+ alt: false,
130
+ ctrl: false,
131
+ cmd: false,
132
+ sequence,
133
+ });
134
+
135
+ expect(expectedCall).toHaveBeenCalledWith(...args);
136
+ unmount();
137
+ },
138
+ );
139
+
140
+ it('displays an error message', async () => {
141
+ const { lastFrame, unmount } = await renderWithProviders(
142
+ <ApiAuthDialog
143
+ onSubmit={onSubmit}
144
+ onCancel={onCancel}
145
+ error="Invalid API Key"
146
+ />,
147
+ );
148
+
149
+ expect(lastFrame()).toContain('Invalid API Key');
150
+ unmount();
151
+ });
152
+
153
+ it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => {
154
+ const { unmount } = await renderWithProviders(
155
+ <ApiAuthDialog onSubmit={onSubmit} onCancel={onCancel} />,
156
+ );
157
+ // Call 0 is ApiAuthDialog (isActive: true)
158
+ // Call 1 is TextInput (isActive: true, priority: true)
159
+ const keypressHandler = mockedUseKeypress.mock.calls[0][0];
160
+
161
+ keypressHandler({
162
+ name: 'c',
163
+ shift: false,
164
+ ctrl: true,
165
+ cmd: false,
166
+ });
167
+
168
+ await waitFor(() => {
169
+ expect(clearApiKey).toHaveBeenCalled();
170
+ expect(mockBuffer.setText).toHaveBeenCalledWith('');
171
+ });
172
+ unmount();
173
+ });
174
+ });
packages/cli/src/ui/auth/ApiAuthDialog.tsx ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type React from 'react';
8
+ import { useRef, useEffect } from 'react';
9
+ import { Box, Text } from 'ink';
10
+ import { theme } from '../semantic-colors.js';
11
+ import { TextInput } from '../components/shared/TextInput.js';
12
+ import { useTextBuffer } from '../components/shared/text-buffer.js';
13
+ import { useUIState } from '../contexts/UIStateContext.js';
14
+ import { clearApiKey, debugLogger } from '@google/gemini-cli-core';
15
+ import { useKeypress } from '../hooks/useKeypress.js';
16
+ import { Command } from '../key/keyMatchers.js';
17
+ import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
18
+
19
+ interface ApiAuthDialogProps {
20
+ onSubmit: (apiKey: string) => void;
21
+ onCancel: () => void;
22
+ error?: string | null;
23
+ defaultValue?: string;
24
+ }
25
+
26
+ export function ApiAuthDialog({
27
+ onSubmit,
28
+ onCancel,
29
+ error,
30
+ defaultValue = '',
31
+ }: ApiAuthDialogProps): React.JSX.Element {
32
+ const keyMatchers = useKeyMatchers();
33
+ const { terminalWidth } = useUIState();
34
+ const viewportWidth = terminalWidth - 8;
35
+
36
+ const pendingPromise = useRef<{ cancel: () => void } | null>(null);
37
+
38
+ useEffect(
39
+ () => () => {
40
+ pendingPromise.current?.cancel();
41
+ },
42
+ [],
43
+ );
44
+
45
+ const initialApiKey = defaultValue;
46
+
47
+ const buffer = useTextBuffer({
48
+ initialText: initialApiKey || '',
49
+ initialCursorOffset: initialApiKey?.length || 0,
50
+ viewport: {
51
+ width: viewportWidth,
52
+ height: 4,
53
+ },
54
+ inputFilter: (text) =>
55
+ text.replace(/[^a-zA-Z0-9_.-]/g, '').replace(/[\r\n]/g, ''),
56
+ singleLine: true,
57
+ });
58
+
59
+ const handleSubmit = (value: string) => {
60
+ onSubmit(value);
61
+ };
62
+
63
+ const handleClear = () => {
64
+ pendingPromise.current?.cancel();
65
+
66
+ let isCancelled = false;
67
+ const wrappedPromise = new Promise<void>((resolve, reject) => {
68
+ clearApiKey().then(
69
+ () => !isCancelled && resolve(),
70
+ (error) => !isCancelled && reject(error),
71
+ );
72
+ });
73
+
74
+ pendingPromise.current = {
75
+ cancel: () => {
76
+ isCancelled = true;
77
+ },
78
+ };
79
+
80
+ return wrappedPromise
81
+ .then(() => {
82
+ buffer.setText('');
83
+ })
84
+ .catch((err) => {
85
+ debugLogger.debug('Failed to clear API key:', err);
86
+ });
87
+ };
88
+
89
+ useKeypress(
90
+ (key) => {
91
+ if (keyMatchers[Command.CLEAR_INPUT](key)) {
92
+ void handleClear();
93
+ return true;
94
+ }
95
+ return false;
96
+ },
97
+ { isActive: true },
98
+ );
99
+
100
+ return (
101
+ <Box
102
+ borderStyle="round"
103
+ borderColor={theme.ui.focus}
104
+ flexDirection="column"
105
+ padding={1}
106
+ width="100%"
107
+ >
108
+ <Text bold color={theme.text.primary}>
109
+ Enter Gemini API Key
110
+ </Text>
111
+ <Box marginTop={1} flexDirection="column">
112
+ <Text color={theme.text.primary}>
113
+ Please enter your Gemini API key. It will be securely stored in your
114
+ system keychain.
115
+ </Text>
116
+ <Text color={theme.text.secondary}>
117
+ You can get an API key from{' '}
118
+ <Text color={theme.text.link}>
119
+ https://aistudio.google.com/app/apikey
120
+ </Text>
121
+ </Text>
122
+ </Box>
123
+ <Box marginTop={1} flexDirection="row">
124
+ <Box
125
+ borderStyle="round"
126
+ borderColor={theme.border.default}
127
+ paddingX={1}
128
+ flexGrow={1}
129
+ >
130
+ <TextInput
131
+ buffer={buffer}
132
+ onSubmit={handleSubmit}
133
+ onCancel={onCancel}
134
+ placeholder="Paste your API key here"
135
+ />
136
+ </Box>
137
+ </Box>
138
+ {error && (
139
+ <Box marginTop={1}>
140
+ <Text color={theme.status.error}>{error}</Text>
141
+ </Box>
142
+ )}
143
+ <Box marginTop={1}>
144
+ <Text color={theme.text.secondary}>
145
+ (Press Enter to submit, Esc to cancel, Ctrl+C to clear stored key)
146
+ </Text>
147
+ </Box>
148
+ </Box>
149
+ );
150
+ }
packages/cli/src/ui/auth/AuthDialog.test.tsx ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * @license
3
+ * Copyright 2025 Google LLC
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { act } from 'react';
8
+ import { renderWithProviders } from '../../test-utils/render.js';
9
+ import {
10
+ describe,
11
+ it,
12
+ expect,
13
+ vi,
14
+ beforeEach,
15
+ afterEach,
16
+ type Mock,
17
+ } from 'vitest';
18
+ import { AuthDialog } from './AuthDialog.js';
19
+ import { AuthType, type Config, debugLogger } from '@google/gemini-cli-core';
20
+ import type { LoadedSettings } from '../../config/settings.js';
21
+ import { AuthState } from '../types.js';
22
+ import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js';
23
+ import { useKeypress } from '../hooks/useKeypress.js';
24
+ import { validateAuthMethodWithSettings } from './useAuth.js';
25
+ import { runExitCleanup } from '../../utils/cleanup.js';
26
+ import { Text } from 'ink';
27
+ import { RELAUNCH_EXIT_CODE } from '../../utils/processUtils.js';
28
+
29
+ // Mocks
30
+ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
31
+ const actual =
32
+ await importOriginal<typeof import('@google/gemini-cli-core')>();
33
+ return {
34
+ ...actual,
35
+ clearCachedCredentialFile: vi.fn(),
36
+ };
37
+ });
38
+
39
+ vi.mock('../../utils/cleanup.js', () => ({
40
+ runExitCleanup: vi.fn(),
41
+ }));
42
+
43
+ vi.mock('./useAuth.js', () => ({
44
+ validateAuthMethodWithSettings: vi.fn(),
45
+ }));
46
+
47
+ vi.mock('../hooks/useKeypress.js', () => ({
48
+ useKeypress: vi.fn(),
49
+ }));
50
+
51
+ vi.mock('../components/shared/RadioButtonSelect.js', () => ({
52
+ RadioButtonSelect: vi.fn(({ items, initialIndex }) => (
53
+ <>
54
+ {items.map((item: { value: string; label: string }, index: number) => (
55
+ <Text key={item.value}>
56
+ {index === initialIndex ? '(selected)' : '(not selected)'}{' '}
57
+ {item.label}
58
+ </Text>
59
+ ))}
60
+ </>
61
+ )),
62
+ }));
63
+
64
+ const mockedUseKeypress = useKeypress as Mock;
65
+ const mockedRadioButtonSelect = RadioButtonSelect as Mock;
66
+ const mockedValidateAuthMethod = validateAuthMethodWithSettings as Mock;
67
+ const mockedRunExitCleanup = runExitCleanup as Mock;
68
+
69
+ describe('AuthDialog', () => {
70
+ let props: {
71
+ config: Config;
72
+ settings: LoadedSettings;
73
+ setAuthState: (state: AuthState) => void;
74
+ authError: string | null;
75
+ onAuthError: (error: string | null) => void;
76
+ setAuthContext: (context: { requiresRestart?: boolean }) => void;
77
+ };
78
+ beforeEach(() => {
79
+ vi.resetAllMocks();
80
+ vi.stubEnv('CLOUD_SHELL', undefined as unknown as string);
81
+ vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', undefined as unknown as string);
82
+ vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', undefined as unknown as string);
83
+ vi.stubEnv('GEMINI_API_KEY', undefined as unknown as string);
84
+
85
+ props = {
86
+ config: {
87
+ isBrowserLaunchSuppressed: vi.fn().mockReturnValue(false),
88
+ } as unknown as Config,
89
+ settings: {
90
+ merged: {
91
+ security: {
92
+ auth: {},
93
+ },
94
+ },
95
+ setValue: vi.fn(),
96
+ } as unknown as LoadedSettings,
97
+ setAuthState: vi.fn(),
98
+ authError: null,
99
+ onAuthError: vi.fn(),
100
+ setAuthContext: vi.fn(),
101
+ };
102
+ });
103
+
104
+ afterEach(() => {
105
+ vi.unstubAllEnvs();
106
+ });
107
+
108
+ describe('Environment Variable Effects on Auth Options', () => {
109
+ const cloudShellLabel = 'Use Cloud Shell user credentials';
110
+ const metadataServerLabel =
111
+ 'Use metadata server application default credentials';
112
+ const computeAdcItem = (label: string) => ({
113
+ label,
114
+ value: AuthType.COMPUTE_ADC,
115
+ key: AuthType.COMPUTE_ADC,
116
+ });
117
+
118
+ it.each([
119
+ {
120
+ env: { CLOUD_SHELL: 'true' },
121
+ shouldContain: [computeAdcItem(cloudShellLabel)],
122
+ shouldNotContain: [computeAdcItem(metadataServerLabel)],
123
+ desc: 'in Cloud Shell',
124
+ },
125
+ {
126
+ env: { GEMINI_CLI_USE_COMPUTE_ADC: 'true' },
127
+ shouldContain: [computeAdcItem(metadataServerLabel)],
128
+ shouldNotContain: [computeAdcItem(cloudShellLabel)],
129
+ desc: 'with GEMINI_CLI_USE_COMPUTE_ADC',
130
+ },
131
+ {
132
+ env: {},
133
+ shouldContain: [],
134
+ shouldNotContain: [
135
+ computeAdcItem(cloudShellLabel),
136
+ computeAdcItem(metadataServerLabel),
137
+ ],
138
+ desc: 'by default',
139
+ },
140
+ ])(
141
+ 'correctly shows/hides COMPUTE_ADC options $desc',
142
+ async ({ env, shouldContain, shouldNotContain }) => {
143
+ for (const [key, value] of Object.entries(env)) {
144
+ vi.stubEnv(key, value as string);
145
+ }
146
+ const { unmount } = await renderWithProviders(
147
+ <AuthDialog {...props} />,
148
+ );
149
+ const items = mockedRadioButtonSelect.mock.calls[0][0].items;
150
+ for (const item of shouldContain) {
151
+ expect(items).toContainEqual(item);
152
+ }
153
+ for (const item of shouldNotContain) {
154
+ expect(items).not.toContainEqual(item);
155
+ }
156
+ unmount();
157
+ },
158
+ );
159
+ });
160
+
161
+ it('filters auth types when enforcedType is set', async () => {
162
+ props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
163
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
164
+ const items = mockedRadioButtonSelect.mock.calls[0][0].items;
165
+ expect(items).toHaveLength(1);
166
+ expect(items[0].value).toBe(AuthType.USE_GEMINI);
167
+ unmount();
168
+ });
169
+
170
+ it('sets initial index to 0 when enforcedType is set', async () => {
171
+ props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
172
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
173
+ const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
174
+ expect(initialIndex).toBe(0);
175
+ unmount();
176
+ });
177
+
178
+ describe('Initial Auth Type Selection', () => {
179
+ it.each([
180
+ {
181
+ setup: () => {
182
+ props.settings.merged.security.auth.selectedType =
183
+ AuthType.USE_VERTEX_AI;
184
+ },
185
+ expected: AuthType.USE_VERTEX_AI,
186
+ desc: 'from settings',
187
+ },
188
+ {
189
+ setup: () => {
190
+ vi.stubEnv('GEMINI_DEFAULT_AUTH_TYPE', AuthType.USE_GEMINI);
191
+ },
192
+ expected: AuthType.USE_GEMINI,
193
+ desc: 'from GEMINI_DEFAULT_AUTH_TYPE env var',
194
+ },
195
+ {
196
+ setup: () => {
197
+ vi.stubEnv('GEMINI_API_KEY', 'test-key');
198
+ },
199
+ expected: AuthType.USE_GEMINI,
200
+ desc: 'from GEMINI_API_KEY env var',
201
+ },
202
+ {
203
+ setup: () => {},
204
+ expected: AuthType.LOGIN_WITH_GOOGLE,
205
+ desc: 'defaults to Sign in with Google',
206
+ },
207
+ ])('selects initial auth type $desc', async ({ setup, expected }) => {
208
+ setup();
209
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
210
+ const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0];
211
+ expect(items[initialIndex].value).toBe(expected);
212
+ unmount();
213
+ });
214
+ });
215
+
216
+ describe('handleAuthSelect', () => {
217
+ it('calls onAuthError if validation fails', async () => {
218
+ mockedValidateAuthMethod.mockResolvedValue('Invalid method');
219
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
220
+ const { onSelect: handleAuthSelect } =
221
+ mockedRadioButtonSelect.mock.calls[0][0];
222
+ await handleAuthSelect(AuthType.USE_GEMINI);
223
+
224
+ expect(mockedValidateAuthMethod).toHaveBeenCalledWith(
225
+ AuthType.USE_GEMINI,
226
+ props.settings,
227
+ );
228
+ expect(props.onAuthError).toHaveBeenCalledWith('Invalid method');
229
+ expect(props.settings.setValue).not.toHaveBeenCalled();
230
+ unmount();
231
+ });
232
+
233
+ it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => {
234
+ mockedValidateAuthMethod.mockResolvedValue(null);
235
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
236
+ const { onSelect: handleAuthSelect } =
237
+ mockedRadioButtonSelect.mock.calls[0][0];
238
+ await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
239
+
240
+ expect(props.setAuthContext).toHaveBeenCalledWith({
241
+ requiresRestart: true,
242
+ });
243
+ unmount();
244
+ });
245
+
246
+ it('sets auth context with requiresRestart: true for USE_VERTEX_AI in Cloud Shell', async () => {
247
+ vi.stubEnv('CLOUD_SHELL', 'true');
248
+ mockedValidateAuthMethod.mockResolvedValue(null);
249
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
250
+ const { onSelect: handleAuthSelect } =
251
+ mockedRadioButtonSelect.mock.calls[0][0];
252
+ await handleAuthSelect(AuthType.USE_VERTEX_AI);
253
+
254
+ expect(props.setAuthContext).toHaveBeenCalledWith({
255
+ requiresRestart: true,
256
+ });
257
+ unmount();
258
+ });
259
+
260
+ it('sets auth context with empty object for USE_VERTEX_AI outside Cloud Shell', async () => {
261
+ vi.stubEnv('CLOUD_SHELL', '');
262
+ mockedValidateAuthMethod.mockResolvedValue(null);
263
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
264
+ const { onSelect: handleAuthSelect } =
265
+ mockedRadioButtonSelect.mock.calls[0][0];
266
+ await handleAuthSelect(AuthType.USE_VERTEX_AI);
267
+
268
+ expect(props.setAuthContext).toHaveBeenCalledWith({});
269
+ unmount();
270
+ });
271
+
272
+ it('sets auth context with empty object for other auth types', async () => {
273
+ mockedValidateAuthMethod.mockResolvedValue(null);
274
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
275
+ const { onSelect: handleAuthSelect } =
276
+ mockedRadioButtonSelect.mock.calls[0][0];
277
+ await handleAuthSelect(AuthType.USE_GEMINI);
278
+
279
+ expect(props.setAuthContext).toHaveBeenCalledWith({});
280
+ unmount();
281
+ });
282
+
283
+ it('always shows API key dialog even when env var is present', async () => {
284
+ mockedValidateAuthMethod.mockResolvedValue(null);
285
+ vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
286
+ // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
287
+
288
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
289
+ const { onSelect: handleAuthSelect } =
290
+ mockedRadioButtonSelect.mock.calls[0][0];
291
+ await handleAuthSelect(AuthType.USE_GEMINI);
292
+
293
+ expect(props.setAuthState).toHaveBeenCalledWith(
294
+ AuthState.AwaitingApiKeyInput,
295
+ );
296
+ unmount();
297
+ });
298
+
299
+ it('always shows API key dialog even when env var is empty string', async () => {
300
+ mockedValidateAuthMethod.mockResolvedValue(null);
301
+ vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
302
+ // props.settings.merged.security.auth.selectedType is undefined here
303
+
304
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
305
+ const { onSelect: handleAuthSelect } =
306
+ mockedRadioButtonSelect.mock.calls[0][0];
307
+ await handleAuthSelect(AuthType.USE_GEMINI);
308
+
309
+ expect(props.setAuthState).toHaveBeenCalledWith(
310
+ AuthState.AwaitingApiKeyInput,
311
+ );
312
+ unmount();
313
+ });
314
+
315
+ it('shows API key dialog on initial setup if no env var is present', async () => {
316
+ mockedValidateAuthMethod.mockResolvedValue(null);
317
+ // process.env['GEMINI_API_KEY'] is not set
318
+ // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
319
+
320
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
321
+ const { onSelect: handleAuthSelect } =
322
+ mockedRadioButtonSelect.mock.calls[0][0];
323
+ await handleAuthSelect(AuthType.USE_GEMINI);
324
+
325
+ expect(props.setAuthState).toHaveBeenCalledWith(
326
+ AuthState.AwaitingApiKeyInput,
327
+ );
328
+ unmount();
329
+ });
330
+
331
+ it('always shows API key dialog on re-auth even if env var is present', async () => {
332
+ mockedValidateAuthMethod.mockResolvedValue(null);
333
+ vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
334
+ // Simulate switching from a different auth method (e.g., Google Login → API key)
335
+ props.settings.merged.security.auth.selectedType =
336
+ AuthType.LOGIN_WITH_GOOGLE;
337
+
338
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
339
+ const { onSelect: handleAuthSelect } =
340
+ mockedRadioButtonSelect.mock.calls[0][0];
341
+ await handleAuthSelect(AuthType.USE_GEMINI);
342
+
343
+ expect(props.setAuthState).toHaveBeenCalledWith(
344
+ AuthState.AwaitingApiKeyInput,
345
+ );
346
+ unmount();
347
+ });
348
+
349
+ it('exits process for Sign in with Google when browser is suppressed', async () => {
350
+ vi.useFakeTimers();
351
+ const exitSpy = vi
352
+ .spyOn(process, 'exit')
353
+ .mockImplementation(() => undefined as never);
354
+ const logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {});
355
+ vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true);
356
+ mockedValidateAuthMethod.mockResolvedValue(null);
357
+
358
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
359
+ const { onSelect: handleAuthSelect } =
360
+ mockedRadioButtonSelect.mock.calls[0][0];
361
+ await act(async () => {
362
+ await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE);
363
+ await vi.runAllTimersAsync();
364
+ });
365
+
366
+ expect(mockedRunExitCleanup).toHaveBeenCalled();
367
+ expect(exitSpy).toHaveBeenCalledWith(RELAUNCH_EXIT_CODE);
368
+
369
+ exitSpy.mockRestore();
370
+ logSpy.mockRestore();
371
+ vi.useRealTimers();
372
+ unmount();
373
+ });
374
+ });
375
+
376
+ it('displays authError when provided', async () => {
377
+ props.authError = 'Something went wrong';
378
+ const { lastFrame, unmount } = await renderWithProviders(
379
+ <AuthDialog {...props} />,
380
+ );
381
+ expect(lastFrame()).toContain('Something went wrong');
382
+ unmount();
383
+ });
384
+
385
+ describe('useKeypress', () => {
386
+ it.each([
387
+ {
388
+ desc: 'does nothing on escape if authError is present',
389
+ setup: () => {
390
+ props.authError = 'Some error';
391
+ },
392
+ expectations: (p: typeof props) => {
393
+ expect(p.onAuthError).not.toHaveBeenCalled();
394
+ expect(p.setAuthState).not.toHaveBeenCalled();
395
+ },
396
+ },
397
+ {
398
+ desc: 'calls onAuthError on escape if no auth method is set',
399
+ setup: () => {
400
+ props.settings.merged.security.auth.selectedType = undefined;
401
+ },
402
+ expectations: (p: typeof props) => {
403
+ expect(p.onAuthError).toHaveBeenCalledWith(
404
+ 'You must select an auth method to proceed. Press Ctrl+C twice to exit.',
405
+ );
406
+ },
407
+ },
408
+ {
409
+ desc: 'calls setAuthState(Unauthenticated) on escape if auth method is set',
410
+ setup: () => {
411
+ props.settings.merged.security.auth.selectedType =
412
+ AuthType.USE_GEMINI;
413
+ },
414
+ expectations: (p: typeof props) => {
415
+ expect(p.setAuthState).toHaveBeenCalledWith(
416
+ AuthState.Unauthenticated,
417
+ );
418
+ expect(p.settings.setValue).not.toHaveBeenCalled();
419
+ },
420
+ },
421
+ ])('$desc', async ({ setup, expectations }) => {
422
+ setup();
423
+ const { unmount } = await renderWithProviders(<AuthDialog {...props} />);
424
+ const keypressHandler = mockedUseKeypress.mock.calls[0][0];
425
+ keypressHandler({ name: 'escape' });
426
+ expectations(props);
427
+ unmount();
428
+ });
429
+ });
430
+
431
+ describe('Snapshots', () => {
432
+ it('renders correctly with default props', async () => {
433
+ const { lastFrame, unmount } = await renderWithProviders(
434
+ <AuthDialog {...props} />,
435
+ );
436
+ expect(lastFrame()).toMatchSnapshot();
437
+ unmount();
438
+ });
439
+
440
+ it('renders correctly with auth error', async () => {
441
+ props.authError = 'Something went wrong';
442
+ const { lastFrame, unmount } = await renderWithProviders(
443
+ <AuthDialog {...props} />,
444
+ );
445
+ expect(lastFrame()).toMatchSnapshot();
446
+ unmount();
447
+ });
448
+
449
+ it('renders correctly with enforced auth type', async () => {
450
+ props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI;
451
+ const { lastFrame, unmount } = await renderWithProviders(
452
+ <AuthDialog {...props} />,
453
+ );
454
+ expect(lastFrame()).toMatchSnapshot();
455
+ unmount();
456
+ });
457
+ });
458
+ });