paris-immersion 0.1.15 → 0.1.17
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/dist/commands/serve.js +53 -1
- package/dist/index.js +1 -1
- package/dist/lib/relay-agent.d.ts +2 -0
- package/dist/lib/relay-agent.js +69 -14
- package/dist/lib/self-update.d.ts +6 -0
- package/dist/lib/self-update.js +76 -0
- package/package.json +1 -1
package/dist/commands/serve.js
CHANGED
|
@@ -7,6 +7,9 @@ import { readCredentials } from '../lib/credentials.js';
|
|
|
7
7
|
import { ensureWorkspace } from './install.js';
|
|
8
8
|
import { startRelayAgent } from '../lib/relay-agent.js';
|
|
9
9
|
import { renderTostudyCommand } from '../templates/tostudy-command.js';
|
|
10
|
+
import { renderHookScript } from '../templates/hook.js';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { maybeSelfUpdate } from '../lib/self-update.js';
|
|
10
13
|
import { c } from '../lib/colors.js';
|
|
11
14
|
/**
|
|
12
15
|
* Restore the executable bit on node-pty's `spawn-helper`. Some install paths
|
|
@@ -175,12 +178,40 @@ function ensureTostudyCommand(cwd) {
|
|
|
175
178
|
}
|
|
176
179
|
}
|
|
177
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Reescreve o HOOK (`.paris/hooks/paris-hook.mjs`) e o bundle MCP vendorizado a
|
|
183
|
+
* cada serve. Crucial: o `paris install` só os escreve na CRIAÇÃO do workspace —
|
|
184
|
+
* num workspace ANTIGO (instalado antes do sync de progresso existir), o binário
|
|
185
|
+
* do CLI se auto-atualiza mas o hook fica CONGELADO e o progresso NUNCA sincroniza
|
|
186
|
+
* (gestão eterna em 0/N). Reescrever a cada serve fecha esse gap. Best-effort,
|
|
187
|
+
* idempotente (ambos são 100% auto-gerados; o settings.json aponta pro mesmo path).
|
|
188
|
+
*/
|
|
189
|
+
function ensureWorkspaceVendored(cwd) {
|
|
190
|
+
try {
|
|
191
|
+
const hooksDir = path.join(cwd, '.paris', 'hooks');
|
|
192
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
193
|
+
writeFileSync(path.join(hooksDir, 'paris-hook.mjs'), renderHookScript(), { mode: 0o755 });
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
/* best-effort */
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const mcpSrc = fileURLToPath(new URL('../../templates/mcp/index.mjs', import.meta.url));
|
|
200
|
+
const mcpDir = path.join(cwd, '.paris', 'mcp');
|
|
201
|
+
mkdirSync(mcpDir, { recursive: true });
|
|
202
|
+
writeFileSync(path.join(mcpDir, 'index.mjs'), readFileSync(mcpSrc), { mode: 0o755 });
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
/* best-effort — sem o bundle, o /tostudy ainda roda; só o fallback MCP fica velho */
|
|
206
|
+
}
|
|
207
|
+
}
|
|
178
208
|
export function spawnCoachPty(cwd) {
|
|
179
209
|
const req = createRequire(import.meta.url);
|
|
180
210
|
ensureSpawnHelperExecutable(req);
|
|
181
211
|
const ptyMod = req('node-pty');
|
|
182
|
-
// Escreve /tostudy ANTES de resolver o kickoff + subir o Claude.
|
|
212
|
+
// Escreve /tostudy + hook + MCP frescos ANTES de resolver o kickoff + subir o Claude.
|
|
183
213
|
ensureTostudyCommand(cwd);
|
|
214
|
+
ensureWorkspaceVendored(cwd);
|
|
184
215
|
const kickoff = resolveKickoff(cwd);
|
|
185
216
|
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : 'bash');
|
|
186
217
|
const shellArgs = process.platform === 'win32'
|
|
@@ -340,6 +371,12 @@ export async function serveCommand(opts) {
|
|
|
340
371
|
console.error(c.red('Não logado.') + ' Rode: ' + c.bold('paris login'));
|
|
341
372
|
process.exit(1);
|
|
342
373
|
}
|
|
374
|
+
// Auto-update no startup (throttle 3h): o aluno NUNCA roda comando de update à
|
|
375
|
+
// mão. Se há versão nova no npm, atualiza ANTES de servir e sai — o daemon
|
|
376
|
+
// (launchd/systemd KeepAlive) respawna na versão nova. Best-effort.
|
|
377
|
+
if (await maybeSelfUpdate((m) => console.log(` ${c.dim(m)}`))) {
|
|
378
|
+
process.exit(0);
|
|
379
|
+
}
|
|
343
380
|
const portNum = opts.port == null ? 31000 : Number(opts.port);
|
|
344
381
|
if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65535) {
|
|
345
382
|
console.error(c.red(`Porta inválida: ${opts.port}`));
|
|
@@ -422,8 +459,11 @@ export async function serveCommand(opts) {
|
|
|
422
459
|
.catch(() => { });
|
|
423
460
|
console.log(` ${c.dim('Ctrl+C pra encerrar.')}`);
|
|
424
461
|
console.log();
|
|
462
|
+
let updateTimer;
|
|
425
463
|
const shutdown = () => {
|
|
426
464
|
try {
|
|
465
|
+
if (updateTimer)
|
|
466
|
+
clearInterval(updateTimer);
|
|
427
467
|
relay?.close();
|
|
428
468
|
}
|
|
429
469
|
catch {
|
|
@@ -432,6 +472,18 @@ export async function serveCommand(opts) {
|
|
|
432
472
|
handle.close();
|
|
433
473
|
process.exit(0);
|
|
434
474
|
};
|
|
475
|
+
// Auto-update periódico: a cada 3h, SE não houver sessão do curso em uso, se
|
|
476
|
+
// atualiza e reinicia (o daemon respawna na versão nova) — sem ninguém rodar
|
|
477
|
+
// nada. Gate por isBusy() pra nunca interromper o aluno no meio do curso.
|
|
478
|
+
updateTimer = setInterval(() => {
|
|
479
|
+
if (relay?.isBusy())
|
|
480
|
+
return;
|
|
481
|
+
void maybeSelfUpdate((m) => console.log(` ${c.dim(m)}`)).then((updated) => {
|
|
482
|
+
if (updated)
|
|
483
|
+
shutdown();
|
|
484
|
+
});
|
|
485
|
+
}, 3 * 3600_000);
|
|
486
|
+
updateTimer.unref?.();
|
|
435
487
|
process.on('SIGINT', shutdown);
|
|
436
488
|
process.on('SIGTERM', shutdown);
|
|
437
489
|
}
|
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import { daemonCommand, restartCommand } from './commands/daemon.js';
|
|
|
13
13
|
const program = new Command()
|
|
14
14
|
.name('paris')
|
|
15
15
|
.description('Paris Immersion — CLI do aluno (login, workspace, tarefas)')
|
|
16
|
-
.version('0.1.
|
|
16
|
+
.version('0.1.17');
|
|
17
17
|
program
|
|
18
18
|
.command('login')
|
|
19
19
|
.description('Autenticar com sua conta Paris (device-code OR --token direto)')
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export interface RelayAgentHandle {
|
|
2
2
|
close: () => void;
|
|
3
|
+
/** true se há uma sessão do Claude viva (pty) — usado pra não auto-atualizar/reiniciar no meio do curso. */
|
|
4
|
+
isBusy: () => boolean;
|
|
3
5
|
}
|
|
4
6
|
/**
|
|
5
7
|
* Inicia o agente relay. Busca o serve-token + info do relay na API (Bearer
|
package/dist/lib/relay-agent.js
CHANGED
|
@@ -51,12 +51,17 @@ export async function startRelayAgent(opts) {
|
|
|
51
51
|
headers: { authorization: `Bearer ${opts.loginToken}` },
|
|
52
52
|
signal: AbortSignal.timeout(8000),
|
|
53
53
|
});
|
|
54
|
-
if (
|
|
55
|
-
return
|
|
56
|
-
|
|
54
|
+
if (res.ok)
|
|
55
|
+
return { kind: 'ok', info: (await res.json()) };
|
|
56
|
+
// 401 (JWT inválido/expirado) e 404 (participant_not_found) são definitivos:
|
|
57
|
+
// re-tentar com o mesmo loginToken nunca vai passar. 5xx é tratado como
|
|
58
|
+
// inalcançável (transitório).
|
|
59
|
+
if (res.status === 401 || res.status === 403 || res.status === 404)
|
|
60
|
+
return { kind: 'unauthorized' };
|
|
61
|
+
return { kind: 'unreachable' };
|
|
57
62
|
}
|
|
58
63
|
catch {
|
|
59
|
-
return
|
|
64
|
+
return { kind: 'unreachable' }; // DNS/timeout/conn refused — a API pode voltar
|
|
60
65
|
}
|
|
61
66
|
};
|
|
62
67
|
// Primeira busca decide se o relay está configurado (senão → modo local-only).
|
|
@@ -65,20 +70,31 @@ export async function startRelayAgent(opts) {
|
|
|
65
70
|
// responde 200 na 1ª tentativa). KeepAlive mantém o serve, mas sem este retry o
|
|
66
71
|
// agente relay nunca subia se a 1ª busca caísse no boot.
|
|
67
72
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
73
|
+
// Mensagem acionável quando o loginToken (installer JWT ~7d) expirou ou o
|
|
74
|
+
// participante foi removido. O daemon roda detached, então isto vai pro log do
|
|
75
|
+
// launchd/systemd (pro diagnóstico) — o aluno é guiado pelo overlay do browser.
|
|
76
|
+
const RELOGIN_HINT = 'paris: login expirado — rode: npm i -g paris-immersion@latest && paris login';
|
|
68
77
|
let initial = await fetchInfo();
|
|
69
|
-
|
|
78
|
+
// Retry no boot SÓ enquanto a API está inalcançável (blip de rede: o daemon sobe
|
|
79
|
+
// antes da rede). Um 'unauthorized' é definitivo — não adianta re-tentar com o
|
|
80
|
+
// mesmo token; sai pro modo local-only e loga o caminho de re-login.
|
|
81
|
+
for (let i = 0; i < 6 && initial.kind === 'unreachable'; i++) {
|
|
70
82
|
await sleep(5000);
|
|
71
83
|
initial = await fetchInfo();
|
|
72
84
|
}
|
|
73
|
-
if (
|
|
85
|
+
if (initial.kind === 'unauthorized') {
|
|
86
|
+
log(RELOGIN_HINT);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
if (initial.kind !== 'ok' || !initial.info.relayUrl || !initial.info.token || !initial.info.e2eKeyB64)
|
|
74
90
|
return null;
|
|
75
91
|
// Mutáveis: o serve-token é de vida curta e é RENOVADO a cada (re)conexão (o
|
|
76
92
|
// loginToken/JWT do aluno é estável). Sem isso, o daemon que sobe no boot fica
|
|
77
93
|
// preso com um token expirado, o relay rejeita o agente, e o terminal embutido
|
|
78
94
|
// fica "Esperando o paris CLI subir" pra sempre mesmo com o serve rodando.
|
|
79
|
-
let relayUrl = initial.relayUrl;
|
|
80
|
-
let token = initial.token;
|
|
81
|
-
let key = Buffer.from(initial.e2eKeyB64, 'base64'); // chave E2E é estável por participante
|
|
95
|
+
let relayUrl = initial.info.relayUrl;
|
|
96
|
+
let token = initial.info.token;
|
|
97
|
+
let key = Buffer.from(initial.info.e2eKeyB64, 'base64'); // chave E2E é estável por participante
|
|
82
98
|
if (key.length !== 32)
|
|
83
99
|
return null;
|
|
84
100
|
let ws = null;
|
|
@@ -197,23 +213,59 @@ export async function startRelayAgent(opts) {
|
|
|
197
213
|
}
|
|
198
214
|
}, 80);
|
|
199
215
|
};
|
|
216
|
+
// Backoff de reconexão. Caminho normal = 2s (rápido, pra reatachar o aluno que
|
|
217
|
+
// recarregou). Mas quando a API responde 401/404 (loginToken expirou ou
|
|
218
|
+
// participante removido), re-discar a 2s com o token velho é um loop infinito
|
|
219
|
+
// que o relay 401-a sem parar — então espaçamos pra 60s e logamos o re-login.
|
|
220
|
+
const RETRY_FAST_MS = 2000;
|
|
221
|
+
const RETRY_UNAUTH_MS = 60_000;
|
|
222
|
+
let nextRetryMs = RETRY_FAST_MS;
|
|
200
223
|
const connect = async () => {
|
|
201
224
|
if (closed)
|
|
202
225
|
return;
|
|
203
226
|
// Renova token + relayUrl + chave a cada tentativa (o serve-token expira;
|
|
204
227
|
// o loginToken é estável). É isto que conserta o "agente preso no boot".
|
|
205
228
|
const fresh = await fetchInfo();
|
|
206
|
-
if (fresh
|
|
207
|
-
relayUrl = fresh.relayUrl;
|
|
208
|
-
token = fresh.token;
|
|
209
|
-
const k = Buffer.from(fresh.e2eKeyB64, 'base64');
|
|
229
|
+
if (fresh.kind === 'ok' && fresh.info.relayUrl && fresh.info.token && fresh.info.e2eKeyB64) {
|
|
230
|
+
relayUrl = fresh.info.relayUrl;
|
|
231
|
+
token = fresh.info.token;
|
|
232
|
+
const k = Buffer.from(fresh.info.e2eKeyB64, 'base64');
|
|
210
233
|
if (k.length === 32)
|
|
211
234
|
key = k;
|
|
235
|
+
nextRetryMs = RETRY_FAST_MS;
|
|
236
|
+
}
|
|
237
|
+
else if (fresh.kind === 'unauthorized') {
|
|
238
|
+
// Token de login morto: NÃO re-disca com o velho (o relay 401-a e o loop
|
|
239
|
+
// nunca surfacing). Loga o caminho de re-login e agenda uma tentativa
|
|
240
|
+
// espaçada — se o aluno rodar `paris login`, a próxima busca passa e reata.
|
|
241
|
+
log(RELOGIN_HINT);
|
|
242
|
+
nextRetryMs = RETRY_UNAUTH_MS;
|
|
243
|
+
if (!closed)
|
|
244
|
+
reconnect = setTimeout(connect, nextRetryMs);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
// Inalcançável (rede/5xx): re-disca com o token ATUAL (pode ainda ser válido
|
|
249
|
+
// — cobre o blip de rede no boot) no ritmo rápido.
|
|
250
|
+
nextRetryMs = RETRY_FAST_MS;
|
|
212
251
|
}
|
|
213
252
|
if (closed)
|
|
214
253
|
return;
|
|
215
254
|
const url = `${relayUrl.replace(/\/$/, '')}/agent?token=${encodeURIComponent(token)}`;
|
|
216
255
|
ws = new WebSocket(url);
|
|
256
|
+
// O relay rejeita o handshake (401) ANTES do upgrade quando o serve-token não
|
|
257
|
+
// valida — isso chega aqui como 'unexpected-response' (não 'open'/'close').
|
|
258
|
+
// Sem este handler o `ws` cai pra abortHandshake → error+close e re-disca a 2s.
|
|
259
|
+
// Tratamos como um sinal de re-busca: o próximo connect() chama fetchInfo() e,
|
|
260
|
+
// se a API disser 'unauthorized', o branch acima espaça/loga. O socket já está
|
|
261
|
+
// sendo abortado pelo `ws`; só registramos pra não cair no caminho silencioso.
|
|
262
|
+
ws.on('unexpected-response', (_req, res) => {
|
|
263
|
+
log(`relay: handshake rejeitado (${res.statusCode}) — re-buscando token`);
|
|
264
|
+
try {
|
|
265
|
+
res.resume();
|
|
266
|
+
}
|
|
267
|
+
catch { /* drena o corpo pra liberar o socket */ }
|
|
268
|
+
});
|
|
217
269
|
ws.on('open', () => {
|
|
218
270
|
// TCP_NODELAY: terminal interativo manda frames pequenos; sem isso o Nagle
|
|
219
271
|
// os enfileira (~40ms/hop, pior ainda com delayed-ACK) → digitação e o 1º
|
|
@@ -291,8 +343,10 @@ export async function startRelayAgent(opts) {
|
|
|
291
343
|
ws.on('close', () => {
|
|
292
344
|
// NÃO mata o pty aqui — mantém o Claude vivo pra reatachar quando o ws
|
|
293
345
|
// reconectar (o viewer-left + grace timer e o shutdown cuidam de matar).
|
|
346
|
+
// nextRetryMs = 2s no caminho normal; 60s quando a API marcou o token como
|
|
347
|
+
// morto (o connect() seguinte re-busca e re-avalia). Evita o loop 2s eterno.
|
|
294
348
|
if (!closed)
|
|
295
|
-
reconnect = setTimeout(connect,
|
|
349
|
+
reconnect = setTimeout(connect, nextRetryMs);
|
|
296
350
|
});
|
|
297
351
|
ws.on('error', () => {
|
|
298
352
|
try {
|
|
@@ -305,6 +359,7 @@ export async function startRelayAgent(opts) {
|
|
|
305
359
|
};
|
|
306
360
|
connect();
|
|
307
361
|
return {
|
|
362
|
+
isBusy: () => !!pty,
|
|
308
363
|
close: () => {
|
|
309
364
|
closed = true;
|
|
310
365
|
if (reconnect)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Se houver uma versão MAIS NOVA no npm, atualiza global e retorna true (o
|
|
3
|
+
* chamador deve `process.exit(0)` pra o daemon respawnar na nova). Throttle de 3h.
|
|
4
|
+
* `force` ignora o throttle (usado no startup; o periódico respeita o throttle).
|
|
5
|
+
*/
|
|
6
|
+
export declare function maybeSelfUpdate(log?: (m: string) => void, force?: boolean): Promise<boolean>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-update do `paris serve`. O aluno NUNCA deve rodar um comando de update à
|
|
3
|
+
* mão — o serve (rodando como daemon) checa o npm, se atualiza sozinho e o
|
|
4
|
+
* KeepAlive do launchd/systemd respawna no binário novo. Best-effort: qualquer
|
|
5
|
+
* falha (offline, sem permissão) é engolida e o serve segue na versão atual.
|
|
6
|
+
*/
|
|
7
|
+
import { execFile } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import { promises as fs } from 'node:fs';
|
|
10
|
+
import { homedir } from 'node:os';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
const execFileAsync = promisify(execFile);
|
|
14
|
+
const PKG = 'paris-immersion';
|
|
15
|
+
const THROTTLE_MS = 3 * 3600_000; // checa no máximo a cada 3h
|
|
16
|
+
function currentVersion() {
|
|
17
|
+
try {
|
|
18
|
+
const req = createRequire(import.meta.url);
|
|
19
|
+
return req('../../package.json').version;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return '0.0.0';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** retorna 1 se a > b, -1 se a < b, 0 se igual (semver x.y.z, ignora pré-release). */
|
|
26
|
+
function cmpSemver(a, b) {
|
|
27
|
+
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
|
|
28
|
+
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
|
|
29
|
+
for (let i = 0; i < 3; i++) {
|
|
30
|
+
if ((pa[i] ?? 0) > (pb[i] ?? 0))
|
|
31
|
+
return 1;
|
|
32
|
+
if ((pa[i] ?? 0) < (pb[i] ?? 0))
|
|
33
|
+
return -1;
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
async function stampPath() {
|
|
38
|
+
const dir = path.join(homedir(), '.paris');
|
|
39
|
+
await fs.mkdir(dir, { recursive: true }).catch(() => { });
|
|
40
|
+
return path.join(dir, '.update-check');
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Se houver uma versão MAIS NOVA no npm, atualiza global e retorna true (o
|
|
44
|
+
* chamador deve `process.exit(0)` pra o daemon respawnar na nova). Throttle de 3h.
|
|
45
|
+
* `force` ignora o throttle (usado no startup; o periódico respeita o throttle).
|
|
46
|
+
*/
|
|
47
|
+
export async function maybeSelfUpdate(log = () => { }, force = false) {
|
|
48
|
+
if (process.env.PARIS_NO_SELF_UPDATE)
|
|
49
|
+
return false; // escape-hatch (dev/CI/offline forçado)
|
|
50
|
+
try {
|
|
51
|
+
const stamp = await stampPath();
|
|
52
|
+
if (!force) {
|
|
53
|
+
try {
|
|
54
|
+
const last = Number(await fs.readFile(stamp, 'utf8'));
|
|
55
|
+
if (Number.isFinite(last) && Date.now() - last < THROTTLE_MS)
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* sem stamp — checa */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
await fs.writeFile(stamp, String(Date.now())).catch(() => { });
|
|
63
|
+
const { stdout } = await execFileAsync('npm', ['view', PKG, 'version'], { timeout: 15000 });
|
|
64
|
+
const latest = stdout.trim();
|
|
65
|
+
const current = currentVersion();
|
|
66
|
+
if (!/^\d+\.\d+\.\d+/.test(latest) || cmpSemver(latest, current) <= 0)
|
|
67
|
+
return false;
|
|
68
|
+
log(`nova versão da CLI: ${current} → ${latest}. Atualizando…`);
|
|
69
|
+
await execFileAsync('npm', ['install', '-g', `${PKG}@${latest}`], { timeout: 120_000 });
|
|
70
|
+
log(`atualizado para ${latest}. Reiniciando o agente…`);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return false; // best-effort — nunca quebra o serve
|
|
75
|
+
}
|
|
76
|
+
}
|
package/package.json
CHANGED