ai-extension-preview 0.1.8 → 0.1.10

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.
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ async function authenticate(host) {
41
41
  const statusRes = await axios.get(`${host}/preview/status/${sessionId}`);
42
42
  const data = statusRes.data;
43
43
  if (data.status === 'linked') {
44
- console.log(chalk.green('✔ Connected!'));
44
+ // console.log(chalk.green('✔ Connected!'));
45
45
  if (!data.jobId) {
46
46
  console.error('Error: No Job ID associated with this connection.');
47
47
  process.exit(1);
@@ -97,7 +97,10 @@ async function main() {
97
97
  });
98
98
  // 2. Register Plugins
99
99
  // Note: In a real dynamic system we might load these from a folder
100
- runtime.logger.info('Registering plugins...');
100
+ // console.log('Registering plugins...');
101
+ // Register Plugins
102
+ // UI Plugin first or last?
103
+ // If first, it captures subsequent logs.
101
104
  runtime.registerPlugin(CorePlugin);
102
105
  runtime.registerPlugin(DownloaderPlugin);
103
106
  runtime.registerPlugin(BrowserPlugin);
@@ -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',
@@ -64,12 +111,6 @@ export const BrowserPlugin = {
64
111
  fs.ensureDirSync(STAGING_DIR);
65
112
  fs.copySync(DIST_DIR, STAGING_DIR);
66
113
  await ctx.actions.runAction('core:log', { level: 'info', message: `Synced code to Staging` });
67
- // DEBUG: Log contents of staging
68
- try {
69
- const files = fs.readdirSync(STAGING_DIR);
70
- await ctx.actions.runAction('core:log', { level: 'info', message: `Staging Contents: ${files.join(', ')}` });
71
- }
72
- catch (e) { }
73
114
  // Emit staged event for ServerPlugin (optional for now, but good practice)
74
115
  ctx.events.emit('browser:staged', { path: STAGING_DIR });
75
116
  }
@@ -77,35 +118,19 @@ export const BrowserPlugin = {
77
118
  await ctx.actions.runAction('core:log', { level: 'error', message: `Failed to sync to staging: ${err.message}` });
78
119
  }
79
120
  };
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
121
  // Initial Sync
102
122
  await syncToStaging();
103
123
  // Resolve proper root AFTER sync
104
124
  let extensionRoot = findExtensionRoot(STAGING_DIR) || STAGING_DIR;
105
125
  // 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.` });
126
+ const validation = validateExtension(extensionRoot);
127
+ if (!validation.valid) {
128
+ await ctx.actions.runAction('core:log', { level: 'error', message: `[CRITICAL] Extension validation failed: ${validation.error} in ${extensionRoot}` });
108
129
  await ctx.actions.runAction('core:log', { level: 'info', message: `Checked Path: ${extensionRoot}` });
130
+ // We proceed anyway? Or should we stop?
131
+ // Previous logic proceeded but logged critical error.
132
+ // Let's keep it logging critical but maybe return false if we wanted to be strict.
133
+ // However, user might fix it live.
109
134
  }
110
135
  else if (extensionRoot !== STAGING_DIR) {
111
136
  await ctx.actions.runAction('core:log', { level: 'info', message: `Detected nested extension at: ${path.basename(extensionRoot)}` });
@@ -114,44 +139,113 @@ export const BrowserPlugin = {
114
139
  ctx.events.on('downloader:updated', async (data) => {
115
140
  await ctx.actions.runAction('core:log', { level: 'info', message: 'Update detected. Syncing to staging...' });
116
141
  await syncToStaging();
142
+ // Re-validate on update?
143
+ // const newRoot = findExtensionRoot(STAGING_DIR) || STAGING_DIR;
144
+ // const newValidation = validateExtension(newRoot);
145
+ // if (!newValidation.valid) ...
117
146
  });
118
147
  await ctx.actions.runAction('core:log', { level: 'info', message: 'Browser running in Detached Mode.' });
119
148
  // Launch Logic
149
+ // Launch Logic
120
150
  if (isWSL) {
121
- const driveLetter = chromePath.match(/\/mnt\/([a-z])\//)?.[1] || 'c';
151
+ // -------------------------------------------------------------------------
152
+ // WSL STRATEGY (Validated 2025-12-24)
153
+ // 1. Use Windows User Profile for staging to avoid Permission/Path issues
154
+ // 2. Use PowerShell script to launch Chrome to reliably pass arguments
155
+ // -------------------------------------------------------------------------
156
+ // 1. Setup Safe Paths (C:\Temp)
157
+ // We use the same path that syncToStaging() used (/mnt/c/Temp/ai-ext-preview)
158
+ const winStagingDir = 'C:\\Temp\\ai-ext-preview';
159
+ const winProfile = 'C:\\Temp\\ai-ext-profile';
160
+ let userProfileWin = 'C:\\Temp'; // Legacy variable support
161
+ const driveLetter = 'c';
162
+ // Calculate final paths
163
+ let finalWinExtensionPath = winStagingDir;
164
+ // Handle nested extension root
165
+ if (extensionRoot !== STAGING_DIR) {
166
+ const relative = path.relative(STAGING_DIR, extensionRoot);
167
+ finalWinExtensionPath = path.posix.join(winStagingDir.replace(/\\/g, '/'), relative).replace(/\//g, '\\');
168
+ }
122
169
  const winChromePath = chromePath
123
170
  .replace(new RegExp(`^/mnt/${driveLetter}/`), `${driveLetter.toUpperCase()}:\\`)
124
171
  .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
172
+ await ctx.actions.runAction('core:log', { level: 'info', message: `WSL Launch Target (Win): ${finalWinExtensionPath}` });
173
+ // await ctx.actions.runAction('core:log', { level: 'info', message: `WSL Profile (Win): ${winProfile}` });
174
+ // Create PowerShell Launch Script
175
+ const psContent = `
176
+ $chromePath = "${winChromePath}"
177
+ $extPath = "${finalWinExtensionPath}"
178
+ $profilePath = "${winProfile}"
179
+
180
+ Write-Host "DEBUG: ChromePath: $chromePath"
181
+ Write-Host "DEBUG: ExtPath: $extPath"
182
+ Write-Host "DEBUG: ProfilePath: $profilePath"
183
+
184
+ # Verify Paths
185
+ if (-not (Test-Path -Path $extPath)) {
186
+ Write-Host "ERROR: Extension Path NOT FOUND!"
187
+ } else {
188
+ Write-Host "DEBUG: Extension Path Exists."
189
+ }
190
+
191
+ # Create Profile Dir if needed
192
+ if (-not (Test-Path -Path $profilePath)) {
193
+ New-Item -ItemType Directory -Force -Path $profilePath | Out-Null
194
+ }
195
+
196
+ $argsList = @(
197
+ "--load-extension=""$extPath""",
198
+ "--user-data-dir=""$profilePath""",
199
+ "--no-first-run",
200
+ "--no-default-browser-check",
201
+ "--disable-gpu",
202
+ "about:blank"
203
+ )
204
+
205
+ # Convert to single string to ensure Start-Process handles it safely
206
+ $argStr = $argsList -join " "
207
+ Write-Host "DEBUG: Args: $argStr"
208
+
209
+ Write-Host "DEBUG: Launching Chrome..."
210
+ Start-Process -FilePath $chromePath -ArgumentList $argStr
139
211
  `;
140
- const batPath = path.join(STAGING_DIR, 'launch.bat');
141
- const winBatPath = 'C:\\Temp\\ai-ext-preview\\launch.bat';
212
+ // Write ps1 to /mnt/c/Temp/ai-ext-preview/launch.ps1 (Same as STAGING_DIR)
213
+ const psPath = path.join(STAGING_DIR, 'launch.ps1');
142
214
  try {
143
- fs.writeFileSync(batPath, batContent);
215
+ await fs.writeFile(psPath, psContent);
144
216
  }
145
217
  catch (e) {
146
- await ctx.actions.runAction('core:log', { level: 'error', message: `WSL Write Bat Failed: ${e.message}` });
218
+ await ctx.actions.runAction('core:log', { level: 'error', message: `WSL Write PS1 Failed: ${e.message}` });
147
219
  }
148
- const cli = 'cmd.exe';
149
- const subprocess = spawn(cli, ['/c', winBatPath], {
220
+ // Execute via PowerShell (Spawn detached)
221
+ // psPathWin is C:\\Temp\\ai-ext-preview\\launch.ps1
222
+ const psPathWin = `${winStagingDir}\\launch.ps1`;
223
+ const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', psPathWin], {
150
224
  detached: true,
151
- stdio: 'ignore',
152
- cwd: '/mnt/c'
225
+ stdio: ['ignore', 'pipe', 'pipe'] // Pipe stderr AND stdout to catch launch errors/debug
226
+ });
227
+ if (child.stdout) {
228
+ child.stdout.on('data', async (chunk) => {
229
+ const msg = chunk.toString();
230
+ await ctx.actions.runAction('core:log', { level: 'info', message: `[PS1] ${msg.trim()}` });
231
+ });
232
+ }
233
+ if (child.stderr) {
234
+ child.stderr.on('data', async (chunk) => {
235
+ const msg = chunk.toString();
236
+ await ctx.actions.runAction('core:log', { level: 'error', message: `Launch Error (Stderr): ${msg}` });
237
+ if (msg.includes('Exec format error')) {
238
+ await ctx.actions.runAction('core:log', { level: 'error', message: `CRITICAL: WSL Interop is broken. Cannot launch Chrome.` });
239
+ await ctx.actions.runAction('core:log', { level: 'error', message: `FIX: Open PowerShell as Admin and run: wsl --shutdown` });
240
+ ctx.events.emit('browser:launch-failed', { reason: 'WSL_INTEROP_BROKEN' });
241
+ }
242
+ });
243
+ }
244
+ child.on('error', async (err) => {
245
+ await ctx.actions.runAction('core:log', { level: 'error', message: `Launch Failed: ${err.message}` });
246
+ ctx.events.emit('browser:launch-failed', { reason: err.message });
153
247
  });
154
- subprocess.unref();
248
+ child.unref();
155
249
  return true;
156
250
  }
157
251
  else {
@@ -32,7 +32,10 @@ export const CorePlugin = {
32
32
  break;
33
33
  default:
34
34
  logger.info(message);
35
+ break;
35
36
  }
37
+ // Emit event for UI
38
+ ctx.events.emit('log', { level, message, timestamp: new Date().toISOString() });
36
39
  return true;
37
40
  }
38
41
  });
@@ -54,33 +54,58 @@ 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
+ // Check if files actually exist
70
+ let forceDownload = false;
71
+ const manifestPath = path.join(DIST_DIR, 'manifest.json');
72
+ if (!fs.existsSync(manifestPath)) {
73
+ await ctx.actions.runAction('core:log', { level: 'warn', message: 'Version match but files missing. Forcing download...' });
74
+ forceDownload = true;
75
+ }
76
+ if (newVersion !== lastModified || forceDownload) {
77
+ if (newVersion !== lastModified) {
78
+ await ctx.actions.runAction('core:log', { level: 'info', message: `New version detected (Old: "${lastModified}", New: "${newVersion}")` });
79
+ }
80
+ const success = await ctx.actions.runAction('downloader:download', null);
81
+ if (success) {
82
+ lastModified = newVersion;
83
+ fs.writeFileSync(VERSION_FILE, newVersion);
84
+ ctx.events.emit('downloader:updated', { version: job.version, jobId: config.jobId });
85
+ }
86
+ }
87
+ }
88
+ else {
89
+ // await ctx.actions.runAction('core:log', { level: 'info', message: `Poll: Job status is ${job.status}` });
90
+ }
91
+ isChecking = false;
92
+ return true;
65
93
  }
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 });
94
+ catch (error) {
95
+ attempt++;
96
+ const isNetworkError = error.code === 'EAI_AGAIN' || error.code === 'ENOTFOUND' || error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT';
97
+ if (attempt < MAX_RETRIES && isNetworkError) {
98
+ await ctx.actions.runAction('core:log', { level: 'warn', message: `Connection failed (${error.code}). Retrying (${attempt}/${MAX_RETRIES})...` });
99
+ await new Promise(r => setTimeout(r, 1000 * attempt)); // Backoff
100
+ continue;
73
101
  }
102
+ isChecking = false;
103
+ await ctx.actions.runAction('core:log', { level: 'error', message: `Check failed: ${error.message}` });
104
+ return false;
74
105
  }
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
106
  }
107
+ isChecking = false;
108
+ return false;
84
109
  }
85
110
  });
86
111
  // Action: Download
@@ -170,19 +195,32 @@ console.log('[Hot Reload] Active for Job:', CURRENT_JOB_ID);
170
195
  }
171
196
  });
172
197
  // Start Polling (Loop)
173
- const scheduleNextCheck = () => {
174
- checkInterval = setTimeout(async () => {
175
- if (!checkInterval)
176
- return; // Disposed
198
+ void ctx.actions.runAction('core:log', { level: 'info', message: 'Starting polling loop (Interval: 2000ms)' });
199
+ // Listen for browser failure to stop polling
200
+ ctx.events.on('browser:launch-failed', () => {
201
+ if (checkInterval) {
202
+ clearInterval(checkInterval);
203
+ checkInterval = undefined;
204
+ ctx.actions.runAction('core:log', { level: 'warn', message: 'Polling stopped due to browser launch failure.' });
205
+ // Update status happens in UI
206
+ }
207
+ });
208
+ checkInterval = setInterval(async () => {
209
+ try {
210
+ // Use actions for main log (UI Plugin captures this)
211
+ // console.error('[DownloaderPlugin] Tick - Checking Status...'); // REMOVE (Outside UI)
212
+ // Silent polling for CLI mode
213
+ // await ctx.actions.runAction('core:log', { level: 'info', message: '[DEBUG] Polling...' });
177
214
  await ctx.actions.runAction('downloader:check', null);
178
- scheduleNextCheck();
179
- }, 2000);
180
- };
181
- scheduleNextCheck();
215
+ }
216
+ catch (err) {
217
+ await ctx.actions.runAction('core:log', { level: 'error', message: `Poll Error: ${err.message}` });
218
+ }
219
+ }, 2000);
182
220
  },
183
221
  dispose(ctx) {
184
222
  if (checkInterval) {
185
- clearTimeout(checkInterval);
223
+ clearInterval(checkInterval);
186
224
  checkInterval = undefined;
187
225
  }
188
226
  }
@@ -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.10",
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
  }