paris-immersion 0.1.14 → 0.1.16
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/install.js +9 -5
- package/dist/commands/serve.js +40 -7
- package/dist/index.js +1 -1
- package/dist/lib/relay-agent.d.ts +2 -0
- package/dist/lib/relay-agent.js +27 -3
- 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/install.js
CHANGED
|
@@ -90,11 +90,15 @@ async function writeWorkspaceFiles(targetDir, boot, apiUrl, email) {
|
|
|
90
90
|
const skillDir = path.join(claudeDir, 'skills', 'paris-coach');
|
|
91
91
|
await fs.mkdir(skillDir, { recursive: true });
|
|
92
92
|
await fs.writeFile(path.join(skillDir, 'SKILL.md'), renderParisCoachSkill());
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
// /tostudy.md — NOSSO slash-command (rota confiável do curso). Escrito nos DOIS
|
|
94
|
+
// lugares que o Claude carrega: o do PROJETO e o GLOBAL (~/.claude/commands) —
|
|
95
|
+
// o global é o que faz funcionar em máquinas que já rodaram `tostudy init`, e
|
|
96
|
+
// agora vale pra todo aluno novo também. Independe do @tostudy-ai/cli.
|
|
97
|
+
const tostudyCmd = renderTostudyCommand();
|
|
98
|
+
for (const dir of [path.join(claudeDir, 'commands'), path.join(homedir(), '.claude', 'commands')]) {
|
|
99
|
+
await fs.mkdir(dir, { recursive: true });
|
|
100
|
+
await fs.writeFile(path.join(dir, 'tostudy.md'), tostudyCmd);
|
|
101
|
+
}
|
|
98
102
|
// CLAUDE.md — workspace-level rules.
|
|
99
103
|
await fs.writeFile(path.join(targetDir, 'CLAUDE.md'), renderClaudeMd({
|
|
100
104
|
immersionName: boot.immersion.name,
|
package/dist/commands/serve.js
CHANGED
|
@@ -7,6 +7,7 @@ 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 { maybeSelfUpdate } from '../lib/self-update.js';
|
|
10
11
|
import { c } from '../lib/colors.js';
|
|
11
12
|
/**
|
|
12
13
|
* Restore the executable bit on node-pty's `spawn-helper`. Some install paths
|
|
@@ -160,13 +161,19 @@ export function resolveKickoff(cwd) {
|
|
|
160
161
|
* workspaces antigos e manter o conteúdo fresco a cada serve. Best-effort.
|
|
161
162
|
*/
|
|
162
163
|
function ensureTostudyCommand(cwd) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
164
|
+
const body = renderTostudyCommand();
|
|
165
|
+
// Escreve nos DOIS lugares que o Claude Code carrega slash-commands: o do
|
|
166
|
+
// PROJETO (workspace) e o GLOBAL (~/.claude/commands). É justamente o comando
|
|
167
|
+
// global que faz o terminal funcionar em máquinas que já passaram por um
|
|
168
|
+
// `tostudy init` — replicamos isso pra TODA máquina, inclusive a do aluno novo.
|
|
169
|
+
for (const dir of [path.join(cwd, '.claude', 'commands'), path.join(homedir(), '.claude', 'commands')]) {
|
|
170
|
+
try {
|
|
171
|
+
mkdirSync(dir, { recursive: true });
|
|
172
|
+
writeFileSync(path.join(dir, 'tostudy.md'), body);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
/* best-effort — se um falhar, o outro cobre; senão cai no fallback de texto */
|
|
176
|
+
}
|
|
170
177
|
}
|
|
171
178
|
}
|
|
172
179
|
export function spawnCoachPty(cwd) {
|
|
@@ -237,6 +244,11 @@ export async function startServeServer(opts) {
|
|
|
237
244
|
wss.once('error', reject);
|
|
238
245
|
});
|
|
239
246
|
wss.on('connection', (ws) => {
|
|
247
|
+
// TCP_NODELAY: sem Nagle no socket local (caminho dev ws://localhost).
|
|
248
|
+
try {
|
|
249
|
+
ws._socket?.setNoDelay?.(true);
|
|
250
|
+
}
|
|
251
|
+
catch { /* noop */ }
|
|
240
252
|
if (opts.dryRun) {
|
|
241
253
|
// Tests use this branch — we just confirm the handshake passed.
|
|
242
254
|
ws.send('READY\n');
|
|
@@ -329,6 +341,12 @@ export async function serveCommand(opts) {
|
|
|
329
341
|
console.error(c.red('Não logado.') + ' Rode: ' + c.bold('paris login'));
|
|
330
342
|
process.exit(1);
|
|
331
343
|
}
|
|
344
|
+
// Auto-update no startup (throttle 3h): o aluno NUNCA roda comando de update à
|
|
345
|
+
// mão. Se há versão nova no npm, atualiza ANTES de servir e sai — o daemon
|
|
346
|
+
// (launchd/systemd KeepAlive) respawna na versão nova. Best-effort.
|
|
347
|
+
if (await maybeSelfUpdate((m) => console.log(` ${c.dim(m)}`))) {
|
|
348
|
+
process.exit(0);
|
|
349
|
+
}
|
|
332
350
|
const portNum = opts.port == null ? 31000 : Number(opts.port);
|
|
333
351
|
if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65535) {
|
|
334
352
|
console.error(c.red(`Porta inválida: ${opts.port}`));
|
|
@@ -411,8 +429,11 @@ export async function serveCommand(opts) {
|
|
|
411
429
|
.catch(() => { });
|
|
412
430
|
console.log(` ${c.dim('Ctrl+C pra encerrar.')}`);
|
|
413
431
|
console.log();
|
|
432
|
+
let updateTimer;
|
|
414
433
|
const shutdown = () => {
|
|
415
434
|
try {
|
|
435
|
+
if (updateTimer)
|
|
436
|
+
clearInterval(updateTimer);
|
|
416
437
|
relay?.close();
|
|
417
438
|
}
|
|
418
439
|
catch {
|
|
@@ -421,6 +442,18 @@ export async function serveCommand(opts) {
|
|
|
421
442
|
handle.close();
|
|
422
443
|
process.exit(0);
|
|
423
444
|
};
|
|
445
|
+
// Auto-update periódico: a cada 3h, SE não houver sessão do curso em uso, se
|
|
446
|
+
// atualiza e reinicia (o daemon respawna na versão nova) — sem ninguém rodar
|
|
447
|
+
// nada. Gate por isBusy() pra nunca interromper o aluno no meio do curso.
|
|
448
|
+
updateTimer = setInterval(() => {
|
|
449
|
+
if (relay?.isBusy())
|
|
450
|
+
return;
|
|
451
|
+
void maybeSelfUpdate((m) => console.log(` ${c.dim(m)}`)).then((updated) => {
|
|
452
|
+
if (updated)
|
|
453
|
+
shutdown();
|
|
454
|
+
});
|
|
455
|
+
}, 3 * 3600_000);
|
|
456
|
+
updateTimer.unref?.();
|
|
424
457
|
process.on('SIGINT', shutdown);
|
|
425
458
|
process.on('SIGTERM', shutdown);
|
|
426
459
|
}
|
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.16');
|
|
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
|
@@ -100,12 +100,20 @@ export async function startRelayAgent(opts) {
|
|
|
100
100
|
const MAX_OUT_BUF = 256 * 1024;
|
|
101
101
|
let outBuf = '';
|
|
102
102
|
let flushTimer;
|
|
103
|
+
// Adaptativo: o PRIMEIRO chunk de uma rajada (depois de um período de silêncio)
|
|
104
|
+
// sai NA HORA — o eco de uma tecla e o início da resposta do Claude não pagam a
|
|
105
|
+
// janela inteira de coalescing. Só a CAUDA da rajada (tokens que chegam dentro
|
|
106
|
+
// da janela, streaming token-a-token) é coalescida, mantendo a escrita lisa.
|
|
107
|
+
// "Fast first byte, smooth tail." lastFlushAt marca o último envio: se passou
|
|
108
|
+
// mais de COALESCE_MS desde então E o buffer estava vazio, é um novo burst.
|
|
109
|
+
let lastFlushAt = 0;
|
|
103
110
|
const flushOut = () => {
|
|
104
111
|
flushTimer = undefined;
|
|
105
112
|
if (!outBuf)
|
|
106
113
|
return;
|
|
107
114
|
const data = outBuf;
|
|
108
115
|
outBuf = '';
|
|
116
|
+
lastFlushAt = Date.now();
|
|
109
117
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
110
118
|
try {
|
|
111
119
|
ws.send(seal(key, TYPE_DATA, Buffer.from(data, 'utf8')), { binary: true });
|
|
@@ -147,9 +155,14 @@ export async function startRelayAgent(opts) {
|
|
|
147
155
|
return;
|
|
148
156
|
}
|
|
149
157
|
pty.onData((d) => {
|
|
158
|
+
// Novo burst = buffer vazio E sem timer armado E passou >COALESCE_MS desde o
|
|
159
|
+
// último flush (= veio depois de um silêncio, p.ex. o aluno apertou Enter e o
|
|
160
|
+
// Claude começa a responder). Captura ANTES do append (depois nunca é vazio).
|
|
161
|
+
const newBurst = !outBuf && !flushTimer && Date.now() - lastFlushAt >= COALESCE_MS;
|
|
150
162
|
outBuf += d;
|
|
151
|
-
// Flush imediato se acumulou muito (evita um frame gigante)
|
|
152
|
-
|
|
163
|
+
// Flush imediato se acumulou muito (evita um frame gigante) OU se é o primeiro
|
|
164
|
+
// chunk de um novo burst (latência baixa pro início da resposta); senão coalesce.
|
|
165
|
+
if (outBuf.length >= MAX_OUT_BUF || newBurst) {
|
|
153
166
|
if (flushTimer) {
|
|
154
167
|
clearTimeout(flushTimer);
|
|
155
168
|
flushTimer = undefined;
|
|
@@ -201,7 +214,17 @@ export async function startRelayAgent(opts) {
|
|
|
201
214
|
return;
|
|
202
215
|
const url = `${relayUrl.replace(/\/$/, '')}/agent?token=${encodeURIComponent(token)}`;
|
|
203
216
|
ws = new WebSocket(url);
|
|
204
|
-
ws.on('open', () =>
|
|
217
|
+
ws.on('open', () => {
|
|
218
|
+
// TCP_NODELAY: terminal interativo manda frames pequenos; sem isso o Nagle
|
|
219
|
+
// os enfileira (~40ms/hop, pior ainda com delayed-ACK) → digitação e o 1º
|
|
220
|
+
// paint de cada resposta ficam visivelmente lentos. _socket é interno do
|
|
221
|
+
// `ws` — optional-chaining + try/catch degrada pro comportamento atual se mudar.
|
|
222
|
+
try {
|
|
223
|
+
ws._socket?.setNoDelay?.(true);
|
|
224
|
+
}
|
|
225
|
+
catch { /* noop */ }
|
|
226
|
+
log(`relay: conectado (${relayUrl})`);
|
|
227
|
+
});
|
|
205
228
|
ws.on('message', (data, isBinary) => {
|
|
206
229
|
if (!isBinary) {
|
|
207
230
|
// Controle do relay (texto): viewer-joined / viewer-left.
|
|
@@ -282,6 +305,7 @@ export async function startRelayAgent(opts) {
|
|
|
282
305
|
};
|
|
283
306
|
connect();
|
|
284
307
|
return {
|
|
308
|
+
isBusy: () => !!pty,
|
|
285
309
|
close: () => {
|
|
286
310
|
closed = true;
|
|
287
311
|
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