haltija 1.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/LICENSE +190 -0
- package/README.md +220 -0
- package/bin/build-bookmarklet.ts +107 -0
- package/bin/cli-subcommand.mjs +537 -0
- package/bin/format-events.mjs +125 -0
- package/bin/format-test.mjs +183 -0
- package/bin/format-tree.mjs +165 -0
- package/bin/hj.mjs +59 -0
- package/bin/mcp-setup.mjs +288 -0
- package/bin/server.ts +9 -0
- package/bin/tosijs-dev.mjs +591 -0
- package/bin/tosijs-dev.ts +74 -0
- package/dist/client.js +387 -0
- package/dist/component.js +6685 -0
- package/dist/index.js +10201 -0
- package/dist/server.js +9847 -0
- package/docs/CI-INTEGRATION.md +230 -0
- package/docs/EXECUTIVE-SUMMARY.md +213 -0
- package/docs/README.md +67 -0
- package/docs/REST-API.md +123 -0
- package/docs/ROADMAP.md +591 -0
- package/docs/UX-CRIMES.md +599 -0
- package/docs/agent-prompt.md +139 -0
- package/docs/getting-started/app.md +96 -0
- package/docs/getting-started/playground.md +75 -0
- package/docs/getting-started/service.md +96 -0
- package/docs/recipes.md +245 -0
- package/haltija-icon.svg +79 -0
- package/package.json +68 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Haltija CLI
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx haltija # Launch desktop app (or server if electron unavailable)
|
|
7
|
+
* npx haltija --server # Server only (for CI, headless, bookmarklet usage)
|
|
8
|
+
* npx haltija --app # Explicitly launch desktop app
|
|
9
|
+
* npx haltija --https # Start HTTPS server (auto-generates certs)
|
|
10
|
+
* npx haltija --headless # Start with headless Chromium (for CI)
|
|
11
|
+
* npx haltija --setup-mcp # Configure Claude Desktop integration
|
|
12
|
+
* npx haltija --help # Show help
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn, execSync as execSyncImported } from 'child_process'
|
|
16
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'
|
|
17
|
+
import { homedir, platform } from 'os'
|
|
18
|
+
import { fileURLToPath } from 'url'
|
|
19
|
+
import { dirname, join } from 'path'
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
22
|
+
const serverPath = join(__dirname, '../dist/server.js')
|
|
23
|
+
|
|
24
|
+
const args = process.argv.slice(2)
|
|
25
|
+
|
|
26
|
+
// Colors for terminal output
|
|
27
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`
|
|
28
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`
|
|
29
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`
|
|
30
|
+
const bold = (s) => `\x1b[1m${s}\x1b[0m`
|
|
31
|
+
const dim = (s) => `\x1b[2m${s}\x1b[0m`
|
|
32
|
+
|
|
33
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
34
|
+
console.log(`
|
|
35
|
+
${bold('haltija')} - Browser control for AI agents
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
haltija [options]
|
|
39
|
+
|
|
40
|
+
Modes:
|
|
41
|
+
${dim('(default)')} Launch desktop app if electron available, otherwise server
|
|
42
|
+
--app Explicitly launch desktop app (Electron)
|
|
43
|
+
--server Server only (for CI, headless, or bookmarklet usage)
|
|
44
|
+
--headless Start with headless Chromium browser (for CI)
|
|
45
|
+
|
|
46
|
+
Options:
|
|
47
|
+
--http HTTP only on port 8700 (default protocol)
|
|
48
|
+
--https HTTPS only on port 8701 (auto-generates certs)
|
|
49
|
+
--both Both HTTP (8700) and HTTPS (8701)
|
|
50
|
+
--headless-url <url> URL to open in headless browser (default: none)
|
|
51
|
+
--snapshots-dir <path> Save snapshots to disk (for CI artifacts)
|
|
52
|
+
--docs-dir <path> Directory with custom docs (*.md files)
|
|
53
|
+
--port <n> Set HTTP port (default: 8700)
|
|
54
|
+
--https-port <n> Set HTTPS port (default: 8701)
|
|
55
|
+
--setup-mcp Configure Claude Desktop MCP integration
|
|
56
|
+
--setup-mcp-check Check MCP configuration status
|
|
57
|
+
--setup-mcp-remove Remove Haltija from Claude Desktop config
|
|
58
|
+
--help, -h Show this help
|
|
59
|
+
|
|
60
|
+
Environment Variables:
|
|
61
|
+
DEV_CHANNEL_PORT HTTP port (default: 8700)
|
|
62
|
+
DEV_CHANNEL_HTTPS_PORT HTTPS port (default: 8701)
|
|
63
|
+
DEV_CHANNEL_MODE 'http', 'https', or 'both' (default: 'http')
|
|
64
|
+
DEV_CHANNEL_SNAPSHOTS_DIR Directory to save snapshots (default: memory only)
|
|
65
|
+
DEV_CHANNEL_DOCS_DIR Directory with custom docs (default: built-in only)
|
|
66
|
+
|
|
67
|
+
Subcommands:
|
|
68
|
+
haltija <command> [args] Run API commands directly (see hj --help)
|
|
69
|
+
haltija tree DOM tree with ref IDs
|
|
70
|
+
haltija click @42 Click element by ref
|
|
71
|
+
haltija type @10 Hello Type text into element
|
|
72
|
+
haltija eval document.title Run JS in browser
|
|
73
|
+
haltija status Server status
|
|
74
|
+
|
|
75
|
+
Use 'hj' as a short alias: hj tree, hj click @42, etc.
|
|
76
|
+
|
|
77
|
+
Examples:
|
|
78
|
+
haltija # Desktop app (or server fallback)
|
|
79
|
+
haltija --app # Desktop app explicitly
|
|
80
|
+
haltija --server # Server only
|
|
81
|
+
haltija --server --https # HTTPS server only
|
|
82
|
+
haltija --headless # Headless browser for CI
|
|
83
|
+
haltija --setup-mcp # Configure Claude Desktop integration
|
|
84
|
+
`)
|
|
85
|
+
process.exit(0)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ============================================
|
|
89
|
+
// MCP Setup Functions
|
|
90
|
+
// ============================================
|
|
91
|
+
|
|
92
|
+
/** Get Claude Desktop config path based on platform */
|
|
93
|
+
function getClaudeDesktopConfigPath() {
|
|
94
|
+
const home = homedir()
|
|
95
|
+
switch (platform()) {
|
|
96
|
+
case 'darwin':
|
|
97
|
+
return join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json')
|
|
98
|
+
case 'win32':
|
|
99
|
+
return join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json')
|
|
100
|
+
case 'linux':
|
|
101
|
+
return join(home, '.config', 'claude', 'claude_desktop_config.json')
|
|
102
|
+
default:
|
|
103
|
+
return join(home, '.config', 'claude', 'claude_desktop_config.json')
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Find the Haltija MCP server entry point */
|
|
108
|
+
function findMcpServerPath() {
|
|
109
|
+
const candidates = [
|
|
110
|
+
join(__dirname, '../apps/mcp/build/index.js'),
|
|
111
|
+
join(__dirname, 'mcp/build/index.js'),
|
|
112
|
+
join(process.cwd(), 'node_modules/haltija/apps/mcp/build/index.js'),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
for (const p of candidates) {
|
|
116
|
+
if (existsSync(p)) return p
|
|
117
|
+
}
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Check MCP configuration status */
|
|
122
|
+
function checkMcpConfig() {
|
|
123
|
+
console.log(bold('\nHaltija MCP Configuration Status\n'))
|
|
124
|
+
|
|
125
|
+
const configPath = getClaudeDesktopConfigPath()
|
|
126
|
+
const mcpPath = findMcpServerPath()
|
|
127
|
+
|
|
128
|
+
// Check Claude Desktop
|
|
129
|
+
if (existsSync(dirname(configPath))) {
|
|
130
|
+
console.log(green('✓') + ' Claude Desktop detected')
|
|
131
|
+
console.log(dim(` Config: ${configPath}`))
|
|
132
|
+
|
|
133
|
+
if (existsSync(configPath)) {
|
|
134
|
+
try {
|
|
135
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'))
|
|
136
|
+
if (config.mcpServers?.haltija) {
|
|
137
|
+
console.log(green('✓') + ' Haltija is configured in Claude Desktop')
|
|
138
|
+
console.log(dim(` Command: ${config.mcpServers.haltija.command} ${config.mcpServers.haltija.args?.join(' ') || ''}`))
|
|
139
|
+
} else {
|
|
140
|
+
console.log(yellow('○') + ' Haltija is not configured')
|
|
141
|
+
console.log(dim(` Run: haltija --setup-mcp`))
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
console.log(yellow('○') + ' Config exists but could not be parsed')
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
console.log(yellow('○') + ' Config file does not exist yet')
|
|
148
|
+
console.log(dim(` Run: haltija --setup-mcp`))
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
console.log(yellow('○') + ' Claude Desktop not detected')
|
|
152
|
+
console.log(dim(` Install from: https://claude.ai/download`))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check MCP server
|
|
156
|
+
if (mcpPath) {
|
|
157
|
+
console.log(green('✓') + ' MCP server found')
|
|
158
|
+
console.log(dim(` Path: ${mcpPath}`))
|
|
159
|
+
} else {
|
|
160
|
+
console.log(red('✗') + ' MCP server not found')
|
|
161
|
+
console.log(dim(` Run from haltija directory or rebuild`))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Check if server is running
|
|
165
|
+
fetch('http://localhost:8700/status', { signal: AbortSignal.timeout(1000) })
|
|
166
|
+
.then(r => {
|
|
167
|
+
if (r.ok) console.log(green('✓') + ' Haltija server is running on port 8700')
|
|
168
|
+
else console.log(yellow('○') + ' Haltija server not running')
|
|
169
|
+
})
|
|
170
|
+
.catch(() => {
|
|
171
|
+
console.log(yellow('○') + ' Haltija server not running')
|
|
172
|
+
console.log(dim(` Start with: haltija`))
|
|
173
|
+
})
|
|
174
|
+
.finally(() => {
|
|
175
|
+
console.log('')
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Setup Claude Desktop MCP integration */
|
|
180
|
+
function setupMcp() {
|
|
181
|
+
const mcpPath = findMcpServerPath()
|
|
182
|
+
if (!mcpPath) {
|
|
183
|
+
console.log(red('Error:') + ' Could not find Haltija MCP server.')
|
|
184
|
+
console.log('Make sure you run this from the haltija directory or have it installed.')
|
|
185
|
+
process.exit(1)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const configPath = getClaudeDesktopConfigPath()
|
|
189
|
+
const configDir = dirname(configPath)
|
|
190
|
+
|
|
191
|
+
// Create config directory if needed
|
|
192
|
+
if (!existsSync(configDir)) {
|
|
193
|
+
console.log(dim(`Creating config directory: ${configDir}`))
|
|
194
|
+
mkdirSync(configDir, { recursive: true })
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Read or create config
|
|
198
|
+
let config = { mcpServers: {} }
|
|
199
|
+
if (existsSync(configPath)) {
|
|
200
|
+
try {
|
|
201
|
+
config = JSON.parse(readFileSync(configPath, 'utf8'))
|
|
202
|
+
if (!config.mcpServers) config.mcpServers = {}
|
|
203
|
+
} catch {
|
|
204
|
+
// Backup invalid config
|
|
205
|
+
const backupPath = configPath + '.backup'
|
|
206
|
+
console.log(yellow('Warning:') + ` Existing config invalid, backing up to ${backupPath}`)
|
|
207
|
+
writeFileSync(backupPath, readFileSync(configPath))
|
|
208
|
+
config = { mcpServers: {} }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Check if already configured
|
|
213
|
+
if (config.mcpServers.haltija) {
|
|
214
|
+
console.log(green('✓') + ' Haltija is already configured in Claude Desktop')
|
|
215
|
+
console.log(dim(` Config: ${configPath}`))
|
|
216
|
+
console.log('')
|
|
217
|
+
console.log('To reconfigure, first run: ' + bold('haltija --setup-mcp-remove'))
|
|
218
|
+
process.exit(0)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Add Haltija
|
|
222
|
+
config.mcpServers.haltija = {
|
|
223
|
+
command: 'node',
|
|
224
|
+
args: [mcpPath]
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
try {
|
|
228
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2))
|
|
229
|
+
console.log(green('✓') + ' Haltija configured successfully!')
|
|
230
|
+
console.log(dim(` Config: ${configPath}`))
|
|
231
|
+
console.log('')
|
|
232
|
+
console.log(bold('Next steps:'))
|
|
233
|
+
console.log(' 1. ' + yellow('Restart Claude Desktop') + ' to load the MCP server')
|
|
234
|
+
console.log(' 2. Start Haltija server: ' + dim('haltija'))
|
|
235
|
+
console.log(' 3. Connect browser and chat with Claude!')
|
|
236
|
+
console.log('')
|
|
237
|
+
} catch (err) {
|
|
238
|
+
console.log(red('Error:') + ` Failed to write config: ${err.message}`)
|
|
239
|
+
process.exit(1)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Remove Haltija from Claude Desktop config */
|
|
244
|
+
function removeMcp() {
|
|
245
|
+
const configPath = getClaudeDesktopConfigPath()
|
|
246
|
+
|
|
247
|
+
if (!existsSync(configPath)) {
|
|
248
|
+
console.log('Claude Desktop config does not exist.')
|
|
249
|
+
process.exit(0)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'))
|
|
254
|
+
if (!config.mcpServers?.haltija) {
|
|
255
|
+
console.log('Haltija is not configured in Claude Desktop.')
|
|
256
|
+
process.exit(0)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
delete config.mcpServers.haltija
|
|
260
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2))
|
|
261
|
+
|
|
262
|
+
console.log(green('✓') + ' Removed Haltija from Claude Desktop config')
|
|
263
|
+
console.log(dim(` Config: ${configPath}`))
|
|
264
|
+
console.log('')
|
|
265
|
+
console.log(yellow('Restart Claude Desktop') + ' to apply changes.')
|
|
266
|
+
console.log('')
|
|
267
|
+
} catch (err) {
|
|
268
|
+
console.log(red('Error:') + ` Failed to update config: ${err.message}`)
|
|
269
|
+
process.exit(1)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ============================================
|
|
274
|
+
// CLI Subcommand Detection
|
|
275
|
+
// ============================================
|
|
276
|
+
|
|
277
|
+
import { isSubcommand, runSubcommand } from './cli-subcommand.mjs'
|
|
278
|
+
|
|
279
|
+
// Check if first positional arg is a subcommand (not a flag, not a port number)
|
|
280
|
+
const firstNonFlag = args.find(a => !a.startsWith('-'))
|
|
281
|
+
if (firstNonFlag && isSubcommand(firstNonFlag)) {
|
|
282
|
+
// Parse --port for subcommand mode
|
|
283
|
+
let subPort = process.env.DEV_CHANNEL_PORT || '8700'
|
|
284
|
+
const subPortIdx = args.indexOf('--port')
|
|
285
|
+
if (subPortIdx !== -1 && args[subPortIdx + 1]) {
|
|
286
|
+
subPort = args[subPortIdx + 1]
|
|
287
|
+
}
|
|
288
|
+
// Collect args after the subcommand, removing --port <n>
|
|
289
|
+
const subIdx = args.indexOf(firstNonFlag)
|
|
290
|
+
const rawSubArgs = args.slice(subIdx + 1)
|
|
291
|
+
const cleanSubArgs = []
|
|
292
|
+
for (let i = 0; i < rawSubArgs.length; i++) {
|
|
293
|
+
if (rawSubArgs[i] === '--port') { i++; continue }
|
|
294
|
+
cleanSubArgs.push(rawSubArgs[i])
|
|
295
|
+
}
|
|
296
|
+
await runSubcommand(firstNonFlag, cleanSubArgs, subPort)
|
|
297
|
+
process.exit(0)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Handle MCP setup commands
|
|
301
|
+
if (args.includes('--setup-mcp')) {
|
|
302
|
+
setupMcp()
|
|
303
|
+
process.exit(0)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (args.includes('--setup-mcp-check')) {
|
|
307
|
+
checkMcpConfig()
|
|
308
|
+
// Don't exit immediately - let the async fetch complete
|
|
309
|
+
setTimeout(() => process.exit(0), 2000)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (args.includes('--setup-mcp-remove')) {
|
|
313
|
+
removeMcp()
|
|
314
|
+
process.exit(0)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Set up environment from args
|
|
318
|
+
const env = { ...process.env }
|
|
319
|
+
|
|
320
|
+
if (args.includes('--https')) {
|
|
321
|
+
env.DEV_CHANNEL_MODE = 'https'
|
|
322
|
+
} else if (args.includes('--both')) {
|
|
323
|
+
env.DEV_CHANNEL_MODE = 'both'
|
|
324
|
+
} else {
|
|
325
|
+
env.DEV_CHANNEL_MODE = env.DEV_CHANNEL_MODE || 'http'
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const portIdx = args.indexOf('--port')
|
|
329
|
+
if (portIdx !== -1 && args[portIdx + 1]) {
|
|
330
|
+
env.DEV_CHANNEL_PORT = args[portIdx + 1]
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const httpsPortIdx = args.indexOf('--https-port')
|
|
334
|
+
if (httpsPortIdx !== -1 && args[httpsPortIdx + 1]) {
|
|
335
|
+
env.DEV_CHANNEL_HTTPS_PORT = args[httpsPortIdx + 1]
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Legacy: first positional arg as port
|
|
339
|
+
const firstArg = args.find(a => !a.startsWith('-'))
|
|
340
|
+
if (firstArg && !isNaN(parseInt(firstArg))) {
|
|
341
|
+
env.DEV_CHANNEL_PORT = firstArg
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Snapshots directory for CI artifact upload
|
|
345
|
+
const snapshotsDirIdx = args.indexOf('--snapshots-dir')
|
|
346
|
+
if (snapshotsDirIdx !== -1 && args[snapshotsDirIdx + 1]) {
|
|
347
|
+
env.DEV_CHANNEL_SNAPSHOTS_DIR = args[snapshotsDirIdx + 1]
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Custom docs directory
|
|
351
|
+
const docsDirIdx = args.indexOf('--docs-dir')
|
|
352
|
+
if (docsDirIdx !== -1 && args[docsDirIdx + 1]) {
|
|
353
|
+
env.DEV_CHANNEL_DOCS_DIR = args[docsDirIdx + 1]
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ============================================
|
|
357
|
+
// Mode Detection
|
|
358
|
+
// ============================================
|
|
359
|
+
|
|
360
|
+
const headlessMode = args.includes('--headless')
|
|
361
|
+
const headlessUrlIdx = args.indexOf('--headless-url')
|
|
362
|
+
const headlessUrl = headlessUrlIdx !== -1 ? args[headlessUrlIdx + 1] : null
|
|
363
|
+
const explicitServer = args.includes('--server')
|
|
364
|
+
const explicitApp = args.includes('--app')
|
|
365
|
+
|
|
366
|
+
/** Detect if Electron desktop app is available */
|
|
367
|
+
function detectElectron() {
|
|
368
|
+
const desktopDir = join(__dirname, '../apps/desktop')
|
|
369
|
+
if (!existsSync(desktopDir)) return null
|
|
370
|
+
|
|
371
|
+
// Check for electron in desktop app's node_modules
|
|
372
|
+
const electronBin = join(desktopDir, 'node_modules/.bin/electron')
|
|
373
|
+
if (existsSync(electronBin)) return { electronBin, desktopDir }
|
|
374
|
+
|
|
375
|
+
// Check if electron is available globally
|
|
376
|
+
try {
|
|
377
|
+
execSyncImported('electron --version', { stdio: 'ignore', timeout: 5000 })
|
|
378
|
+
return { electronBin: 'electron', desktopDir }
|
|
379
|
+
} catch {}
|
|
380
|
+
|
|
381
|
+
return null
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Read version from package.json */
|
|
385
|
+
function getVersion() {
|
|
386
|
+
try {
|
|
387
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, '../package.json'), 'utf8'))
|
|
388
|
+
return pkg.version || '0.0.0'
|
|
389
|
+
} catch {
|
|
390
|
+
return '0.0.0'
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** Print startup banner */
|
|
395
|
+
function printBanner(mode, port) {
|
|
396
|
+
const version = getVersion()
|
|
397
|
+
const url = `http://localhost:${port}`
|
|
398
|
+
console.log('')
|
|
399
|
+
console.log(dim('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
|
|
400
|
+
console.log(` ${bold('Haltija')} v${version} ${dim('—')} ${green(url)}`)
|
|
401
|
+
console.log(` Mode: ${mode === 'app' ? 'Desktop App' : mode === 'headless' ? 'Headless' : 'Server'}`)
|
|
402
|
+
console.log('')
|
|
403
|
+
console.log(dim(' Agent setup:'))
|
|
404
|
+
console.log(` MCP: ${dim('bunx haltija --setup-mcp')}`)
|
|
405
|
+
console.log(` Curl: ${dim(`curl ${url}/tree`)}`)
|
|
406
|
+
console.log(` Docs: ${dim(`curl ${url}/docs`)}`)
|
|
407
|
+
console.log(dim('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
|
|
408
|
+
console.log('')
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Launch the Electron desktop app */
|
|
412
|
+
function launchApp(electronInfo, port) {
|
|
413
|
+
printBanner('app', port)
|
|
414
|
+
|
|
415
|
+
const child = spawn(electronInfo.electronBin, [electronInfo.desktopDir], {
|
|
416
|
+
env: { ...env, DEV_CHANNEL_PORT: String(port) },
|
|
417
|
+
stdio: 'inherit'
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
child.on('error', (err) => {
|
|
421
|
+
console.error(red('Error:') + ` Failed to launch desktop app: ${err.message}`)
|
|
422
|
+
console.log(dim('Falling back to server mode...'))
|
|
423
|
+
console.log('')
|
|
424
|
+
startServer(port)
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
child.on('exit', code => {
|
|
428
|
+
process.exit(code || 0)
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Start the server (current behavior) */
|
|
433
|
+
function startServer(port) {
|
|
434
|
+
printBanner('server', port)
|
|
435
|
+
tryBun()
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Start headless browser after server is ready
|
|
439
|
+
const startHeadlessBrowser = async (port) => {
|
|
440
|
+
console.log('[tosijs-dev] Starting headless Chromium browser...')
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
// Dynamic import to avoid requiring playwright if not using headless mode
|
|
444
|
+
const { chromium } = await import('playwright')
|
|
445
|
+
|
|
446
|
+
const browser = await chromium.launch({
|
|
447
|
+
headless: true,
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
const context = await browser.newContext({
|
|
451
|
+
viewport: { width: 1280, height: 720 },
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
const page = await context.newPage()
|
|
455
|
+
|
|
456
|
+
// Inject tosijs-dev widget into every page
|
|
457
|
+
await context.addInitScript({
|
|
458
|
+
content: `
|
|
459
|
+
// Auto-inject tosijs-dev widget
|
|
460
|
+
(function() {
|
|
461
|
+
if (document.querySelector('tosijs-dev')) return;
|
|
462
|
+
|
|
463
|
+
console.log(
|
|
464
|
+
'%c🦉 tosijs-dev%c headless mode',
|
|
465
|
+
'background:#6366f1;color:white;padding:2px 8px;border-radius:3px 0 0 3px;font-weight:bold',
|
|
466
|
+
'background:#22c55e;color:white;padding:2px 8px;border-radius:0 3px 3px 0'
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
fetch('http://localhost:${port}/inject.js')
|
|
470
|
+
.then(r => r.text())
|
|
471
|
+
.then(eval)
|
|
472
|
+
.catch(e => console.error('[tosijs-dev] Failed to inject:', e));
|
|
473
|
+
})();
|
|
474
|
+
`
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
// Navigate to URL if specified
|
|
478
|
+
if (headlessUrl) {
|
|
479
|
+
console.log(`[tosijs-dev] Opening ${headlessUrl}...`)
|
|
480
|
+
await page.goto(headlessUrl, { waitUntil: 'domcontentloaded' })
|
|
481
|
+
} else {
|
|
482
|
+
// Navigate to test page by default
|
|
483
|
+
await page.goto(`http://localhost:${port}/`, { waitUntil: 'domcontentloaded' })
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
console.log('[tosijs-dev] Headless browser ready. Widget auto-injected.')
|
|
487
|
+
console.log('[tosijs-dev] Use POST /navigate to change pages.')
|
|
488
|
+
|
|
489
|
+
// Keep browser alive
|
|
490
|
+
process.on('SIGINT', async () => {
|
|
491
|
+
console.log('\\n[tosijs-dev] Closing browser...')
|
|
492
|
+
await browser.close()
|
|
493
|
+
process.exit(0)
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
process.on('SIGTERM', async () => {
|
|
497
|
+
await browser.close()
|
|
498
|
+
process.exit(0)
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
} catch (err) {
|
|
502
|
+
if (err.code === 'ERR_MODULE_NOT_FOUND') {
|
|
503
|
+
console.error('[tosijs-dev] Playwright not installed. Run: npm install playwright')
|
|
504
|
+
console.error('[tosijs-dev] Then: npx playwright install chromium')
|
|
505
|
+
} else {
|
|
506
|
+
console.error('[tosijs-dev] Failed to start headless browser:', err.message)
|
|
507
|
+
}
|
|
508
|
+
process.exit(1)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Try bun first, fall back to node
|
|
513
|
+
const tryBun = () => {
|
|
514
|
+
const bun = spawn('bun', ['run', serverPath], {
|
|
515
|
+
env,
|
|
516
|
+
stdio: 'inherit'
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
bun.on('error', () => {
|
|
520
|
+
// Bun not available, try node
|
|
521
|
+
tryNode()
|
|
522
|
+
})
|
|
523
|
+
|
|
524
|
+
bun.on('exit', code => {
|
|
525
|
+
process.exit(code || 0)
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
// Start headless browser after a short delay for server to start
|
|
529
|
+
if (headlessMode) {
|
|
530
|
+
setTimeout(() => {
|
|
531
|
+
startHeadlessBrowser(env.DEV_CHANNEL_PORT || 8700)
|
|
532
|
+
}, 2000)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const tryNode = () => {
|
|
537
|
+
console.log('[tosijs-dev] Bun not found, using Node.js (some features may be limited)')
|
|
538
|
+
|
|
539
|
+
const node = spawn('node', [serverPath], {
|
|
540
|
+
env,
|
|
541
|
+
stdio: 'inherit'
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
node.on('error', (err) => {
|
|
545
|
+
console.error('Failed to start server:', err.message)
|
|
546
|
+
process.exit(1)
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
node.on('exit', code => {
|
|
550
|
+
process.exit(code || 0)
|
|
551
|
+
})
|
|
552
|
+
|
|
553
|
+
// Start headless browser after a short delay for server to start
|
|
554
|
+
if (headlessMode) {
|
|
555
|
+
setTimeout(() => {
|
|
556
|
+
startHeadlessBrowser(env.DEV_CHANNEL_PORT || 8700)
|
|
557
|
+
}, 2000)
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ============================================
|
|
562
|
+
// Launch Mode Selection
|
|
563
|
+
// ============================================
|
|
564
|
+
|
|
565
|
+
const port = env.DEV_CHANNEL_PORT || '8700'
|
|
566
|
+
|
|
567
|
+
if (headlessMode) {
|
|
568
|
+
// Headless mode: start server + headless browser
|
|
569
|
+
printBanner('headless', port)
|
|
570
|
+
tryBun()
|
|
571
|
+
} else if (explicitServer) {
|
|
572
|
+
// Explicit server-only mode
|
|
573
|
+
startServer(port)
|
|
574
|
+
} else if (explicitApp) {
|
|
575
|
+
// Explicit app mode - fail if electron not available
|
|
576
|
+
const electronInfo = detectElectron()
|
|
577
|
+
if (!electronInfo) {
|
|
578
|
+
console.error(red('Error:') + ' Desktop app not available.')
|
|
579
|
+
console.log(dim('Electron not found. Install it in apps/desktop/ or use --server mode.'))
|
|
580
|
+
process.exit(1)
|
|
581
|
+
}
|
|
582
|
+
launchApp(electronInfo, port)
|
|
583
|
+
} else {
|
|
584
|
+
// Default: try app, fall back to server
|
|
585
|
+
const electronInfo = detectElectron()
|
|
586
|
+
if (electronInfo) {
|
|
587
|
+
launchApp(electronInfo, port)
|
|
588
|
+
} else {
|
|
589
|
+
startServer(port)
|
|
590
|
+
}
|
|
591
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* tosijs-dev CLI (Bun version)
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* bunx tosijs-dev # Start HTTP server on port 8700
|
|
7
|
+
* bunx tosijs-dev --https # Start HTTPS server (auto-generates certs)
|
|
8
|
+
* bunx tosijs-dev --both # Start both HTTP and HTTPS
|
|
9
|
+
* bunx tosijs-dev --help # Show help
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const args = process.argv.slice(2)
|
|
13
|
+
|
|
14
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
15
|
+
console.log(`
|
|
16
|
+
tosijs-dev - Browser control for AI agents
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
tosijs-dev [options]
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--http HTTP only on port 8700 (default)
|
|
23
|
+
--https HTTPS only on port 8701 (auto-generates certs)
|
|
24
|
+
--both Both HTTP (8700) and HTTPS (8701)
|
|
25
|
+
--port <n> Set HTTP port (default: 8700)
|
|
26
|
+
--https-port <n> Set HTTPS port (default: 8701)
|
|
27
|
+
--help, -h Show this help
|
|
28
|
+
|
|
29
|
+
Environment Variables:
|
|
30
|
+
DEV_CHANNEL_PORT HTTP port (default: 8700)
|
|
31
|
+
DEV_CHANNEL_HTTPS_PORT HTTPS port (default: 8701)
|
|
32
|
+
DEV_CHANNEL_MODE 'http', 'https', or 'both' (default: 'http')
|
|
33
|
+
|
|
34
|
+
Examples:
|
|
35
|
+
tosijs-dev # HTTP on 8700
|
|
36
|
+
tosijs-dev --https # HTTPS on 8701 (generates certs with mkcert or openssl)
|
|
37
|
+
tosijs-dev --both # HTTP on 8700 + HTTPS on 8701
|
|
38
|
+
tosijs-dev --port 3000 # HTTP on 3000
|
|
39
|
+
|
|
40
|
+
Once running, curl the /docs endpoint for full API documentation.
|
|
41
|
+
`)
|
|
42
|
+
process.exit(0)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Parse args
|
|
46
|
+
if (args.includes('--https')) {
|
|
47
|
+
process.env.DEV_CHANNEL_MODE = 'https'
|
|
48
|
+
} else if (args.includes('--both')) {
|
|
49
|
+
process.env.DEV_CHANNEL_MODE = 'both'
|
|
50
|
+
} else {
|
|
51
|
+
process.env.DEV_CHANNEL_MODE = process.env.DEV_CHANNEL_MODE || 'http'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const portIdx = args.indexOf('--port')
|
|
55
|
+
if (portIdx !== -1 && args[portIdx + 1]) {
|
|
56
|
+
process.env.DEV_CHANNEL_PORT = args[portIdx + 1]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const httpsPortIdx = args.indexOf('--https-port')
|
|
60
|
+
if (httpsPortIdx !== -1 && args[httpsPortIdx + 1]) {
|
|
61
|
+
process.env.DEV_CHANNEL_HTTPS_PORT = args[httpsPortIdx + 1]
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Legacy: first positional arg as port
|
|
65
|
+
const firstArg = args.find(a => !a.startsWith('-'))
|
|
66
|
+
if (firstArg && !isNaN(parseInt(firstArg))) {
|
|
67
|
+
process.env.DEV_CHANNEL_PORT = firstArg
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Import and start server (the server prints its own startup message)
|
|
71
|
+
import('../dist/server.js').catch(err => {
|
|
72
|
+
console.error('Failed to start server:', err)
|
|
73
|
+
process.exit(1)
|
|
74
|
+
})
|