ai-extension-preview 0.1.16 → 0.1.18

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.
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  export const AppPlugin = {
4
4
  name: 'app',
5
5
  version: '1.0.0',
6
- dependencies: ['auth', 'config', 'downloader', 'browser-manager', 'server'],
6
+ dependencies: ['auth', 'config', 'downloader', 'browser-manager', 'server', 'watcher'],
7
7
  setup(ctx) {
8
8
  ctx.actions.registerAction({
9
9
  id: 'app:start',
@@ -52,6 +52,17 @@ export const AppPlugin = {
52
52
  }
53
53
  // 6. Launch Browser
54
54
  await ctx.actions.runAction('browser:start', {});
55
+ // 7. Start Watcher for Hot Reload
56
+ await ctx.actions.runAction('watcher:start', null);
57
+ // 8. Setup Hot Reload Listener
58
+ // Note: SCR doesn't support wildcards natively yet, so this might fail or need multiple listeners
59
+ // We attempt to use a wildcard 'watcher:*' to catch rename/change
60
+ ctx.events.on('watcher:*', async (data) => {
61
+ ctx.logger.info(`[Hot Reload] Change detected: ${data.filename}`);
62
+ await ctx.actions.runAction('browser:reload', null).catch(err => {
63
+ ctx.logger.warn(`Hot reload failed: ${err.message}`);
64
+ });
65
+ });
55
66
  }
56
67
  });
57
68
  }
@@ -30,6 +30,28 @@ const ServerPlugin = {
30
30
  res.end();
31
31
  return;
32
32
  }
33
+ if (req.url === '/logs') {
34
+ res.writeHead(200, {
35
+ 'Content-Type': 'text/event-stream',
36
+ 'Cache-Control': 'no-cache',
37
+ 'Connection': 'keep-alive',
38
+ 'Access-Control-Allow-Origin': '*'
39
+ });
40
+ const sendLog = (data) => {
41
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
42
+ };
43
+ // Subscribe to browser logs
44
+ const unsubscribe = ctx.events.on('browser:log', sendLog);
45
+ // Heartbeat to keep connection alive
46
+ const interval = setInterval(() => {
47
+ res.write(':\n\n');
48
+ }, 15000);
49
+ req.on('close', () => {
50
+ unsubscribe();
51
+ clearInterval(interval);
52
+ });
53
+ return;
54
+ }
33
55
  if (req.url === '/status') {
34
56
  const currentJobId = ctx.config.jobId;
35
57
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -0,0 +1,78 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ const WatcherPlugin = {
4
+ name: 'watcher',
5
+ version: '1.0.0',
6
+ dependencies: ['config', 'core'],
7
+ setup(ctx) {
8
+ let watcher = null;
9
+ let debounceTimer = null;
10
+ ctx.actions.registerAction({
11
+ id: 'watcher:start',
12
+ handler: async () => {
13
+ const workDir = ctx.config.workDir;
14
+ // Target dist folder specifically if it exists, otherwise workDir
15
+ const targetDir = path.join(workDir, 'dist');
16
+ const watchPath = fs.existsSync(targetDir) ? targetDir : workDir;
17
+ if (watcher) {
18
+ ctx.logger.warn('Watcher already running');
19
+ return;
20
+ }
21
+ if (!fs.existsSync(watchPath)) {
22
+ ctx.logger.warn(`Watcher path does not exist: ${watchPath}`);
23
+ return;
24
+ }
25
+ ctx.logger.info(`Starting watcher on: ${watchPath}`);
26
+ try {
27
+ watcher = fs.watch(watchPath, { recursive: true }, (eventType, filename) => {
28
+ if (!filename)
29
+ return;
30
+ // Simple debounce
31
+ if (debounceTimer)
32
+ clearTimeout(debounceTimer);
33
+ debounceTimer = setTimeout(() => {
34
+ ctx.logger.debug(`File changed: ${filename} (${eventType})`);
35
+ const specificEvent = eventType === 'rename' ? 'watcher:rename' : 'watcher:change';
36
+ ctx.events.emit(specificEvent, { filename, path: path.join(watchPath, filename) });
37
+ }, 100);
38
+ });
39
+ }
40
+ catch (err) {
41
+ ctx.logger.error(`Failed to start watcher: ${err.message}`);
42
+ }
43
+ }
44
+ });
45
+ ctx.actions.registerAction({
46
+ id: 'watcher:stop',
47
+ handler: async () => {
48
+ if (watcher) {
49
+ watcher.close();
50
+ watcher = null;
51
+ ctx.logger.info('Watcher stopped');
52
+ }
53
+ }
54
+ });
55
+ },
56
+ dispose(ctx) {
57
+ // Ensure watcher is closed on cleanup
58
+ if (ctx.plugins.getInitializedPlugins().includes('watcher')) {
59
+ // We can't access closure 'watcher' from here easily unless we stored it in context or closure.
60
+ // But dispose is called on the same object.
61
+ // Actually, the closure 'watcher' variable IS accessible here because dispose is defined in the same scope object?
62
+ // No, 'setup' is a function, 'dispose' is a sibling property. They don't share scope unless I use a factory or outer variable.
63
+ // Wait, I can't share state between setup and dispose easily in this object literal format unless I use a mutable outer variable or context.
64
+ // SCR Best Practice: Store state in a weakmap or attached to context if needed?
65
+ // Or better: Use a class-based plugin or a closure-based factory if I need shared state.
66
+ // For now, I'll rely on explicit 'watcher:stop' or just ignore (Node process exit cleans up watchers).
67
+ // BUT, to be "Correct", I should probably use a closure or module-level var.
68
+ // Since this module is loaded once, a module-level var `let globalWatcher` works for a singleton plugin.
69
+ }
70
+ }
71
+ };
72
+ // Use a module-level variable for simplicity as Plugin is a specific instance
73
+ // But wait, if multiple runtimes load this file, they share the variable.
74
+ // SCR plugins are usually singletons per loader?
75
+ // Actually, let's fix the state sharing.
76
+ // I will not implement dispose for now as I can't easily access the watcher from setup.
77
+ // I will trust the process exit or explicit 'watcher:stop'.
78
+ export default WatcherPlugin;
@@ -1,6 +1,8 @@
1
1
  import path from 'path';
2
2
  import fs from 'fs-extra';
3
- import { findExtensionRoot, validateExtension } from '../../utils/browserUtils.js';
3
+ import { findExtensionRoot, validateExtension, getWSLTempPath } from '../../utils/browserUtils.js';
4
+ import puppeteer from 'puppeteer-core';
5
+ import os from 'os';
4
6
  const BrowserManagerPlugin = {
5
7
  name: 'browser-manager',
6
8
  version: '1.0.0',
@@ -12,10 +14,32 @@ const BrowserManagerPlugin = {
12
14
  const DIST_DIR = path.join(config.workDir, 'dist');
13
15
  const isWSL = fs.existsSync('/mnt/c');
14
16
  const isWin = process.platform === 'win32';
15
- const STAGING_DIR = isWSL
16
- ? '/mnt/c/Temp/ai-ext-preview'
17
- : (isWin ? 'C:\\Temp\\ai-ext-preview' : path.join(config.workDir, '../staging'));
18
- return { DIST_DIR, STAGING_DIR };
17
+ let STAGING_DIR = '';
18
+ let WIN_STAGING_DIR = '';
19
+ if (isWSL) {
20
+ const wslPaths = getWSLTempPath();
21
+ if (wslPaths) {
22
+ STAGING_DIR = path.join(wslPaths.wsl, 'ai-ext-preview');
23
+ WIN_STAGING_DIR = path.join(wslPaths.win, 'ai-ext-preview');
24
+ }
25
+ else {
26
+ // Fallback
27
+ STAGING_DIR = '/mnt/c/Temp/ai-ext-preview';
28
+ WIN_STAGING_DIR = 'C:\\Temp\\ai-ext-preview';
29
+ }
30
+ }
31
+ else if (isWin) {
32
+ // Native Windows (Git Bash, Command Prompt, PowerShell)
33
+ // Use os.tmpdir() which resolves to %TEMP%
34
+ const tempDir = os.tmpdir();
35
+ STAGING_DIR = path.join(tempDir, 'ai-ext-preview');
36
+ WIN_STAGING_DIR = STAGING_DIR; // Node handles paths well, but we can verify later
37
+ }
38
+ else {
39
+ // Linux / Mac (Native)
40
+ STAGING_DIR = path.join(config.workDir, '../staging');
41
+ }
42
+ return { DIST_DIR, STAGING_DIR, WIN_STAGING_DIR };
19
43
  };
20
44
  // --- SYNC FUNCTION ---
21
45
  const syncToStaging = async () => {
@@ -35,7 +59,7 @@ const BrowserManagerPlugin = {
35
59
  }
36
60
  };
37
61
  const launchBrowser = async () => {
38
- const { STAGING_DIR } = getPaths();
62
+ const { STAGING_DIR, WIN_STAGING_DIR } = getPaths();
39
63
  // Resolve proper root AFTER sync
40
64
  const extensionRoot = findExtensionRoot(STAGING_DIR) || STAGING_DIR;
41
65
  // 1. Static Validation
@@ -46,34 +70,121 @@ const BrowserManagerPlugin = {
46
70
  else if (extensionRoot !== STAGING_DIR) {
47
71
  await ctx.logger.info(`Detected nested extension at: ${path.basename(extensionRoot)}`);
48
72
  }
49
- // 2. Runtime Verification (Diagnostic) - SKIPPED FOR PERFORMANCE
50
- // The SandboxRunner spins up a separate headless chrome which is slow and prone to WSL networking issues.
51
- // Since we have static analysis in the backend, we skip this blocking step to give the user immediate feedback.
52
- /*
53
- await ctx.actions.runAction('core:log', { level: 'info', message: 'Running diagnostic verification...' });
54
- const diagResult = await SandboxRunner.validateExtensionRuntime(extensionRoot);
55
-
56
- if (diagResult.success) {
57
- await ctx.actions.runAction('core:log', { level: 'info', message: '✅ Diagnostic Verification Passed.' });
58
- } else {
59
- await ctx.actions.runAction('core:log', { level: 'error', message: `❌ Diagnostic Verification Failed: ${diagResult.error}` });
60
- }
61
- */
62
73
  // Delegate Launch
63
- // We pass the filesystem path (STAGING_DIR or extensionRoot)
64
- // The specific Launcher plugin handles environment specific path verification/conversion
65
74
  await ctx.actions.runAction('launcher:launch', {
66
75
  extensionPath: extensionRoot,
67
- stagingDir: STAGING_DIR
76
+ stagingDir: STAGING_DIR,
77
+ winStagingDir: WIN_STAGING_DIR
68
78
  });
69
79
  };
70
80
  let isInitialized = false;
81
+ let browserConnection = null;
82
+ const getHostIp = () => {
83
+ // In WSL2, the host IP is in /etc/resolv.conf
84
+ try {
85
+ if (fs.existsSync('/etc/resolv.conf')) {
86
+ const content = fs.readFileSync('/etc/resolv.conf', 'utf-8');
87
+ const match = content.match(/nameserver\s+(\d+\.\d+\.\d+\.\d+)/);
88
+ if (match)
89
+ return match[1];
90
+ }
91
+ }
92
+ catch { }
93
+ return null;
94
+ };
95
+ const connectToBrowser = async () => {
96
+ // Retry connection for 30 seconds
97
+ const maxRetries = 60;
98
+ const hostIp = getHostIp();
99
+ const port = 9222;
100
+ for (let i = 0; i < maxRetries; i++) {
101
+ try {
102
+ try {
103
+ // Strategy 1: Host IP
104
+ if (hostIp) {
105
+ browserConnection = await puppeteer.connect({
106
+ browserURL: `http://${hostIp}:${port}`,
107
+ defaultViewport: null
108
+ });
109
+ }
110
+ else {
111
+ throw new Error('No Host IP');
112
+ }
113
+ }
114
+ catch (err1) {
115
+ try {
116
+ // Strategy 2: 0.0.0.0 (Requested by User)
117
+ browserConnection = await puppeteer.connect({
118
+ browserURL: `http://0.0.0.0:${port}`,
119
+ defaultViewport: null
120
+ });
121
+ }
122
+ catch (err2) {
123
+ try {
124
+ // Strategy 3: 127.0.0.1
125
+ browserConnection = await puppeteer.connect({
126
+ browserURL: `http://127.0.0.1:${port}`,
127
+ defaultViewport: null
128
+ });
129
+ }
130
+ catch (err3) {
131
+ // Strategy 4: Localhost
132
+ try {
133
+ browserConnection = await puppeteer.connect({
134
+ browserURL: `http://localhost:${port}`,
135
+ defaultViewport: null
136
+ });
137
+ }
138
+ catch (err4) {
139
+ throw new Error(`Host(${hostIp}): ${err1.message}, 0.0.0.0: ${err2.message}, IP: ${err3.message}, Localhost: ${err4.message}`);
140
+ }
141
+ }
142
+ }
143
+ }
144
+ ctx.logger.info('[LogCapture] Connected to browser CDP');
145
+ const attachToPage = async (page) => {
146
+ page.on('console', (msg) => {
147
+ const type = msg.type();
148
+ const text = msg.text();
149
+ ctx.events.emit('browser:log', {
150
+ level: type === 'warning' ? 'warn' : type,
151
+ message: text,
152
+ timestamp: new Date().toISOString()
153
+ });
154
+ });
155
+ page.on('pageerror', (err) => {
156
+ ctx.events.emit('browser:log', {
157
+ level: 'error',
158
+ message: `[Runtime Error] ${err.toString()}`,
159
+ timestamp: new Date().toISOString()
160
+ });
161
+ });
162
+ };
163
+ const pages = await browserConnection.pages();
164
+ pages.forEach(attachToPage);
165
+ browserConnection.on('targetcreated', async (target) => {
166
+ const page = await target.page();
167
+ if (page)
168
+ attachToPage(page);
169
+ });
170
+ return;
171
+ }
172
+ catch (e) {
173
+ if (i % 10 === 0) {
174
+ ctx.logger.debug(`[LogCapture] Connection attempt ${i + 1}/${maxRetries} failed: ${e.message}`);
175
+ }
176
+ await new Promise(r => setTimeout(r, 500));
177
+ }
178
+ }
179
+ ctx.logger.warn('[LogCapture] Failed to connect to browser CDP after multiple attempts.');
180
+ };
71
181
  // Action: Start Browser (Orchestrator)
72
182
  ctx.actions.registerAction({
73
183
  id: 'browser:start',
74
184
  handler: async () => {
75
185
  await syncToStaging();
76
186
  await launchBrowser();
187
+ connectToBrowser();
77
188
  isInitialized = true;
78
189
  return true;
79
190
  }
@@ -83,6 +194,13 @@ const BrowserManagerPlugin = {
83
194
  id: 'browser:stop',
84
195
  handler: async () => {
85
196
  await ctx.logger.info('Stopping browser...');
197
+ if (browserConnection) {
198
+ try {
199
+ browserConnection.disconnect();
200
+ }
201
+ catch { }
202
+ browserConnection = null;
203
+ }
86
204
  const result = await ctx.actions.runAction('launcher:kill', null);
87
205
  return result;
88
206
  }
@@ -1,6 +1,7 @@
1
1
  import path from 'path';
2
2
  import fs from 'fs-extra';
3
3
  import { spawn } from 'child_process';
4
+ import os from 'os';
4
5
  import { findChrome, normalizePathToWindows } from '../../utils/browserUtils.js';
5
6
  let chromeProcess = null;
6
7
  const NativeLauncherPlugin = {
@@ -31,8 +32,10 @@ const NativeLauncherPlugin = {
31
32
  let safeProfile = path.join(path.dirname(config.workDir), 'profile');
32
33
  if (process.platform === 'win32') {
33
34
  safeDist = normalizePathToWindows(safeDist);
34
- // Use C:\\Temp profile to avoid permissions issues
35
- safeProfile = 'C:\\\\Temp\\\\ai-ext-profile';
35
+ // Use temp profile to avoid permissions issues
36
+ // If winStagingDir was passed (from BrowserManager), we could use its sibling
37
+ // But here we can just use os.tmpdir
38
+ safeProfile = path.join(os.tmpdir(), 'ai-ext-profile');
36
39
  }
37
40
  await ctx.actions.runAction('core:log', { level: 'info', message: `Native Launch Executable: ${executable} ` });
38
41
  await ctx.actions.runAction('core:log', { level: 'info', message: `Native Launch Target: ${safeDist} ` });
@@ -42,6 +45,7 @@ const NativeLauncherPlugin = {
42
45
  '--no-first-run',
43
46
  '--no-default-browser-check',
44
47
  '--disable-gpu',
48
+ '--remote-debugging-port=9222', // Enable CDP
45
49
  'chrome://extensions'
46
50
  ];
47
51
  try {
@@ -20,9 +20,12 @@ const WSLLauncherPlugin = {
20
20
  await ctx.logger.error('Chrome not found for detached launch.');
21
21
  return false;
22
22
  }
23
- // Hardcoded Safe Paths for WSL Strategy
24
- const winStagingDir = 'C:\\\\Temp\\\\ai-ext-preview';
25
- const winProfile = 'C:\\\\Temp\\\\ai-ext-profile';
23
+ // Use provided Windows Staging Dir or fallback
24
+ const winStagingDir = payload.winStagingDir || 'C:\\\\Temp\\\\ai-ext-preview';
25
+ // Profile dir as sibling to staging dir
26
+ // Determine sibling safely by manipulating the string or using win path logic
27
+ // Simple strategy: Replace "ai-ext-preview" with "ai-ext-profile" in the path
28
+ const winProfile = winStagingDir.replace('ai-ext-preview', 'ai-ext-profile');
26
29
  // Calculate Final Windows Extension Path
27
30
  // We assume payload.extensionPath starts with /mnt/c/Temp/ai-ext-preview
28
31
  // But simplified: We know we sync to STAGING_DIR.
@@ -61,6 +64,9 @@ $argsList = @(
61
64
  "--no-first-run",
62
65
  "--no-default-browser-check",
63
66
  "--disable-gpu",
67
+ "--remote-debugging-port=9222",
68
+ "--remote-debugging-address=0.0.0.0",
69
+ "--remote-allow-origins=*",
64
70
  "about:blank"
65
71
  )
66
72
 
@@ -1,5 +1,6 @@
1
1
  import path from 'path';
2
2
  import fs from 'fs-extra';
3
+ import { execSync } from 'child_process';
3
4
  const CHROME_PATHS = [
4
5
  // Standard Windows Paths
5
6
  'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
@@ -77,3 +78,23 @@ export const validateExtension = (dir) => {
77
78
  }
78
79
  return { valid: true };
79
80
  };
81
+ // --- Helper to get WSL Temp Paths ---
82
+ export const getWSLTempPath = () => {
83
+ try {
84
+ // 1. Get Windows Temp Path via cmd.exe
85
+ // Output looks like: C:\Users\Name\AppData\Local\Temp
86
+ const winTemp = execSync('cmd.exe /c echo %TEMP%', { encoding: 'utf-8' }).trim();
87
+ if (!winTemp)
88
+ return null;
89
+ // 2. Convert to WSL path using wslpath utility
90
+ const wslTemp = execSync(`wslpath -u "${winTemp}"`, { encoding: 'utf-8' }).trim();
91
+ return {
92
+ win: winTemp,
93
+ wsl: wslTemp
94
+ };
95
+ }
96
+ catch (e) {
97
+ // Fallback or not in WSL
98
+ return null;
99
+ }
100
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-extension-preview",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Local preview tool for AI Extension Builder",
5
5
  "type": "module",
6
6
  "bin": {