@gxp-dev/tools 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/.github/workflows/npm-publish.yml +48 -0
  2. package/CLAUDE.md +400 -0
  3. package/README.md +247 -0
  4. package/REFACTOR_PLAN.md +194 -0
  5. package/bin/gx-devtools.js +87 -0
  6. package/bin/lib/cli.js +251 -0
  7. package/bin/lib/commands/assets.js +337 -0
  8. package/bin/lib/commands/build.js +259 -0
  9. package/bin/lib/commands/datastore.js +433 -0
  10. package/bin/lib/commands/dev.js +328 -0
  11. package/bin/lib/commands/extensions.js +298 -0
  12. package/bin/lib/commands/index.js +35 -0
  13. package/bin/lib/commands/init.js +307 -0
  14. package/bin/lib/commands/publish.js +189 -0
  15. package/bin/lib/commands/socket.js +158 -0
  16. package/bin/lib/commands/ssl.js +47 -0
  17. package/bin/lib/constants.js +120 -0
  18. package/bin/lib/tui/App.tsx +600 -0
  19. package/bin/lib/tui/components/CommandInput.tsx +278 -0
  20. package/bin/lib/tui/components/GeminiPanel.tsx +161 -0
  21. package/bin/lib/tui/components/Header.tsx +27 -0
  22. package/bin/lib/tui/components/LogPanel.tsx +122 -0
  23. package/bin/lib/tui/components/TabBar.tsx +56 -0
  24. package/bin/lib/tui/components/WelcomeScreen.tsx +80 -0
  25. package/bin/lib/tui/index.tsx +63 -0
  26. package/bin/lib/tui/services/ExtensionService.ts +122 -0
  27. package/bin/lib/tui/services/GeminiService.ts +395 -0
  28. package/bin/lib/tui/services/ServiceManager.ts +336 -0
  29. package/bin/lib/tui/services/SocketService.ts +204 -0
  30. package/bin/lib/tui/services/ViteService.ts +107 -0
  31. package/bin/lib/tui/services/index.ts +13 -0
  32. package/bin/lib/utils/files.js +180 -0
  33. package/bin/lib/utils/index.js +17 -0
  34. package/bin/lib/utils/paths.js +138 -0
  35. package/bin/lib/utils/prompts.js +71 -0
  36. package/bin/lib/utils/ssl.js +233 -0
  37. package/browser-extensions/README.md +1 -0
  38. package/browser-extensions/chrome/background.js +857 -0
  39. package/browser-extensions/chrome/content.js +51 -0
  40. package/browser-extensions/chrome/devtools.html +9 -0
  41. package/browser-extensions/chrome/devtools.js +23 -0
  42. package/browser-extensions/chrome/icons/gx_off_128.png +0 -0
  43. package/browser-extensions/chrome/icons/gx_off_16.png +0 -0
  44. package/browser-extensions/chrome/icons/gx_off_32.png +0 -0
  45. package/browser-extensions/chrome/icons/gx_off_64.png +0 -0
  46. package/browser-extensions/chrome/icons/gx_on_128.png +0 -0
  47. package/browser-extensions/chrome/icons/gx_on_16.png +0 -0
  48. package/browser-extensions/chrome/icons/gx_on_32.png +0 -0
  49. package/browser-extensions/chrome/icons/gx_on_64.png +0 -0
  50. package/browser-extensions/chrome/inspector.js +1087 -0
  51. package/browser-extensions/chrome/manifest.json +70 -0
  52. package/browser-extensions/chrome/panel.html +638 -0
  53. package/browser-extensions/chrome/panel.js +862 -0
  54. package/browser-extensions/chrome/popup.html +399 -0
  55. package/browser-extensions/chrome/popup.js +515 -0
  56. package/browser-extensions/chrome/rules.json +1 -0
  57. package/browser-extensions/chrome/test-chrome.html +145 -0
  58. package/browser-extensions/chrome/test-mixed-content.html +190 -0
  59. package/browser-extensions/chrome/test-uri-pattern.html +199 -0
  60. package/browser-extensions/firefox/README.md +134 -0
  61. package/browser-extensions/firefox/background.js +804 -0
  62. package/browser-extensions/firefox/content.js +120 -0
  63. package/browser-extensions/firefox/debug-errors.html +229 -0
  64. package/browser-extensions/firefox/debug-https.html +113 -0
  65. package/browser-extensions/firefox/devtools.html +9 -0
  66. package/browser-extensions/firefox/devtools.js +24 -0
  67. package/browser-extensions/firefox/icons/gx_off_128.png +0 -0
  68. package/browser-extensions/firefox/icons/gx_off_16.png +0 -0
  69. package/browser-extensions/firefox/icons/gx_off_32.png +0 -0
  70. package/browser-extensions/firefox/icons/gx_off_64.png +0 -0
  71. package/browser-extensions/firefox/icons/gx_on_128.png +0 -0
  72. package/browser-extensions/firefox/icons/gx_on_16.png +0 -0
  73. package/browser-extensions/firefox/icons/gx_on_32.png +0 -0
  74. package/browser-extensions/firefox/icons/gx_on_64.png +0 -0
  75. package/browser-extensions/firefox/inspector.js +1087 -0
  76. package/browser-extensions/firefox/manifest.json +67 -0
  77. package/browser-extensions/firefox/panel.html +638 -0
  78. package/browser-extensions/firefox/panel.js +862 -0
  79. package/browser-extensions/firefox/popup.html +525 -0
  80. package/browser-extensions/firefox/popup.js +536 -0
  81. package/browser-extensions/firefox/test-gramercy.html +126 -0
  82. package/browser-extensions/firefox/test-imports.html +58 -0
  83. package/browser-extensions/firefox/test-masking.html +147 -0
  84. package/browser-extensions/firefox/test-uri-pattern.html +199 -0
  85. package/docs/DOCUSAURUS_IMPORT.md +378 -0
  86. package/docs/_category_.json +8 -0
  87. package/docs/app-manifest.md +272 -0
  88. package/docs/building-for-platform.md +315 -0
  89. package/docs/dev-tools.md +291 -0
  90. package/docs/getting-started.md +180 -0
  91. package/docs/gxp-store.md +305 -0
  92. package/docs/index.md +44 -0
  93. package/package.json +77 -0
  94. package/runtime/PortalContainer.vue +326 -0
  95. package/runtime/dev-tools/DevToolsModal.vue +217 -0
  96. package/runtime/dev-tools/LayoutSwitcher.vue +221 -0
  97. package/runtime/dev-tools/MockDataEditor.vue +621 -0
  98. package/runtime/dev-tools/SocketSimulator.vue +562 -0
  99. package/runtime/dev-tools/StoreInspector.vue +644 -0
  100. package/runtime/dev-tools/index.js +6 -0
  101. package/runtime/gxpStringsPlugin.js +428 -0
  102. package/runtime/index.html +22 -0
  103. package/runtime/main.js +32 -0
  104. package/runtime/mock-api/auth-middleware.js +97 -0
  105. package/runtime/mock-api/image-generator.js +221 -0
  106. package/runtime/mock-api/index.js +197 -0
  107. package/runtime/mock-api/response-generator.js +394 -0
  108. package/runtime/mock-api/route-generator.js +323 -0
  109. package/runtime/mock-api/socket-triggers.js +371 -0
  110. package/runtime/mock-api/spec-loader.js +300 -0
  111. package/runtime/server.js +180 -0
  112. package/runtime/stores/gxpPortalConfigStore.js +554 -0
  113. package/runtime/stores/index.js +6 -0
  114. package/runtime/vite-inspector-plugin.js +749 -0
  115. package/runtime/vite-source-tracker-plugin.js +232 -0
  116. package/runtime/vite.config.js +402 -0
  117. package/scripts/launch-chrome.js +90 -0
  118. package/scripts/pack-chrome.js +91 -0
  119. package/socket-events/AiSessionMessageCreated.json +18 -0
  120. package/socket-events/SocialStreamPostCreated.json +24 -0
  121. package/socket-events/SocialStreamPostVariantCompleted.json +23 -0
  122. package/template/README.md +332 -0
  123. package/template/app-manifest.json +32 -0
  124. package/template/dev-assets/images/avatar-placeholder.png +0 -0
  125. package/template/dev-assets/images/background-placeholder.jpg +0 -0
  126. package/template/dev-assets/images/banner-placeholder.jpg +0 -0
  127. package/template/dev-assets/images/icon-placeholder.png +0 -0
  128. package/template/dev-assets/images/logo-placeholder.png +0 -0
  129. package/template/dev-assets/images/product-placeholder.jpg +0 -0
  130. package/template/dev-assets/images/thumbnail-placeholder.jpg +0 -0
  131. package/template/env.example +51 -0
  132. package/template/gitignore +53 -0
  133. package/template/index.html +22 -0
  134. package/template/main.js +28 -0
  135. package/template/src/DemoPage.vue +459 -0
  136. package/template/src/Plugin.vue +38 -0
  137. package/template/src/stores/index.js +9 -0
  138. package/template/src/stores/test-data.json +173 -0
  139. package/template/theme-layouts/AdditionalStyling.css +0 -0
  140. package/template/theme-layouts/PrivateLayout.vue +39 -0
  141. package/template/theme-layouts/PublicLayout.vue +39 -0
  142. package/template/theme-layouts/SystemLayout.vue +39 -0
  143. package/template/vite.config.js +333 -0
  144. package/tsconfig.tui.json +21 -0
  145. package/vite.config.js +164 -0
@@ -0,0 +1,80 @@
1
+ import React from 'react';
2
+ import { Box, Text } from 'ink';
3
+
4
+ const LOGO = `
5
+ ██████╗ ██╗ ██╗██████╗
6
+ ██╔════╝ ╚██╗██╔╝██╔══██╗
7
+ ██║ ███╗ ╚███╔╝ ██████╔╝
8
+ ██║ ██║ ██╔██╗ ██╔═══╝
9
+ ╚██████╔╝██╔╝ ██╗██║
10
+ ╚═════╝ ╚═╝ ╚═╝╚═╝
11
+ `;
12
+
13
+ export default function WelcomeScreen() {
14
+ return (
15
+ <Box
16
+ flexDirection="column"
17
+ alignItems="center"
18
+ justifyContent="center"
19
+ flexGrow={1}
20
+ padding={1}
21
+ >
22
+ <Text color="blue">{LOGO}</Text>
23
+
24
+ <Box marginTop={1}>
25
+ <Text bold color="white">GxP DevStudio</Text>
26
+ </Box>
27
+
28
+ <Box marginTop={1}>
29
+ <Text color="gray">Interactive development environment for GxP plugins</Text>
30
+ </Box>
31
+
32
+ <Box marginTop={2} flexDirection="row" justifyContent="center">
33
+ {/* Quick Start Column */}
34
+ <Box flexDirection="column" marginRight={4}>
35
+ <Text color="cyan" bold>Quick Start</Text>
36
+ <Box marginTop={1} flexDirection="column">
37
+ <Text> <Text color="yellow">/dev</Text> Start Vite dev server</Text>
38
+ <Text> <Text color="yellow">/dev --with-socket</Text> Start Vite + Socket.IO</Text>
39
+ <Text> <Text color="yellow">/dev --no-socket</Text> Start Vite only (no Socket)</Text>
40
+ <Text> <Text color="yellow">/socket</Text> Start Socket.IO server</Text>
41
+ <Text> <Text color="yellow">/ext chrome</Text> Launch Chrome extension</Text>
42
+ <Text> <Text color="yellow">/help</Text> Show all commands</Text>
43
+ </Box>
44
+ </Box>
45
+
46
+ {/* Keyboard Shortcuts Column */}
47
+ <Box flexDirection="column" marginLeft={4}>
48
+ <Text color="cyan" bold>Keyboard Shortcuts</Text>
49
+ <Box marginTop={1} flexDirection="column">
50
+ <Text> <Text color="green">Tab</Text> Cycle through tabs</Text>
51
+ <Text> <Text color="green">Left/Right</Text> Switch tabs</Text>
52
+ <Text> <Text color="green">Ctrl+K</Text> Stop current service</Text>
53
+ <Text> <Text color="green">Ctrl+L</Text> Clear current log</Text>
54
+ <Text> <Text color="green">Ctrl+C</Text> Exit application</Text>
55
+ </Box>
56
+ </Box>
57
+ </Box>
58
+
59
+ <Box marginTop={2} flexDirection="column" alignItems="center">
60
+ <Text color="cyan" bold>Socket Events</Text>
61
+ <Box marginTop={1} flexDirection="column" alignItems="center">
62
+ <Text color="gray">Use <Text color="yellow">/socket list</Text> to see available events</Text>
63
+ <Text color="gray">Use <Text color="yellow">/socket send EventName</Text> to simulate events</Text>
64
+ </Box>
65
+ </Box>
66
+
67
+ <Box marginTop={2} flexDirection="column" alignItems="center">
68
+ <Text color="cyan" bold>Browser Extensions</Text>
69
+ <Box marginTop={1} flexDirection="column" alignItems="center">
70
+ <Text color="gray">Test your plugin on live GxP pages with the browser extension</Text>
71
+ <Text color="gray">Open DevTools and use the "GxP Inspector" panel to select components</Text>
72
+ </Box>
73
+ </Box>
74
+
75
+ <Box marginTop={2}>
76
+ <Text dimColor>Type a command below to get started...</Text>
77
+ </Box>
78
+ </Box>
79
+ );
80
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ import React from 'react';
3
+ import { render } from 'ink';
4
+ import dotenv from 'dotenv';
5
+ import path from 'path';
6
+ import App from './App.js';
7
+ import { serviceManager } from './services/index.js';
8
+
9
+ // Load .env from the current working directory (project directory)
10
+ dotenv.config({ path: path.join(process.cwd(), '.env') });
11
+
12
+ interface TUIOptions {
13
+ autoStart?: string[]; // Commands to auto-start (e.g., ['dev', 'socket'])
14
+ args?: Record<string, unknown>; // Command arguments
15
+ }
16
+
17
+ export function startTUI(options: TUIOptions = {}) {
18
+ // Check if stdin supports raw mode (required for Ink input handling)
19
+ // This can fail when:
20
+ // - Running in CI environments
21
+ // - Piped input (stdin is not a TTY)
22
+ // - Some terminal emulators
23
+ const stdin = process.stdin;
24
+ const stdinIsTTY = stdin.isTTY === true;
25
+
26
+ // If stdin doesn't support raw mode, we need to handle it gracefully
27
+ // Ink 5.x uses stdin by default and requires raw mode for input
28
+ if (!stdinIsTTY) {
29
+ console.error('Warning: Terminal does not support interactive mode.');
30
+ console.error('The TUI requires an interactive terminal (TTY) to function.');
31
+ console.error('');
32
+ console.error('Try running the command directly from your terminal, not from a script or pipe.');
33
+ console.error('');
34
+ console.error('Alternatively, use the non-TUI commands:');
35
+ console.error(' npm run dev # Start Vite dev server');
36
+ console.error(' npm run dev-http # Start HTTP dev server');
37
+ console.error(' gxdev socket list # List socket events');
38
+ process.exit(1);
39
+ return;
40
+ }
41
+
42
+ const { waitUntilExit } = render(
43
+ <App
44
+ autoStart={options.autoStart}
45
+ args={options.args}
46
+ />
47
+ );
48
+
49
+ waitUntilExit().then(() => {
50
+ // Ensure all services are stopped before exiting
51
+ serviceManager.forceStopAll();
52
+ process.exit(0);
53
+ });
54
+ }
55
+
56
+ // Check if run directly (ESM way)
57
+ import { fileURLToPath } from 'url';
58
+ const isMain = process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url);
59
+ if (isMain) {
60
+ startTUI();
61
+ }
62
+
63
+ export default startTUI;
@@ -0,0 +1,122 @@
1
+ import { serviceManager, ServiceConfig } from './ServiceManager.js';
2
+ import path from 'path';
3
+ import fs from 'fs';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ export type BrowserType = 'chrome' | 'firefox';
7
+
8
+ export interface ExtensionOptions {
9
+ browser: BrowserType;
10
+ cwd?: string;
11
+ useHttps?: boolean;
12
+ port?: number | string;
13
+ }
14
+
15
+ // Get the toolkit root directory
16
+ function getToolkitRoot(): string {
17
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
+ // Navigate from dist/tui/services to project root (3 levels up)
19
+ // dist/tui/services -> dist/tui -> dist -> gx-devtools
20
+ return path.resolve(__dirname, '..', '..', '..');
21
+ }
22
+
23
+ // Find the extension path (project-local or toolkit built-in)
24
+ function findExtensionPath(browser: BrowserType, cwd: string): string | null {
25
+ // Check local project first
26
+ const localPath = path.join(cwd, 'browser-extensions', browser);
27
+ if (fs.existsSync(localPath)) {
28
+ return localPath;
29
+ }
30
+
31
+ // Fall back to toolkit's built-in extension
32
+ const toolkitPath = path.join(getToolkitRoot(), 'browser-extensions', browser);
33
+ if (fs.existsSync(toolkitPath)) {
34
+ return toolkitPath;
35
+ }
36
+
37
+ return null;
38
+ }
39
+
40
+ export function startExtension(options: ExtensionOptions): void {
41
+ const { browser, useHttps = true, port = 3060 } = options;
42
+ const cwd = options.cwd || process.cwd();
43
+ const serviceId = `ext-${browser}`;
44
+
45
+ // Compute the start URL based on options
46
+ const protocol = useHttps ? 'https' : 'http';
47
+ const startUrl = `${protocol}://localhost:${port}`;
48
+
49
+ // Check if already running
50
+ if (serviceManager.isRunning(serviceId)) {
51
+ return;
52
+ }
53
+
54
+ const extensionPath = findExtensionPath(browser, cwd);
55
+ if (!extensionPath) {
56
+ // Create a dummy service to show the error
57
+ const errorState = serviceManager.start({
58
+ id: serviceId,
59
+ name: `${browser.charAt(0).toUpperCase() + browser.slice(1)} Extension`,
60
+ command: 'echo',
61
+ args: [`Extension not found for ${browser}`],
62
+ cwd,
63
+ });
64
+ return;
65
+ }
66
+
67
+ if (browser === 'firefox') {
68
+ const config: ServiceConfig = {
69
+ id: serviceId,
70
+ name: 'Firefox Extension',
71
+ command: 'npx',
72
+ args: ['web-ext', 'run', '--source-dir', extensionPath, '--start-url', startUrl],
73
+ cwd,
74
+ env: {
75
+ FORCE_COLOR: '1',
76
+ },
77
+ };
78
+ serviceManager.start(config);
79
+ } else if (browser === 'chrome') {
80
+ // For Chrome, we need to use the launch script
81
+ const toolkitRoot = getToolkitRoot();
82
+ let scriptPath = path.join(cwd, 'scripts', 'launch-chrome.js');
83
+
84
+ if (!fs.existsSync(scriptPath)) {
85
+ scriptPath = path.join(toolkitRoot, 'scripts', 'launch-chrome.js');
86
+ }
87
+
88
+ if (!fs.existsSync(scriptPath)) {
89
+ const errorState = serviceManager.start({
90
+ id: serviceId,
91
+ name: 'Chrome Extension',
92
+ command: 'echo',
93
+ args: ['Chrome launcher script not found'],
94
+ cwd,
95
+ });
96
+ return;
97
+ }
98
+
99
+ const config: ServiceConfig = {
100
+ id: serviceId,
101
+ name: 'Chrome Extension',
102
+ command: 'node',
103
+ args: [scriptPath],
104
+ cwd,
105
+ env: {
106
+ FORCE_COLOR: '1',
107
+ CHROME_EXTENSION_PATH: extensionPath,
108
+ USE_HTTPS: String(useHttps),
109
+ NODE_PORT: String(port),
110
+ },
111
+ };
112
+ serviceManager.start(config);
113
+ }
114
+ }
115
+
116
+ export function stopExtension(browser: BrowserType): boolean {
117
+ return serviceManager.stop(`ext-${browser}`);
118
+ }
119
+
120
+ export function isExtensionRunning(browser: BrowserType): boolean {
121
+ return serviceManager.isRunning(`ext-${browser}`);
122
+ }
@@ -0,0 +1,395 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import http from 'http';
4
+ import { fileURLToPath } from 'url';
5
+ import open from 'open';
6
+ import { EventEmitter } from 'events';
7
+
8
+ // Gemini configuration interface
9
+ export interface GeminiConfig {
10
+ systemPrompt?: string;
11
+ includeDocs?: string[];
12
+ projectContext?: boolean;
13
+ maxContextTokens?: number;
14
+ }
15
+
16
+ // Auth tokens interface
17
+ interface AuthTokens {
18
+ accessToken: string;
19
+ refreshToken: string;
20
+ expiresAt: number;
21
+ }
22
+
23
+ // Get the gxdev config directory
24
+ function getConfigDir(): string {
25
+ const home = process.env.HOME || process.env.USERPROFILE || '';
26
+ return path.join(home, '.gxdev');
27
+ }
28
+
29
+ // Ensure config directory exists
30
+ function ensureConfigDir(): void {
31
+ const configDir = getConfigDir();
32
+ if (!fs.existsSync(configDir)) {
33
+ fs.mkdirSync(configDir, { recursive: true });
34
+ }
35
+ }
36
+
37
+ // Get auth file path
38
+ function getAuthFilePath(): string {
39
+ return path.join(getConfigDir(), 'gemini-auth.json');
40
+ }
41
+
42
+ // Get config file path
43
+ function getConfigFilePath(): string {
44
+ return path.join(getConfigDir(), 'gemini-config.json');
45
+ }
46
+
47
+ // Get docs directory path
48
+ function getDocsDir(): string {
49
+ return path.join(getConfigDir(), 'gemini-docs');
50
+ }
51
+
52
+ // Load saved auth tokens
53
+ export function loadAuthTokens(): AuthTokens | null {
54
+ try {
55
+ const authPath = getAuthFilePath();
56
+ if (fs.existsSync(authPath)) {
57
+ const content = fs.readFileSync(authPath, 'utf-8');
58
+ return JSON.parse(content);
59
+ }
60
+ } catch {
61
+ // Invalid or missing auth file
62
+ }
63
+ return null;
64
+ }
65
+
66
+ // Save auth tokens
67
+ function saveAuthTokens(tokens: AuthTokens): void {
68
+ ensureConfigDir();
69
+ const authPath = getAuthFilePath();
70
+ fs.writeFileSync(authPath, JSON.stringify(tokens, null, 2));
71
+ }
72
+
73
+ // Clear auth tokens
74
+ export function clearAuthTokens(): void {
75
+ const authPath = getAuthFilePath();
76
+ if (fs.existsSync(authPath)) {
77
+ fs.unlinkSync(authPath);
78
+ }
79
+ }
80
+
81
+ // Load Gemini config
82
+ export function loadGeminiConfig(): GeminiConfig {
83
+ try {
84
+ const configPath = getConfigFilePath();
85
+ if (fs.existsSync(configPath)) {
86
+ const content = fs.readFileSync(configPath, 'utf-8');
87
+ return JSON.parse(content);
88
+ }
89
+ } catch {
90
+ // Invalid or missing config file
91
+ }
92
+ return {
93
+ systemPrompt: 'You are a helpful assistant for GxP plugin development.',
94
+ includeDocs: [],
95
+ projectContext: true,
96
+ maxContextTokens: 4000,
97
+ };
98
+ }
99
+
100
+ // Save Gemini config
101
+ export function saveGeminiConfig(config: GeminiConfig): void {
102
+ ensureConfigDir();
103
+ const configPath = getConfigFilePath();
104
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
105
+ }
106
+
107
+ // Check if authenticated
108
+ export function isAuthenticated(): boolean {
109
+ const tokens = loadAuthTokens();
110
+ if (!tokens) return false;
111
+ // Check if token is expired (with 5 min buffer)
112
+ return tokens.expiresAt > Date.now() + 5 * 60 * 1000;
113
+ }
114
+
115
+ // Gemini Service class
116
+ export class GeminiService extends EventEmitter {
117
+ private conversationHistory: Array<{ role: string; content: string }> = [];
118
+ private projectContext: string = '';
119
+
120
+ constructor() {
121
+ super();
122
+ }
123
+
124
+ // Start OAuth flow
125
+ async startOAuthFlow(): Promise<{ success: boolean; message: string }> {
126
+ return new Promise((resolve) => {
127
+ // Create local server for OAuth callback
128
+ const PORT = 8234;
129
+ let server: http.Server;
130
+
131
+ const handleCallback = (req: http.IncomingMessage, res: http.ServerResponse) => {
132
+ const url = new URL(req.url || '', `http://localhost:${PORT}`);
133
+
134
+ if (url.pathname === '/callback') {
135
+ const code = url.searchParams.get('code');
136
+ const error = url.searchParams.get('error');
137
+
138
+ if (error) {
139
+ res.writeHead(200, { 'Content-Type': 'text/html' });
140
+ res.end('<html><body><h1>Authentication Failed</h1><p>You can close this window.</p></body></html>');
141
+ server.close();
142
+ resolve({ success: false, message: `OAuth error: ${error}` });
143
+ return;
144
+ }
145
+
146
+ if (code) {
147
+ // Exchange code for tokens
148
+ this.exchangeCodeForTokens(code)
149
+ .then((tokens) => {
150
+ saveAuthTokens(tokens);
151
+ res.writeHead(200, { 'Content-Type': 'text/html' });
152
+ res.end('<html><body><h1>Authentication Successful!</h1><p>You can close this window and return to gxdev.</p></body></html>');
153
+ server.close();
154
+ resolve({ success: true, message: 'Successfully authenticated with Google.' });
155
+ })
156
+ .catch((err) => {
157
+ res.writeHead(200, { 'Content-Type': 'text/html' });
158
+ res.end('<html><body><h1>Authentication Failed</h1><p>Error exchanging code for tokens.</p></body></html>');
159
+ server.close();
160
+ resolve({ success: false, message: `Token exchange error: ${err.message}` });
161
+ });
162
+ return;
163
+ }
164
+ }
165
+
166
+ res.writeHead(404);
167
+ res.end('Not found');
168
+ };
169
+
170
+ server = http.createServer(handleCallback);
171
+
172
+ server.listen(PORT, () => {
173
+ // Construct OAuth URL
174
+ // Note: These are placeholder values - you'll need to set up actual Google Cloud credentials
175
+ const clientId = process.env.GOOGLE_CLIENT_ID || 'YOUR_CLIENT_ID';
176
+ const redirectUri = `http://localhost:${PORT}/callback`;
177
+ const scope = 'https://www.googleapis.com/auth/generative-language';
178
+
179
+ const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?` +
180
+ `client_id=${encodeURIComponent(clientId)}` +
181
+ `&redirect_uri=${encodeURIComponent(redirectUri)}` +
182
+ `&response_type=code` +
183
+ `&scope=${encodeURIComponent(scope)}` +
184
+ `&access_type=offline` +
185
+ `&prompt=consent`;
186
+
187
+ this.emit('log', `Opening browser for Google authentication...`);
188
+ this.emit('log', `If browser doesn't open, visit: ${authUrl}`);
189
+
190
+ open(authUrl).catch(() => {
191
+ this.emit('log', `Could not open browser automatically. Please visit the URL above.`);
192
+ });
193
+ });
194
+
195
+ // Timeout after 5 minutes
196
+ setTimeout(() => {
197
+ server.close();
198
+ resolve({ success: false, message: 'Authentication timed out. Please try again.' });
199
+ }, 5 * 60 * 1000);
200
+ });
201
+ }
202
+
203
+ // Exchange authorization code for tokens
204
+ private async exchangeCodeForTokens(code: string): Promise<AuthTokens> {
205
+ const clientId = process.env.GOOGLE_CLIENT_ID || 'YOUR_CLIENT_ID';
206
+ const clientSecret = process.env.GOOGLE_CLIENT_SECRET || 'YOUR_CLIENT_SECRET';
207
+ const redirectUri = 'http://localhost:8234/callback';
208
+
209
+ const response = await fetch('https://oauth2.googleapis.com/token', {
210
+ method: 'POST',
211
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
212
+ body: new URLSearchParams({
213
+ code,
214
+ client_id: clientId,
215
+ client_secret: clientSecret,
216
+ redirect_uri: redirectUri,
217
+ grant_type: 'authorization_code',
218
+ }),
219
+ });
220
+
221
+ if (!response.ok) {
222
+ throw new Error(`Token exchange failed: ${response.statusText}`);
223
+ }
224
+
225
+ const data = await response.json() as any;
226
+ return {
227
+ accessToken: data.access_token,
228
+ refreshToken: data.refresh_token,
229
+ expiresAt: Date.now() + (data.expires_in * 1000),
230
+ };
231
+ }
232
+
233
+ // Refresh access token
234
+ private async refreshAccessToken(): Promise<void> {
235
+ const tokens = loadAuthTokens();
236
+ if (!tokens?.refreshToken) {
237
+ throw new Error('No refresh token available');
238
+ }
239
+
240
+ const clientId = process.env.GOOGLE_CLIENT_ID || 'YOUR_CLIENT_ID';
241
+ const clientSecret = process.env.GOOGLE_CLIENT_SECRET || 'YOUR_CLIENT_SECRET';
242
+
243
+ const response = await fetch('https://oauth2.googleapis.com/token', {
244
+ method: 'POST',
245
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
246
+ body: new URLSearchParams({
247
+ refresh_token: tokens.refreshToken,
248
+ client_id: clientId,
249
+ client_secret: clientSecret,
250
+ grant_type: 'refresh_token',
251
+ }),
252
+ });
253
+
254
+ if (!response.ok) {
255
+ throw new Error('Failed to refresh token');
256
+ }
257
+
258
+ const data = await response.json() as any;
259
+ saveAuthTokens({
260
+ accessToken: data.access_token,
261
+ refreshToken: tokens.refreshToken,
262
+ expiresAt: Date.now() + (data.expires_in * 1000),
263
+ });
264
+ }
265
+
266
+ // Get valid access token (refresh if needed)
267
+ private async getAccessToken(): Promise<string> {
268
+ const tokens = loadAuthTokens();
269
+ if (!tokens) {
270
+ throw new Error('Not authenticated. Run /gemini enable first.');
271
+ }
272
+
273
+ // Check if token needs refresh (with 5 min buffer)
274
+ if (tokens.expiresAt < Date.now() + 5 * 60 * 1000) {
275
+ await this.refreshAccessToken();
276
+ const newTokens = loadAuthTokens();
277
+ return newTokens!.accessToken;
278
+ }
279
+
280
+ return tokens.accessToken;
281
+ }
282
+
283
+ // Load project context
284
+ loadProjectContext(cwd: string): void {
285
+ const files = ['CLAUDE.md', 'README.md', 'package.json'];
286
+ const contextParts: string[] = [];
287
+
288
+ for (const file of files) {
289
+ const filePath = path.join(cwd, file);
290
+ if (fs.existsSync(filePath)) {
291
+ try {
292
+ const content = fs.readFileSync(filePath, 'utf-8');
293
+ contextParts.push(`=== ${file} ===\n${content.slice(0, 2000)}`);
294
+ } catch {
295
+ // Skip unreadable files
296
+ }
297
+ }
298
+ }
299
+
300
+ this.projectContext = contextParts.join('\n\n');
301
+ }
302
+
303
+ // Load custom docs
304
+ loadCustomDocs(): string {
305
+ const docsDir = getDocsDir();
306
+ if (!fs.existsSync(docsDir)) {
307
+ return '';
308
+ }
309
+
310
+ const docParts: string[] = [];
311
+ try {
312
+ const files = fs.readdirSync(docsDir).filter(f => f.endsWith('.md'));
313
+ for (const file of files.slice(0, 5)) { // Limit to 5 docs
314
+ const content = fs.readFileSync(path.join(docsDir, file), 'utf-8');
315
+ docParts.push(`=== ${file} ===\n${content.slice(0, 2000)}`);
316
+ }
317
+ } catch {
318
+ // Skip on error
319
+ }
320
+
321
+ return docParts.join('\n\n');
322
+ }
323
+
324
+ // Send message to Gemini
325
+ async sendMessage(message: string): Promise<string> {
326
+ const config = loadGeminiConfig();
327
+ const accessToken = await this.getAccessToken();
328
+
329
+ // Build context
330
+ let systemContext = config.systemPrompt || '';
331
+ if (config.projectContext && this.projectContext) {
332
+ systemContext += '\n\nProject Context:\n' + this.projectContext;
333
+ }
334
+ const customDocs = this.loadCustomDocs();
335
+ if (customDocs) {
336
+ systemContext += '\n\nDocumentation:\n' + customDocs;
337
+ }
338
+
339
+ // Add to conversation history
340
+ this.conversationHistory.push({ role: 'user', content: message });
341
+
342
+ // Prepare request body for Gemini API
343
+ const requestBody = {
344
+ contents: [
345
+ {
346
+ role: 'user',
347
+ parts: [{ text: systemContext + '\n\nUser: ' + message }]
348
+ }
349
+ ],
350
+ generationConfig: {
351
+ maxOutputTokens: 2048,
352
+ temperature: 0.7,
353
+ }
354
+ };
355
+
356
+ // Call Gemini API
357
+ const response = await fetch(
358
+ 'https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent',
359
+ {
360
+ method: 'POST',
361
+ headers: {
362
+ 'Authorization': `Bearer ${accessToken}`,
363
+ 'Content-Type': 'application/json',
364
+ },
365
+ body: JSON.stringify(requestBody),
366
+ }
367
+ );
368
+
369
+ if (!response.ok) {
370
+ const errorText = await response.text();
371
+ throw new Error(`Gemini API error: ${response.status} - ${errorText}`);
372
+ }
373
+
374
+ const data = await response.json() as any;
375
+ const responseText = data.candidates?.[0]?.content?.parts?.[0]?.text || 'No response generated.';
376
+
377
+ // Add to conversation history
378
+ this.conversationHistory.push({ role: 'assistant', content: responseText });
379
+
380
+ return responseText;
381
+ }
382
+
383
+ // Clear conversation history
384
+ clearConversation(): void {
385
+ this.conversationHistory = [];
386
+ }
387
+
388
+ // Get conversation history
389
+ getConversationHistory(): Array<{ role: string; content: string }> {
390
+ return [...this.conversationHistory];
391
+ }
392
+ }
393
+
394
+ // Singleton instance
395
+ export const geminiService = new GeminiService();