ai-extension-preview 0.1.8 → 0.1.9

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.
@@ -22,7 +22,28 @@ function findChrome() {
22
22
  }
23
23
  return null;
24
24
  }
25
- const normalizePathToWindows = (p) => {
25
+ // --- Helper to find actual extension root (handle nested folder in zip) ---
26
+ export const findExtensionRoot = (dir) => {
27
+ if (fs.existsSync(path.join(dir, 'manifest.json')))
28
+ return dir;
29
+ // Check immediate subdirectories (depth 1)
30
+ try {
31
+ const items = fs.readdirSync(dir);
32
+ for (const item of items) {
33
+ const fullPath = path.join(dir, item);
34
+ if (fs.statSync(fullPath).isDirectory()) {
35
+ if (fs.existsSync(path.join(fullPath, 'manifest.json'))) {
36
+ return fullPath;
37
+ }
38
+ }
39
+ }
40
+ }
41
+ catch (e) {
42
+ // Dir might be empty or invalid
43
+ }
44
+ return null;
45
+ };
46
+ export const normalizePathToWindows = (p) => {
26
47
  // Handle Git Bash /c/ style
27
48
  const gitBashMatch = p.match(/^\/([a-z])\/(.*)/i);
28
49
  if (gitBashMatch) {
@@ -31,6 +52,32 @@ const normalizePathToWindows = (p) => {
31
52
  // Handle Forward slashes
32
53
  return p.replace(/\//g, '\\');
33
54
  };
55
+ export const stripTrailingSlash = (p) => {
56
+ return p.replace(/[\\\/]+$/, '');
57
+ };
58
+ // --- Helper to validate extension directory existence and structure ---
59
+ export const validateExtension = (dir) => {
60
+ if (!fs.existsSync(dir)) {
61
+ return { valid: false, error: 'Directory does not exist' };
62
+ }
63
+ const stats = fs.statSync(dir);
64
+ if (!stats.isDirectory()) {
65
+ return { valid: false, error: 'Path is not a directory' };
66
+ }
67
+ const manifestPath = path.join(dir, 'manifest.json');
68
+ if (!fs.existsSync(manifestPath)) {
69
+ return { valid: false, error: 'manifest.json missing' };
70
+ }
71
+ // Basic JSON validity check
72
+ try {
73
+ const content = fs.readFileSync(manifestPath, 'utf-8');
74
+ JSON.parse(content);
75
+ }
76
+ catch (e) {
77
+ return { valid: false, error: 'manifest.json is invalid JSON' };
78
+ }
79
+ return { valid: true };
80
+ };
34
81
  export const BrowserPlugin = {
35
82
  name: 'browser',
36
83
  version: '1.0.0',
@@ -77,35 +124,19 @@ export const BrowserPlugin = {
77
124
  await ctx.actions.runAction('core:log', { level: 'error', message: `Failed to sync to staging: ${err.message}` });
78
125
  }
79
126
  };
80
- // --- Helper to find actual extension root (handle nested folder in zip) ---
81
- const findExtensionRoot = (dir) => {
82
- if (fs.existsSync(path.join(dir, 'manifest.json')))
83
- return dir;
84
- // Check immediate subdirectories (depth 1)
85
- try {
86
- const items = fs.readdirSync(dir);
87
- for (const item of items) {
88
- const fullPath = path.join(dir, item);
89
- if (fs.statSync(fullPath).isDirectory()) {
90
- if (fs.existsSync(path.join(fullPath, 'manifest.json'))) {
91
- return fullPath;
92
- }
93
- }
94
- }
95
- }
96
- catch (e) {
97
- // Dir might be empty or invalid
98
- }
99
- return null;
100
- };
101
127
  // Initial Sync
102
128
  await syncToStaging();
103
129
  // Resolve proper root AFTER sync
104
130
  let extensionRoot = findExtensionRoot(STAGING_DIR) || STAGING_DIR;
105
131
  // Check if we found a valid root
106
- if (!fs.existsSync(path.join(extensionRoot, 'manifest.json'))) {
107
- await ctx.actions.runAction('core:log', { level: 'error', message: `[CRITICAL] manifest.json not found in ${extensionRoot}. Extension will not load.` });
132
+ const validation = validateExtension(extensionRoot);
133
+ if (!validation.valid) {
134
+ await ctx.actions.runAction('core:log', { level: 'error', message: `[CRITICAL] Extension validation failed: ${validation.error} in ${extensionRoot}` });
108
135
  await ctx.actions.runAction('core:log', { level: 'info', message: `Checked Path: ${extensionRoot}` });
136
+ // We proceed anyway? Or should we stop?
137
+ // Previous logic proceeded but logged critical error.
138
+ // Let's keep it logging critical but maybe return false if we wanted to be strict.
139
+ // However, user might fix it live.
109
140
  }
110
141
  else if (extensionRoot !== STAGING_DIR) {
111
142
  await ctx.actions.runAction('core:log', { level: 'info', message: `Detected nested extension at: ${path.basename(extensionRoot)}` });
@@ -114,44 +145,107 @@ export const BrowserPlugin = {
114
145
  ctx.events.on('downloader:updated', async (data) => {
115
146
  await ctx.actions.runAction('core:log', { level: 'info', message: 'Update detected. Syncing to staging...' });
116
147
  await syncToStaging();
148
+ // Re-validate on update?
149
+ // const newRoot = findExtensionRoot(STAGING_DIR) || STAGING_DIR;
150
+ // const newValidation = validateExtension(newRoot);
151
+ // if (!newValidation.valid) ...
117
152
  });
118
153
  await ctx.actions.runAction('core:log', { level: 'info', message: 'Browser running in Detached Mode.' });
119
154
  // Launch Logic
155
+ // Launch Logic
120
156
  if (isWSL) {
121
- const driveLetter = chromePath.match(/\/mnt\/([a-z])\//)?.[1] || 'c';
157
+ // -------------------------------------------------------------------------
158
+ // WSL STRATEGY (Validated 2025-12-24)
159
+ // 1. Use Windows User Profile for staging to avoid Permission/Path issues
160
+ // 2. Use PowerShell script to launch Chrome to reliably pass arguments
161
+ // -------------------------------------------------------------------------
162
+ // 1. Get Windows User Profile Path
163
+ let userProfileWin = '';
164
+ try {
165
+ // Use async exec to avoid blocking
166
+ const { exec } = await import('child_process');
167
+ const util = await import('util');
168
+ const execAsync = util.promisify(exec);
169
+ const { stdout } = await execAsync('cmd.exe /c echo %USERPROFILE%', { encoding: 'utf8' });
170
+ userProfileWin = stdout.trim();
171
+ }
172
+ catch (e) {
173
+ await ctx.actions.runAction('core:log', { level: 'error', message: 'Failed to detect Windows User Profile. Defaulting to C:\\Temp' });
174
+ userProfileWin = 'C:\\Temp';
175
+ }
176
+ const stagingDirName = '.ai-extension-preview';
177
+ const stagingDirWin = path.posix.join(userProfileWin.replace(/\\/g, '/'), stagingDirName).replace(/\//g, '\\');
178
+ // Map Win Path -> WSL Path for copying
179
+ const driveMatch = userProfileWin.match(/^([a-zA-Z]):/);
180
+ const driveLetter = driveMatch ? driveMatch[1].toLowerCase() : 'c';
181
+ const userProfileRoute = userProfileWin.substring(3).replace(/\\/g, '/'); // Users/Name
182
+ const wslStagingBase = `/mnt/${driveLetter}/${userProfileRoute}`;
183
+ const wslStagingDir = path.posix.join(wslStagingBase, stagingDirName);
184
+ try {
185
+ if (await fs.pathExists(wslStagingDir))
186
+ await fs.remove(wslStagingDir);
187
+ // Use async copy to prevent blocking event loop (Fixes 25s lag)
188
+ await fs.copy(STAGING_DIR, wslStagingDir);
189
+ }
190
+ catch (copyErr) {
191
+ await ctx.actions.runAction('core:log', { level: 'error', message: `WSL Staging Copy Failed: ${copyErr.message}` });
192
+ }
193
+ // Calculate final paths
194
+ let finalWinExtensionPath = stagingDirWin;
195
+ // Handle nested extension root
196
+ if (extensionRoot !== STAGING_DIR) {
197
+ const relative = path.relative(STAGING_DIR, extensionRoot);
198
+ finalWinExtensionPath = path.posix.join(stagingDirWin.replace(/\\/g, '/'), relative).replace(/\//g, '\\');
199
+ }
122
200
  const winChromePath = chromePath
123
201
  .replace(new RegExp(`^/mnt/${driveLetter}/`), `${driveLetter.toUpperCase()}:\\`)
124
202
  .replace(/\//g, '\\');
125
- // Calculate Windows path for the extension root
126
- // Base Win: C:\Temp\ai-ext-preview
127
- // Base Linux: /mnt/c/Temp/ai-ext-preview
128
- // If extensionRoot is /mnt/c/Temp/ai-ext-preview/subdir => C:\Temp\ai-ext-preview\subdir
129
- // Use relative path logic to be safe
130
- const baseLinux = '/mnt/c/Temp/ai-ext-preview';
131
- const relative = path.relative(baseLinux, extensionRoot);
132
- const winDistRoot = relative ? `C:\\Temp\\ai-ext-preview\\${relative}` : 'C:\\Temp\\ai-ext-preview';
133
- const finalWinDist = winDistRoot.replace(/\//g, '\\');
134
- const winProfile = 'C:\\Temp\\ai-ext-profile';
135
- await ctx.actions.runAction('core:log', { level: 'info', message: `WSL Launch Target: ${finalWinDist}` });
136
- const batContent = `@echo off
137
- start "" "${winChromePath}" --load-extension="${finalWinDist}" --user-data-dir="${winProfile}" --no-first-run --no-default-browser-check --disable-gpu about:blank
138
- exit
203
+ const winProfile = path.posix.join(userProfileWin.replace(/\\/g, '/'), '.ai-extension-profile').replace(/\//g, '\\');
204
+ await ctx.actions.runAction('core:log', { level: 'info', message: `WSL Launch Target (Win): ${finalWinExtensionPath}` });
205
+ // await ctx.actions.runAction('core:log', { level: 'info', message: `WSL Profile (Win): ${winProfile}` });
206
+ // Create PowerShell Launch Script
207
+ const psContent = `
208
+ $chromePath = "${winChromePath}"
209
+ $extPath = "${finalWinExtensionPath}"
210
+ $profilePath = "${winProfile}"
211
+
212
+ # Create Profile Dir if needed
213
+ if (-not (Test-Path -Path $profilePath)) {
214
+ New-Item -ItemType Directory -Force -Path $profilePath | Out-Null
215
+ }
216
+
217
+ $argsList = @(
218
+ "--load-extension=$extPath",
219
+ "--user-data-dir=$profilePath",
220
+ "--no-first-run",
221
+ "--no-default-browser-check",
222
+ "--disable-gpu",
223
+ "chrome://extensions"
224
+ )
225
+
226
+ Start-Process -FilePath $chromePath -ArgumentList $argsList
139
227
  `;
140
- const batPath = path.join(STAGING_DIR, 'launch.bat');
141
- const winBatPath = 'C:\\Temp\\ai-ext-preview\\launch.bat';
228
+ const psPath = path.join(wslStagingDir, 'launch.ps1');
229
+ const winPsPath = path.posix.join(stagingDirWin.replace(/\\/g, '/'), 'launch.ps1').replace(/\//g, '\\');
142
230
  try {
143
- fs.writeFileSync(batPath, batContent);
231
+ await fs.writeFile(psPath, psContent);
144
232
  }
145
233
  catch (e) {
146
- await ctx.actions.runAction('core:log', { level: 'error', message: `WSL Write Bat Failed: ${e.message}` });
234
+ await ctx.actions.runAction('core:log', { level: 'error', message: `WSL Write PS1 Failed: ${e.message}` });
235
+ }
236
+ // Execute PowerShell
237
+ const cli = 'powershell.exe';
238
+ try {
239
+ const subprocess = spawn(cli, ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', winPsPath], {
240
+ detached: true, // We still detach the PowerShell process itself
241
+ stdio: 'ignore',
242
+ cwd: `/mnt/${driveLetter}`
243
+ });
244
+ subprocess.unref();
245
+ }
246
+ catch (spawnErr) {
247
+ await ctx.actions.runAction('core:log', { level: 'error', message: `WSL Spawn Error: ${spawnErr.message}` });
147
248
  }
148
- const cli = 'cmd.exe';
149
- const subprocess = spawn(cli, ['/c', winBatPath], {
150
- detached: true,
151
- stdio: 'ignore',
152
- cwd: '/mnt/c'
153
- });
154
- subprocess.unref();
155
249
  return true;
156
250
  }
157
251
  else {
@@ -54,33 +54,49 @@ export const DownloaderPlugin = {
54
54
  if (isChecking)
55
55
  return true; // Skip if busy
56
56
  isChecking = true;
57
- try {
58
- const res = await client.get(`/jobs/${config.jobId}`);
59
- const job = res.data;
60
- const newVersion = job.version;
61
- // If no version in job yet, fall back to timestamp or ignore
62
- if (!newVersion && !lastModified) {
63
- // First run, just verify it exists
64
- // We might want to download anyway if we don't have it locally
57
+ const MAX_RETRIES = 3;
58
+ let attempt = 0;
59
+ while (attempt < MAX_RETRIES) {
60
+ try {
61
+ const res = await client.get(`/jobs/${config.jobId}`);
62
+ const job = res.data;
63
+ const newVersion = job.version;
64
+ // If no version in job yet, fall back to timestamp or ignore
65
+ if (!newVersion && !lastModified) {
66
+ // First run, just verify it exists
67
+ }
68
+ if (job.status === 'completed') {
69
+ if (newVersion !== lastModified) {
70
+ await ctx.actions.runAction('core:log', { level: 'info', message: `New version detected (Old: "${lastModified}", New: "${newVersion}")` });
71
+ const success = await ctx.actions.runAction('downloader:download', null);
72
+ if (success) {
73
+ lastModified = newVersion;
74
+ fs.writeFileSync(VERSION_FILE, newVersion);
75
+ ctx.events.emit('downloader:updated', { version: job.version, jobId: config.jobId });
76
+ }
77
+ }
78
+ }
79
+ else {
80
+ // await ctx.actions.runAction('core:log', { level: 'info', message: `Poll: Job status is ${job.status}` });
81
+ }
82
+ isChecking = false;
83
+ return true;
65
84
  }
66
- if (job.status === 'completed' && newVersion !== lastModified) {
67
- await ctx.actions.runAction('core:log', { level: 'info', message: `New version detected (Old: "${lastModified}", New: "${newVersion}")` });
68
- const success = await ctx.actions.runAction('downloader:download', null);
69
- if (success) {
70
- lastModified = newVersion;
71
- fs.writeFileSync(VERSION_FILE, newVersion);
72
- ctx.events.emit('downloader:updated', { version: job.version, jobId: config.jobId });
85
+ catch (error) {
86
+ attempt++;
87
+ const isNetworkError = error.code === 'EAI_AGAIN' || error.code === 'ENOTFOUND' || error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT';
88
+ if (attempt < MAX_RETRIES && isNetworkError) {
89
+ await ctx.actions.runAction('core:log', { level: 'warn', message: `Connection failed (${error.code}). Retrying (${attempt}/${MAX_RETRIES})...` });
90
+ await new Promise(r => setTimeout(r, 1000 * attempt)); // Backoff
91
+ continue;
73
92
  }
93
+ isChecking = false;
94
+ await ctx.actions.runAction('core:log', { level: 'error', message: `Check failed: ${error.message}` });
95
+ return false;
74
96
  }
75
- isChecking = false;
76
- return true;
77
- }
78
- catch (error) {
79
- isChecking = false;
80
- await ctx.actions.runAction('core:log', { level: 'error', message: `Check failed: ${error.message}` });
81
- // Return false only on actual error, so index.ts knows to fail
82
- return true;
83
97
  }
98
+ isChecking = false;
99
+ return false;
84
100
  }
85
101
  });
86
102
  // Action: Download
@@ -170,19 +186,22 @@ console.log('[Hot Reload] Active for Job:', CURRENT_JOB_ID);
170
186
  }
171
187
  });
172
188
  // Start Polling (Loop)
173
- const scheduleNextCheck = () => {
174
- checkInterval = setTimeout(async () => {
175
- if (!checkInterval)
176
- return; // Disposed
189
+ console.error('[DownloaderPlugin] Starting polling loop (Interval: 2000ms)');
190
+ checkInterval = setInterval(async () => {
191
+ try {
192
+ // Use actions for main log, but console.error for guaranteed debug output
193
+ // await ctx.actions.runAction('core:log', { level: 'info', message: '[DEBUG] Polling Tick...' });
194
+ console.error('[DownloaderPlugin] Tick - Checking Status...');
177
195
  await ctx.actions.runAction('downloader:check', null);
178
- scheduleNextCheck();
179
- }, 2000);
180
- };
181
- scheduleNextCheck();
196
+ }
197
+ catch (err) {
198
+ console.error('[DownloaderPlugin] Poll Error:', err);
199
+ }
200
+ }, 2000);
182
201
  },
183
202
  dispose(ctx) {
184
203
  if (checkInterval) {
185
- clearTimeout(checkInterval);
204
+ clearInterval(checkInterval);
186
205
  checkInterval = undefined;
187
206
  }
188
207
  }
@@ -24,6 +24,7 @@ export const ServerPlugin = {
24
24
  }
25
25
  if (req.url === '/status') {
26
26
  const currentJobId = ctx.host.config.jobId;
27
+ ctx.actions.runAction('core:log', { level: 'info', message: `[DEBUG] Server: Extension requested status (Reporting: ${currentVersion})` });
27
28
  res.writeHead(200, { 'Content-Type': 'application/json' });
28
29
  res.end(JSON.stringify({
29
30
  version: currentVersion,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-extension-preview",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Local preview tool for AI Extension Builder",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47,6 +47,7 @@
47
47
  "@types/ws": "^8.5.13",
48
48
  "shx": "^0.4.0",
49
49
  "tsx": "^4.21.0",
50
- "typescript": "^5.7.2"
50
+ "typescript": "^5.7.2",
51
+ "vitest": "^4.0.16"
51
52
  }
52
- }
53
+ }