@phenx-inc/ctlsurf 0.3.12 → 0.3.13
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/ctlsurf-worker.js +264 -3
- package/out/headless/index.mjs +1 -1
- package/out/headless/index.mjs.map +1 -1
- package/package.json +1 -1
package/bin/ctlsurf-worker.js
CHANGED
|
@@ -233,7 +233,16 @@ function detectMode(argv) {
|
|
|
233
233
|
|
|
234
234
|
const mode = detectMode(args)
|
|
235
235
|
|
|
236
|
-
|
|
236
|
+
// Validate the ctlsurf API key before launching — on install/update or when
|
|
237
|
+
// the stored key is missing/rejected. A check failure never blocks launch.
|
|
238
|
+
checkApiKey(mode).catch(() => {}).then(() => launch(mode))
|
|
239
|
+
|
|
240
|
+
function launch(launchMode) {
|
|
241
|
+
if (launchMode === 'desktop') runDesktop()
|
|
242
|
+
else runTerminal()
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function runDesktop() {
|
|
237
246
|
let electronPath
|
|
238
247
|
try {
|
|
239
248
|
electronPath = require('electron')
|
|
@@ -278,8 +287,6 @@ if (mode === 'desktop') {
|
|
|
278
287
|
} catch (err) {
|
|
279
288
|
process.exit(err.status || 0)
|
|
280
289
|
}
|
|
281
|
-
} else {
|
|
282
|
-
runTerminal()
|
|
283
290
|
}
|
|
284
291
|
|
|
285
292
|
function installElectron() {
|
|
@@ -314,3 +321,257 @@ function runTerminal() {
|
|
|
314
321
|
process.exit(err.status || 0)
|
|
315
322
|
}
|
|
316
323
|
}
|
|
324
|
+
|
|
325
|
+
// ─── API key check (install/update or invalid key) ────────────────
|
|
326
|
+
|
|
327
|
+
function getPkgVersion() {
|
|
328
|
+
try { return require(path.join(ROOT, 'package.json')).version || '0.0.0' }
|
|
329
|
+
catch { return '0.0.0' }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// settings.json locations. Terminal and desktop modes historically use
|
|
333
|
+
// different dirs: src/main/settingsDir.ts gives 'ctlsurf-worker' for the
|
|
334
|
+
// headless path, while desktop is Electron's userData under app name
|
|
335
|
+
// 'ctlsurf' (see src/main/index.ts). We touch both so a key entered once
|
|
336
|
+
// is consistent regardless of which mode launches next.
|
|
337
|
+
function settingsPathForMode(m) {
|
|
338
|
+
const home = os.homedir()
|
|
339
|
+
if (m === 'desktop') {
|
|
340
|
+
if (process.platform === 'darwin') {
|
|
341
|
+
return path.join(home, 'Library', 'Application Support', 'ctlsurf', 'settings.json')
|
|
342
|
+
}
|
|
343
|
+
if (process.platform === 'win32') {
|
|
344
|
+
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ctlsurf', 'settings.json')
|
|
345
|
+
}
|
|
346
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'ctlsurf', 'settings.json')
|
|
347
|
+
}
|
|
348
|
+
if (process.platform === 'darwin') {
|
|
349
|
+
return path.join(home, 'Library', 'Application Support', 'ctlsurf-worker', 'settings.json')
|
|
350
|
+
}
|
|
351
|
+
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'ctlsurf-worker', 'settings.json')
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function allSettingsPaths() {
|
|
355
|
+
return [settingsPathForMode('terminal'), settingsPathForMode('desktop')]
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function defaultSettings() {
|
|
359
|
+
return {
|
|
360
|
+
activeProfile: 'production',
|
|
361
|
+
profiles: {
|
|
362
|
+
production: {
|
|
363
|
+
name: 'Production',
|
|
364
|
+
apiKey: '',
|
|
365
|
+
baseUrl: 'https://app.ctlsurf.com',
|
|
366
|
+
dataspacePageId: '',
|
|
367
|
+
trackTime: true,
|
|
368
|
+
idleTimeoutMin: 15,
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
logChat: false,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function readSettings(p) {
|
|
376
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf-8')) }
|
|
377
|
+
catch { return null }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function writeSettings(p, data) {
|
|
381
|
+
try {
|
|
382
|
+
fs.mkdirSync(path.dirname(p), { recursive: true })
|
|
383
|
+
fs.writeFileSync(p, JSON.stringify(data, null, 2))
|
|
384
|
+
return true
|
|
385
|
+
} catch { return false }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Normalize to the profiles shape, mirroring the orchestrator's legacy
|
|
389
|
+
// migration so we never clobber a pre-profiles settings file.
|
|
390
|
+
function normalizeSettings(s) {
|
|
391
|
+
if (s && s.profiles) return s
|
|
392
|
+
const base = defaultSettings()
|
|
393
|
+
if (s) {
|
|
394
|
+
base.profiles.production.apiKey = s.ctlsurfApiKey || ''
|
|
395
|
+
base.profiles.production.baseUrl = s.ctlsurfBaseUrl || base.profiles.production.baseUrl
|
|
396
|
+
base.profiles.production.dataspacePageId = s.ctlsurfDataspacePageId || ''
|
|
397
|
+
base.logChat = !!s.logChat
|
|
398
|
+
}
|
|
399
|
+
return base
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function activeProfile(s) {
|
|
403
|
+
const id = s.activeProfile || 'production'
|
|
404
|
+
return s.profiles[id] || s.profiles.production
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function maskKey(k) {
|
|
408
|
+
if (!k) return '(none)'
|
|
409
|
+
if (k.length <= 12) return k.slice(0, 3) + '…'
|
|
410
|
+
return k.slice(0, 8) + '…' + k.slice(-3)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// POST JSON, resolve with the HTTP status (0 on network failure/timeout).
|
|
414
|
+
// Uses the http/https modules directly to avoid Node 18's experimental
|
|
415
|
+
// fetch warning leaking into the launcher output.
|
|
416
|
+
function httpPostJson(urlStr, headers, bodyObj, timeoutMs) {
|
|
417
|
+
return new Promise((resolve) => {
|
|
418
|
+
let u
|
|
419
|
+
try { u = new URL(urlStr) } catch { return resolve(0) }
|
|
420
|
+
const mod = u.protocol === 'http:' ? require('http') : require('https')
|
|
421
|
+
const body = JSON.stringify(bodyObj)
|
|
422
|
+
const req = mod.request(u, {
|
|
423
|
+
method: 'POST',
|
|
424
|
+
headers: {
|
|
425
|
+
'Content-Type': 'application/json',
|
|
426
|
+
'Content-Length': Buffer.byteLength(body),
|
|
427
|
+
...headers,
|
|
428
|
+
},
|
|
429
|
+
timeout: timeoutMs,
|
|
430
|
+
}, (res) => {
|
|
431
|
+
res.resume()
|
|
432
|
+
resolve(res.statusCode || 0)
|
|
433
|
+
})
|
|
434
|
+
req.on('timeout', () => { req.destroy(); resolve(0) })
|
|
435
|
+
req.on('error', () => resolve(0))
|
|
436
|
+
req.write(body)
|
|
437
|
+
req.end()
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// → 'ok' | 'unauthorized' | 'unreachable'
|
|
442
|
+
async function validateKey(key, baseUrl) {
|
|
443
|
+
const url = String(baseUrl).replace(/\/+$/, '') + '/api/mcp'
|
|
444
|
+
const status = await httpPostJson(
|
|
445
|
+
url,
|
|
446
|
+
{ Authorization: `Bearer ${key}` },
|
|
447
|
+
{ jsonrpc: '2.0', method: 'ping', id: 1 },
|
|
448
|
+
10000,
|
|
449
|
+
)
|
|
450
|
+
if (status === 200) return 'ok'
|
|
451
|
+
if (status === 401 || status === 403) return 'unauthorized'
|
|
452
|
+
return 'unreachable'
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function ask(question) {
|
|
456
|
+
return new Promise((resolve) => {
|
|
457
|
+
const readline = require('readline')
|
|
458
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
|
459
|
+
rl.question(question, (answer) => { rl.close(); resolve(answer || '') })
|
|
460
|
+
})
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Persist a key to every worker settings.json profile + the shell rc.
|
|
464
|
+
function persistKey(key) {
|
|
465
|
+
for (const p of allSettingsPaths()) {
|
|
466
|
+
const s = normalizeSettings(readSettings(p))
|
|
467
|
+
activeProfile(s).apiKey = key
|
|
468
|
+
writeSettings(p, s)
|
|
469
|
+
}
|
|
470
|
+
console.log(` ${G}✓${R} Saved to worker settings.`)
|
|
471
|
+
|
|
472
|
+
const home = os.homedir()
|
|
473
|
+
const rc = ['.zshrc', '.bashrc', '.bash_profile']
|
|
474
|
+
.map((f) => path.join(home, f))
|
|
475
|
+
.find((f) => fs.existsSync(f))
|
|
476
|
+
if (rc) {
|
|
477
|
+
try {
|
|
478
|
+
let content = fs.readFileSync(rc, 'utf-8')
|
|
479
|
+
content = content
|
|
480
|
+
.split('\n')
|
|
481
|
+
.filter((l) => !/^\s*export\s+CTLSURF_API_KEY=/.test(l))
|
|
482
|
+
.join('\n')
|
|
483
|
+
if (content && !content.endsWith('\n')) content += '\n'
|
|
484
|
+
content += `\n# ctlsurf worker API key\nexport CTLSURF_API_KEY="${key}"\n`
|
|
485
|
+
fs.writeFileSync(rc, content)
|
|
486
|
+
console.log(` ${G}✓${R} Exported CTLSURF_API_KEY in ${rc}`)
|
|
487
|
+
console.log(` ${D}Restart your shell or run: source ${rc}${R}`)
|
|
488
|
+
} catch {
|
|
489
|
+
console.log(` ${Y}!${R} Could not update ${rc} — set CTLSURF_API_KEY manually.`)
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
console.log('')
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Prompt for a key, validate it, persist on success. Up to 3 attempts.
|
|
496
|
+
async function promptForNewKey(baseUrl, optional) {
|
|
497
|
+
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
498
|
+
const entered = (await ask(` Enter ctlsurf API key: `)).trim()
|
|
499
|
+
if (!entered) {
|
|
500
|
+
console.log(optional ? ' Keeping existing key.\n' : ' Continuing without a key.\n')
|
|
501
|
+
return
|
|
502
|
+
}
|
|
503
|
+
process.stdout.write(` Validating… `)
|
|
504
|
+
const result = await validateKey(entered, baseUrl)
|
|
505
|
+
if (result === 'ok') {
|
|
506
|
+
console.log(`${G}OK${R}`)
|
|
507
|
+
persistKey(entered)
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
if (result === 'unreachable') {
|
|
511
|
+
console.log(`${Y}server unreachable${R}`)
|
|
512
|
+
console.log(` Saving anyway — the worker will retry the connection.`)
|
|
513
|
+
persistKey(entered)
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
console.log(`${Y}rejected${R}`)
|
|
517
|
+
if (attempt < 3) console.log(` That key was not accepted. Try again.\n`)
|
|
518
|
+
else console.log(` ${Y}Giving up after 3 attempts. Continuing.${R}\n`)
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Validate the stored key on launch; prompt on install/update or when the
|
|
523
|
+
// key is missing/invalid. Never throws — launch must not be blocked.
|
|
524
|
+
async function checkApiKey(m) {
|
|
525
|
+
if (!process.stdin.isTTY) return // no terminal — can't prompt
|
|
526
|
+
if (args.includes('--skip-setup')) return
|
|
527
|
+
if (args.includes('--api-key')) return // explicit override — trust it
|
|
528
|
+
|
|
529
|
+
const currentVersion = getPkgVersion()
|
|
530
|
+
const settings = normalizeSettings(readSettings(settingsPathForMode(m)))
|
|
531
|
+
const firstRun = !settings.lastVersion
|
|
532
|
+
const versionChanged = settings.lastVersion !== currentVersion
|
|
533
|
+
|
|
534
|
+
const profile = activeProfile(settings)
|
|
535
|
+
const baseUrl = profile.baseUrl || process.env.CTLSURF_BASE_URL || 'https://app.ctlsurf.com'
|
|
536
|
+
const currentKey = profile.apiKey || process.env.CTLSURF_API_KEY || ''
|
|
537
|
+
|
|
538
|
+
let reason = null // 'missing' | 'invalid' | 'update'
|
|
539
|
+
if (!currentKey) {
|
|
540
|
+
reason = 'missing'
|
|
541
|
+
} else {
|
|
542
|
+
const result = await validateKey(currentKey, baseUrl)
|
|
543
|
+
if (result === 'unauthorized') reason = 'invalid'
|
|
544
|
+
else if (result === 'ok' && versionChanged) reason = 'update'
|
|
545
|
+
// 'unreachable' → offline; don't prompt, don't block launch
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (reason) {
|
|
549
|
+
console.log(`\n${B} ctlsurf${R} ${D}— API key check${R}\n`)
|
|
550
|
+
if (reason === 'missing') {
|
|
551
|
+
console.log(` No ctlsurf API key is configured.`)
|
|
552
|
+
console.log(` Get one from: ${B}https://app.ctlsurf.com/settings${R} ${D}(API Keys tab)${R}\n`)
|
|
553
|
+
await promptForNewKey(baseUrl, false)
|
|
554
|
+
} else if (reason === 'invalid') {
|
|
555
|
+
console.log(` ${Y}Your ctlsurf API key was rejected${R} ${D}(${maskKey(currentKey)})${R}.`)
|
|
556
|
+
console.log(` It may be revoked, expired, or for a different server.`)
|
|
557
|
+
console.log(` Get a new one from: ${B}https://app.ctlsurf.com/settings${R} ${D}(API Keys tab)${R}\n`)
|
|
558
|
+
await promptForNewKey(baseUrl, false)
|
|
559
|
+
} else if (reason === 'update') {
|
|
560
|
+
console.log(firstRun
|
|
561
|
+
? ` Welcome to ctlsurf v${currentVersion}.`
|
|
562
|
+
: ` ctlsurf was updated to v${currentVersion}.`)
|
|
563
|
+
console.log(` ${G}Your API key is valid${R} ${D}(${maskKey(currentKey)})${R}.`)
|
|
564
|
+
const ans = (await ask(` Replace it? ${D}(y/N)${R} `)).trim()
|
|
565
|
+
console.log('')
|
|
566
|
+
if (/^y(es)?$/i.test(ans)) await promptForNewKey(baseUrl, true)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Record the version so the install/update prompt fires only once per
|
|
571
|
+
// release. Written to both mode paths to avoid a re-prompt on mode switch.
|
|
572
|
+
for (const p of allSettingsPaths()) {
|
|
573
|
+
const s = normalizeSettings(readSettings(p))
|
|
574
|
+
s.lastVersion = currentVersion
|
|
575
|
+
writeSettings(p, s)
|
|
576
|
+
}
|
|
577
|
+
}
|
package/out/headless/index.mjs
CHANGED
|
@@ -5471,7 +5471,7 @@ var require_package = __commonJS({
|
|
|
5471
5471
|
"package.json"(exports, module) {
|
|
5472
5472
|
module.exports = {
|
|
5473
5473
|
name: "@phenx-inc/ctlsurf",
|
|
5474
|
-
version: "0.3.
|
|
5474
|
+
version: "0.3.13",
|
|
5475
5475
|
description: "Agent-agnostic terminal and desktop app for ctlsurf \u2014 run Claude Code, Codex, or any coding agent with live session logging and remote control",
|
|
5476
5476
|
main: "out/main/index.js",
|
|
5477
5477
|
bin: {
|