@nectarcrm/ccx 0.4.1 → 0.5.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.
Files changed (3) hide show
  1. package/README.md +3 -2
  2. package/bin/ccx.js +119 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -17,9 +17,10 @@ ccx login # autoriza este computador (abre o navegador
17
17
  ccx # mostra suas contas e troca a conta deste computador
18
18
  ccx claude-infra # troca direto
19
19
  ccx status # conta atual e uso de cada conta
20
+ ccx list # contas que dá pra usar agora, uma por linha (--json: detalhes)
20
21
  ccx doctor # confere se o claude está mesmo usando a conta escolhida (e o que pode atrapalhar)
21
- ccx sync # manda pro app o resumo das sessões (roda sozinho após o login e a cada 5 min)
22
- ccx sair # o claude volta ao seu login normal
22
+ ccx sync # manda pro app o resumo das sessões (sozinho às 12h00 e 17h30; assim, na hora)
23
+ ccx off # o claude volta ao seu login normal
23
24
  ccx logout # sai do app neste computador
24
25
  ```
25
26
 
package/bin/ccx.js CHANGED
@@ -6,9 +6,11 @@
6
6
  // ccx mostra suas contas e troca a conta deste computador
7
7
  // ccx <conta> troca direto (nome curto, ex.: claude-infra)
8
8
  // ccx status conta atual e uso das contas liberadas pra você
9
+ // ccx list nomes curtos das contas que dá pra usar agora, a mais folgada primeiro
10
+ // (--json: todas, com uso, reset, a atual e a recomendada)
9
11
  // ccx doctor confere se o claude está mesmo usando a conta escolhida
10
12
  // ccx sync manda pro app o resumo das sessões do Claude (totais por projeto e dia; nunca o texto)
11
- // ccx sair tira a conta deste computador (o claude volta ao login normal)
13
+ // ccx off tira a conta deste computador (o claude volta ao login normal)
12
14
  // ccx logout sai do app: tira a conta, a autorização e o vigia
13
15
  // ccx --version versão instalada (atualizar: npm install -g @nectarcrm/ccx@latest)
14
16
  //
@@ -16,7 +18,8 @@
16
18
  // (terminal, VS Code, JetBrains) usa essa conta. Claudes já abertos continuam na anterior.
17
19
  // O vigia roda sozinho a cada 5 min: avisa o app, atualiza o token se o admin trocou
18
20
  // e tira a conta se o acesso for removido. Os tokens gastos vão pro app pela telemetria.
19
- // O vigia também roda o sync, então o resumo dos projetos chega sozinho.
21
+ // O resumo dos projetos vai sozinho depois das 12h00 e das 17h30; ccx sync manda na hora.
22
+ // Uma vez por dia o vigia confere se há ccx novo no npm e se atualiza.
20
23
 
21
24
  import { execFileSync } from 'node:child_process'
22
25
  import { appendFileSync, chmodSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs'
@@ -159,7 +162,7 @@ const run = (cmd, args, input) => execFileSync(cmd, args, { input, stdio: [input
159
162
  const crontabWithout = () => { try { return run('crontab', ['-l']).split('\n').filter(l => l && !l.includes(CRON_TAG)) } catch { return [] } }
160
163
 
161
164
  function installLinuxAgent() {
162
- const cmdLine = `"${process.execPath}" "${SELF}" vigia`
165
+ const cmdLine = `"${process.execPath}" "${SELF}" watch`
163
166
  try {
164
167
  run('systemctl', ['--user', 'show-environment']) // tem systemd do usuário?
165
168
  mkdirSync(UNIT_DIR, { recursive: true })
@@ -198,7 +201,7 @@ function installAgent() {
198
201
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
199
202
  <plist version="1.0"><dict>
200
203
  <key>Label</key><string>${AGENT_ID}</string>
201
- <key>ProgramArguments</key><array><string>${process.execPath}</string><string>${SELF}</string><string>vigia</string></array>
204
+ <key>ProgramArguments</key><array><string>${process.execPath}</string><string>${SELF}</string><string>watch</string></array>
202
205
  <key>StartInterval</key><integer>${AGENT_MINUTES * 60}</integer>
203
206
  <key>RunAtLoad</key><true/>
204
207
  <key>StandardErrorPath</key><string>${LOG}</string>
@@ -211,8 +214,9 @@ function installAgent() {
211
214
  return true
212
215
  }
213
216
  if (WIN) {
217
+ // node.exe é app de console: direto pelo Agendador abre uma janela a cada rodada. O conhost --headless roda sem janela.
214
218
  execFileSync('schtasks', ['/Create', '/F', '/SC', 'MINUTE', '/MO', String(AGENT_MINUTES), '/TN', AGENT_ID,
215
- '/TR', `"${process.execPath}" "${SELF}" vigia`], { stdio: 'ignore' })
219
+ '/TR', `conhost.exe --headless "${process.execPath}" "${SELF}" watch`], { stdio: 'ignore' })
216
220
  return true
217
221
  }
218
222
  return installLinuxAgent()
@@ -243,8 +247,6 @@ async function agentOnce() {
243
247
  writeJson(APP, removeAccount(app))
244
248
  return log('login vencido: conta tirada deste computador')
245
249
  }
246
- try { await sync(app, key, true) }
247
- catch (e) { log(`sync falhou: ${e.message}`) }
248
250
  const r = await api(app, key, 'POST', '/api/cli/heartbeat', { account_id: app.account?.id ?? null }, 20_000)
249
251
  if (r.status === 401) {
250
252
  writeJson(APP, removeAccount(app))
@@ -378,7 +380,7 @@ function addToDay(days, e) {
378
380
  return key
379
381
  }
380
382
 
381
- // Só um sync por vez (o vigia pode rodar junto com o login). Trava velha (sync que caiu) é ignorada.
383
+ // Só um sync por vez (dois ccx sync ao mesmo tempo). Trava velha (sync que caiu) é ignorada.
382
384
  function takeLock() {
383
385
  mkdirSync(DIR, { recursive: true })
384
386
  try { closeSync(openSync(SYNC_LOCK, 'wx')); return true }
@@ -391,7 +393,7 @@ function takeLock() {
391
393
 
392
394
  async function sync(app, key, quiet = false) {
393
395
  const say = msg => { if (!quiet) process.stderr.write(msg) }
394
- if (!takeLock()) return say('Outro sync já está rodando. Tente de novo daqui a pouco.\n')
396
+ if (!takeLock()) { say('Outro sync já está rodando. Tente de novo daqui a pouco.\n'); return null }
395
397
  try {
396
398
  let state = readJson(SYNC_STATE, {})
397
399
  if (state.v !== SYNC_VERSION) state = {}
@@ -451,6 +453,81 @@ async function sync(app, key, quiet = false) {
451
453
  finally { rmSync(SYNC_LOCK, { force: true }) }
452
454
  }
453
455
 
456
+ // Sync automático: o watch roda a cada 5 min e manda o resumo depois das 12h00 e das 17h30 (hora do computador).
457
+ // Computador desligado no horário: manda na primeira rodada depois que ligar. Falhou: tenta de novo na próxima.
458
+ const SYNC_SLOTS = [[12, 0], [17, 30]]
459
+ const SYNC_AUTO = join(DIR, 'sync-auto.json')
460
+ function lastSyncSlot(now = new Date()) {
461
+ const slots = [-1, 0].flatMap(d => SYNC_SLOTS.map(([h, m]) => new Date(now.getFullYear(), now.getMonth(), now.getDate() + d, h, m)))
462
+ return slots.filter(t => t <= now).at(-1)
463
+ }
464
+ async function autoSync() {
465
+ const app = readJson(APP, null)
466
+ const key = app && secretGet()
467
+ if (!app || !key) return
468
+ const last = readJson(SYNC_AUTO, {}).at
469
+ if (last && new Date(last) >= lastSyncSlot()) return
470
+ const log = msg => appendFileSync(LOG, `${new Date().toISOString()} ${msg}\n`)
471
+ try {
472
+ const sent = await sync(app, key, true)
473
+ if (sent === null) return // outro sync rodando: tenta na próxima
474
+ writeJson(SYNC_AUTO, { at: new Date().toISOString() })
475
+ log(`sync automático: ${sent} ${sent === 1 ? 'resumo' : 'resumos'}`)
476
+ }
477
+ catch (e) { log(`sync automático falhou: ${e.message}`) }
478
+ }
479
+
480
+ // ---------- atualização automática ----------
481
+ // Uma vez por dia o watch confere a versão no npm e, se houver nova, instala no mesmo lugar onde o ccx está.
482
+ // Não conseguiu (npm pede sudo, sem npm): o ccx avisa no terminal até alguém atualizar.
483
+ const UPDATE = join(DIR, 'update.json')
484
+ const PKG = '@nectarcrm/ccx'
485
+ const myVersion = () => readJson(join(dirname(SELF), '..', 'package.json'), {}).version ?? '0'
486
+ const newer = (a, b) => { // a é mais nova que b?
487
+ const pa = a.split('.').map(Number), pb = b.split('.').map(Number)
488
+ for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0)
489
+ return false
490
+ }
491
+ // Pasta do npm onde o ccx foi instalado (-g): <prefixo>/lib/node_modules/@nectarcrm/ccx (Windows: sem o lib)
492
+ function globalPrefix() {
493
+ const pkgDir = dirname(dirname(SELF))
494
+ const mods = dirname(dirname(pkgDir))
495
+ if (basename(dirname(pkgDir)) !== '@nectarcrm' || basename(mods) !== 'node_modules') return null // rodando de fora do npm -g
496
+ return WIN ? dirname(mods) : dirname(dirname(mods))
497
+ }
498
+ async function autoUpdate() {
499
+ const state = readJson(UPDATE, {})
500
+ if (state.checked_at && Date.now() - Date.parse(state.checked_at) < 86_400_000) return
501
+ const log = msg => appendFileSync(LOG, `${new Date().toISOString()} ${msg}\n`)
502
+ const mine = myVersion()
503
+ let latest
504
+ try {
505
+ const r = await fetch(`https://registry.npmjs.org/${PKG.replace('/', '%2f')}/latest`, { signal: AbortSignal.timeout(10_000) })
506
+ latest = (await r.json()).version
507
+ }
508
+ catch { return } // sem internet: tenta na próxima rodada
509
+ const save = extra => writeJson(UPDATE, { checked_at: new Date().toISOString(), latest, ...extra })
510
+ if (!latest || !newer(latest, mine)) return save({})
511
+ const prefix = globalPrefix()
512
+ if (!prefix) return save({ error: 'ccx não foi instalado com npm install -g' })
513
+ try {
514
+ const npm = join(dirname(process.execPath), WIN ? 'npm.cmd' : 'npm')
515
+ execFileSync(existsSync(npm) ? npm : 'npm', ['install', '-g', '--prefix', prefix, `${PKG}@${latest}`], { stdio: 'ignore', timeout: 180_000, shell: WIN })
516
+ log(`ccx atualizado: ${mine} → ${latest}`)
517
+ save({})
518
+ }
519
+ catch (e) {
520
+ const msg = e.message.split('\n')[0]
521
+ log(`não consegui atualizar o ccx pra ${latest}: ${msg}`)
522
+ save({ error: msg })
523
+ }
524
+ }
525
+ // No terminal: avisa quando a atualização automática não deu conta
526
+ function updateNotice() {
527
+ const { latest } = readJson(UPDATE, {})
528
+ if (latest && newer(latest, myVersion())) console.error(c(33, `Tem ccx novo (${latest}). Atualize: npm install -g ${PKG}@latest`))
529
+ }
530
+
454
531
  // ---------- doctor: confere se o claude está mesmo usando a conta escolhida ----------
455
532
  // Nunca mostra token: compara só a impressão digital (fingerprint).
456
533
  const AUTH_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX']
@@ -563,7 +640,7 @@ async function doctor() {
563
640
  }
564
641
  else ok('Telemetria ligada (tokens vão pro painel)')
565
642
  if (agentInstalled()) ok(`Vigia instalado (roda a cada ${AGENT_MINUTES} min)`)
566
- else aviso('O vigia não está instalado: token novo e sync não chegam sozinhos', 'ccx (escolha a conta de novo)')
643
+ else aviso('O vigia não está instalado: token novo e acesso removido não chegam sozinhos', 'ccx (escolha a conta de novo)')
567
644
  // Só o que deu errado nas últimas 24 h (o log tem a data no começo de cada linha)
568
645
  const dayAgo = new Date(Date.now() - 86_400_000).toISOString()
569
646
  const lastErr = existsSync(LOG) && readFileSync(LOG, 'utf8').trim().split('\n').filter(l => l >= dayAgo && /falhou|removid|vencid/.test(l)).pop()
@@ -678,14 +755,19 @@ async function useAccount(app, key, wanted) {
678
755
  if (process.env.ANTHROPIC_API_KEY) console.error(c(33, 'Aviso: ANTHROPIC_API_KEY está definida e tem prioridade sobre a conta escolhida.'))
679
756
  console.error(`\n${c(32, '✓')} Este computador agora usa a conta ${c(1, s.data.account.label)}.`)
680
757
  console.error(' Todo claude aberto a partir de agora usa essa conta. Os que já estão abertos continuam na anterior.')
681
- if (!agent) console.error(c(33, ' Vigia automático indisponível neste sistema: rode "ccx vigia" de vez em quando.'))
758
+ if (!agent) console.error(c(33, ' Vigia automático indisponível neste sistema: rode "ccx watch" de vez em quando.'))
682
759
  }
683
760
 
684
761
  // ---------- comandos ----------
685
762
  const [cmd, ...rest] = process.argv.slice(2)
686
763
 
764
+ // Nomes antigos em português: ainda funcionam, mas avisam (só no terminal; o agendamento chama vigia sem ninguém ver)
765
+ const DEPRECATED = { sair: 'off', vigia: 'watch' }
766
+ if (DEPRECATED[cmd] && isTTY) console.error(c(33, `Aviso: "ccx ${cmd}" vai deixar de existir. Use "ccx ${DEPRECATED[cmd]}".`))
767
+ if (isTTY && cmd !== 'watch' && cmd !== 'vigia') updateNotice()
768
+
687
769
  if (cmd === 'help' || cmd === '-h' || cmd === '--help') {
688
- console.log(readFileSync(SELF, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 18).map(l => l.slice(3)).join('\n'))
770
+ console.log(readFileSync(SELF, 'utf8').split('\n').filter(l => l.startsWith('//')).slice(0, 21).map(l => l.slice(3)).join('\n'))
689
771
  process.exit(0)
690
772
  }
691
773
 
@@ -694,8 +776,10 @@ if (cmd === '--version' || cmd === '-v' || cmd === 'version') {
694
776
  process.exit(0)
695
777
  }
696
778
 
697
- if (cmd === 'vigia') {
779
+ if (cmd === 'watch' || cmd === 'vigia') { // vigia: os agendamentos já instalados ainda chamam assim
698
780
  await agentOnce()
781
+ await autoSync()
782
+ await autoUpdate()
699
783
  process.exit(0)
700
784
  }
701
785
 
@@ -716,7 +800,7 @@ if (cmd === 'logout') {
716
800
  process.exit(0)
717
801
  }
718
802
 
719
- if (cmd === 'sair') {
803
+ if (cmd === 'off' || cmd === 'sair') {
720
804
  writeJson(APP, removeAccount(app))
721
805
  console.error('Pronto: este computador não usa mais conta do app. O claude volta ao seu login normal. Pra escolher de novo: ccx')
722
806
  process.exit(0)
@@ -738,12 +822,26 @@ if (cmd === 'status') {
738
822
  process.exit(0)
739
823
  }
740
824
 
825
+ // Pra scripts e agentes: uma conta por linha, pronta pra `ccx <conta>`
826
+ if (cmd === 'list') {
827
+ const r = await api(app, key, 'GET', '/api/cli/status?fresh=1')
828
+ if (r.status !== 200) die(r.data.message ?? `Falhou (HTTP ${r.status})`)
829
+ const peak = a => Math.max(a.five_hour_pct ?? 0, a.weekly_pct ?? 0)
830
+ const list = [...r.data.accounts].sort((a, b) => b.available - a.available || peak(a) - peak(b) || a.slug.localeCompare(b.slug))
831
+ if (rest.includes('--json')) {
832
+ console.log(JSON.stringify({
833
+ current: app.account ? (r.data.accounts.find(a => a.id === app.account.id)?.slug ?? null) : null,
834
+ recommended: r.data.recommended,
835
+ accounts: list.map(a => ({
836
+ slug: a.slug, label: a.label, available: a.available, status: a.status,
837
+ five_hour_pct: a.five_hour_pct, five_hour_resets_at: a.five_hour_resets_at,
838
+ weekly_pct: a.weekly_pct, weekly_resets_at: a.weekly_resets_at,
839
+ })),
840
+ }, null, 2))
841
+ }
842
+ else for (const a of list.filter(a => a.available)) console.log(a.slug)
843
+ process.exit(0)
844
+ }
845
+
741
846
  // `ccx`, `ccx login ...` (depois de autorizar) ou `ccx <conta>`
742
847
  await useAccount(app, key, cmd && cmd !== 'login' ? cmd : await chooseAccount(app, key))
743
-
744
- // Logo depois do login, manda o resumo das sessões (as próximas vão pelo vigia)
745
- if (cmd === 'login') {
746
- console.error('')
747
- try { await sync(app, key) }
748
- catch (e) { console.error(c(33, `\nO sync falhou (${e.message}). Tente: ccx sync`)) }
749
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nectarcrm/ccx",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Escolhe qual conta Claude este computador usa, conectado ao app Contas Claude da Nectar.",
5
5
  "type": "module",
6
6
  "bin": { "ccx": "bin/ccx.js" },