ai-extension-preview 0.1.13 → 0.1.15

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
@@ -2,19 +2,18 @@
2
2
  import 'dotenv/config'; // Load .env
3
3
  import { Command } from 'commander';
4
4
  import path from 'path';
5
- import { fileURLToPath } from 'url';
6
- import fs from 'fs-extra';
7
5
  import os from 'os';
8
6
  import { Runtime } from 'skeleton-crew-runtime';
7
+ import { ConfigPlugin } from './plugins/ConfigPlugin.js';
9
8
  import { CorePlugin } from './plugins/CorePlugin.js';
10
9
  import { DownloaderPlugin } from './plugins/DownloaderPlugin.js';
11
10
  import { BrowserManagerPlugin } from './plugins/browser/BrowserManagerPlugin.js';
12
11
  import { WSLLauncherPlugin } from './plugins/browser/WSLLauncherPlugin.js';
13
12
  import { NativeLauncherPlugin } from './plugins/browser/NativeLauncherPlugin.js';
14
13
  import { ServerPlugin } from './plugins/ServerPlugin.js';
15
- import axios from 'axios';
14
+ import { AuthPlugin } from './plugins/AuthPlugin.js'; // [NEW]
15
+ import { AppPlugin } from './plugins/AppPlugin.js'; // [NEW]
16
16
  import chalk from 'chalk';
17
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
18
17
  const DEFAULT_HOST = process.env.API_HOST || 'https://ai-extension-builder.01kb6018z1t9tpaza4y5f1c56w.lmapp.run/api';
19
18
  const program = new Command();
20
19
  program
@@ -26,138 +25,53 @@ program
26
25
  .option('--user <user>', 'User ID (if required)')
27
26
  .parse(process.argv);
28
27
  const options = program.opts();
29
- async function authenticate(host, port) {
30
- try {
31
- // 1. Init Session with port
32
- console.log('[DEBUG] Sending port to backend:', port);
33
- const initRes = await axios({
34
- method: 'post',
35
- url: `${host}/preview/init`,
36
- data: { port },
37
- headers: {
38
- 'Content-Type': 'application/json'
39
- }
40
- });
41
- console.log('[DEBUG] Init response:', initRes.data);
42
- const { code, sessionId } = initRes.data;
43
- console.log('\n' + chalk.bgBlue.bold(' DETACHED PREVIEW MODE ') + '\n');
44
- console.log('To connect, please go to your Extension Dashboard and click "Connect Preview".');
45
- console.log('Enter the following code:');
46
- console.log('\n' + chalk.green.bold(` ${code} `) + '\n');
47
- console.log('Waiting for connection...');
48
- // 2. Poll for Status
49
- while (true) {
50
- await new Promise(resolve => setTimeout(resolve, 2000));
51
- try {
52
- const statusRes = await axios.get(`${host}/preview/status/${sessionId}`);
53
- const data = statusRes.data;
54
- if (data.status === 'linked') {
55
- console.log(chalk.green('✔ Connected!'));
56
- if (!data.jobId) {
57
- console.error('Error: No Job ID associated with this connection.');
58
- process.exit(1);
59
- }
60
- console.log('[DEBUG] Received userId:', data.userId);
61
- console.log('[DEBUG] Received jobId:', data.jobId);
62
- return {
63
- jobId: data.jobId,
64
- userId: data.userId,
65
- token: data.token || ''
66
- };
67
- }
68
- if (data.status === 'expired') {
69
- console.error(chalk.red('Code expired. Please restart.'));
70
- process.exit(1);
71
- }
72
- }
73
- catch (err) {
74
- // Ignore poll errors, keep trying
75
- }
76
- }
77
- }
78
- catch (error) {
79
- console.error('Authentication failed:', error);
80
- throw error;
81
- }
82
- }
83
28
  // Use os.homedir() to ensure we have write permissions
84
- // Git Bash sometimes defaults cwd to C:\Program Files\Git which causes EPERM
85
29
  const HOME_DIR = os.homedir();
86
- const WORK_DIR = path.join(HOME_DIR, '.ai-extension-preview', options.job || 'default'); // Use default if job not provided yet
30
+ // Initial workdir based on options, or specific 'default' if not yet known.
31
+ // AuthPlugin will update this if job changes.
32
+ const WORK_DIR = path.join(HOME_DIR, '.ai-extension-preview', options.job || 'default');
87
33
  (async () => {
88
- const { job: jobId, host, token, user: userId } = options;
89
- // 1. Initialize Runtime first to allocate port
34
+ const { job: initialJobId, host, token, user: userId } = options;
35
+ // 1. Initialize Runtime with Config
90
36
  const runtime = new Runtime({
91
- hostContext: {
92
- config: {
93
- host,
94
- token: token || '',
95
- user: userId || '',
96
- jobId: jobId || '',
97
- workDir: WORK_DIR
98
- }
99
- }
37
+ config: {
38
+ host,
39
+ token: token || '',
40
+ user: userId || '',
41
+ jobId: initialJobId || '',
42
+ workDir: WORK_DIR
43
+ },
44
+ hostContext: {} // Clear hostContext config wrapping
100
45
  });
46
+ // Register Plugins
101
47
  runtime.logger.info('Registering plugins...');
102
48
  runtime.registerPlugin(CorePlugin);
49
+ runtime.registerPlugin(ConfigPlugin);
103
50
  runtime.registerPlugin(DownloaderPlugin);
104
51
  runtime.registerPlugin(BrowserManagerPlugin);
105
52
  runtime.registerPlugin(WSLLauncherPlugin);
106
53
  runtime.registerPlugin(NativeLauncherPlugin);
107
54
  runtime.registerPlugin(ServerPlugin);
55
+ runtime.registerPlugin(AuthPlugin); // [NEW]
56
+ runtime.registerPlugin(AppPlugin); // [NEW]
108
57
  runtime.logger.info('Initializing runtime...');
109
58
  await runtime.initialize();
110
59
  const ctx = runtime.getContext();
111
- // Get allocated port from ServerPlugin
112
- const allocatedPort = ctx.hotReloadPort;
113
- if (!allocatedPort) {
114
- console.error('Failed to allocate server port');
115
- process.exit(1);
116
- }
117
- // 2. Now authenticate with the allocated port
118
- let finalJobId = jobId;
119
- let finalUserId = userId;
120
- let finalToken = token;
121
- if (!jobId || !userId) {
122
- const authData = await authenticate(host, allocatedPort);
123
- finalJobId = authData.jobId;
124
- finalUserId = authData.userId;
125
- finalToken = authData.token;
126
- // Update runtime config with auth data
127
- ctx.host.config.jobId = finalJobId;
128
- ctx.host.config.user = finalUserId;
129
- ctx.host.config.token = finalToken;
130
- }
131
- // 3. Start LifeCycle
132
- await ctx.actions.runAction('core:log', { level: 'info', message: 'Initializing Local Satellite...' });
133
- // Ensure work dir exists
134
- await fs.ensureDir(WORK_DIR);
135
- // Initial Check - Must succeed to continue
136
- const success = await ctx.actions.runAction('downloader:check', null);
137
- if (!success) {
138
- await ctx.actions.runAction('core:log', { level: 'error', message: 'Initial check failed. Could not verify job or download extension.' });
139
- process.exit(1);
140
- }
141
- // Wait for Extension files (Manifest)
142
- const manifestPath = path.join(WORK_DIR, 'dist', 'manifest.json');
143
- let attempts = 0;
144
- const maxAttempts = 60; // 2 minutes
145
- console.log('[DEBUG] Waiting for extension files...');
146
- while (!fs.existsSync(manifestPath) && attempts < maxAttempts) {
147
- await new Promise(r => setTimeout(r, 2000));
148
- attempts++;
149
- if (attempts % 5 === 0)
150
- console.log(`Waiting for extension generation... (${attempts * 2}s)`);
60
+ // 2. Start App Flow
61
+ try {
62
+ await ctx.actions.runAction('app:start', null);
151
63
  }
152
- if (!fs.existsSync(manifestPath)) {
153
- await ctx.actions.runAction('core:log', { level: 'error', message: 'Timed out waiting for extension files. Status check succeeded but files are missing.' });
64
+ catch (error) {
65
+ console.error(chalk.red('App Error:'), error.message);
66
+ await runtime.shutdown();
154
67
  process.exit(1);
155
68
  }
156
- // Launch Browser
157
- await ctx.actions.runAction('browser:start', {});
158
- // Keep process alive
69
+ // Keep process alive handled by Node event loop because ServerPlugin has an open server
70
+ // and Browser processes might be attached.
71
+ // Graceful Shutdown
159
72
  process.on('SIGINT', async () => {
160
73
  await ctx.actions.runAction('core:log', { level: 'info', message: 'Shutting down...' });
74
+ await runtime.shutdown();
161
75
  process.exit(0);
162
76
  });
163
77
  runtime.logger.info('Press Ctrl+C to exit.');
@@ -168,7 +82,6 @@ const WORK_DIR = path.join(HOME_DIR, '.ai-extension-preview', options.job || 'de
168
82
  // Handle global errors
169
83
  process.on('uncaughtException', (err) => {
170
84
  if (err.code === 'ECONNRESET' || err.message?.includes('ECONNRESET')) {
171
- // Ignore pipe errors frequently caused by web-ext/chrome teardown
172
85
  return;
173
86
  }
174
87
  console.error('Uncaught Exception:', err);
@@ -0,0 +1,58 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ export const AppPlugin = {
4
+ name: 'app',
5
+ version: '1.0.0',
6
+ dependencies: ['auth', 'config', 'downloader', 'browser-manager', 'server'],
7
+ setup(ctx) {
8
+ ctx.actions.registerAction({
9
+ id: 'app:start',
10
+ handler: async () => {
11
+ await ctx.actions.runAction('core:log', { level: 'info', message: 'Initializing Local Satellite...' });
12
+ // 1. Authenticate (if needed)
13
+ // AuthPlugin will automatically skip if already config'd, or prompt if needed
14
+ // It will also update config via config:set
15
+ await ctx.actions.runAction('auth:login');
16
+ // 2. Validate Configuration (Now that we have potential Auth data)
17
+ try {
18
+ await ctx.actions.runAction('config:validate', null);
19
+ }
20
+ catch (e) {
21
+ throw new Error(`Configuration Invalid: ${e.message}`);
22
+ }
23
+ // 3. Get Updated Config
24
+ const workDir = ctx.config.workDir;
25
+ // 3. Ensure Work Directory
26
+ await fs.ensureDir(workDir);
27
+ // 4. Initial Download/Check
28
+ const success = await ctx.actions.runAction('downloader:check', null);
29
+ if (!success) {
30
+ await ctx.actions.runAction('core:log', { level: 'error', message: 'Initial check failed. Could not verify job or download extension.' });
31
+ // We don't exit process here, but we might throw to stop flow
32
+ throw new Error('Initial check failed');
33
+ }
34
+ // 5. Wait for Extension Manifest
35
+ const manifestPath = path.join(workDir, 'dist', 'manifest.json');
36
+ let attempts = 0;
37
+ const maxAttempts = 60; // 2 minutes
38
+ // This logic could be in a 'watcher' plugin but fits here for now as part of "Startup Sequence"
39
+ if (!fs.existsSync(manifestPath)) {
40
+ await ctx.actions.runAction('core:log', { level: 'info', message: '[DEBUG] Waiting for extension files...' });
41
+ while (!fs.existsSync(manifestPath) && attempts < maxAttempts) {
42
+ await new Promise(r => setTimeout(r, 2000));
43
+ attempts++;
44
+ if (attempts % 5 === 0) {
45
+ await ctx.actions.runAction('core:log', { level: 'info', message: `Waiting for extension generation... (${attempts * 2}s)` });
46
+ }
47
+ }
48
+ }
49
+ if (!fs.existsSync(manifestPath)) {
50
+ await ctx.actions.runAction('core:log', { level: 'error', message: 'Timed out waiting for extension files. Status check succeeded but files are missing.' });
51
+ throw new Error('Timeout waiting for files');
52
+ }
53
+ // 6. Launch Browser
54
+ await ctx.actions.runAction('browser:start', {});
55
+ }
56
+ });
57
+ }
58
+ };
@@ -0,0 +1,88 @@
1
+ import axios from 'axios';
2
+ import chalk from 'chalk';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ export const AuthPlugin = {
6
+ name: 'auth',
7
+ version: '1.0.0',
8
+ dependencies: ['config', 'server'],
9
+ setup(ctx) {
10
+ ctx.actions.registerAction({
11
+ id: 'auth:login',
12
+ handler: async () => {
13
+ const hostContext = ctx.config;
14
+ // If we already have JobID and UserID, we might skip, but let's assume we need to verify or start fresh if missing
15
+ if (hostContext.jobId && hostContext.user) {
16
+ await ctx.actions.runAction('core:log', { level: 'info', message: 'Auth: Job ID and User ID present. Skipping login.' });
17
+ return { jobId: hostContext.jobId, user: hostContext.user, token: hostContext.token };
18
+ }
19
+ // We need the port from ServerPlugin
20
+ // We need the port from ServerPlugin
21
+ const allocatedPort = ctx.config.hotReloadPort;
22
+ if (!allocatedPort) {
23
+ throw new Error('Server port not found. Ensure ServerPlugin is loaded before AuthPlugin logic runs.');
24
+ }
25
+ const host = hostContext.host;
26
+ await ctx.actions.runAction('core:log', { level: 'info', message: `Auth: Initiating login flow on ${host} with port ${allocatedPort}` });
27
+ try {
28
+ // 1. Init Session with port
29
+ const initRes = await axios({
30
+ method: 'post',
31
+ url: `${host}/preview/init`,
32
+ data: { port: allocatedPort },
33
+ headers: { 'Content-Type': 'application/json' }
34
+ });
35
+ const { code, sessionId } = initRes.data;
36
+ console.log('\n' + chalk.bgBlue.bold(' DETACHED PREVIEW MODE ') + '\n');
37
+ console.log('To connect, please go to your Extension Dashboard and click "Connect Preview".');
38
+ console.log('Enter the following code:');
39
+ console.log('\n' + chalk.green.bold(` ${code} `) + '\n');
40
+ console.log('Waiting for connection...');
41
+ // 2. Poll for Status
42
+ let attempts = 0;
43
+ while (true) {
44
+ await new Promise(resolve => setTimeout(resolve, 2000));
45
+ attempts++;
46
+ // Check if we should abort (e.g. from a cancel signal? unimplemented for now)
47
+ try {
48
+ const statusRes = await axios.get(`${host}/preview/status/${sessionId}`);
49
+ const data = statusRes.data;
50
+ if (data.status === 'linked') {
51
+ console.log(chalk.green('✔ Connected!'));
52
+ if (!data.jobId) {
53
+ throw new Error('No Job ID associated with this connection.');
54
+ }
55
+ const authData = {
56
+ jobId: data.jobId,
57
+ user: data.userId,
58
+ token: data.token || ''
59
+ };
60
+ // UPGRADE CONFIG
61
+ await ctx.actions.runAction('config:set', {
62
+ jobId: authData.jobId,
63
+ user: authData.user,
64
+ token: authData.token,
65
+ workDir: path.join(os.homedir(), '.ai-extension-preview', authData.jobId)
66
+ });
67
+ return authData;
68
+ }
69
+ if (data.status === 'expired') {
70
+ throw new Error('Code expired. Please restart.');
71
+ }
72
+ }
73
+ catch (err) {
74
+ if (err.message && (err.message.includes('expired') || err.message.includes('No Job ID'))) {
75
+ throw err;
76
+ }
77
+ // Ignore poll errors
78
+ }
79
+ }
80
+ }
81
+ catch (error) {
82
+ await ctx.actions.runAction('core:log', { level: 'error', message: `Authentication failed: ${error.message}` });
83
+ throw error;
84
+ }
85
+ }
86
+ });
87
+ }
88
+ };
@@ -0,0 +1,51 @@
1
+ import { z } from 'zod';
2
+ export const ConfigPlugin = {
3
+ name: 'config',
4
+ version: '1.0.0',
5
+ dependencies: [],
6
+ async setup(ctx) {
7
+ // 1. Define Schema
8
+ const configSchema = z.object({
9
+ host: z.string().url(),
10
+ jobId: z.string().min(1, "Job ID is required"),
11
+ token: z.string().optional(),
12
+ user: z.string().optional(),
13
+ workDir: z.string()
14
+ });
15
+ ctx.actions.registerAction({
16
+ id: 'config:validate',
17
+ handler: async () => {
18
+ const config = ctx.config;
19
+ try {
20
+ configSchema.parse(config);
21
+ return true;
22
+ }
23
+ catch (error) {
24
+ if (error instanceof z.ZodError) {
25
+ const issues = error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join(', ');
26
+ await ctx.actions.runAction('core:log', { level: 'error', message: `Config Validation Failed: ${issues}` });
27
+ }
28
+ throw error;
29
+ }
30
+ }
31
+ });
32
+ // [NEW] Allow runtime config updates
33
+ ctx.actions.registerAction({
34
+ id: 'config:set',
35
+ handler: async (payload) => {
36
+ ctx.getRuntime().updateConfig(payload);
37
+ const config = ctx.config;
38
+ // Validate after set? Optional, but good practice.
39
+ try {
40
+ configSchema.parse(config);
41
+ }
42
+ catch (e) {
43
+ // Log but don't revert for now, trust the caller or add rollback logic if needed.
44
+ // Just warn for now
45
+ await ctx.actions.runAction('core:log', { level: 'warn', message: 'Config updated but validation failed. Some features may not work.' });
46
+ }
47
+ return config;
48
+ }
49
+ });
50
+ }
51
+ };
@@ -4,8 +4,7 @@ export const CorePlugin = {
4
4
  version: '1.0.0',
5
5
  setup(ctx) {
6
6
  console.log('CorePlugin: setup called');
7
- // We assume config is passed in hostContext
8
- const config = ctx.host.config;
7
+ const config = ctx.config;
9
8
  ctx.actions.registerAction({
10
9
  id: 'core:config',
11
10
  handler: async () => config
@@ -15,9 +14,7 @@ export const CorePlugin = {
15
14
  id: 'core:log',
16
15
  handler: async (payload) => {
17
16
  // Access default logger from Runtime
18
- const rt = typeof ctx.getRuntime === 'function' ? ctx.getRuntime() : ctx.runtime;
19
- // Logger is now public
20
- const logger = rt.logger || console;
17
+ const logger = ctx.logger;
21
18
  const { level, message } = payload;
22
19
  switch (level) {
23
20
  case 'error':
@@ -8,12 +8,20 @@ let checkInterval;
8
8
  export const DownloaderPlugin = {
9
9
  name: 'downloader',
10
10
  version: '1.0.0',
11
+ dependencies: ['config'],
11
12
  setup(ctx) {
12
- const config = ctx.host.config;
13
- const DIST_DIR = path.join(config.workDir, 'dist');
14
- const DOWNLOAD_PATH = path.join(config.workDir, 'extension.zip');
13
+ // Helper to get paths dynamically
14
+ const getPaths = () => {
15
+ const workDir = ctx.config.workDir;
16
+ return {
17
+ DIST_DIR: path.join(workDir, 'dist'),
18
+ DOWNLOAD_PATH: path.join(workDir, 'extension.zip'),
19
+ VERSION_FILE: path.join(workDir, 'version')
20
+ };
21
+ };
15
22
  // Helper function to create axios client with current config
16
23
  const createClient = () => {
24
+ const config = ctx.config;
17
25
  const rawToken = config.token ? String(config.token) : '';
18
26
  const token = rawToken.replace(/^Bearer\s+/i, '').trim();
19
27
  // Auto-extract user ID from token if not provided
@@ -42,11 +50,16 @@ export const DownloaderPlugin = {
42
50
  })
43
51
  });
44
52
  };
45
- const VERSION_FILE = path.join(config.workDir, 'version');
46
53
  let lastModified = '';
47
- if (fs.existsSync(VERSION_FILE)) {
48
- lastModified = fs.readFileSync(VERSION_FILE, 'utf-8').trim();
54
+ let currentWorkDir = '';
55
+ // Check initial state if workDir exists
56
+ try {
57
+ const { VERSION_FILE } = getPaths();
58
+ if (fs.existsSync(VERSION_FILE)) {
59
+ lastModified = fs.readFileSync(VERSION_FILE, 'utf-8').trim();
60
+ }
49
61
  }
62
+ catch (e) { }
50
63
  let isChecking = false;
51
64
  // Action: Check Status
52
65
  ctx.actions.registerAction({
@@ -55,12 +68,23 @@ export const DownloaderPlugin = {
55
68
  if (isChecking)
56
69
  return true; // Skip if busy
57
70
  isChecking = true;
71
+ const { jobId, workDir } = ctx.config;
72
+ const { DIST_DIR, VERSION_FILE } = getPaths();
73
+ // Reset lastModified if workDir changed
74
+ if (workDir !== currentWorkDir) {
75
+ currentWorkDir = workDir;
76
+ lastModified = '';
77
+ if (fs.existsSync(VERSION_FILE)) {
78
+ lastModified = fs.readFileSync(VERSION_FILE, 'utf-8').trim();
79
+ }
80
+ }
81
+ await ctx.actions.runAction('core:log', { level: 'info', message: 'Checking for updates...' });
58
82
  const MAX_RETRIES = 3;
59
83
  let attempt = 0;
60
84
  while (attempt < MAX_RETRIES) {
61
85
  try {
62
86
  const client = createClient(); // Create client with current config
63
- const res = await client.get(`/jobs/${config.jobId}`);
87
+ const res = await client.get(`/jobs/${jobId}`);
64
88
  const job = res.data;
65
89
  const newVersion = job.version;
66
90
  // If no version in job yet, fall back to timestamp or ignore
@@ -83,7 +107,7 @@ export const DownloaderPlugin = {
83
107
  if (success) {
84
108
  lastModified = newVersion;
85
109
  fs.writeFileSync(VERSION_FILE, newVersion);
86
- ctx.events.emit('downloader:updated', { version: job.version, jobId: config.jobId });
110
+ ctx.events.emit('downloader:updated', { version: job.version, jobId: ctx.config.jobId });
87
111
  }
88
112
  }
89
113
  }
@@ -117,21 +141,22 @@ export const DownloaderPlugin = {
117
141
  const spinner = ora('Downloading new version...').start();
118
142
  try {
119
143
  const client = createClient(); // Create client with current config
120
- const response = await client.get(`/download/${config.jobId}`, {
144
+ const { DIST_DIR, DOWNLOAD_PATH, VERSION_FILE } = getPaths();
145
+ const response = await client.get(`/download/${ctx.config.jobId}`, {
121
146
  responseType: 'arraybuffer'
122
147
  });
123
- await fs.ensureDir(config.workDir);
148
+ await fs.ensureDir(ctx.config.workDir);
124
149
  await fs.writeFile(DOWNLOAD_PATH, response.data);
125
150
  await fs.emptyDir(DIST_DIR);
126
151
  const zip = new AdmZip(DOWNLOAD_PATH);
127
152
  zip.extractAllTo(DIST_DIR, true);
128
153
  // --- HOT RELOAD INJECTION ---
129
154
  try {
130
- // Get dynamically allocated port from ServerPlugin
131
- const hotReloadPort = ctx.hotReloadPort || 3500;
155
+ // Get dynamically allocated port from ServerPlugin via config
156
+ const hotReloadPort = ctx.config.hotReloadPort || 3500;
132
157
  const HOT_RELOAD_CODE = `
133
158
  const EVENT_SOURCE_URL = 'http://localhost:${hotReloadPort}/status';
134
- const CURRENT_JOB_ID = '${config.jobId}';
159
+ const CURRENT_JOB_ID = '${ctx.config.jobId}';
135
160
  let lastVersion = null;
136
161
  let lastJobId = null;
137
162
 
@@ -2,7 +2,9 @@ import http from 'http';
2
2
  export const ServerPlugin = {
3
3
  name: 'server',
4
4
  version: '1.0.0',
5
+ dependencies: ['config'],
5
6
  async setup(ctx) {
7
+ // const context = ctx as PreviewContext; // No longer needed
6
8
  let currentVersion = '0.0.0';
7
9
  // Try to bind to a port, retrying with incremented ports on failure
8
10
  const startPort = 3500;
@@ -29,7 +31,7 @@ export const ServerPlugin = {
29
31
  return;
30
32
  }
31
33
  if (req.url === '/status') {
32
- const currentJobId = ctx.host.config.jobId;
34
+ const currentJobId = ctx.config.jobId;
33
35
  res.writeHead(200, { 'Content-Type': 'application/json' });
34
36
  res.end(JSON.stringify({
35
37
  version: currentVersion,
@@ -38,15 +40,37 @@ export const ServerPlugin = {
38
40
  }));
39
41
  }
40
42
  else if (req.url === '/refresh' && req.method === 'POST') {
41
- // Trigger manual check
42
- ctx.actions.runAction('core:log', { level: 'info', message: '[API] Refresh request received' });
43
- ctx.actions.runAction('downloader:check', null).then((result) => {
44
- ctx.actions.runAction('core:log', { level: 'info', message: `[API] Check result: ${result}` });
45
- }).catch((err) => {
46
- ctx.actions.runAction('core:log', { level: 'error', message: `[API] Check failed: ${err.message}` });
43
+ // Collect body
44
+ let body = '';
45
+ req.on('data', chunk => {
46
+ body += chunk.toString();
47
47
  });
48
- res.writeHead(200, { 'Content-Type': 'application/json' });
49
- res.end(JSON.stringify({ success: true }));
48
+ req.on('end', () => {
49
+ let newJobId = null;
50
+ try {
51
+ if (body) {
52
+ const data = JSON.parse(body);
53
+ if (data.jobId) {
54
+ newJobId = data.jobId;
55
+ ctx.getRuntime().updateConfig({ jobId: newJobId });
56
+ ctx.actions.runAction('core:log', { level: 'info', message: `[API] Switched to new Job ID: ${newJobId}` });
57
+ }
58
+ }
59
+ }
60
+ catch (e) {
61
+ // Ignore parse error
62
+ }
63
+ // Trigger manual check
64
+ ctx.actions.runAction('core:log', { level: 'info', message: '[API] Refresh request received' });
65
+ ctx.actions.runAction('downloader:check', null).then((result) => {
66
+ ctx.actions.runAction('core:log', { level: 'info', message: `[API] Check result: ${result}` });
67
+ }).catch((err) => {
68
+ ctx.actions.runAction('core:log', { level: 'error', message: `[API] Check failed: ${err.message}` });
69
+ });
70
+ res.writeHead(200, { 'Content-Type': 'application/json' });
71
+ res.end(JSON.stringify({ success: true, jobId: ctx.config.jobId }));
72
+ });
73
+ return; // Return because we handle response in 'end' callback
50
74
  }
51
75
  else if (req.url === '/disconnect' && req.method === 'POST') {
52
76
  // Trigger browser stop
@@ -110,7 +134,7 @@ export const ServerPlugin = {
110
134
  return;
111
135
  }
112
136
  // Store port in context for DownloaderPlugin to use
113
- ctx.hotReloadPort = allocatedPort;
137
+ ctx.getRuntime().updateConfig({ hotReloadPort: allocatedPort });
114
138
  // Store server instance to close later
115
139
  ctx._serverInstance = server;
116
140
  },
@@ -4,18 +4,22 @@ import { findExtensionRoot, validateExtension } from '../../utils/browserUtils.j
4
4
  export const BrowserManagerPlugin = {
5
5
  name: 'browser-manager',
6
6
  version: '1.0.0',
7
+ dependencies: ['config', 'downloader'],
7
8
  setup(ctx) {
8
- const config = ctx.host.config;
9
- const DIST_DIR = path.join(config.workDir, 'dist');
10
- // --- Centralized Path Strategy ---
11
- const isWSL = fs.existsSync('/mnt/c');
12
- const isWin = process.platform === 'win32';
13
- // Unified Staging Path (C:\\Temp for Windows/WSL, local for others)
14
- const STAGING_DIR = isWSL
15
- ? '/mnt/c/Temp/ai-ext-preview'
16
- : (isWin ? 'C:\\Temp\\ai-ext-preview' : path.join(config.workDir, '../staging'));
9
+ // Helper to get dynamic paths
10
+ const getPaths = () => {
11
+ const config = ctx.config;
12
+ const DIST_DIR = path.join(config.workDir, 'dist');
13
+ const isWSL = fs.existsSync('/mnt/c');
14
+ 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 };
19
+ };
17
20
  // --- SYNC FUNCTION ---
18
21
  const syncToStaging = async () => {
22
+ const { DIST_DIR, STAGING_DIR } = getPaths();
19
23
  try {
20
24
  if (fs.existsSync(STAGING_DIR)) {
21
25
  fs.emptyDirSync(STAGING_DIR);
@@ -31,9 +35,10 @@ export const BrowserManagerPlugin = {
31
35
  }
32
36
  };
33
37
  const launchBrowser = async () => {
38
+ const { STAGING_DIR } = getPaths();
34
39
  // Resolve proper root AFTER sync
35
40
  const extensionRoot = findExtensionRoot(STAGING_DIR) || STAGING_DIR;
36
- // Validate
41
+ // 1. Static Validation
37
42
  const validation = validateExtension(extensionRoot);
38
43
  if (!validation.valid) {
39
44
  await ctx.actions.runAction('core:log', { level: 'error', message: `[CRITICAL] Extension validation failed: ${validation.error} in ${extensionRoot}` });
@@ -41,6 +46,19 @@ export const BrowserManagerPlugin = {
41
46
  else if (extensionRoot !== STAGING_DIR) {
42
47
  await ctx.actions.runAction('core:log', { level: 'info', message: `Detected nested extension at: ${path.basename(extensionRoot)}` });
43
48
  }
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
+ */
44
62
  // Delegate Launch
45
63
  // We pass the filesystem path (STAGING_DIR or extensionRoot)
46
64
  // The specific Launcher plugin handles environment specific path verification/conversion
@@ -72,7 +90,15 @@ export const BrowserManagerPlugin = {
72
90
  // Event: Update detected
73
91
  ctx.events.on('downloader:updated', async () => {
74
92
  if (isInitialized) {
75
- await ctx.actions.runAction('core:log', { level: 'info', message: 'Update detected. Syncing to staging...' });
93
+ await ctx.actions.runAction('core:log', { level: 'info', message: 'Update detected. Restarting browser...' });
94
+ try {
95
+ await ctx.actions.runAction('browser:stop', {});
96
+ }
97
+ catch (e) {
98
+ // Ignore if already stopped
99
+ }
100
+ // [Optimization] Wait for process cleanup to avoid "Open in new tab" race condition
101
+ await new Promise(r => setTimeout(r, 1000));
76
102
  await ctx.actions.runAction('browser:start', {});
77
103
  }
78
104
  });
@@ -6,6 +6,7 @@ let chromeProcess = null;
6
6
  export const NativeLauncherPlugin = {
7
7
  name: 'native-launcher',
8
8
  version: '1.0.0',
9
+ dependencies: ['config'],
9
10
  setup(ctx) {
10
11
  // Only active if NOT in WSL
11
12
  const isWSL = fs.existsSync('/mnt/c');
@@ -14,7 +15,7 @@ export const NativeLauncherPlugin = {
14
15
  ctx.actions.registerAction({
15
16
  id: 'launcher:launch',
16
17
  handler: async (payload) => {
17
- const config = ctx.host.config;
18
+ const config = ctx.config;
18
19
  const chromePath = findChrome();
19
20
  if (!chromePath) {
20
21
  await ctx.actions.runAction('core:log', { level: 'error', message: 'Chrome not found.' });
@@ -6,6 +6,7 @@ let chromePid = null;
6
6
  export const WSLLauncherPlugin = {
7
7
  name: 'wsl-launcher',
8
8
  version: '1.0.0',
9
+ dependencies: ['config'],
9
10
  setup(ctx) {
10
11
  // Only active in WSL
11
12
  const isWSL = fs.existsSync('/mnt/c');
@@ -118,19 +119,37 @@ Write-Host "CHROME_PID:$($process.Id)"
118
119
  if (chromePid) {
119
120
  await ctx.actions.runAction('core:log', { level: 'info', message: `Terminating Chrome process (PID: ${chromePid})...` });
120
121
  try {
121
- // Use taskkill via PowerShell
122
- const killChild = spawn('powershell.exe', ['-Command', `Stop-Process -Id ${chromePid} -Force`], {
123
- stdio: 'ignore'
124
- });
125
- killChild.on('exit', async (code) => {
126
- if (code === 0) {
127
- await ctx.actions.runAction('core:log', { level: 'info', message: 'Chrome process terminated successfully.' });
128
- chromePid = null;
129
- }
130
- else {
131
- await ctx.actions.runAction('core:log', { level: 'warn', message: `taskkill exited with code ${code}` });
122
+ // 1. Try Stop-Process first (Graceful)
123
+ const killCmd = `
124
+ $targetPid = ${chromePid}
125
+ try {
126
+ Stop-Process -Id $targetPid -Force -ErrorAction Stop
127
+ Write-Host "STOPPED"
128
+ } catch {
129
+ try {
130
+ taskkill.exe /F /PID $targetPid
131
+ Write-Host "TASKKILLED"
132
+ } catch {
133
+ Write-Host "FAILED: $_"
134
+ exit 1
135
+ }
132
136
  }
137
+ `;
138
+ const killChild = spawn('powershell.exe', ['-Command', killCmd], { stdio: 'pipe' });
139
+ // Capture output to debug why it might fail
140
+ if (killChild.stdout) {
141
+ killChild.stdout.on('data', d => ctx.actions.runAction('core:log', { level: 'debug', message: `[KillParams] ${d}` }));
142
+ }
143
+ if (killChild.stderr) {
144
+ killChild.stderr.on('data', d => ctx.actions.runAction('core:log', { level: 'warn', message: `[KillMsg] ${d}` }));
145
+ }
146
+ await new Promise((resolve) => {
147
+ killChild.on('exit', (code) => {
148
+ resolve();
149
+ });
133
150
  });
151
+ await ctx.actions.runAction('core:log', { level: 'info', message: 'Chrome process termination signal sent.' });
152
+ chromePid = null;
134
153
  return true;
135
154
  }
136
155
  catch (err) {
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,192 @@
1
+ import puppeteer from 'puppeteer-core';
2
+ import path from 'path';
3
+ import fs from 'fs-extra';
4
+ import { spawn, execSync } from 'child_process';
5
+ import axios from 'axios';
6
+ import { findChrome } from './browserUtils.js';
7
+ export class SandboxRunner {
8
+ /**
9
+ * Launch a headless browser with the extension loaded to verify it can initialize.
10
+ * @param extensionPath Absolute path to the unpacked extension directory
11
+ * @param chromePath Optional path to Chrome executable. If not provided, attempts to auto-detect.
12
+ */
13
+ static async validateExtensionRuntime(extensionPath, chromePath) {
14
+ const logs = [];
15
+ const executablePath = chromePath || findChrome();
16
+ if (!executablePath) {
17
+ return {
18
+ success: false,
19
+ logs,
20
+ error: 'Chrome executable not found. Cannot run verification.'
21
+ };
22
+ }
23
+ const isWSL = executablePath.startsWith('/mnt/');
24
+ if (isWSL) {
25
+ logs.push('[Sandbox] WSL Environment detected. Using "Spawn & Connect" strategy.');
26
+ return this.runWSLCheck(extensionPath, executablePath, logs);
27
+ }
28
+ else {
29
+ return this.runStandardCheck(extensionPath, executablePath, logs);
30
+ }
31
+ }
32
+ static async runStandardCheck(extensionPath, executablePath, logs) {
33
+ let browser;
34
+ try {
35
+ logs.push(`[Sandbox] Launching standard verification for: ${extensionPath}`);
36
+ logs.push(`[Sandbox] Using Chrome at: ${executablePath}`);
37
+ browser = await puppeteer.launch({
38
+ headless: true,
39
+ executablePath: executablePath,
40
+ args: [
41
+ `--disable-extensions-except=${extensionPath}`,
42
+ `--load-extension=${extensionPath}`,
43
+ '--no-sandbox',
44
+ '--disable-setuid-sandbox'
45
+ ]
46
+ });
47
+ return await this.performChecks(browser, extensionPath, logs);
48
+ }
49
+ catch (error) {
50
+ console.error('[Sandbox] Standard Launch Error:', error);
51
+ return { success: false, logs, error: error instanceof Error ? error.message : String(error) };
52
+ }
53
+ finally {
54
+ if (browser)
55
+ await browser.close();
56
+ }
57
+ }
58
+ static async runWSLCheck(extensionPath, linuxChromePath, logs) {
59
+ let browser;
60
+ let chromePid = null;
61
+ try {
62
+ // 1. Path Conversion (Linux -> Windows)
63
+ const driveMatch = linuxChromePath.match(/^\/mnt\/([a-z])\//);
64
+ if (!driveMatch)
65
+ throw new Error(`Could not parse drive letter from ${linuxChromePath}`);
66
+ const driveLetter = driveMatch[1];
67
+ const winChromePath = linuxChromePath
68
+ .replace(new RegExp(`^/mnt/${driveLetter}/`), `${driveLetter.toUpperCase()}:\\`)
69
+ .replace(/\//g, '\\');
70
+ // 1b. Detect Host IP (WSL DNS Resolver IP)
71
+ let hostIp = '127.0.0.1';
72
+ try {
73
+ const resolveConf = fs.readFileSync('/etc/resolv.conf', 'utf-8');
74
+ const match = resolveConf.match(/nameserver\s+([\d.]+)/);
75
+ if (match)
76
+ hostIp = match[1];
77
+ logs.push(`[Sandbox] Host IP detected: ${hostIp}`);
78
+ }
79
+ catch (e) {
80
+ logs.push(`[Sandbox] Failed to detect Host IP, fallback to 127.0.0.1: ${e}`);
81
+ }
82
+ let winExtensionPath = extensionPath;
83
+ const extDriveMatch = extensionPath.match(/^\/mnt\/([a-z])\//);
84
+ if (extDriveMatch) {
85
+ winExtensionPath = extensionPath
86
+ .replace(new RegExp(`^/mnt/${extDriveMatch[1]}/`), `${extDriveMatch[1].toUpperCase()}:\\`)
87
+ .replace(/\//g, '\\');
88
+ }
89
+ else {
90
+ logs.push('[Sandbox] WARNING: Extension path is not in /mnt/. Windows Chrome might not see it.');
91
+ }
92
+ // 2. Spawn Chrome via PowerShell
93
+ const port = 9222;
94
+ const winProfile = `C:\\Temp\\ai-ext-sandbox-${Date.now()}`;
95
+ const args = [
96
+ `--headless=new`,
97
+ `--disable-extensions-except="${winExtensionPath}"`,
98
+ `--load-extension="${winExtensionPath}"`,
99
+ `--user-data-dir="${winProfile}"`,
100
+ '--no-sandbox',
101
+ '--disable-gpu',
102
+ '--disable-dev-shm-usage',
103
+ '--no-first-run',
104
+ '--no-default-browser-check',
105
+ `--remote-debugging-port=${port}`,
106
+ `--remote-debugging-address=0.0.0.0`, // Bind to all interfaces so WSL can see it
107
+ `--remote-allow-origins=*` // Allow puppeteer connection
108
+ ];
109
+ const psCommand = `Start-Process -FilePath "${winChromePath}" -ArgumentList '${args.join(' ')}' -PassThru`;
110
+ logs.push(`[Sandbox] Spawning Chrome via PowerShell on port ${port}...`);
111
+ logs.push(`[Sandbox] Profile: ${winProfile}`);
112
+ const child = spawn('powershell.exe', ['-Command', psCommand], { stdio: 'pipe' });
113
+ await new Promise((resolve, reject) => {
114
+ child.stdout.on('data', (data) => {
115
+ const output = data.toString();
116
+ const match = output.match(/\s+(\d+)\s+\d+\s+chrome/i) || output.match(/Id\s+:\s+(\d+)/);
117
+ if (match) {
118
+ chromePid = parseInt(match[1], 10);
119
+ logs.push(`[Sandbox] Chrome PID: ${chromePid}`);
120
+ }
121
+ });
122
+ child.on('close', (code) => {
123
+ if (code === 0)
124
+ resolve();
125
+ else
126
+ reject(new Error(`PowerShell exited with code ${code}`));
127
+ });
128
+ });
129
+ // 3. Wait for Port
130
+ logs.push('[Sandbox] Waiting for Chrome to accept connections...');
131
+ let connected = false;
132
+ // Increased timeout to 15s (30 * 500ms)
133
+ for (let i = 0; i < 30; i++) {
134
+ try {
135
+ // Use hostIp, not localhost
136
+ await axios.get(`http://${hostIp}:${port}/json/version`, { timeout: 1000 });
137
+ connected = true;
138
+ break;
139
+ }
140
+ catch (e) {
141
+ await new Promise(r => setTimeout(r, 500));
142
+ }
143
+ }
144
+ if (!connected)
145
+ throw new Error(`Timed out waiting for Chrome debug port ${port}`);
146
+ // 4. Connect Puppeteer
147
+ logs.push('[Sandbox] Connecting Puppeteer...');
148
+ browser = await puppeteer.connect({
149
+ browserURL: `http://${hostIp}:${port}`
150
+ });
151
+ // 5. Perform Checks
152
+ const result = await this.performChecks(browser, extensionPath, logs);
153
+ return result;
154
+ }
155
+ catch (error) {
156
+ console.error('[Sandbox] WSL Check Error:', error);
157
+ return { success: false, logs, error: error instanceof Error ? error.message : String(error) };
158
+ }
159
+ finally {
160
+ if (browser)
161
+ await browser.disconnect();
162
+ if (chromePid) {
163
+ logs.push(`[Sandbox] Killing Chrome PID ${chromePid}...`);
164
+ try {
165
+ execSync(`powershell.exe -Command "Stop-Process -Id ${chromePid} -Force"`);
166
+ }
167
+ catch (e) { /* ignore */ }
168
+ }
169
+ }
170
+ }
171
+ static async performChecks(browser, extensionPath, logs) {
172
+ await new Promise(r => setTimeout(r, 2000));
173
+ const targets = await browser.targets();
174
+ const backgroundTarget = targets.find(t => t.type() === 'service_worker' || t.type() === 'background_page');
175
+ if (!backgroundTarget) {
176
+ const manifestPath = path.join(extensionPath, 'manifest.json');
177
+ if (fs.existsSync(manifestPath)) {
178
+ const manifest = await fs.readJson(manifestPath);
179
+ if (manifest.background) {
180
+ return { success: false, logs, error: 'Background Service Worker defined in manifest but failed to start.' };
181
+ }
182
+ else {
183
+ logs.push('[Sandbox] No background script defined in manifest. Skipping worker check.');
184
+ }
185
+ }
186
+ }
187
+ else {
188
+ logs.push('Background worker started successfully.');
189
+ }
190
+ return { success: true, logs };
191
+ }
192
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-extension-preview",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Local preview tool for AI Extension Builder",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,9 +37,10 @@
37
37
  "node-fetch": "^3.3.2",
38
38
  "ora": "^8.1.1",
39
39
  "puppeteer-core": "^24.33.0",
40
- "skeleton-crew-runtime": "^0.1.5",
40
+ "skeleton-crew-runtime": "0.2.0",
41
41
  "web-ext": "^8.3.0",
42
- "ws": "^8.18.0"
42
+ "ws": "^8.18.0",
43
+ "zod": "^4.2.1"
43
44
  },
44
45
  "devDependencies": {
45
46
  "@types/adm-zip": "^0.5.6",
@@ -51,4 +52,4 @@
51
52
  "typescript": "^5.7.2",
52
53
  "vitest": "^4.0.16"
53
54
  }
54
- }
55
+ }