ai-extension-preview 0.1.7 → 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,6 +22,62 @@ function findChrome() {
22
22
  }
23
23
  return null;
24
24
  }
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) => {
47
+ // Handle Git Bash /c/ style
48
+ const gitBashMatch = p.match(/^\/([a-z])\/(.*)/i);
49
+ if (gitBashMatch) {
50
+ return `${gitBashMatch[1].toUpperCase()}:\\${gitBashMatch[2].replace(/\//g, '\\')}`;
51
+ }
52
+ // Handle Forward slashes
53
+ return p.replace(/\//g, '\\');
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
+ };
25
81
  export const BrowserPlugin = {
26
82
  name: 'browser',
27
83
  version: '1.0.0',
@@ -36,7 +92,11 @@ export const BrowserPlugin = {
36
92
  return false;
37
93
  }
38
94
  const isWSL = fs.existsSync('/mnt/c');
39
- let executable = chromePath; // Define in scope
95
+ let executable = chromePath;
96
+ // Normalize Executable for Native Windows (Git Bash)
97
+ if (!isWSL && process.platform === 'win32') {
98
+ executable = normalizePathToWindows(chromePath);
99
+ }
40
100
  const STAGING_DIR = isWSL ? '/mnt/c/Temp/ai-ext-preview' : path.join(config.workDir, '../staging');
41
101
  const WIN_PROFILE_DIR = 'C:/Temp/ai-ext-profile';
42
102
  // For native windows/linux, use local staging path
@@ -51,6 +111,12 @@ export const BrowserPlugin = {
51
111
  fs.ensureDirSync(STAGING_DIR);
52
112
  fs.copySync(DIST_DIR, STAGING_DIR);
53
113
  await ctx.actions.runAction('core:log', { level: 'info', message: `Synced code to Staging` });
114
+ // DEBUG: Log contents of staging
115
+ try {
116
+ const files = fs.readdirSync(STAGING_DIR);
117
+ await ctx.actions.runAction('core:log', { level: 'info', message: `Staging Contents: ${files.join(', ')}` });
118
+ }
119
+ catch (e) { }
54
120
  // Emit staged event for ServerPlugin (optional for now, but good practice)
55
121
  ctx.events.emit('browser:staged', { path: STAGING_DIR });
56
122
  }
@@ -58,34 +124,19 @@ export const BrowserPlugin = {
58
124
  await ctx.actions.runAction('core:log', { level: 'error', message: `Failed to sync to staging: ${err.message}` });
59
125
  }
60
126
  };
61
- // --- Helper to find actual extension root (handle nested folder in zip) ---
62
- const findExtensionRoot = (dir) => {
63
- if (fs.existsSync(path.join(dir, 'manifest.json')))
64
- return dir;
65
- // Check immediate subdirectories (depth 1)
66
- try {
67
- const items = fs.readdirSync(dir);
68
- for (const item of items) {
69
- const fullPath = path.join(dir, item);
70
- if (fs.statSync(fullPath).isDirectory()) {
71
- if (fs.existsSync(path.join(fullPath, 'manifest.json'))) {
72
- return fullPath;
73
- }
74
- }
75
- }
76
- }
77
- catch (e) {
78
- // Dir might be empty or invalid
79
- }
80
- return null;
81
- };
82
127
  // Initial Sync
83
128
  await syncToStaging();
84
129
  // Resolve proper root AFTER sync
85
130
  let extensionRoot = findExtensionRoot(STAGING_DIR) || STAGING_DIR;
86
131
  // Check if we found a valid root
87
- if (!fs.existsSync(path.join(extensionRoot, 'manifest.json'))) {
88
- await ctx.actions.runAction('core:log', { level: 'error', message: `[CRITICAL] manifest.json not found in ${STAGING_DIR}. 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}` });
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.
89
140
  }
90
141
  else if (extensionRoot !== STAGING_DIR) {
91
142
  await ctx.actions.runAction('core:log', { level: 'info', message: `Detected nested extension at: ${path.basename(extensionRoot)}` });
@@ -94,43 +145,107 @@ export const BrowserPlugin = {
94
145
  ctx.events.on('downloader:updated', async (data) => {
95
146
  await ctx.actions.runAction('core:log', { level: 'info', message: 'Update detected. Syncing to staging...' });
96
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) ...
97
152
  });
98
153
  await ctx.actions.runAction('core:log', { level: 'info', message: 'Browser running in Detached Mode.' });
99
154
  // Launch Logic
155
+ // Launch Logic
100
156
  if (isWSL) {
101
- 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
+ }
102
200
  const winChromePath = chromePath
103
201
  .replace(new RegExp(`^/mnt/${driveLetter}/`), `${driveLetter.toUpperCase()}:\\`)
104
202
  .replace(/\//g, '\\');
105
- // Calculate Windows path for the extension root
106
- // Base Win: C:\Temp\ai-ext-preview
107
- // Base Linux: /mnt/c/Temp/ai-ext-preview
108
- // If extensionRoot is /mnt/c/Temp/ai-ext-preview/subdir => C:\Temp\ai-ext-preview\subdir
109
- // Use relative path logic to be safe
110
- const baseLinux = '/mnt/c/Temp/ai-ext-preview';
111
- const relative = path.relative(baseLinux, extensionRoot);
112
- const winDistRoot = relative ? `C:\\Temp\\ai-ext-preview\\${relative}` : 'C:\\Temp\\ai-ext-preview';
113
- const finalWinDist = winDistRoot.replace(/\//g, '\\');
114
- const winProfile = 'C:\\Temp\\ai-ext-profile';
115
- const batContent = `@echo off
116
- start "" "${winChromePath}" --load-extension="${finalWinDist}" --user-data-dir="${winProfile}" --no-first-run --no-default-browser-check --disable-gpu about:blank
117
- 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
118
227
  `;
119
- const batPath = path.join(STAGING_DIR, 'launch.bat');
120
- 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, '\\');
121
230
  try {
122
- fs.writeFileSync(batPath, batContent);
231
+ await fs.writeFile(psPath, psContent);
123
232
  }
124
233
  catch (e) {
125
- // Fallback if staging writes fail inside WSL mount for some reason?
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}` });
126
248
  }
127
- const cli = 'cmd.exe';
128
- const subprocess = spawn(cli, ['/c', winBatPath], {
129
- detached: true,
130
- stdio: 'ignore',
131
- cwd: '/mnt/c'
132
- });
133
- subprocess.unref();
134
249
  return true;
135
250
  }
136
251
  else {
@@ -138,8 +253,8 @@ exit
138
253
  // Use extensionRoot which points to the detected subfolder or root
139
254
  const safeDist = path.resolve(extensionRoot);
140
255
  const safeProfile = path.join(path.dirname(config.workDir), 'profile'); // ~/.ai-extension-preview/profile
141
- await ctx.actions.runAction('core:log', { level: 'info', message: `SPAWN: ${executable}` });
142
- await ctx.actions.runAction('core:log', { level: 'info', message: `EXT PATH: ${safeDist}` });
256
+ await ctx.actions.runAction('core:log', { level: 'info', message: `Native Launch Executable: ${executable}` });
257
+ await ctx.actions.runAction('core:log', { level: 'info', message: `Native Launch Target: ${safeDist}` });
143
258
  const cleanArgs = [
144
259
  `--load-extension=${safeDist}`,
145
260
  `--user-data-dir=${safeProfile}`,
@@ -148,11 +263,16 @@ exit
148
263
  '--disable-gpu',
149
264
  'chrome://extensions'
150
265
  ];
151
- const subprocess = spawn(executable, cleanArgs, {
152
- detached: true,
153
- stdio: 'ignore'
154
- });
155
- subprocess.unref();
266
+ try {
267
+ const subprocess = spawn(executable, cleanArgs, {
268
+ detached: true,
269
+ stdio: 'ignore'
270
+ });
271
+ subprocess.unref();
272
+ }
273
+ catch (spawnErr) {
274
+ await ctx.actions.runAction('core:log', { level: 'error', message: `Spawn Failed: ${spawnErr.message}` });
275
+ }
156
276
  return true;
157
277
  }
158
278
  };
@@ -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.7",
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
  }