paris-immersion 0.1.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/README.md +50 -0
- package/dist/commands/doctor.d.ts +5 -0
- package/dist/commands/doctor.js +104 -0
- package/dist/commands/graduate.d.ts +1 -0
- package/dist/commands/graduate.js +13 -0
- package/dist/commands/install.d.ts +4 -0
- package/dist/commands/install.js +88 -0
- package/dist/commands/login.d.ts +4 -0
- package/dist/commands/login.js +101 -0
- package/dist/commands/logout.d.ts +1 -0
- package/dist/commands/logout.js +20 -0
- package/dist/commands/reopen.d.ts +3 -0
- package/dist/commands/reopen.js +9 -0
- package/dist/commands/serve.d.ts +31 -0
- package/dist/commands/serve.js +180 -0
- package/dist/commands/status.d.ts +1 -0
- package/dist/commands/status.js +25 -0
- package/dist/commands/tasks.d.ts +1 -0
- package/dist/commands/tasks.js +32 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +62 -0
- package/dist/lib/api.d.ts +16 -0
- package/dist/lib/api.js +45 -0
- package/dist/lib/colors.d.ts +10 -0
- package/dist/lib/colors.js +18 -0
- package/dist/lib/credentials.d.ts +16 -0
- package/dist/lib/credentials.js +45 -0
- package/dist/lib/marker.d.ts +17 -0
- package/dist/lib/marker.js +27 -0
- package/dist/templates/claude-md.d.ts +5 -0
- package/dist/templates/claude-md.js +47 -0
- package/dist/templates/hook.d.ts +12 -0
- package/dist/templates/hook.js +132 -0
- package/dist/templates/settings.d.ts +12 -0
- package/dist/templates/settings.js +58 -0
- package/dist/templates/skill.d.ts +1 -0
- package/dist/templates/skill.js +61 -0
- package/package.json +55 -0
- package/templates/mcp/index.mjs +18171 -0
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# paris-immersion
|
|
2
|
+
|
|
3
|
+
CLI dos alunos da **Paris Immersion** — login, instalação do workspace da imersão, controle de tarefas e diagnóstico de ambiente.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm i -g paris-immersion
|
|
7
|
+
# ou
|
|
8
|
+
pnpm add -g paris-immersion
|
|
9
|
+
# ou
|
|
10
|
+
yarn global add paris-immersion
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Como começar
|
|
14
|
+
|
|
15
|
+
Depois de instalar, você terá o comando `paris` disponível no terminal.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
paris login # Autentica via device-code flow (abre o browser pra confirmar)
|
|
19
|
+
paris install # Cria o workspace da sua imersão na pasta atual
|
|
20
|
+
paris status # Vê em qual fase você está + tarefa em foco
|
|
21
|
+
paris tasks # Lista as tarefas do dia
|
|
22
|
+
paris doctor # Diagnostica credentials, marker, hooks, conectividade
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Fluxo do aluno
|
|
26
|
+
|
|
27
|
+
1. **Login** — `paris login` abre uma URL com um código tipo `ABCD-1234`. Você acessa, confirma no painel Paris Immersion (já logado com sua conta) e o CLI fica autenticado.
|
|
28
|
+
2. **Install** — `paris install` baixa templates e configura o Claude Code pra te guiar pela imersão usando o **Sócio** (IA da Paris).
|
|
29
|
+
3. **Durante a imersão** — Cada tarefa do dia tem um Sócio dedicado que te ajuda. Marque conclusões com `paris tasks`.
|
|
30
|
+
4. **Reabrir tarefa** — `paris reopen <mission-id>` se precisar retomar algo.
|
|
31
|
+
5. **Graduate** — `paris graduate` quando concluir a imersão.
|
|
32
|
+
|
|
33
|
+
## Comandos
|
|
34
|
+
|
|
35
|
+
| Comando | O que faz |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `paris login` | Autentica com sua conta Paris (device-code flow) |
|
|
38
|
+
| `paris install` | Instala o workspace da imersão na pasta atual |
|
|
39
|
+
| `paris doctor` | Diagnostica credentials, marker, hooks, conectividade |
|
|
40
|
+
| `paris tasks` | Lista tarefas do dia (CLI-only, não abre Claude) |
|
|
41
|
+
| `paris status` | Resumo da fase atual + tarefa em foco |
|
|
42
|
+
| `paris reopen <id>` | Reabre uma tarefa marcada como concluída |
|
|
43
|
+
| `paris graduate` | Marca a imersão como concluída |
|
|
44
|
+
| `paris logout` | Remove credenciais locais |
|
|
45
|
+
|
|
46
|
+
## Suporte
|
|
47
|
+
|
|
48
|
+
Em caso de problemas, rode `paris doctor` e mande o output pro time da Paris.
|
|
49
|
+
|
|
50
|
+
— Paris Group · [parisgroup.ai](https://parisgroup.ai)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { apiAuthFetch, apiFetch, defaultApiUrl, ApiError } from '../lib/api.js';
|
|
4
|
+
import { readCredentials, credentialsPath } from '../lib/credentials.js';
|
|
5
|
+
import { findMarker } from '../lib/marker.js';
|
|
6
|
+
import { c } from '../lib/colors.js';
|
|
7
|
+
export async function doctorCommand(opts = {}) {
|
|
8
|
+
const checks = [];
|
|
9
|
+
// 1. Credentials file
|
|
10
|
+
const creds = await readCredentials().catch(() => null);
|
|
11
|
+
if (!creds) {
|
|
12
|
+
checks.push({ name: 'credentials', ok: false, detail: `not found at ${credentialsPath()} — run \`paris login\`` });
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
checks.push({ name: 'credentials', ok: true, detail: `logged in as ${creds.email}` });
|
|
16
|
+
}
|
|
17
|
+
// 2. API reachable + token still valid
|
|
18
|
+
if (creds) {
|
|
19
|
+
try {
|
|
20
|
+
const workspace = await apiAuthFetch('/immersion-app/v1/workspace');
|
|
21
|
+
checks.push({ name: 'api / token', ok: true, detail: `${workspace.immersion.name} · ${workspace.phase} · day ${workspace.day_number} (${workspace.status})` });
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
checks.push({
|
|
25
|
+
name: 'api / token',
|
|
26
|
+
ok: false,
|
|
27
|
+
detail: err instanceof ApiError ? `${err.status} ${err.message}` : String(err),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// 3. Workspace marker
|
|
32
|
+
const found = await findMarker();
|
|
33
|
+
if (!found) {
|
|
34
|
+
checks.push({ name: 'workspace marker', ok: false, detail: 'no .paris/workspace.json walking up from cwd — run `paris install` here' });
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
checks.push({ name: 'workspace marker', ok: true, detail: `${path.join(found.dir, '.paris/workspace.json')}` });
|
|
38
|
+
// 4. Hooks present
|
|
39
|
+
const hookPath = path.join(found.dir, '.paris', 'hooks', 'paris-hook.mjs');
|
|
40
|
+
const hookExists = await fs.access(hookPath).then(() => true).catch(() => false);
|
|
41
|
+
checks.push({ name: 'paris-hook.mjs', ok: hookExists, detail: hookExists ? hookPath : 'missing — re-run `paris install --dir <here>`' });
|
|
42
|
+
// 5. .claude/settings.json
|
|
43
|
+
const settingsPath = path.join(found.dir, '.claude', 'settings.json');
|
|
44
|
+
const settingsExists = await fs.access(settingsPath).then(() => true).catch(() => false);
|
|
45
|
+
checks.push({ name: '.claude/settings.json', ok: settingsExists, detail: settingsExists ? settingsPath : 'missing — re-run `paris install`' });
|
|
46
|
+
}
|
|
47
|
+
// Report
|
|
48
|
+
console.log();
|
|
49
|
+
console.log(c.bold('paris doctor'));
|
|
50
|
+
console.log(c.dim(`API: ${creds?.api_url ?? defaultApiUrl()}`));
|
|
51
|
+
console.log();
|
|
52
|
+
let failures = 0;
|
|
53
|
+
for (const ch of checks) {
|
|
54
|
+
const icon = ch.ok ? c.green('✓') : c.red('✗');
|
|
55
|
+
console.log(` ${icon} ${ch.name}${ch.detail ? c.dim(' — ' + ch.detail) : ''}`);
|
|
56
|
+
if (!ch.ok)
|
|
57
|
+
failures++;
|
|
58
|
+
}
|
|
59
|
+
console.log();
|
|
60
|
+
if (failures === 0) {
|
|
61
|
+
console.log(c.green('Tudo certo.'));
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
console.log(c.yellow(`${failures} problema(s) encontrados.`));
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
}
|
|
67
|
+
if (opts.report) {
|
|
68
|
+
await reportInstallStatus(checks, creds?.token, creds?.api_url);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* POST /api/onboarding/v2/install-status — used by Phase 3 of the onboarding
|
|
73
|
+
* wizard to flip install_completed_at when every check passes, or stash a
|
|
74
|
+
* blocked-signal when something failed. Best-effort: a failed report is logged
|
|
75
|
+
* to stderr but never raises (the doctor output is the real signal to the user).
|
|
76
|
+
*/
|
|
77
|
+
async function reportInstallStatus(checks, token, apiUrl) {
|
|
78
|
+
if (!token) {
|
|
79
|
+
console.error(c.yellow(' ! --report ignorado: sem credenciais (rode `paris login`).'));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const allOk = checks.every((c) => c.ok);
|
|
83
|
+
const failedNames = checks.filter((c) => !c.ok).map((c) => c.name);
|
|
84
|
+
const body = {
|
|
85
|
+
step: 'all',
|
|
86
|
+
status: allOk ? 'ok' : 'failed',
|
|
87
|
+
os: process.platform,
|
|
88
|
+
arch: process.arch,
|
|
89
|
+
detail: failedNames.length ? `failed: ${failedNames.join(',')}` : undefined,
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
await apiFetch('/api/onboarding/v2/install-status', {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
body,
|
|
95
|
+
token,
|
|
96
|
+
apiUrl: apiUrl ?? defaultApiUrl(),
|
|
97
|
+
});
|
|
98
|
+
console.log(c.dim(` → reported install-status=${body.status} to api`));
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
const msg = err instanceof ApiError ? `${err.status} ${err.message}` : (err instanceof Error ? err.message : String(err));
|
|
102
|
+
console.error(c.yellow(` ! falha ao enviar install-status: ${msg}`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function graduateCommand(): Promise<void>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { apiAuthFetch } from '../lib/api.js';
|
|
2
|
+
import { c } from '../lib/colors.js';
|
|
3
|
+
export async function graduateCommand() {
|
|
4
|
+
const res = await apiAuthFetch('/immersion-app/v1/graduate', {
|
|
5
|
+
method: 'POST',
|
|
6
|
+
});
|
|
7
|
+
if (res.status === 'already_graduated') {
|
|
8
|
+
console.log(c.yellow(`Já estava graduado em ${res.graduated_at.slice(0, 10)}.`));
|
|
9
|
+
}
|
|
10
|
+
else {
|
|
11
|
+
console.log(c.green(`✓ Graduado em ${res.graduated_at.slice(0, 10)}. Hooks ficam silenciosos a partir da próxima sessão.`));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { apiAuthFetch } from '../lib/api.js';
|
|
5
|
+
import { findMarker, writeMarker } from '../lib/marker.js';
|
|
6
|
+
import { readCredentials } from '../lib/credentials.js';
|
|
7
|
+
import { c } from '../lib/colors.js';
|
|
8
|
+
import { renderClaudeMd } from '../templates/claude-md.js';
|
|
9
|
+
import { renderSettingsJson } from '../templates/settings.js';
|
|
10
|
+
import { renderParisCoachSkill } from '../templates/skill.js';
|
|
11
|
+
import { renderHookScript } from '../templates/hook.js';
|
|
12
|
+
export async function installCommand(opts) {
|
|
13
|
+
const creds = await readCredentials();
|
|
14
|
+
if (!creds) {
|
|
15
|
+
throw new Error('Você precisa rodar `paris login` antes.');
|
|
16
|
+
}
|
|
17
|
+
// Refuse to re-install on top of an existing marker without -d <new path>.
|
|
18
|
+
const existing = await findMarker();
|
|
19
|
+
if (existing && !opts.dir) {
|
|
20
|
+
console.log(c.yellow(`Marker já existe em ${existing.dir}.`));
|
|
21
|
+
console.log(c.dim('Use `paris doctor` pra validar, ou passe --dir <novo-path> pra criar outro workspace.'));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
process.stdout.write(c.dim('Buscando contexto da imersão...\n'));
|
|
25
|
+
const slugQuery = opts.immersion ? `?immersion=${encodeURIComponent(opts.immersion)}` : '';
|
|
26
|
+
const boot = await apiAuthFetch(`/immersion-app/v1/workspace/bootstrap${slugQuery}`, {
|
|
27
|
+
method: 'POST',
|
|
28
|
+
});
|
|
29
|
+
const targetDir = path.resolve(opts.dir ?? `paris-${boot.immersion.slug}`);
|
|
30
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
31
|
+
// .paris/workspace.json — the marker.
|
|
32
|
+
await writeMarker(targetDir, { ...boot.marker, api_url: creds.api_url });
|
|
33
|
+
// .paris/hooks/paris-hook.mjs — installed Claude Code hook.
|
|
34
|
+
const hooksDir = path.join(targetDir, '.paris', 'hooks');
|
|
35
|
+
await fs.mkdir(hooksDir, { recursive: true });
|
|
36
|
+
await fs.writeFile(path.join(hooksDir, 'paris-hook.mjs'), renderHookScript(), { mode: 0o755 });
|
|
37
|
+
// .paris/mcp/index.mjs — vendored, self-contained MCP server bundle. Shipped
|
|
38
|
+
// inside this package (templates/mcp/index.mjs) and copied verbatim so the
|
|
39
|
+
// workspace needs no npm install and no npx fetch at session start.
|
|
40
|
+
const mcpDir = path.join(targetDir, '.paris', 'mcp');
|
|
41
|
+
await fs.mkdir(mcpDir, { recursive: true });
|
|
42
|
+
await fs.writeFile(path.join(mcpDir, 'index.mjs'), await readVendoredMcpBundle(), { mode: 0o755 });
|
|
43
|
+
// .claude/settings.json — wires hooks + MCP server.
|
|
44
|
+
const claudeDir = path.join(targetDir, '.claude');
|
|
45
|
+
await fs.mkdir(claudeDir, { recursive: true });
|
|
46
|
+
// Write both settings.json (so the regular interactive `claude` picks it up)
|
|
47
|
+
// and settings.local.json (so `claude -p` does too — in -p mode, local
|
|
48
|
+
// settings are the reliably-loaded source for hooks).
|
|
49
|
+
const settingsPayload = renderSettingsJson({ apiUrl: creds.api_url });
|
|
50
|
+
await fs.writeFile(path.join(claudeDir, 'settings.json'), JSON.stringify(settingsPayload, null, 2));
|
|
51
|
+
await fs.writeFile(path.join(claudeDir, 'settings.local.json'), JSON.stringify(settingsPayload, null, 2));
|
|
52
|
+
// .claude/skills/paris-coach/SKILL.md
|
|
53
|
+
const skillDir = path.join(claudeDir, 'skills', 'paris-coach');
|
|
54
|
+
await fs.mkdir(skillDir, { recursive: true });
|
|
55
|
+
await fs.writeFile(path.join(skillDir, 'SKILL.md'), renderParisCoachSkill());
|
|
56
|
+
// CLAUDE.md — workspace-level rules.
|
|
57
|
+
await fs.writeFile(path.join(targetDir, 'CLAUDE.md'), renderClaudeMd({
|
|
58
|
+
immersionName: boot.immersion.name,
|
|
59
|
+
immersionSlug: boot.immersion.slug,
|
|
60
|
+
participantEmail: creds.email,
|
|
61
|
+
}));
|
|
62
|
+
console.log();
|
|
63
|
+
console.log(c.green(`✓ Workspace pronto em ${targetDir}`));
|
|
64
|
+
console.log();
|
|
65
|
+
console.log(c.bold('Próximo passo:'));
|
|
66
|
+
console.log(` cd ${path.relative(process.cwd(), targetDir) || '.'}`);
|
|
67
|
+
console.log(' claude');
|
|
68
|
+
console.log();
|
|
69
|
+
console.log(c.dim(`Imersão: ${boot.immersion.name} · fase ${boot.phase} · dia ${boot.day_number}`));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Reads the bundled MCP server shipped with this package. Lives at
|
|
73
|
+
* `<pkg>/templates/mcp/index.mjs` — same relative location from both the
|
|
74
|
+
* compiled `dist/commands/install.js` and the dev `src/commands/install.ts`.
|
|
75
|
+
* Built by `scripts/build-mcp-bundle.mjs` (runs in the `build` script before
|
|
76
|
+
* publish). If it's missing the build is broken — fail loud rather than ship a
|
|
77
|
+
* workspace whose Claude session has no coach tools.
|
|
78
|
+
*/
|
|
79
|
+
async function readVendoredMcpBundle() {
|
|
80
|
+
const bundlePath = fileURLToPath(new URL('../../templates/mcp/index.mjs', import.meta.url));
|
|
81
|
+
try {
|
|
82
|
+
return await fs.readFile(bundlePath, 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw new Error(`MCP bundle não encontrado em ${bundlePath}. ` +
|
|
86
|
+
'Build incompleto — rode `pnpm --filter paris-immersion build` (que gera o bundle) antes de publicar.');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { hostname, platform } from 'node:os';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { apiFetch, ApiError, defaultApiUrl } from '../lib/api.js';
|
|
4
|
+
import { writeCredentials } from '../lib/credentials.js';
|
|
5
|
+
import { c } from '../lib/colors.js';
|
|
6
|
+
export async function loginCommand(opts) {
|
|
7
|
+
const apiUrl = opts.apiUrl ?? defaultApiUrl();
|
|
8
|
+
// --token: skip device-code flow. Usado pelo installer (PARIS_ONBOARDING_TOKEN
|
|
9
|
+
// env mintado pelo wizard /api/onboarding/v2/installer-token).
|
|
10
|
+
// Valida o token contra /auth/me antes de persistir.
|
|
11
|
+
if (opts.token) {
|
|
12
|
+
process.stdout.write(c.dim(`Validando token via ${apiUrl}...\n`));
|
|
13
|
+
try {
|
|
14
|
+
const me = await apiFetch('/auth/me', {
|
|
15
|
+
method: 'GET',
|
|
16
|
+
apiUrl,
|
|
17
|
+
token: opts.token,
|
|
18
|
+
});
|
|
19
|
+
await writeCredentials({
|
|
20
|
+
token: opts.token,
|
|
21
|
+
user_id: me.id,
|
|
22
|
+
email: me.email,
|
|
23
|
+
api_url: apiUrl,
|
|
24
|
+
obtained_at: new Date().toISOString(),
|
|
25
|
+
});
|
|
26
|
+
console.log(c.green(`✓ Autenticado como ${me.email}.`));
|
|
27
|
+
console.log(c.dim(' Token salvo em ~/.paris/credentials.'));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const msg = err instanceof ApiError ? `${err.status} ${err.message}` : String(err);
|
|
32
|
+
throw new Error(`Login falhou (token inválido ou expirado): ${msg}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
process.stdout.write(c.dim(`Conectando a ${apiUrl}...\n`));
|
|
36
|
+
const start = await apiFetch('/auth/cli/start', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
apiUrl,
|
|
39
|
+
body: {
|
|
40
|
+
client_info: {
|
|
41
|
+
hostname: safeHostname(),
|
|
42
|
+
platform: platform(),
|
|
43
|
+
cli_version: '0.1.0',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
console.log();
|
|
48
|
+
console.log(c.bold('Para autorizar o CLI, abra:'));
|
|
49
|
+
console.log(' ' + c.cyan(start.verification_uri));
|
|
50
|
+
console.log();
|
|
51
|
+
console.log(c.bold('Confirme que o código abaixo aparece na página:'));
|
|
52
|
+
console.log(' ' + c.green(c.bold(start.user_code)));
|
|
53
|
+
console.log();
|
|
54
|
+
console.log(c.dim(`Esse código expira em ${Math.floor(start.expires_in / 60)} minutos.`));
|
|
55
|
+
console.log(c.dim('Aguardando aprovação...'));
|
|
56
|
+
console.log();
|
|
57
|
+
const deadline = Date.now() + start.expires_in * 1000;
|
|
58
|
+
let intervalMs = start.interval * 1000;
|
|
59
|
+
while (Date.now() < deadline) {
|
|
60
|
+
await delay(intervalMs);
|
|
61
|
+
try {
|
|
62
|
+
const poll = await apiFetch('/auth/cli/poll', {
|
|
63
|
+
method: 'POST',
|
|
64
|
+
apiUrl,
|
|
65
|
+
body: { device_code: start.device_code },
|
|
66
|
+
});
|
|
67
|
+
if (poll.status === 'approved') {
|
|
68
|
+
await writeCredentials({
|
|
69
|
+
token: poll.token,
|
|
70
|
+
user_id: poll.user.id,
|
|
71
|
+
email: poll.user.email,
|
|
72
|
+
api_url: apiUrl,
|
|
73
|
+
obtained_at: new Date().toISOString(),
|
|
74
|
+
});
|
|
75
|
+
console.log(c.green(`✓ Autenticado como ${poll.user.email}.`));
|
|
76
|
+
console.log(c.dim(` Token salvo em ~/.paris/credentials.`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (poll.status === 'authorization_pending') {
|
|
80
|
+
// Honor server-suggested cadence.
|
|
81
|
+
intervalMs = poll.interval * 1000;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
if (err instanceof ApiError && (err.status === 410 || err.status === 404)) {
|
|
87
|
+
throw new Error(`Login falhou: ${err.message}`);
|
|
88
|
+
}
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw new Error('Login expirou. Rode `paris login` de novo.');
|
|
93
|
+
}
|
|
94
|
+
function safeHostname() {
|
|
95
|
+
try {
|
|
96
|
+
return hostname();
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return 'unknown';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function logoutCommand(): Promise<void>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { apiAuthFetch } from '../lib/api.js';
|
|
2
|
+
import { deleteCredentials, readCredentials } from '../lib/credentials.js';
|
|
3
|
+
import { c } from '../lib/colors.js';
|
|
4
|
+
export async function logoutCommand() {
|
|
5
|
+
const creds = await readCredentials();
|
|
6
|
+
if (!creds) {
|
|
7
|
+
console.log(c.dim('Nenhuma sessão local pra encerrar.'));
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
// Best-effort server-side revocation. If it fails (offline, server down) we
|
|
11
|
+
// still wipe the local credentials so the user isn't stranded.
|
|
12
|
+
try {
|
|
13
|
+
await apiAuthFetch('/auth/logout', { method: 'POST', body: {} });
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// ignore
|
|
17
|
+
}
|
|
18
|
+
await deleteCredentials();
|
|
19
|
+
console.log(c.green('✓ Logout completo.'));
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { apiAuthFetch } from '../lib/api.js';
|
|
2
|
+
import { c } from '../lib/colors.js';
|
|
3
|
+
export async function reopenCommand(missionId, opts) {
|
|
4
|
+
const res = await apiAuthFetch(`/immersion-app/v1/missions/${missionId}/reopen`, {
|
|
5
|
+
method: 'POST',
|
|
6
|
+
body: { reason: opts.reason ?? 'student reopen' },
|
|
7
|
+
});
|
|
8
|
+
console.log(c.green(`✓ Reaberta: ${res.mission_id}`));
|
|
9
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface ServeOptions {
|
|
2
|
+
port: number;
|
|
3
|
+
allowedOrigins: string[];
|
|
4
|
+
token: string;
|
|
5
|
+
/** When true, accept connections without spawning a pty — used by tests + dry-run handshake checks. */
|
|
6
|
+
dryRun?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface ServeHandle {
|
|
9
|
+
port: number;
|
|
10
|
+
close: () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Start a localhost WebSocket server that brokers a pty (running `claude`) to a
|
|
14
|
+
* connected browser-side xterm.js. Two gates protect the channel:
|
|
15
|
+
*
|
|
16
|
+
* 1. Origin header must be in `allowedOrigins`. Browsers always set Origin on
|
|
17
|
+
* WS handshakes, so a same-origin web page can connect but a script from
|
|
18
|
+
* evil.com can't.
|
|
19
|
+
* 2. `?token=...` query param must match `opts.token`. The token is the
|
|
20
|
+
* value persisted in ~/.paris/credentials at `paris login` time.
|
|
21
|
+
*
|
|
22
|
+
* The server binds to 127.0.0.1 only — never 0.0.0.0 — so it's invisible on
|
|
23
|
+
* the LAN. xterm.js still reaches it because `ws://localhost` is a secure
|
|
24
|
+
* context per W3C Secure Contexts spec, even from an https:// page.
|
|
25
|
+
*/
|
|
26
|
+
export declare function startServeServer(opts: ServeOptions): Promise<ServeHandle>;
|
|
27
|
+
/** Read the bearer token used by the web client to authenticate against `paris serve`. */
|
|
28
|
+
export declare function readServeToken(): Promise<string>;
|
|
29
|
+
export declare function serveCommand(opts: {
|
|
30
|
+
port?: string | number;
|
|
31
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { WebSocketServer } from 'ws';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { readCredentials } from '../lib/credentials.js';
|
|
4
|
+
import { c } from '../lib/colors.js';
|
|
5
|
+
const DEFAULT_ALLOWED_ORIGINS = [
|
|
6
|
+
'https://app.paris-group.com',
|
|
7
|
+
'https://paris-immersion.vercel.app',
|
|
8
|
+
'http://localhost:3010',
|
|
9
|
+
];
|
|
10
|
+
/**
|
|
11
|
+
* Start a localhost WebSocket server that brokers a pty (running `claude`) to a
|
|
12
|
+
* connected browser-side xterm.js. Two gates protect the channel:
|
|
13
|
+
*
|
|
14
|
+
* 1. Origin header must be in `allowedOrigins`. Browsers always set Origin on
|
|
15
|
+
* WS handshakes, so a same-origin web page can connect but a script from
|
|
16
|
+
* evil.com can't.
|
|
17
|
+
* 2. `?token=...` query param must match `opts.token`. The token is the
|
|
18
|
+
* value persisted in ~/.paris/credentials at `paris login` time.
|
|
19
|
+
*
|
|
20
|
+
* The server binds to 127.0.0.1 only — never 0.0.0.0 — so it's invisible on
|
|
21
|
+
* the LAN. xterm.js still reaches it because `ws://localhost` is a secure
|
|
22
|
+
* context per W3C Secure Contexts spec, even from an https:// page.
|
|
23
|
+
*/
|
|
24
|
+
export async function startServeServer(opts) {
|
|
25
|
+
const wss = new WebSocketServer({
|
|
26
|
+
port: opts.port,
|
|
27
|
+
host: '127.0.0.1',
|
|
28
|
+
verifyClient: (info, cb) => {
|
|
29
|
+
const origin = info.origin ?? '';
|
|
30
|
+
if (!opts.allowedOrigins.includes(origin)) {
|
|
31
|
+
cb(false, 403, 'origin not allowed');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const url = new URL(info.req.url ?? '/', 'http://localhost');
|
|
35
|
+
if (url.searchParams.get('token') !== opts.token) {
|
|
36
|
+
cb(false, 401, 'invalid token');
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
cb(true);
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
// Wait for the listening event so handle.port is final (random port if 0).
|
|
43
|
+
await new Promise((resolve, reject) => {
|
|
44
|
+
wss.once('listening', () => resolve());
|
|
45
|
+
wss.once('error', reject);
|
|
46
|
+
});
|
|
47
|
+
wss.on('connection', (ws) => {
|
|
48
|
+
if (opts.dryRun) {
|
|
49
|
+
// Tests use this branch — we just confirm the handshake passed.
|
|
50
|
+
ws.send('READY\n');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
let pty;
|
|
54
|
+
try {
|
|
55
|
+
// Lazy require — keeps `paris serve --help` working even if the native
|
|
56
|
+
// node-pty binary failed to compile/install on this platform.
|
|
57
|
+
const req = createRequire(import.meta.url);
|
|
58
|
+
const ptyMod = req('node-pty');
|
|
59
|
+
const shell = process.env.SHELL || (process.platform === 'win32' ? 'powershell.exe' : 'bash');
|
|
60
|
+
const shellArgs = process.platform === 'win32' ? ['-NoLogo'] : ['-l', '-c', 'claude'];
|
|
61
|
+
pty = ptyMod.spawn(shell, shellArgs, {
|
|
62
|
+
name: 'xterm-color',
|
|
63
|
+
cols: 80,
|
|
64
|
+
rows: 24,
|
|
65
|
+
cwd: process.cwd(),
|
|
66
|
+
env: {
|
|
67
|
+
...process.env,
|
|
68
|
+
TOSTUDY_MCP_ENABLED: '1',
|
|
69
|
+
PARIS_MCP_ENABLED: '1',
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
75
|
+
ws.send(`ERROR: pty spawn failed: ${msg}\n`);
|
|
76
|
+
ws.close(1011, 'pty_unavailable');
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
const p = pty;
|
|
80
|
+
p.onData((d) => {
|
|
81
|
+
try {
|
|
82
|
+
ws.send(d);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// ws may already be closed mid-write — pty.kill on close cleans up
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
if (p.onExit) {
|
|
89
|
+
p.onExit(() => {
|
|
90
|
+
try {
|
|
91
|
+
ws.close(1000, 'pty_exited');
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
/* noop */
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
ws.on('message', (m) => {
|
|
99
|
+
const s = m.toString();
|
|
100
|
+
if (s.startsWith('RESIZE:')) {
|
|
101
|
+
const [cols, rows] = s.slice('RESIZE:'.length).split(',').map(Number);
|
|
102
|
+
if (Number.isFinite(cols) && Number.isFinite(rows)) {
|
|
103
|
+
try {
|
|
104
|
+
p.resize(cols, rows);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* ignore resize race */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
p.write(s);
|
|
113
|
+
});
|
|
114
|
+
ws.on('close', () => {
|
|
115
|
+
try {
|
|
116
|
+
p.kill();
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* noop */
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
const addr = wss.address();
|
|
124
|
+
const port = typeof addr === 'object' && addr ? addr.port : opts.port;
|
|
125
|
+
return {
|
|
126
|
+
port,
|
|
127
|
+
close: () => {
|
|
128
|
+
for (const client of wss.clients) {
|
|
129
|
+
try {
|
|
130
|
+
client.terminate();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
/* noop */
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
wss.close();
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
/** Read the bearer token used by the web client to authenticate against `paris serve`. */
|
|
141
|
+
export async function readServeToken() {
|
|
142
|
+
const creds = await readCredentials().catch(() => null);
|
|
143
|
+
if (!creds)
|
|
144
|
+
return '';
|
|
145
|
+
return creds.token ?? '';
|
|
146
|
+
}
|
|
147
|
+
export async function serveCommand(opts) {
|
|
148
|
+
const token = await readServeToken();
|
|
149
|
+
if (!token) {
|
|
150
|
+
console.error(c.red('Não logado.') + ' Rode: ' + c.bold('paris login'));
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
const portNum = opts.port == null ? 31000 : Number(opts.port);
|
|
154
|
+
if (!Number.isFinite(portNum) || portNum < 0 || portNum > 65535) {
|
|
155
|
+
console.error(c.red(`Porta inválida: ${opts.port}`));
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
const allowedOrigins = (process.env.PARIS_SERVE_ORIGINS?.split(',').map((s) => s.trim()).filter(Boolean)) ?? DEFAULT_ALLOWED_ORIGINS;
|
|
159
|
+
let handle;
|
|
160
|
+
try {
|
|
161
|
+
handle = await startServeServer({ port: portNum, allowedOrigins, token });
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
165
|
+
console.error(c.red(`Falha ao iniciar paris serve: ${msg}`));
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
console.log();
|
|
169
|
+
console.log(c.bold('paris serve'));
|
|
170
|
+
console.log(` ${c.green('✓')} WebSocket em ${c.bold(`ws://localhost:${handle.port}`)}`);
|
|
171
|
+
console.log(` ${c.dim('origens permitidas:')} ${allowedOrigins.join(', ')}`);
|
|
172
|
+
console.log(` ${c.dim('Ctrl+C pra encerrar.')}`);
|
|
173
|
+
console.log();
|
|
174
|
+
const shutdown = () => {
|
|
175
|
+
handle.close();
|
|
176
|
+
process.exit(0);
|
|
177
|
+
};
|
|
178
|
+
process.on('SIGINT', shutdown);
|
|
179
|
+
process.on('SIGTERM', shutdown);
|
|
180
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function statusCommand(): Promise<void>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { apiAuthFetch } from '../lib/api.js';
|
|
2
|
+
import { c } from '../lib/colors.js';
|
|
3
|
+
export async function statusCommand() {
|
|
4
|
+
const [workspace, current] = await Promise.all([
|
|
5
|
+
apiAuthFetch('/immersion-app/v1/workspace'),
|
|
6
|
+
apiAuthFetch('/immersion-app/v1/missions/current'),
|
|
7
|
+
]);
|
|
8
|
+
console.log();
|
|
9
|
+
console.log(c.bold(`${workspace.immersion.name} (${workspace.immersion.slug})`));
|
|
10
|
+
console.log(` ${c.dim('status')}: ${workspace.status === 'active' ? c.green('active') : c.yellow('graduated')}`);
|
|
11
|
+
console.log(` ${c.dim('fase')}: ${workspace.phase} · dia ${workspace.day_number}`);
|
|
12
|
+
console.log(` ${c.dim('janela')}: ${workspace.immersion.starts_at.slice(0, 10)} → ${workspace.immersion.ends_at.slice(0, 10)}`);
|
|
13
|
+
if (workspace.graduated_at) {
|
|
14
|
+
console.log(` ${c.dim('graduado em')}: ${workspace.graduated_at.slice(0, 10)}`);
|
|
15
|
+
}
|
|
16
|
+
console.log();
|
|
17
|
+
if (current.current) {
|
|
18
|
+
console.log(c.bold('Tarefa em foco:'));
|
|
19
|
+
console.log(` ${c.cyan(current.current.title)} ${c.dim('(' + current.current.deliverable_type + ', ' + current.current.status + ')')}`);
|
|
20
|
+
console.log(` ${c.dim(current.current.id)}`);
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
console.log(c.dim('Sem tarefa em foco.'));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function tasksCommand(): Promise<void>;
|