ai-extension-preview 0.1.0

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/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # AI Extension Preview
2
+
3
+ A local companion tool for the **AI Extension Builder**.
4
+ This tool allows you to instantly preview and test your AI-generated Chrome Extensions locally, bridging the gap between the **AI Extension Builder** and your local browser.
5
+
6
+ ## Features
7
+
8
+ - **Instant Preview:** Launches a Chrome instance with your extension loaded.
9
+ - **Live Updates:** Automatically detects new builds from the AI Builder and reloads your extension (Hot Reload).
10
+ - **Secure Connection:** Uses a short-code "Device Flow" authentication to securely link to your account.
11
+ - **Cross-Platform:** Works on Windows, macOS, and Linux (including WSL/Git Bash via Detached Mode).
12
+
13
+ ## Usage
14
+
15
+ You do NOT need to install this package globally. Just run it with `npx`:
16
+
17
+ ```bash
18
+ npx ai-extension-preview
19
+ ```
20
+
21
+ ### Setup Flow
22
+
23
+ 1. Run the command above.
24
+ 2. The tool will display a **Link Code** (e.g., `ABCD`).
25
+ 3. Go to your **AI Extension Builder Dashboard**.
26
+ 4. Click **"Connect Preview"** and enter the code.
27
+ 5. Enjoy! The tool will automatically download and launch your active extension.
28
+
29
+ ## Requirements
30
+
31
+ - Node.js v18+
32
+ - Google Chrome or Chromium installed.
package/dist/index.js ADDED
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config'; // Load .env
3
+ import { Command } from 'commander';
4
+ import path from 'path';
5
+ import { fileURLToPath } from 'url';
6
+ import fs from 'fs-extra';
7
+ import { Runtime } from 'skeleton-crew-runtime';
8
+ import { CorePlugin } from './plugins/CorePlugin.js';
9
+ import { DownloaderPlugin } from './plugins/DownloaderPlugin.js';
10
+ import { BrowserPlugin } from './plugins/BrowserPlugin.js';
11
+ import axios from 'axios';
12
+ import chalk from 'chalk';
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ const DEFAULT_HOST = process.env.API_HOST || 'https://ai-extension-builder.01kb6018z1t9tpaza4y5f1c56w.lmapp.run/api';
15
+ const program = new Command();
16
+ program
17
+ .name('preview')
18
+ .description('Live preview companion for AI Extension Builder')
19
+ .option('--job <job>', 'Job ID to preview')
20
+ .option('--host <host>', 'API Host URL', DEFAULT_HOST)
21
+ .option('--token <token>', 'Auth Token (if required)')
22
+ .option('--user <user>', 'User ID (if required)')
23
+ .parse(process.argv);
24
+ const options = program.opts();
25
+ async function authenticate(host) {
26
+ try {
27
+ // 1. Init Session
28
+ const initRes = await axios.post(`${host}/preview/init`);
29
+ const { code, sessionId } = initRes.data;
30
+ console.log('\n' + chalk.bgBlue.bold(' DETACHED PREVIEW MODE ') + '\n');
31
+ console.log('To connect, please go to your Extension Dashboard and click "Connect Preview".');
32
+ console.log('Enter the following code:');
33
+ console.log('\n' + chalk.green.bold(` ${code} `) + '\n');
34
+ console.log('Waiting for connection...');
35
+ // 2. Poll for Status
36
+ while (true) {
37
+ await new Promise(resolve => setTimeout(resolve, 2000));
38
+ try {
39
+ const statusRes = await axios.get(`${host}/preview/status/${sessionId}`);
40
+ const data = statusRes.data;
41
+ if (data.status === 'linked') {
42
+ console.log(chalk.green('✔ Connected!'));
43
+ if (!data.jobId) {
44
+ console.error('Error: No Job ID associated with this connection.');
45
+ process.exit(1);
46
+ }
47
+ return {
48
+ jobId: data.jobId,
49
+ userId: data.userId,
50
+ token: 'session:' + sessionId // Use session ID as token for now
51
+ };
52
+ }
53
+ if (data.status === 'expired') {
54
+ console.error(chalk.red('Code expired. Please restart.'));
55
+ process.exit(1);
56
+ }
57
+ }
58
+ catch (e) {
59
+ // Ignore transient network errors
60
+ }
61
+ }
62
+ }
63
+ catch (error) {
64
+ console.error(chalk.red(`Failed to initialize session: ${error.message}`));
65
+ process.exit(1);
66
+ }
67
+ }
68
+ async function main() {
69
+ let jobId = options.job;
70
+ let userId = options.user;
71
+ let token = options.token;
72
+ const host = options.host;
73
+ // Interactive Auth Flow if no Job ID provided
74
+ if (!jobId) {
75
+ const authData = await authenticate(host);
76
+ jobId = authData.jobId;
77
+ userId = authData.userId || userId;
78
+ token = authData.token || token;
79
+ }
80
+ const WORK_DIR = path.join(process.cwd(), '.preview', jobId);
81
+ // 1. Initialize Runtime
82
+ const runtime = new Runtime({
83
+ hostContext: {
84
+ config: {
85
+ host,
86
+ token,
87
+ user: userId,
88
+ jobId,
89
+ workDir: WORK_DIR
90
+ }
91
+ }
92
+ });
93
+ // 2. Register Plugins
94
+ // Note: In a real dynamic system we might load these from a folder
95
+ runtime.logger.info('Registering plugins...');
96
+ runtime.registerPlugin(CorePlugin);
97
+ runtime.registerPlugin(DownloaderPlugin);
98
+ runtime.registerPlugin(BrowserPlugin);
99
+ runtime.logger.info('Initializing runtime...');
100
+ await runtime.initialize();
101
+ const ctx = runtime.getContext();
102
+ // 3. Start LifeCycle
103
+ await ctx.actions.runAction('core:log', { level: 'info', message: 'Initializing Local Satellite...' });
104
+ // Ensure work dir exists
105
+ await fs.ensureDir(WORK_DIR);
106
+ // Initial Check - Must succeed to continue
107
+ const success = await ctx.actions.runAction('downloader:check', null);
108
+ if (!success) {
109
+ await ctx.actions.runAction('core:log', { level: 'error', message: 'Initial check failed. Could not verify job or download extension.' });
110
+ process.exit(1);
111
+ }
112
+ // Start Browser (This will block until browser is closed OR return immediately if detached)
113
+ const browserSessionResult = await ctx.actions.runAction('browser:start', null);
114
+ // If detached launch (result=true) or web-ext blocked and finished...
115
+ // We should ONLY exit if the loop is also done (which it never is unless disposed).
116
+ // Actually, if web-ext finishes (e.g. user closed browser), we might want to exit?
117
+ // But for Detached Mode, we MUST stay open to poll updates.
118
+ // If browser:start returned, it means either:
119
+ // 1. Browser closed (web-ext mode) -> we arguably should exit.
120
+ // 2. Detached mode started -> we MUST NOT exit.
121
+ // Changing logic: rely on SIGINT to exit.
122
+ runtime.logger.info('Press Ctrl+C to exit.');
123
+ }
124
+ // Handle global errors
125
+ process.on('uncaughtException', (err) => {
126
+ if (err.code === 'ECONNRESET' || err.message?.includes('ECONNRESET')) {
127
+ // Ignore pipe errors frequently caused by web-ext/chrome teardown
128
+ return;
129
+ }
130
+ console.error('Uncaught Exception:', err);
131
+ process.exit(1);
132
+ });
133
+ process.on('unhandledRejection', (reason) => {
134
+ console.error('Unhandled Rejection:', reason);
135
+ });
136
+ main();
@@ -0,0 +1,113 @@
1
+ import webExt from 'web-ext';
2
+ import path from 'path';
3
+ import { spawn } from 'child_process';
4
+ import fs from 'fs';
5
+ const CHROME_PATHS = [
6
+ '/mnt/c/Program Files/Google/Chrome/Application/chrome.exe',
7
+ '/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe',
8
+ '/c/Program Files/Google/Chrome/Application/chrome.exe',
9
+ '/c/Program Files (x86)/Google/Chrome/Application/chrome.exe',
10
+ '/usr/bin/google-chrome',
11
+ '/usr/bin/chromium'
12
+ ];
13
+ function findChrome() {
14
+ for (const p of CHROME_PATHS) {
15
+ if (fs.existsSync(p))
16
+ return p;
17
+ }
18
+ return null;
19
+ }
20
+ export const BrowserPlugin = {
21
+ name: 'browser',
22
+ version: '1.0.0',
23
+ setup(ctx) {
24
+ const config = ctx.host.config;
25
+ const DIST_DIR = path.join(config.workDir, 'dist');
26
+ let runner = null;
27
+ const launchDetached = async () => {
28
+ const chromePath = findChrome();
29
+ if (!chromePath) {
30
+ await ctx.actions.runAction('core:log', { level: 'error', message: 'Chrome not found for detached launch.' });
31
+ return false;
32
+ }
33
+ // Log with prefix to indicate we are handling the Env quirk
34
+ await ctx.actions.runAction('core:log', { level: 'info', message: '[WSL] Copying extension to Windows Temp: C:\\ai-ext-preview' });
35
+ // In a real scenario we might need to copy to a Windows-accessible path if /home/... isn't mapped well,
36
+ // but usually \\wsl$\... works or user is mapped.
37
+ // For now assuming direct path works or user has mapping.
38
+ // Actually, verify path.
39
+ // IMPORTANT: In WSL, standard linux paths might not be readable by Windows Chrome directly
40
+ // without `\\wsl$\...` mapping.
41
+ // However, previous logs showed "Failed to load... CDP connection closed" which means
42
+ // Chrome DID try to load it but failed communication.
43
+ // So path is likely fine.
44
+ await ctx.actions.runAction('core:log', { level: 'warning', message: 'Switching to Detached Mode (WSL/GitBash detected).' });
45
+ await ctx.actions.runAction('core:log', { level: 'info', message: 'Browser polling/logging is disabled. Please reload manually on updates.' });
46
+ const subprocess = spawn(chromePath, [
47
+ `--load-extension=${DIST_DIR}`,
48
+ '--disable-gpu',
49
+ 'https://google.com'
50
+ ], {
51
+ detached: true,
52
+ stdio: 'ignore'
53
+ });
54
+ subprocess.unref();
55
+ return true;
56
+ };
57
+ ctx.actions.registerAction({
58
+ id: 'browser:start',
59
+ handler: async () => {
60
+ await ctx.actions.runAction('core:log', { level: 'info', message: 'Launching browser...' });
61
+ try {
62
+ // Try web-ext first
63
+ const runResult = await webExt.cmd.run({
64
+ sourceDir: DIST_DIR,
65
+ target: 'chromium',
66
+ browserConsole: false,
67
+ startUrl: ['https://google.com'],
68
+ noInput: true,
69
+ keepProfileChanges: false,
70
+ args: [
71
+ '--start-maximized',
72
+ '--no-sandbox',
73
+ '--disable-gpu',
74
+ '--disable-dev-shm-usage'
75
+ ]
76
+ }, {
77
+ shouldExitProgram: false
78
+ });
79
+ runner = runResult;
80
+ await ctx.actions.runAction('core:log', { level: 'success', message: 'Browser session ended.' });
81
+ return true;
82
+ }
83
+ catch (err) {
84
+ // Check for expected environment failures
85
+ if (err.code === 'ECONNRESET' || err.message?.includes('CDP connection closed')) {
86
+ // Log specific WSL message for clarity
87
+ await ctx.actions.runAction('core:log', { level: 'warning', message: 'WSL: CDP connection dropped (expected). Browser is running detached.' });
88
+ await ctx.actions.runAction('core:log', { level: 'info', message: 'Please reload extension manually in Chrome if needed.' });
89
+ return await launchDetached();
90
+ }
91
+ if (err.code !== 'ECONNRESET') {
92
+ await ctx.actions.runAction('core:log', { level: 'error', message: `Browser failed: ${err.message}` });
93
+ }
94
+ return false;
95
+ }
96
+ }
97
+ });
98
+ ctx.events.on('downloader:updated', async () => {
99
+ if (runner && runner.reloadAllExtensions) {
100
+ await ctx.actions.runAction('core:log', { level: 'info', message: 'Triggering browser reload...' });
101
+ try {
102
+ runner.reloadAllExtensions();
103
+ }
104
+ catch (e) {
105
+ // Ignore
106
+ }
107
+ }
108
+ else {
109
+ await ctx.actions.runAction('core:log', { level: 'info', message: 'Update installed. Please reload extension in Chrome.' });
110
+ }
111
+ });
112
+ }
113
+ };
@@ -0,0 +1,40 @@
1
+ import chalk from 'chalk';
2
+ export const CorePlugin = {
3
+ name: 'core',
4
+ version: '1.0.0',
5
+ setup(ctx) {
6
+ console.log('CorePlugin: setup called');
7
+ // We assume config is passed in hostContext
8
+ const config = ctx.host.config;
9
+ ctx.actions.registerAction({
10
+ id: 'core:config',
11
+ handler: async () => config
12
+ });
13
+ console.log('CorePlugin: Registering core:log');
14
+ ctx.actions.registerAction({
15
+ id: 'core:log',
16
+ handler: async (payload) => {
17
+ // 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;
21
+ const { level, message } = payload;
22
+ switch (level) {
23
+ case 'error':
24
+ logger.error(chalk.red(message));
25
+ break;
26
+ case 'warn':
27
+ logger.warn(chalk.yellow(message));
28
+ break;
29
+ case 'success':
30
+ // Default logger usually has info/warn/error/debug. Map success to info (green)
31
+ logger.info(chalk.green(message));
32
+ break;
33
+ default:
34
+ logger.info(message);
35
+ }
36
+ return true;
37
+ }
38
+ });
39
+ }
40
+ };
@@ -0,0 +1,122 @@
1
+ import axios from 'axios';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import AdmZip from 'adm-zip';
5
+ import ora from 'ora';
6
+ import https from 'https';
7
+ let checkInterval;
8
+ export const DownloaderPlugin = {
9
+ name: 'downloader',
10
+ version: '1.0.0',
11
+ 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');
15
+ const rawToken = config.token ? String(config.token) : '';
16
+ const token = rawToken.replace(/^Bearer\s+/i, '').trim();
17
+ // Auto-extract user ID from token if not provided
18
+ let userId = config.user;
19
+ if (!userId && token) {
20
+ try {
21
+ const parts = token.split('.');
22
+ if (parts.length === 3) {
23
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
24
+ userId = payload.id || payload.sub || payload.userId;
25
+ // Add cleanup logging
26
+ if (userId)
27
+ ctx.actions.runAction('core:log', { level: 'info', message: `Extracted User ID: ${userId}` });
28
+ }
29
+ }
30
+ catch (e) {
31
+ // Ignore parse errors
32
+ }
33
+ }
34
+ const client = axios.create({
35
+ baseURL: config.host,
36
+ headers: {
37
+ 'Authorization': token ? `Bearer ${token}` : undefined,
38
+ 'X-User-Id': userId
39
+ },
40
+ httpsAgent: new https.Agent({
41
+ rejectUnauthorized: false
42
+ })
43
+ });
44
+ let lastModified = '';
45
+ let isChecking = false;
46
+ // Action: Check Status
47
+ ctx.actions.registerAction({
48
+ id: 'downloader:check',
49
+ handler: async () => {
50
+ if (isChecking)
51
+ return true; // Skip if busy
52
+ isChecking = true;
53
+ try {
54
+ const res = await client.get(`/jobs/${config.jobId}`);
55
+ const job = res.data;
56
+ const newVersion = job.version;
57
+ // If no version in job yet, fall back to timestamp or ignore
58
+ if (!newVersion && !lastModified) {
59
+ // First run, just verify it exists
60
+ // We might want to download anyway if we don't have it locally
61
+ }
62
+ if (job.status === 'completed' && newVersion !== lastModified) {
63
+ await ctx.actions.runAction('core:log', { level: 'info', message: `New version detected (Old: "${lastModified}", New: "${newVersion}")` });
64
+ const success = await ctx.actions.runAction('downloader:download', null);
65
+ if (success) {
66
+ lastModified = newVersion;
67
+ ctx.events.emit('downloader:updated', { version: job.version });
68
+ }
69
+ }
70
+ isChecking = false;
71
+ return true;
72
+ }
73
+ catch (error) {
74
+ isChecking = false;
75
+ await ctx.actions.runAction('core:log', { level: 'error', message: `Check failed: ${error.message}` });
76
+ // Return false only on actual error, so index.ts knows to fail
77
+ return true;
78
+ }
79
+ }
80
+ });
81
+ // Action: Download
82
+ ctx.actions.registerAction({
83
+ id: 'downloader:download',
84
+ handler: async () => {
85
+ const spinner = ora('Downloading new version...').start();
86
+ try {
87
+ const response = await client.get(`/download/${config.jobId}`, {
88
+ responseType: 'arraybuffer'
89
+ });
90
+ await fs.ensureDir(config.workDir);
91
+ await fs.writeFile(DOWNLOAD_PATH, response.data);
92
+ await fs.emptyDir(DIST_DIR);
93
+ const zip = new AdmZip(DOWNLOAD_PATH);
94
+ zip.extractAllTo(DIST_DIR, true);
95
+ spinner.succeed('Updated extension code!');
96
+ return true;
97
+ }
98
+ catch (error) {
99
+ spinner.fail(`Failed to download: ${error.message}`);
100
+ await ctx.actions.runAction('core:log', { level: 'error', message: `Download failed: ${error.message}` });
101
+ return false;
102
+ }
103
+ }
104
+ });
105
+ // Start Polling (Loop)
106
+ const scheduleNextCheck = () => {
107
+ checkInterval = setTimeout(async () => {
108
+ if (!checkInterval)
109
+ return; // Disposed
110
+ await ctx.actions.runAction('downloader:check', null);
111
+ scheduleNextCheck();
112
+ }, 2000);
113
+ };
114
+ scheduleNextCheck();
115
+ },
116
+ dispose(ctx) {
117
+ if (checkInterval) {
118
+ clearTimeout(checkInterval);
119
+ checkInterval = undefined;
120
+ }
121
+ }
122
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "ai-extension-preview",
3
+ "version": "0.1.0",
4
+ "description": "Local preview tool for AI Extension Builder",
5
+ "type": "module",
6
+ "bin": {
7
+ "ai-extension-preview": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "keywords": [
14
+ "ai",
15
+ "chrome-extension",
16
+ "builder",
17
+ "preview",
18
+ "dev-tool"
19
+ ],
20
+ "author": "AI Extension Builder",
21
+ "license": "MIT",
22
+ "scripts": {
23
+ "build": "shx rm -rf dist && tsc -b",
24
+ "start": "tsx src/index.ts",
25
+ "dev": "tsx watch src/index.ts",
26
+ "preview": "node dist/index.js"
27
+ },
28
+ "dependencies": {
29
+ "adm-zip": "^0.5.16",
30
+ "axios": "^1.7.9",
31
+ "chalk": "^5.3.0",
32
+ "commander": "^12.1.0",
33
+ "dotenv": "^17.2.3",
34
+ "fs-extra": "^11.2.0",
35
+ "inquirer": "^12.0.1",
36
+ "node-fetch": "^3.3.2",
37
+ "ora": "^8.1.1",
38
+ "puppeteer-core": "^24.33.0",
39
+ "skeleton-crew-runtime": "^0.1.5",
40
+ "web-ext": "^8.3.0",
41
+ "ws": "^8.18.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/adm-zip": "^0.5.6",
45
+ "@types/fs-extra": "^11.0.4",
46
+ "@types/node": "^22.10.1",
47
+ "@types/ws": "^8.5.13",
48
+ "shx": "^0.4.0",
49
+ "tsx": "^4.21.0",
50
+ "typescript": "^5.7.2"
51
+ }
52
+ }