opensteer 0.2.0 → 0.4.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/bin/opensteer.mjs CHANGED
@@ -1,22 +1,32 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { connect } from 'net'
3
+ import { createHash } from 'crypto'
4
4
  import { spawn } from 'child_process'
5
- import { existsSync, readFileSync, unlinkSync, mkdirSync } from 'fs'
6
- import { join, dirname } from 'path'
7
- import { homedir } from 'os'
5
+ import { existsSync, readFileSync, readdirSync, unlinkSync, writeFileSync } from 'fs'
6
+ import { connect } from 'net'
7
+ import { tmpdir } from 'os'
8
+ import { basename, dirname, join } from 'path'
8
9
  import { fileURLToPath } from 'url'
9
10
 
10
11
  const __dirname = dirname(fileURLToPath(import.meta.url))
11
-
12
- const RUNTIME_DIR = join(homedir(), '.opensteer')
13
- const SOCKET_PATH = join(RUNTIME_DIR, 'opensteer.sock')
14
- const PID_PATH = join(RUNTIME_DIR, 'opensteer.pid')
15
12
  const SERVER_SCRIPT = join(__dirname, '..', 'dist', 'cli', 'server.js')
16
13
 
17
14
  const CONNECT_TIMEOUT = 15000
18
15
  const POLL_INTERVAL = 100
19
16
  const RESPONSE_TIMEOUT = 120000
17
+ const RUNTIME_PREFIX = 'opensteer-'
18
+ const SOCKET_SUFFIX = '.sock'
19
+ const PID_SUFFIX = '.pid'
20
+ const CLOSE_ALL_REQUEST = { id: 1, command: 'close', args: {} }
21
+
22
+ function getVersion() {
23
+ try {
24
+ const pkgPath = join(__dirname, '..', 'package.json')
25
+ return JSON.parse(readFileSync(pkgPath, 'utf-8')).version
26
+ } catch {
27
+ return 'unknown'
28
+ }
29
+ }
20
30
 
21
31
  function parseArgs(argv) {
22
32
  const args = argv.slice(2)
@@ -25,6 +35,11 @@ function parseArgs(argv) {
25
35
  process.exit(0)
26
36
  }
27
37
 
38
+ if (args[0] === '--version' || args[0] === '-v') {
39
+ console.log(getVersion())
40
+ process.exit(0)
41
+ }
42
+
28
43
  const command = args[0]
29
44
  const flags = {}
30
45
  const positional = []
@@ -56,10 +71,78 @@ function parseValue(str) {
56
71
  return str
57
72
  }
58
73
 
74
+ function sanitizeNamespace(value) {
75
+ const trimmed = String(value || '').trim()
76
+ if (!trimmed || trimmed === '.' || trimmed === '..') {
77
+ return 'default'
78
+ }
79
+
80
+ const replaced = trimmed.replace(/[^a-zA-Z0-9_-]+/g, '_')
81
+ const collapsed = replaced.replace(/_+/g, '_')
82
+ const bounded = collapsed.replace(/^_+|_+$/g, '')
83
+
84
+ return bounded || 'default'
85
+ }
86
+
87
+ function getActiveNamespacePath() {
88
+ const hash = createHash('md5').update(process.cwd()).digest('hex').slice(0, 16)
89
+ return join(tmpdir(), `${RUNTIME_PREFIX}active-${hash}`)
90
+ }
91
+
92
+ function readActiveNamespace() {
93
+ try {
94
+ const filePath = getActiveNamespacePath()
95
+ if (!existsSync(filePath)) return null
96
+ const ns = readFileSync(filePath, 'utf-8').trim()
97
+ return ns || null
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ function writeActiveNamespace(namespace) {
104
+ try {
105
+ writeFileSync(getActiveNamespacePath(), namespace)
106
+ } catch { /* best-effort */ }
107
+ }
108
+
109
+ function resolveNamespace(flags) {
110
+ if (flags.name !== undefined && String(flags.name).trim().length > 0) {
111
+ return { namespace: sanitizeNamespace(String(flags.name)), source: 'flag' }
112
+ }
113
+
114
+ if (
115
+ typeof process.env.OPENSTEER_NAME === 'string' &&
116
+ process.env.OPENSTEER_NAME.trim().length > 0
117
+ ) {
118
+ return { namespace: sanitizeNamespace(process.env.OPENSTEER_NAME), source: 'env' }
119
+ }
120
+
121
+ const active = readActiveNamespace()
122
+ if (active && isServerRunning(active)) {
123
+ return { namespace: active, source: 'active' }
124
+ }
125
+
126
+ const cwdBase = basename(process.cwd())
127
+ if (cwdBase && cwdBase !== '.' && cwdBase !== '/') {
128
+ return { namespace: sanitizeNamespace(cwdBase), source: 'cwd' }
129
+ }
130
+
131
+ return { namespace: 'default', source: 'default' }
132
+ }
133
+
134
+ function getSocketPath(namespace) {
135
+ return join(tmpdir(), `${RUNTIME_PREFIX}${namespace}${SOCKET_SUFFIX}`)
136
+ }
137
+
138
+ function getPidPath(namespace) {
139
+ return join(tmpdir(), `${RUNTIME_PREFIX}${namespace}${PID_SUFFIX}`)
140
+ }
141
+
59
142
  function buildRequest(command, flags, positional) {
60
143
  const id = 1
61
144
  const globalFlags = {}
62
- for (const key of ['name', 'headless', 'json', 'connect-url', 'channel', 'profile-dir']) {
145
+ for (const key of ['headless', 'json', 'connect-url', 'channel', 'profile-dir']) {
63
146
  if (key in flags) {
64
147
  globalFlags[key] = flags[key]
65
148
  delete flags[key]
@@ -165,38 +248,66 @@ function buildRequest(command, flags, positional) {
165
248
  return { id, command, args }
166
249
  }
167
250
 
168
- function isServerRunning() {
169
- if (!existsSync(PID_PATH)) return false
251
+ function readPid(pidPath) {
252
+ if (!existsSync(pidPath)) {
253
+ return null
254
+ }
255
+
256
+ const parsed = Number.parseInt(readFileSync(pidPath, 'utf-8').trim(), 10)
257
+ if (!Number.isInteger(parsed) || parsed <= 0) {
258
+ return null
259
+ }
260
+
261
+ return parsed
262
+ }
263
+
264
+ function isPidAlive(pid) {
170
265
  try {
171
- const pid = parseInt(readFileSync(PID_PATH, 'utf-8').trim(), 10)
172
266
  process.kill(pid, 0)
173
267
  return true
174
268
  } catch {
175
- cleanStaleFiles()
176
269
  return false
177
270
  }
178
271
  }
179
272
 
180
- function cleanStaleFiles() {
273
+ function cleanStaleFiles(namespace) {
181
274
  try {
182
- unlinkSync(SOCKET_PATH)
275
+ unlinkSync(getSocketPath(namespace))
183
276
  } catch { }
184
277
  try {
185
- unlinkSync(PID_PATH)
278
+ unlinkSync(getPidPath(namespace))
186
279
  } catch { }
187
280
  }
188
281
 
189
- function startServer() {
190
- mkdirSync(RUNTIME_DIR, { recursive: true })
282
+ function isServerRunning(namespace) {
283
+ const pidPath = getPidPath(namespace)
284
+ const pid = readPid(pidPath)
285
+ if (!pid) {
286
+ cleanStaleFiles(namespace)
287
+ return false
288
+ }
289
+
290
+ if (!isPidAlive(pid)) {
291
+ cleanStaleFiles(namespace)
292
+ return false
293
+ }
294
+
295
+ return existsSync(getSocketPath(namespace))
296
+ }
191
297
 
298
+ function startServer(namespace) {
192
299
  const child = spawn('node', [SERVER_SCRIPT], {
193
300
  detached: true,
194
301
  stdio: ['ignore', 'ignore', 'ignore'],
302
+ env: {
303
+ ...process.env,
304
+ OPENSTEER_NAME: namespace,
305
+ },
195
306
  })
196
307
  child.unref()
197
308
  }
198
309
 
199
- function waitForSocket(timeout) {
310
+ function waitForSocket(socketPath, timeout) {
200
311
  return new Promise((resolve, reject) => {
201
312
  const start = Date.now()
202
313
 
@@ -206,7 +317,7 @@ function waitForSocket(timeout) {
206
317
  return
207
318
  }
208
319
 
209
- if (existsSync(SOCKET_PATH)) {
320
+ if (existsSync(socketPath)) {
210
321
  resolve()
211
322
  return
212
323
  }
@@ -218,9 +329,9 @@ function waitForSocket(timeout) {
218
329
  })
219
330
  }
220
331
 
221
- function sendCommand(request) {
332
+ function sendCommand(socketPath, request) {
222
333
  return new Promise((resolve, reject) => {
223
- const socket = connect(SOCKET_PATH)
334
+ const socket = connect(socketPath)
224
335
  let buffer = ''
225
336
  let settled = false
226
337
 
@@ -270,6 +381,71 @@ function sendCommand(request) {
270
381
  })
271
382
  }
272
383
 
384
+ function listSessions() {
385
+ const sessions = []
386
+ const entries = readdirSync(tmpdir())
387
+
388
+ for (const entry of entries) {
389
+ if (!entry.startsWith(RUNTIME_PREFIX) || !entry.endsWith(PID_SUFFIX)) {
390
+ continue
391
+ }
392
+
393
+ const name = entry.slice(
394
+ RUNTIME_PREFIX.length,
395
+ entry.length - PID_SUFFIX.length
396
+ )
397
+ if (!name) {
398
+ continue
399
+ }
400
+
401
+ const pid = readPid(join(tmpdir(), entry))
402
+ if (!pid || !isPidAlive(pid)) {
403
+ cleanStaleFiles(name)
404
+ continue
405
+ }
406
+
407
+ sessions.push({ name, pid })
408
+ }
409
+
410
+ sessions.sort((a, b) => a.name.localeCompare(b.name))
411
+ return sessions
412
+ }
413
+
414
+ async function closeAllSessions() {
415
+ const sessions = listSessions()
416
+ const closed = []
417
+ const failures = []
418
+
419
+ for (const session of sessions) {
420
+ const socketPath = getSocketPath(session.name)
421
+ if (!existsSync(socketPath)) {
422
+ cleanStaleFiles(session.name)
423
+ continue
424
+ }
425
+
426
+ try {
427
+ const response = await sendCommand(socketPath, CLOSE_ALL_REQUEST)
428
+ if (response && response.ok === true) {
429
+ closed.push(session)
430
+ } else {
431
+ failures.push(
432
+ `${session.name}: ${response?.error || 'unknown close error'}`
433
+ )
434
+ }
435
+ } catch (err) {
436
+ failures.push(
437
+ `${session.name}: ${err instanceof Error ? err.message : String(err)}`
438
+ )
439
+ }
440
+ }
441
+
442
+ if (failures.length > 0) {
443
+ throw new Error(`Failed to close sessions: ${failures.join('; ')}`)
444
+ }
445
+
446
+ return closed
447
+ }
448
+
273
449
  function output(data) {
274
450
  process.stdout.write(JSON.stringify(data) + '\n')
275
451
  }
@@ -289,6 +465,11 @@ Navigation:
289
465
  forward Go forward
290
466
  reload Reload page
291
467
  close Close browser and server
468
+ close --all Close all active namespace-scoped servers
469
+
470
+ Sessions:
471
+ sessions List active namespace-scoped sessions
472
+ status Show resolved namespace and session state
292
473
 
293
474
  Observation:
294
475
  snapshot [--mode action] Get page snapshot
@@ -334,7 +515,7 @@ Utility:
334
515
  extract <schema-json> Extract structured data
335
516
 
336
517
  Global Flags:
337
- --name <namespace> Storage namespace (default: "cli")
518
+ --name <namespace> Session namespace (default: CWD basename or OPENSTEER_NAME)
338
519
  --headless Launch browser in headless mode
339
520
  --connect-url <url> Connect to a running browser (e.g. http://localhost:9222)
340
521
  --channel <browser> Use installed browser (chrome, chrome-beta, msedge)
@@ -343,8 +524,10 @@ Global Flags:
343
524
  --selector <css> Target element by CSS selector
344
525
  --description <text> Description for selector persistence
345
526
  --help Show this help
527
+ --version, -v Show version
346
528
 
347
529
  Environment:
530
+ OPENSTEER_NAME Default session namespace when --name is omitted
348
531
  OPENSTEER_MODE Runtime mode: "local" (default) or "remote"
349
532
  OPENSTEER_API_KEY Required when remote mode is selected
350
533
  OPENSTEER_BASE_URL Override remote control-plane base URL
@@ -353,26 +536,66 @@ Environment:
353
536
 
354
537
  async function main() {
355
538
  const { command, flags, positional } = parseArgs(process.argv)
539
+ const { namespace, source: namespaceSource } = resolveNamespace(flags)
540
+ const socketPath = getSocketPath(namespace)
541
+
542
+ if (command === 'sessions') {
543
+ output({ ok: true, sessions: listSessions() })
544
+ return
545
+ }
546
+
547
+ if (command === 'status') {
548
+ output({
549
+ ok: true,
550
+ namespace,
551
+ namespaceSource,
552
+ serverRunning: isServerRunning(namespace),
553
+ socketPath,
554
+ sessions: listSessions(),
555
+ })
556
+ return
557
+ }
558
+
559
+ if (command === 'close' && flags.all === true) {
560
+ try {
561
+ const closed = await closeAllSessions()
562
+ output({ ok: true, closed })
563
+ } catch (err) {
564
+ error(err instanceof Error ? err.message : 'Failed to close sessions')
565
+ }
566
+ return
567
+ }
568
+
569
+ delete flags.name
570
+ delete flags.all
356
571
  const request = buildRequest(command, flags, positional)
357
572
 
358
- if (!isServerRunning()) {
573
+ if (!isServerRunning(namespace)) {
574
+ if (command !== 'open') {
575
+ error(
576
+ `No server running for namespace '${namespace}' (resolved from ${namespaceSource}). Run 'opensteer open' first or use 'opensteer sessions' to see active sessions.`
577
+ )
578
+ }
359
579
  if (!existsSync(SERVER_SCRIPT)) {
360
580
  error(
361
581
  `Server script not found: ${SERVER_SCRIPT}. Run the build script first.`
362
582
  )
363
583
  }
364
- startServer()
584
+ startServer(namespace)
365
585
  try {
366
- await waitForSocket(CONNECT_TIMEOUT)
586
+ await waitForSocket(socketPath, CONNECT_TIMEOUT)
367
587
  } catch {
368
588
  error('Failed to start server. Check that the build is complete.')
369
589
  }
370
590
  }
371
591
 
372
592
  try {
373
- const response = await sendCommand(request)
593
+ const response = await sendCommand(socketPath, request)
374
594
 
375
595
  if (response.ok) {
596
+ if (command === 'open') {
597
+ writeActiveNamespace(namespace)
598
+ }
376
599
  output({ ok: true, ...response.result })
377
600
  } else {
378
601
  process.stderr.write(
@@ -381,7 +604,7 @@ async function main() {
381
604
  process.exit(1)
382
605
  }
383
606
  } catch (err) {
384
- error(err.message || 'Connection failed')
607
+ error(err instanceof Error ? err.message : 'Connection failed')
385
608
  }
386
609
  }
387
610