redmine-context 1.0.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/LICENSE +21 -0
- package/README.md +449 -0
- package/dist/bundle/index.d.ts +5 -0
- package/dist/bundle/index.js +5 -0
- package/dist/bundle/json.d.ts +90 -0
- package/dist/bundle/json.js +266 -0
- package/dist/bundle/markdown.d.ts +75 -0
- package/dist/bundle/markdown.js +294 -0
- package/dist/bundle/search-list.d.ts +43 -0
- package/dist/bundle/search-list.js +53 -0
- package/dist/bundle/stable-stringify.d.ts +26 -0
- package/dist/bundle/stable-stringify.js +50 -0
- package/dist/cache/contract.d.ts +157 -0
- package/dist/cache/contract.js +0 -0
- package/dist/cache/disk-index.d.ts +82 -0
- package/dist/cache/disk-index.js +220 -0
- package/dist/cache/disk.d.ts +133 -0
- package/dist/cache/disk.js +313 -0
- package/dist/cache/gc.d.ts +78 -0
- package/dist/cache/gc.js +123 -0
- package/dist/cache/get-or-compute.d.ts +36 -0
- package/dist/cache/get-or-compute.js +52 -0
- package/dist/cache/index.d.ts +9 -0
- package/dist/cache/index.js +8 -0
- package/dist/cache/keys.d.ts +76 -0
- package/dist/cache/keys.js +78 -0
- package/dist/cache/memory.d.ts +48 -0
- package/dist/cache/memory.js +110 -0
- package/dist/cache-first.d.ts +127 -0
- package/dist/cache-first.js +227 -0
- package/dist/client/errors.d.ts +33 -0
- package/dist/client/errors.js +49 -0
- package/dist/client/http.d.ts +110 -0
- package/dist/client/http.js +207 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +5 -0
- package/dist/client/issues.d.ts +71 -0
- package/dist/client/issues.js +100 -0
- package/dist/client/search.d.ts +58 -0
- package/dist/client/search.js +81 -0
- package/dist/config/credentials.d.ts +247 -0
- package/dist/config/credentials.js +427 -0
- package/dist/config/doctor.d.ts +123 -0
- package/dist/config/doctor.js +260 -0
- package/dist/config/index.d.ts +6 -0
- package/dist/config/index.js +6 -0
- package/dist/config/keyring.d.ts +96 -0
- package/dist/config/keyring.js +158 -0
- package/dist/config/login.d.ts +97 -0
- package/dist/config/login.js +189 -0
- package/dist/config/settings.d.ts +94 -0
- package/dist/config/settings.js +140 -0
- package/dist/contract.d.ts +173 -0
- package/dist/contract.js +27 -0
- package/dist/core.d.ts +1 -0
- package/dist/core.js +8 -0
- package/dist/extract/audio-extractor.d.ts +105 -0
- package/dist/extract/audio-extractor.js +156 -0
- package/dist/extract/audio.d.ts +126 -0
- package/dist/extract/audio.js +184 -0
- package/dist/extract/dispatcher.d.ts +132 -0
- package/dist/extract/dispatcher.js +115 -0
- package/dist/extract/download.d.ts +111 -0
- package/dist/extract/download.js +261 -0
- package/dist/extract/duration.d.ts +106 -0
- package/dist/extract/duration.js +148 -0
- package/dist/extract/ffmpeg.d.ts +56 -0
- package/dist/extract/ffmpeg.js +95 -0
- package/dist/extract/gguf.d.ts +137 -0
- package/dist/extract/gguf.js +215 -0
- package/dist/extract/index.d.ts +19 -0
- package/dist/extract/index.js +19 -0
- package/dist/extract/magic.d.ts +80 -0
- package/dist/extract/magic.js +282 -0
- package/dist/extract/ooxml.d.ts +131 -0
- package/dist/extract/ooxml.js +336 -0
- package/dist/extract/pdf.d.ts +147 -0
- package/dist/extract/pdf.js +322 -0
- package/dist/extract/queue.d.ts +167 -0
- package/dist/extract/queue.js +217 -0
- package/dist/extract/subprocess.d.ts +145 -0
- package/dist/extract/subprocess.js +181 -0
- package/dist/extract/tesseract.d.ts +153 -0
- package/dist/extract/tesseract.js +321 -0
- package/dist/extract/video-extractor.d.ts +84 -0
- package/dist/extract/video-extractor.js +89 -0
- package/dist/extract/video.d.ts +198 -0
- package/dist/extract/video.js +418 -0
- package/dist/extract/which.d.ts +63 -0
- package/dist/extract/which.js +72 -0
- package/dist/extract/whisper-extract.d.ts +211 -0
- package/dist/extract/whisper-extract.js +323 -0
- package/dist/extract/whisper.d.ts +50 -0
- package/dist/extract/whisper.js +67 -0
- package/dist/extract/zip.d.ts +50 -0
- package/dist/extract/zip.js +165 -0
- package/dist/extract-issue-attachments.d.ts +82 -0
- package/dist/extract-issue-attachments.js +156 -0
- package/dist/fetch-attachment-text.d.ts +113 -0
- package/dist/fetch-attachment-text.js +155 -0
- package/dist/fetch-issue-bundle.d.ts +81 -0
- package/dist/fetch-issue-bundle.js +93 -0
- package/dist/fetch-issue-search.d.ts +74 -0
- package/dist/fetch-issue-search.js +120 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +67 -0
- package/dist/normalize/collections.d.ts +52 -0
- package/dist/normalize/collections.js +170 -0
- package/dist/normalize/helpers.d.ts +35 -0
- package/dist/normalize/helpers.js +59 -0
- package/dist/normalize/index.d.ts +2 -0
- package/dist/normalize/index.js +2 -0
- package/dist/normalize/issue.d.ts +34 -0
- package/dist/normalize/issue.js +154 -0
- package/dist/surfaces/cli/commands.d.ts +61 -0
- package/dist/surfaces/cli/commands.js +262 -0
- package/dist/surfaces/cli/main.d.ts +32 -0
- package/dist/surfaces/cli/main.js +210 -0
- package/dist/surfaces/cli/prompts.d.ts +66 -0
- package/dist/surfaces/cli/prompts.js +147 -0
- package/dist/surfaces/cli/tty.d.ts +27 -0
- package/dist/surfaces/cli/tty.js +35 -0
- package/dist/surfaces/cli/types.d.ts +39 -0
- package/dist/surfaces/cli/types.js +7 -0
- package/dist/surfaces/mcp/server.d.ts +171 -0
- package/dist/surfaces/mcp/server.js +427 -0
- package/dist/surfaces/tui/app.d.ts +55 -0
- package/dist/surfaces/tui/app.js +180 -0
- package/dist/surfaces/tui/attachment-status.d.ts +79 -0
- package/dist/surfaces/tui/attachment-status.js +113 -0
- package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
- package/dist/surfaces/tui/components/breadcrumb.js +31 -0
- package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
- package/dist/surfaces/tui/components/gradient-text.js +75 -0
- package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
- package/dist/surfaces/tui/components/scroll-view.js +77 -0
- package/dist/surfaces/tui/components/spinner.d.ts +12 -0
- package/dist/surfaces/tui/components/spinner.js +39 -0
- package/dist/surfaces/tui/components/text-input.d.ts +45 -0
- package/dist/surfaces/tui/components/text-input.js +114 -0
- package/dist/surfaces/tui/format-file-size.d.ts +24 -0
- package/dist/surfaces/tui/format-file-size.js +43 -0
- package/dist/surfaces/tui/glyphs.d.ts +47 -0
- package/dist/surfaces/tui/glyphs.js +84 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
- package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
- package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
- package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
- package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
- package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
- package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
- package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
- package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
- package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
- package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
- package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
- package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
- package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
- package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
- package/dist/surfaces/tui/index.d.ts +50 -0
- package/dist/surfaces/tui/index.js +158 -0
- package/dist/surfaces/tui/instance.d.ts +37 -0
- package/dist/surfaces/tui/instance.js +36 -0
- package/dist/surfaces/tui/job-registry.d.ts +113 -0
- package/dist/surfaces/tui/job-registry.js +123 -0
- package/dist/surfaces/tui/job-status.d.ts +45 -0
- package/dist/surfaces/tui/job-status.js +81 -0
- package/dist/surfaces/tui/navigation.d.ts +74 -0
- package/dist/surfaces/tui/navigation.js +87 -0
- package/dist/surfaces/tui/palettes.d.ts +38 -0
- package/dist/surfaces/tui/palettes.js +244 -0
- package/dist/surfaces/tui/screen.d.ts +30 -0
- package/dist/surfaces/tui/screen.js +51 -0
- package/dist/surfaces/tui/screens/about.d.ts +2 -0
- package/dist/surfaces/tui/screens/about.js +35 -0
- package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
- package/dist/surfaces/tui/screens/appearance.js +74 -0
- package/dist/surfaces/tui/screens/config.d.ts +2 -0
- package/dist/surfaces/tui/screens/config.js +82 -0
- package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
- package/dist/surfaces/tui/screens/doctor.js +109 -0
- package/dist/surfaces/tui/screens/export.d.ts +2 -0
- package/dist/surfaces/tui/screens/export.js +168 -0
- package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
- package/dist/surfaces/tui/screens/home-selection.js +80 -0
- package/dist/surfaces/tui/screens/home.d.ts +2 -0
- package/dist/surfaces/tui/screens/home.js +200 -0
- package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
- package/dist/surfaces/tui/screens/issue-detail.js +182 -0
- package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
- package/dist/surfaces/tui/screens/jobs.js +89 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
- package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
- package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
- package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
- package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
- package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
- package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
- package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
- package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
- package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
- package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
- package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
- package/dist/surfaces/tui/screens/welcome.js +95 -0
- package/dist/surfaces/tui/status-color.d.ts +21 -0
- package/dist/surfaces/tui/status-color.js +23 -0
- package/dist/surfaces/tui/symbols.d.ts +231 -0
- package/dist/surfaces/tui/symbols.js +14 -0
- package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
- package/dist/surfaces/tui/terminal-colors.js +39 -0
- package/dist/surfaces/tui/theme.d.ts +156 -0
- package/dist/surfaces/tui/theme.js +86 -0
- package/dist/surfaces/tui/truncate.d.ts +31 -0
- package/dist/surfaces/tui/truncate.js +81 -0
- package/package.json +93 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Superfície MCP (stdio) do `redmine-context` (M1-12).
|
|
3
|
+
*
|
|
4
|
+
* Expõe a tool read-only `get_issue_context(issue_id, format?)`, que reutiliza a
|
|
5
|
+
* orquestração `fetchIssueBundle` do core. Segue a mesma fronteira da CLI
|
|
6
|
+
* (ADR-005): importa o core EXCLUSIVAMENTE por `../../index.js`; nenhum módulo
|
|
7
|
+
* interno é acessado diretamente (regra eslint `no-restricted-imports`).
|
|
8
|
+
*
|
|
9
|
+
* Segurança: NENHUMA tool aceita URL/host arbitrário — a instância vem sempre da
|
|
10
|
+
* config/env do processo (`REDMINE_URL`; sem ela, a URL persistida no `login`,
|
|
11
|
+
* #187) + cascata de credencial. Quando a URL vem da persistida (fonte mutável),
|
|
12
|
+
* a credencial de AMBIENTE (`REDMINE_API_KEY`, instance-agnóstica) é DESABILITADA
|
|
13
|
+
* — só credencial pinada à instância (keychain/arquivo) é aceita, impedindo que um
|
|
14
|
+
* `settings.json` adulterado exfiltre a chave (ver `resolveInstance`). No transporte
|
|
15
|
+
* stdio o protocolo ocupa o stdout, portanto todo diagnóstico vai para stderr.
|
|
16
|
+
*/
|
|
17
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
18
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
import * as core from '../../index.js';
|
|
21
|
+
import { fenceBlock } from '../../index.js';
|
|
22
|
+
/** Interpreta `REDMINE_INSECURE` (`1`/`true`, case-insensitive) como boolean. */
|
|
23
|
+
function parseInsecure(env) {
|
|
24
|
+
const raw = env.REDMINE_INSECURE;
|
|
25
|
+
return raw !== undefined && /^(1|true)$/i.test(raw.trim());
|
|
26
|
+
}
|
|
27
|
+
/** Nome canônico da tool de contexto de issue. */
|
|
28
|
+
export const TOOL_NAME = 'get_issue_context';
|
|
29
|
+
/** Nome canônico da tool de busca de issues. */
|
|
30
|
+
export const SEARCH_TOOL_NAME = 'search_issues';
|
|
31
|
+
/** Nome canônico da tool de texto de anexo. */
|
|
32
|
+
export const ATTACHMENT_TOOL_NAME = 'get_attachment_text';
|
|
33
|
+
/** Limite default de resultados da tool `search_issues` (documentado no schema). */
|
|
34
|
+
export const SEARCH_DEFAULT_LIMIT = 25;
|
|
35
|
+
/** Teto de resultados aceito pela tool `search_issues`. */
|
|
36
|
+
const SEARCH_MAX_LIMIT = 100;
|
|
37
|
+
/** Schema zod dos argumentos da tool (sem URL/host: a instância vem da env). */
|
|
38
|
+
const INPUT_SCHEMA = {
|
|
39
|
+
issue_id: z.number().int().positive().describe('Identificador numérico da issue no Redmine'),
|
|
40
|
+
format: z
|
|
41
|
+
.enum(['markdown', 'json'])
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Formato de saída: 'markdown' (padrão) ou 'json'"),
|
|
44
|
+
extract_attachments: z
|
|
45
|
+
.boolean()
|
|
46
|
+
.optional()
|
|
47
|
+
.describe('Extrai o texto (OCR) dos anexos de imagem e o embute no bundle. Default: false (adiciona latência de download+OCR). O M4 traz o modo cache-first/processing.'),
|
|
48
|
+
};
|
|
49
|
+
/** Schema zod da tool `get_attachment_text` (read-only, sem URL/host). */
|
|
50
|
+
const ATTACHMENT_INPUT_SCHEMA = {
|
|
51
|
+
issue_id: z.number().int().positive().describe('Identificador numérico da issue que contém o anexo'),
|
|
52
|
+
attachment_id: z.number().int().positive().describe('Identificador numérico do anexo a extrair'),
|
|
53
|
+
};
|
|
54
|
+
/** Schema zod da tool `search_issues` (read-only, sem URL/host). */
|
|
55
|
+
const SEARCH_INPUT_SCHEMA = {
|
|
56
|
+
query: z
|
|
57
|
+
.string()
|
|
58
|
+
.min(1)
|
|
59
|
+
.optional()
|
|
60
|
+
.describe('Termo full-text via /search.json (best-effort). Se a busca falhar, degrada para os filtros estruturados com aviso.'),
|
|
61
|
+
project_id: z.number().int().positive().optional().describe('Filtro estruturado project_id'),
|
|
62
|
+
status_id: z
|
|
63
|
+
.union([z.number().int(), z.string()])
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("Filtro status_id: um id, 'open', 'closed' ou '*'"),
|
|
66
|
+
assigned_to_id: z
|
|
67
|
+
.union([z.number().int(), z.string()])
|
|
68
|
+
.optional()
|
|
69
|
+
.describe("Filtro assigned_to_id: um id ou 'me'"),
|
|
70
|
+
updated_on: z
|
|
71
|
+
.string()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe('Filtro updated_on no formato do Redmine (ex.: >=2026-01-01, <=2026-12-31)'),
|
|
74
|
+
limit: z
|
|
75
|
+
.number()
|
|
76
|
+
.int()
|
|
77
|
+
.positive()
|
|
78
|
+
.max(SEARCH_MAX_LIMIT)
|
|
79
|
+
.optional()
|
|
80
|
+
.describe(`Máximo de resultados paginados (default ${SEARCH_DEFAULT_LIMIT}, teto ${SEARCH_MAX_LIMIT})`),
|
|
81
|
+
};
|
|
82
|
+
/** Extrai uma mensagem legível de um erro desconhecido. */
|
|
83
|
+
function messageOf(error) {
|
|
84
|
+
return error instanceof Error ? error.message : String(error);
|
|
85
|
+
}
|
|
86
|
+
/** Monta um `CallToolResult` de erro (isError) com uma única mensagem de texto. */
|
|
87
|
+
function errorResult(message) {
|
|
88
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
89
|
+
}
|
|
90
|
+
/** Monta um `CallToolResult` de sucesso com o bundle serializado. */
|
|
91
|
+
function textResult(content) {
|
|
92
|
+
return { content: [{ type: 'text', text: content }] };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Traduz um erro da operação em uma mensagem MCP clara e tipada.
|
|
96
|
+
*
|
|
97
|
+
* 403/404/401 recebem texto orientado; os demais propagam a mensagem original.
|
|
98
|
+
* Nenhum resultado é cacheado aqui — o erro é sempre recomputado por chamada.
|
|
99
|
+
*
|
|
100
|
+
* @param error - Erro capturado durante o fetch/bundle.
|
|
101
|
+
* @param issueId - Id da issue para compor a mensagem.
|
|
102
|
+
* @returns A mensagem a ser exibida no `isError`.
|
|
103
|
+
*/
|
|
104
|
+
function typedErrorMessage(error, issueId) {
|
|
105
|
+
if (error instanceof core.RedmineNotFoundError) {
|
|
106
|
+
return `Issue #${issueId} não encontrada (404). Verifique o id na instância configurada.`;
|
|
107
|
+
}
|
|
108
|
+
if (error instanceof core.RedmineForbiddenError) {
|
|
109
|
+
return `Acesso negado à issue #${issueId} (403). A credencial não tem permissão para vê-la.`;
|
|
110
|
+
}
|
|
111
|
+
if (error instanceof core.RedmineAuthError) {
|
|
112
|
+
return 'Falha de autenticação (401). Verifique a credencial em REDMINE_API_KEY.';
|
|
113
|
+
}
|
|
114
|
+
return messageOf(error);
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Resolve `REDMINE_URL` + credencial da env (nunca de argumentos das tools).
|
|
118
|
+
*
|
|
119
|
+
* Fonte única da política "sem URL/host nos argumentos": ambas as tools passam
|
|
120
|
+
* por aqui. Em falha devolve um {@link CallToolResult} de erro orientado; em
|
|
121
|
+
* sucesso, a instância pronta para uso.
|
|
122
|
+
*
|
|
123
|
+
* @param deps - Ver {@link McpServerDeps}.
|
|
124
|
+
* @returns A instância resolvida, ou um CallToolResult de erro (`isError`).
|
|
125
|
+
*/
|
|
126
|
+
async function resolveInstance(deps) {
|
|
127
|
+
// REDMINE_URL tem precedência; sem ela, cai na URL persistida no login (#187),
|
|
128
|
+
// via o resolvedor único (trata string vazia como ausente). Continua UMA
|
|
129
|
+
// instância configurada (env/persistida) — nunca de argumento de tool.
|
|
130
|
+
const persistedUrl = deps.settings ? await deps.settings.getInstanceUrl() : undefined;
|
|
131
|
+
const resolved = core.resolveInstanceUrl({ envUrl: deps.env.REDMINE_URL, persistedUrl });
|
|
132
|
+
if (resolved === undefined) {
|
|
133
|
+
return errorResult('Instância não configurada. Defina REDMINE_URL no ambiente do processo ou rode `redmine-context login`.');
|
|
134
|
+
}
|
|
135
|
+
const { url: baseUrl, origin } = resolved;
|
|
136
|
+
// Auditabilidade (#187): quando a instância NÃO veio da env (fallback persistido),
|
|
137
|
+
// registra a URL em uso no diagnóstico (o stdout é do protocolo → vai ao stderr).
|
|
138
|
+
if (origin !== 'env') {
|
|
139
|
+
deps.log?.(`instância resolvida via ${origin}: ${baseUrl}`);
|
|
140
|
+
}
|
|
141
|
+
let apiKey;
|
|
142
|
+
try {
|
|
143
|
+
apiKey = await deps.resolveApiKey(baseUrl, {
|
|
144
|
+
env: deps.env,
|
|
145
|
+
// SEGURANÇA (#187): URL de fonte MUTÁVEL (settings.json persistido, origem
|
|
146
|
+
// `config`) NÃO pode usar a REDMINE_API_KEY instance-agnóstica — só credencial
|
|
147
|
+
// PINADA à instância (keychain/arquivo). Impede que um settings.json adulterado
|
|
148
|
+
// redirecione a chave real para um host arbitrário (fail-closed).
|
|
149
|
+
allowEnvFallback: origin !== 'config',
|
|
150
|
+
// Aviso de migração/keychain vai para o diagnóstico (stdout é do protocolo).
|
|
151
|
+
logger: { warn: (message) => deps.log?.(message) },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
return errorResult(messageOf(error));
|
|
156
|
+
}
|
|
157
|
+
if (apiKey === undefined || apiKey.length === 0) {
|
|
158
|
+
return errorResult(`Nenhuma credencial encontrada para ${baseUrl}. Configure REDMINE_API_KEY (ou o arquivo de credenciais via 'redmine-context login').`);
|
|
159
|
+
}
|
|
160
|
+
return { baseUrl, apiKey };
|
|
161
|
+
}
|
|
162
|
+
/** Type guard: distingue a instância resolvida de um CallToolResult de erro. */
|
|
163
|
+
function isResolved(value) {
|
|
164
|
+
return 'baseUrl' in value;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Cria o handler da tool `get_issue_context`, testável isoladamente.
|
|
168
|
+
*
|
|
169
|
+
* Resolve a instância/credencial a partir da env (nunca de argumentos), invoca
|
|
170
|
+
* `fetchIssueBundle` e devolve o bundle. Erros viram `isError` com mensagem
|
|
171
|
+
* tipada; a ausência de `REDMINE_URL`/credencial orienta a configuração.
|
|
172
|
+
*
|
|
173
|
+
* @param deps - Ver {@link McpServerDeps}.
|
|
174
|
+
* @returns Função assíncrona que recebe os argumentos e devolve um CallToolResult.
|
|
175
|
+
* @example
|
|
176
|
+
* const handler = createGetIssueContextHandler(defaultMcpDeps());
|
|
177
|
+
* const result = await handler({ issue_id: 42, format: 'json' });
|
|
178
|
+
*/
|
|
179
|
+
export function createGetIssueContextHandler(deps) {
|
|
180
|
+
return async (args) => {
|
|
181
|
+
const resolved = await resolveInstance(deps);
|
|
182
|
+
if (!isResolved(resolved))
|
|
183
|
+
return resolved;
|
|
184
|
+
const { baseUrl, apiKey } = resolved;
|
|
185
|
+
const format = args.format === 'json' ? 'json' : 'md';
|
|
186
|
+
try {
|
|
187
|
+
let content;
|
|
188
|
+
for await (const event of deps.fetchIssueBundle({
|
|
189
|
+
baseUrl,
|
|
190
|
+
apiKey,
|
|
191
|
+
issueId: args.issue_id,
|
|
192
|
+
format,
|
|
193
|
+
toolVersion: deps.toolVersion,
|
|
194
|
+
insecure: deps.insecure ?? false,
|
|
195
|
+
extractAttachments: args.extract_attachments ?? false,
|
|
196
|
+
// Cache-first (#70): embute o texto já pronto e marca o restante como
|
|
197
|
+
// `processing` sem bloquear na extração cara (a computação corre em background).
|
|
198
|
+
cacheFirst: true,
|
|
199
|
+
})) {
|
|
200
|
+
if (event.kind === 'progress') {
|
|
201
|
+
deps.log?.(event.message);
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
content = event.value.content;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (content === undefined) {
|
|
208
|
+
return errorResult('A operação não produziu um bundle.');
|
|
209
|
+
}
|
|
210
|
+
return textResult(content);
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
return errorResult(typedErrorMessage(error, args.issue_id));
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Traduz erros da busca em mensagens claras (sem `issue_id`, que não se aplica).
|
|
219
|
+
*
|
|
220
|
+
* @param error - Erro capturado durante a busca.
|
|
221
|
+
* @returns Mensagem a exibir no `isError`.
|
|
222
|
+
*/
|
|
223
|
+
function typedSearchErrorMessage(error) {
|
|
224
|
+
if (error instanceof core.RedmineAuthError) {
|
|
225
|
+
return 'Falha de autenticação (401). Verifique a credencial em REDMINE_API_KEY.';
|
|
226
|
+
}
|
|
227
|
+
if (error instanceof core.RedmineForbiddenError) {
|
|
228
|
+
return 'Acesso negado (403). A credencial não tem permissão para esta busca.';
|
|
229
|
+
}
|
|
230
|
+
return messageOf(error);
|
|
231
|
+
}
|
|
232
|
+
/** Monta os filtros estruturados a partir dos argumentos definidos da tool. */
|
|
233
|
+
function searchFiltersOf(args) {
|
|
234
|
+
return {
|
|
235
|
+
project_id: args.project_id,
|
|
236
|
+
status_id: args.status_id,
|
|
237
|
+
assigned_to_id: args.assigned_to_id,
|
|
238
|
+
updated_on: args.updated_on,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Cria o handler da tool `search_issues`, testável isoladamente.
|
|
243
|
+
*
|
|
244
|
+
* Resolve a instância/credencial da env (nunca de argumentos), delega à
|
|
245
|
+
* orquestração `fetchIssueSearch` (filtros + full-text best-effort) e devolve a
|
|
246
|
+
* lista compacta em Markdown. A degradação da busca full-text NÃO é erro: o
|
|
247
|
+
* aviso já vem embutido no payload retornado pelo core.
|
|
248
|
+
*
|
|
249
|
+
* @param deps - Ver {@link McpServerDeps}.
|
|
250
|
+
* @returns Função assíncrona que recebe os argumentos e devolve um CallToolResult.
|
|
251
|
+
* @example
|
|
252
|
+
* const handler = createSearchIssuesHandler(defaultMcpDeps());
|
|
253
|
+
* const result = await handler({ query: 'timeout', project_id: 5 });
|
|
254
|
+
*/
|
|
255
|
+
export function createSearchIssuesHandler(deps) {
|
|
256
|
+
return async (args) => {
|
|
257
|
+
const resolved = await resolveInstance(deps);
|
|
258
|
+
if (!isResolved(resolved))
|
|
259
|
+
return resolved;
|
|
260
|
+
const { baseUrl, apiKey } = resolved;
|
|
261
|
+
try {
|
|
262
|
+
const result = await deps.searchIssues({
|
|
263
|
+
baseUrl,
|
|
264
|
+
apiKey,
|
|
265
|
+
filters: searchFiltersOf(args),
|
|
266
|
+
query: args.query,
|
|
267
|
+
limit: args.limit ?? SEARCH_DEFAULT_LIMIT,
|
|
268
|
+
});
|
|
269
|
+
// Degradação vira aviso no corpo (não isError); logamos para diagnóstico.
|
|
270
|
+
if (result.degraded)
|
|
271
|
+
deps.log?.(result.warnings.join(' '));
|
|
272
|
+
return textResult(result.content);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
return errorResult(typedSearchErrorMessage(error));
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Traduz erros da extração de anexo em mensagens claras e tipadas.
|
|
281
|
+
*
|
|
282
|
+
* 404/403/401 do Redmine e o {@link core.AttachmentNotFoundError} recebem texto
|
|
283
|
+
* orientado; os demais propagam a mensagem original. Nenhum resultado é cacheado
|
|
284
|
+
* aqui — o erro é sempre recomputado por chamada (sem cache indevido).
|
|
285
|
+
*
|
|
286
|
+
* @param error - Erro capturado durante o fetch/extração.
|
|
287
|
+
* @param issueId - Id da issue para compor a mensagem.
|
|
288
|
+
* @param attachmentId - Id do anexo para compor a mensagem.
|
|
289
|
+
* @returns A mensagem a ser exibida no `isError`.
|
|
290
|
+
*/
|
|
291
|
+
function typedAttachmentErrorMessage(error, issueId, attachmentId) {
|
|
292
|
+
if (error instanceof core.AttachmentNotFoundError) {
|
|
293
|
+
return `Anexo #${attachmentId} não encontrado na issue #${issueId}. Verifique o id do anexo.`;
|
|
294
|
+
}
|
|
295
|
+
return typedErrorMessage(error, issueId);
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Renderiza o {@link AttachmentTextResult} como conteúdo MCP de sucesso.
|
|
299
|
+
*
|
|
300
|
+
* Com texto (`done`/`text`): devolve-o dentro da fence `<untrusted-content>` — é
|
|
301
|
+
* conteúdo DERIVADO do anexo, logo não confiável (padrão do repo). Sem texto
|
|
302
|
+
* (`pending`/`processing`/`skipped`/`unsupported`/`failed`): devolve `status` +
|
|
303
|
+
* `reason`/`hint` legíveis — NUNCA um erro genérico; o `hint` é texto nosso
|
|
304
|
+
* (ex.: como instalar o tesseract), fora da fence por ser confiável.
|
|
305
|
+
*
|
|
306
|
+
* @param result - Resultado da extração do anexo.
|
|
307
|
+
* @returns CallToolResult de sucesso (não `isError`).
|
|
308
|
+
*/
|
|
309
|
+
function renderAttachmentText(result) {
|
|
310
|
+
const { extraction } = result;
|
|
311
|
+
if (typeof extraction.text === 'string' && extraction.text.trim() !== '') {
|
|
312
|
+
return textResult(fenceBlock(extraction.text));
|
|
313
|
+
}
|
|
314
|
+
const lines = [`Anexo #${result.attachmentId}: extração ${extraction.status} (sem texto disponível).`];
|
|
315
|
+
const reason = extraction.metadata?.['reason'];
|
|
316
|
+
const hint = extraction.metadata?.['hint'];
|
|
317
|
+
if (typeof reason === 'string')
|
|
318
|
+
lines.push(`Motivo: ${reason}`);
|
|
319
|
+
if (typeof hint === 'string')
|
|
320
|
+
lines.push(hint);
|
|
321
|
+
return textResult(lines.join('\n'));
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Cria o handler da tool `get_attachment_text`, testável isoladamente.
|
|
325
|
+
*
|
|
326
|
+
* Resolve a instância/credencial da env (nunca de argumentos), delega à
|
|
327
|
+
* orquestração cache-first `fetchAttachmentText` (texto já cacheado volta na
|
|
328
|
+
* hora; mídia pesada ainda não processada volta como `processing` SEM bloquear —
|
|
329
|
+
* M4-11 #70) e devolve o texto extraído dentro da fence untrusted. Anexo não
|
|
330
|
+
* processável / `processing` vira status legível (não `isError`); 403/404 do
|
|
331
|
+
* Redmine e anexo inexistente viram `isError` tipado.
|
|
332
|
+
*
|
|
333
|
+
* @param deps - Ver {@link McpServerDeps}.
|
|
334
|
+
* @returns Função assíncrona que recebe os argumentos e devolve um CallToolResult.
|
|
335
|
+
* @example
|
|
336
|
+
* const handler = createGetAttachmentTextHandler(defaultMcpDeps());
|
|
337
|
+
* const result = await handler({ issue_id: 42, attachment_id: 77 });
|
|
338
|
+
*/
|
|
339
|
+
export function createGetAttachmentTextHandler(deps) {
|
|
340
|
+
return async (args) => {
|
|
341
|
+
const resolved = await resolveInstance(deps);
|
|
342
|
+
if (!isResolved(resolved))
|
|
343
|
+
return resolved;
|
|
344
|
+
const { baseUrl, apiKey } = resolved;
|
|
345
|
+
try {
|
|
346
|
+
const result = await deps.fetchAttachmentText({
|
|
347
|
+
baseUrl,
|
|
348
|
+
apiKey,
|
|
349
|
+
issueId: args.issue_id,
|
|
350
|
+
attachmentId: args.attachment_id,
|
|
351
|
+
insecure: deps.insecure ?? false,
|
|
352
|
+
});
|
|
353
|
+
return renderAttachmentText(result);
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
return errorResult(typedAttachmentErrorMessage(error, args.issue_id, args.attachment_id));
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Constrói um {@link McpServer} com a tool `get_issue_context` registrada.
|
|
362
|
+
*
|
|
363
|
+
* A tool é read-only e não expõe URL/host — a instância vem sempre da env.
|
|
364
|
+
*
|
|
365
|
+
* @param deps - Ver {@link McpServerDeps}.
|
|
366
|
+
* @returns O server pronto para `connect(transport)`.
|
|
367
|
+
*/
|
|
368
|
+
export function createMcpServer(deps) {
|
|
369
|
+
const server = new McpServer({ name: core.TOOL_NAME, version: deps.toolVersion });
|
|
370
|
+
const handler = createGetIssueContextHandler(deps);
|
|
371
|
+
const searchHandler = createSearchIssuesHandler(deps);
|
|
372
|
+
const attachmentHandler = createGetAttachmentTextHandler(deps);
|
|
373
|
+
server.registerTool(TOOL_NAME, {
|
|
374
|
+
title: 'Contexto de issue do Redmine',
|
|
375
|
+
description: 'Busca uma issue na instância Redmine configurada (REDMINE_URL) e retorna seu contexto completo empacotado. Read-only.',
|
|
376
|
+
inputSchema: INPUT_SCHEMA,
|
|
377
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
378
|
+
}, (args) => handler(args));
|
|
379
|
+
server.registerTool(SEARCH_TOOL_NAME, {
|
|
380
|
+
title: 'Buscar issues no Redmine',
|
|
381
|
+
description: 'Busca issues na instância configurada (REDMINE_URL) por filtros estruturados (project_id, status_id, assigned_to_id, updated_on) e, opcionalmente, texto livre (query, best-effort via /search). Retorna uma lista compacta paginada. Read-only.',
|
|
382
|
+
inputSchema: SEARCH_INPUT_SCHEMA,
|
|
383
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
384
|
+
}, (args) => searchHandler(args));
|
|
385
|
+
server.registerTool(ATTACHMENT_TOOL_NAME, {
|
|
386
|
+
title: 'Texto extraído de um anexo do Redmine',
|
|
387
|
+
description: 'Extrai (com cache) o texto de um anexo de uma issue na instância configurada (REDMINE_URL) e o retorna dentro de uma fence de conteúdo não confiável. Anexo não processável retorna o status/motivo legível (skipped/unsupported/failed), não um erro. Read-only.',
|
|
388
|
+
inputSchema: ATTACHMENT_INPUT_SCHEMA,
|
|
389
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
390
|
+
}, (args) => attachmentHandler(args));
|
|
391
|
+
return server;
|
|
392
|
+
}
|
|
393
|
+
/** Constrói as dependências default apontando para o core e o processo reais. */
|
|
394
|
+
export function defaultMcpDeps() {
|
|
395
|
+
return {
|
|
396
|
+
fetchIssueBundle: core.fetchIssueBundle,
|
|
397
|
+
searchIssues: core.fetchIssueSearch,
|
|
398
|
+
fetchAttachmentText: core.fetchAttachmentTextCacheFirst,
|
|
399
|
+
resolveApiKey: core.resolveApiKey,
|
|
400
|
+
env: process.env,
|
|
401
|
+
settings: core.defaultSettingsStore(),
|
|
402
|
+
toolVersion: core.TOOL_VERSION,
|
|
403
|
+
insecure: parseInsecure(process.env),
|
|
404
|
+
// Diagnóstico SEMPRE em stderr: o stdout pertence ao protocolo stdio.
|
|
405
|
+
log: (message) => void process.stderr.write(`${message}\n`),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/* c8 ignore start -- wire de I/O (stdio transport); coberto pelo teste E2E real (#20). */
|
|
409
|
+
/**
|
|
410
|
+
* Sobe o server MCP no transporte stdio e resolve quando a conexão fecha.
|
|
411
|
+
*
|
|
412
|
+
* O stdout é reservado ao protocolo; qualquer log vai para stderr.
|
|
413
|
+
*
|
|
414
|
+
* @param overrides - Dependências injetáveis (defaults via {@link defaultMcpDeps}).
|
|
415
|
+
* @returns Promise que resolve quando o transporte encerra.
|
|
416
|
+
*/
|
|
417
|
+
export async function runStdioServer(overrides = {}) {
|
|
418
|
+
const deps = { ...defaultMcpDeps(), ...overrides };
|
|
419
|
+
const server = createMcpServer(deps);
|
|
420
|
+
const transport = new StdioServerTransport();
|
|
421
|
+
await server.connect(transport);
|
|
422
|
+
deps.log?.('redmine-context MCP server pronto (stdio).');
|
|
423
|
+
await new Promise((resolve) => {
|
|
424
|
+
transport.onclose = () => resolve();
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
/* c8 ignore stop */
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { SettingsStore } from '../../index.js';
|
|
2
|
+
import { type OnboardingReAuth } from './screens/onboarding/onboarding-context.js';
|
|
3
|
+
import { type ScreenName } from './screen.js';
|
|
4
|
+
import { type Theme, type ThemeController } from './theme.js';
|
|
5
|
+
/**
|
|
6
|
+
* O que `Esc` deve fazer dado o estado atual — extraída como função pura
|
|
7
|
+
* (fix do review #119) para poder ser testada isoladamente
|
|
8
|
+
* (`tests/surfaces/tui/app.test.tsx`) sem precisar montar toda a árvore do
|
|
9
|
+
* Ink nem depender de uma tela de dados real (#29+) para chegar no estado de
|
|
10
|
+
* re-auth ativo.
|
|
11
|
+
*/
|
|
12
|
+
export type EscapeAction = {
|
|
13
|
+
kind: 'abort-reauth';
|
|
14
|
+
origin: ScreenName;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'pop';
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Decide a ação de `Esc`: abandono do re-auth (fix do review #119) quando a
|
|
20
|
+
* tela atual é do fluxo de onboarding (`onboarding-*`) E há um `reAuth` em
|
|
21
|
+
* andamento; `pop()` simples em qualquer outro caso (comportamento original,
|
|
22
|
+
* M2-04).
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveEscapeAction(current: ScreenName, reAuth: OnboardingReAuth | undefined): EscapeAction;
|
|
25
|
+
/**
|
|
26
|
+
* App raiz da TUI: só monta os providers (tema, pilha de navegação,
|
|
27
|
+
* onboarding — M2-05.1). `OnboardingProvider` recebe os callbacks REAIS de
|
|
28
|
+
* `useOnboardingCallbacks` (#28) — o wiring com o core (`loginWithPassword`,
|
|
29
|
+
* `validateApiKey`, credential cascade) vive só ali, nunca nas telas em si
|
|
30
|
+
* (ADR-005).
|
|
31
|
+
*/
|
|
32
|
+
/** Props do {@link App} — todas opcionais para não quebrar `render(<App/>)` nos testes. */
|
|
33
|
+
export interface AppProps {
|
|
34
|
+
/**
|
|
35
|
+
* Id da paleta inicial (#190). Ausente ⇒ {@link DEFAULT_THEME} (comportamento
|
|
36
|
+
* atual, usado pelos testes); o `runTui` passa a paleta resolvida do settings.
|
|
37
|
+
*/
|
|
38
|
+
initialPaletteId?: string;
|
|
39
|
+
/** Store para PERSISTIR a paleta escolhida (#190). Ausente ⇒ persistência é no-op. */
|
|
40
|
+
settings?: Pick<SettingsStore, 'setPaletteId'>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Estado do tema/paleta (#190) — extraído para teste determinístico (sem depender
|
|
44
|
+
* do teclado): mantém a paleta ativa, resolve o {@link Theme} e expõe o
|
|
45
|
+
* {@link ThemeController} (preview troca ao vivo; select troca + persiste best-effort).
|
|
46
|
+
*
|
|
47
|
+
* @param initialPaletteId - Paleta inicial; `undefined` ⇒ {@link DEFAULT_THEME} (testes).
|
|
48
|
+
* @param settings - Store para persistir a paleta no `select` (opcional).
|
|
49
|
+
* @returns O `theme` resolvido + o `controller`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function useThemeControllerState(initialPaletteId?: string, settings?: Pick<SettingsStore, 'setPaletteId'>): {
|
|
52
|
+
theme: Theme;
|
|
53
|
+
controller: ThemeController;
|
|
54
|
+
};
|
|
55
|
+
export declare function App(props?: AppProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Roteador de telas da TUI (M2-01, evoluído em M2-04 para pilha + casca
|
|
4
|
+
* visual global).
|
|
5
|
+
*
|
|
6
|
+
* `App` só instancia os providers (tema, pilha de navegação) — quem de fato
|
|
7
|
+
* consome o contexto e renderiza a casca visual (breadcrumb fixo, aviso de
|
|
8
|
+
* saída, tela atual) é `AppShell`, um componente à parte porque precisa
|
|
9
|
+
* estar DENTRO dos providers para poder chamar `useTheme()`/`useNavigation()`.
|
|
10
|
+
*
|
|
11
|
+
* Atalhos globais (qualquer tela): `q` sai, `Esc` volta (`pop()` da pilha,
|
|
12
|
+
* ver `navigation.tsx`), `Ctrl+C` segue o padrão de duas pressões (ver
|
|
13
|
+
* `hooks/use-exit-guard.ts`). `/` era reservado (no-op) para a busca antes
|
|
14
|
+
* da M2-07 existir; a semântica real agora vive na home (`screens/home.tsx`,
|
|
15
|
+
* que registra seu PRÓPRIO `useInput` para `/`) — o handler global permanece
|
|
16
|
+
* no-op aqui, o que automaticamente CONTINUA bloqueando `/` em qualquer tela
|
|
17
|
+
* que não seja a home (nenhuma outra tela trata essa tecla).
|
|
18
|
+
*
|
|
19
|
+
* Fix do review do PR #119: `Esc` numa tela `onboarding-*` enquanto um
|
|
20
|
+
* re-auth (M2-13, #36) está em andamento (`OnboardingContext.reAuth`) passa
|
|
21
|
+
* a significar ABANDONO explícito do re-login, em vez do `pop()` simples de
|
|
22
|
+
* sempre — sem isso, a `Promise` do `guard()` original
|
|
23
|
+
* (`hooks/use-auth-guard.ts`) ficaria pendurada para sempre. Nesse caso:
|
|
24
|
+
* `abortReAuth()` rejeita as pendências com `ReAuthAbortedError` e a
|
|
25
|
+
* navegação volta direto para a tela de ORIGEM do 401 (`popTo`,
|
|
26
|
+
* `navigation.tsx`) em vez de só desempilhar uma tela por vez.
|
|
27
|
+
*
|
|
28
|
+
* M2-07 (#30): `Esc` no campo de busca da home precisa fechar a busca em vez
|
|
29
|
+
* de desempilhar a home inteira — `consumeEscapeInterceptor()`
|
|
30
|
+
* (`hooks/use-escape-interceptor.ts`) é consultado ANTES do `pop()` padrão;
|
|
31
|
+
* se a home registrou um interceptor (busca aberta), ele é quem trata o Esc
|
|
32
|
+
* e o `pop()`/abandono de re-auth abaixo é pulado nesse ciclo.
|
|
33
|
+
*/
|
|
34
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
35
|
+
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
36
|
+
import { Breadcrumb } from './components/breadcrumb.js';
|
|
37
|
+
import { TerminalSizeProvider, useTerminalHeight } from './hooks/use-terminal-width.js';
|
|
38
|
+
import { applyTerminalColors } from './terminal-colors.js';
|
|
39
|
+
import { ReAuthAbortedError } from './hooks/use-auth-guard.js';
|
|
40
|
+
import { consumeEscapeInterceptor } from './hooks/use-escape-interceptor.js';
|
|
41
|
+
import { useExitGuard } from './hooks/use-exit-guard.js';
|
|
42
|
+
import { useOnboardingCallbacks } from './hooks/use-onboarding-callbacks.js';
|
|
43
|
+
import { JobRegistryProvider } from './job-registry.js';
|
|
44
|
+
import { NavigationProvider, useNavigation, useNavigationStack } from './navigation.js';
|
|
45
|
+
import { HomeSelectionProvider } from './screens/home-selection.js';
|
|
46
|
+
import { LoadedIssueProvider } from './screens/loaded-issue-context.js';
|
|
47
|
+
import { OnboardingProvider, useOnboarding, } from './screens/onboarding/onboarding-context.js';
|
|
48
|
+
import { DEFAULT_PALETTE_ID, resolvePalette } from './palettes.js';
|
|
49
|
+
import { INITIAL_SCREEN, SCREENS } from './screen.js';
|
|
50
|
+
import { symbols } from './symbols.js';
|
|
51
|
+
import { DEFAULT_THEME, ThemeControllerProvider, ThemeProvider, useTheme, } from './theme.js';
|
|
52
|
+
/**
|
|
53
|
+
* Decide a ação de `Esc`: abandono do re-auth (fix do review #119) quando a
|
|
54
|
+
* tela atual é do fluxo de onboarding (`onboarding-*`) E há um `reAuth` em
|
|
55
|
+
* andamento; `pop()` simples em qualquer outro caso (comportamento original,
|
|
56
|
+
* M2-04).
|
|
57
|
+
*/
|
|
58
|
+
export function resolveEscapeAction(current, reAuth) {
|
|
59
|
+
if (reAuth !== undefined && current.startsWith('onboarding-')) {
|
|
60
|
+
return { kind: 'abort-reauth', origin: reAuth.origin };
|
|
61
|
+
}
|
|
62
|
+
return { kind: 'pop' };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Estado do tema/paleta (#190) — extraído para teste determinístico (sem depender
|
|
66
|
+
* do teclado): mantém a paleta ativa, resolve o {@link Theme} e expõe o
|
|
67
|
+
* {@link ThemeController} (preview troca ao vivo; select troca + persiste best-effort).
|
|
68
|
+
*
|
|
69
|
+
* @param initialPaletteId - Paleta inicial; `undefined` ⇒ {@link DEFAULT_THEME} (testes).
|
|
70
|
+
* @param settings - Store para persistir a paleta no `select` (opcional).
|
|
71
|
+
* @returns O `theme` resolvido + o `controller`.
|
|
72
|
+
*/
|
|
73
|
+
export function useThemeControllerState(initialPaletteId, settings) {
|
|
74
|
+
const [paletteId, setPaletteId] = useState(initialPaletteId);
|
|
75
|
+
const theme = paletteId !== undefined ? resolvePalette(paletteId).theme : DEFAULT_THEME;
|
|
76
|
+
const controller = useMemo(() => ({
|
|
77
|
+
paletteId: paletteId ?? DEFAULT_PALETTE_ID,
|
|
78
|
+
preview: (id) => setPaletteId(id),
|
|
79
|
+
select: async (id) => {
|
|
80
|
+
setPaletteId(id);
|
|
81
|
+
// Persistência best-effort: uma falha ao salvar não deve quebrar a TUI.
|
|
82
|
+
try {
|
|
83
|
+
await settings?.setPaletteId(id);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
/* segue com a paleta aplicada nesta sessão */
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
}), [paletteId, settings]);
|
|
90
|
+
return { theme, controller };
|
|
91
|
+
}
|
|
92
|
+
export function App(props = {}) {
|
|
93
|
+
const { initialPaletteId, settings } = props;
|
|
94
|
+
const navigation = useNavigationStack(INITIAL_SCREEN);
|
|
95
|
+
const onboardingCallbacks = useOnboardingCallbacks();
|
|
96
|
+
// Paleta ativa (#190) — ver {@link useThemeControllerState}.
|
|
97
|
+
const { theme, controller } = useThemeControllerState(initialPaletteId, settings);
|
|
98
|
+
// Aplica as cores da paleta como default do terminal (OSC) sempre que ela muda
|
|
99
|
+
// (troca ao vivo na tela Aparência) — o fundo cobre a tela e todo texto sem cor
|
|
100
|
+
// explícita fica legível. Só em TTY real; nos testes (`ink-testing-library`) o
|
|
101
|
+
// stdout não é TTY, então não escreve nada.
|
|
102
|
+
const { stdout } = useStdout();
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (stdout?.isTTY === true)
|
|
105
|
+
applyTerminalColors(stdout, theme);
|
|
106
|
+
}, [theme, stdout]);
|
|
107
|
+
return (_jsx(TerminalSizeProvider, { children: _jsx(ThemeProvider, { theme: theme, children: _jsx(ThemeControllerProvider, { value: controller, children: _jsx(NavigationProvider, { value: navigation, children: _jsx(OnboardingProvider, { callbacks: onboardingCallbacks, children: _jsx(HomeSelectionProvider, { children: _jsx(LoadedIssueProvider, { children: _jsx(JobRegistryProvider, { children: _jsx(AppShell, {}) }) }) }) }) }) }) }) }));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Casca visual do app: breadcrumb fixo + aviso de saída (quando armado) +
|
|
111
|
+
* tela atual. Só existe dentro dos providers de `App` — depende de
|
|
112
|
+
* `useTheme()`/`useNavigation()`.
|
|
113
|
+
*/
|
|
114
|
+
function AppShell() {
|
|
115
|
+
const { exit } = useApp();
|
|
116
|
+
const { current, pop, popTo, stack } = useNavigation();
|
|
117
|
+
const { reAuth, abortReAuth } = useOnboarding();
|
|
118
|
+
const theme = useTheme();
|
|
119
|
+
const { armed } = useExitGuard(exit);
|
|
120
|
+
// Full-screen (#190): o app ocupa a altura TODA do terminal. `minHeight` (não
|
|
121
|
+
// `height` fixo) evita a amostragem do Yoga em listas longas (achado B2). As
|
|
122
|
+
// telas usam um espaçador (`flexGrow`) antes dos atalhos para ancorá-los no
|
|
123
|
+
// rodapé, estilo nano/nvim/tmux.
|
|
124
|
+
const rows = useTerminalHeight();
|
|
125
|
+
// Handler ESTÁVEL (refs + useCallback): identidade nova a cada render faz o
|
|
126
|
+
// useInput des/re-subscrever no efeito pós-commit, abrindo janelas em que
|
|
127
|
+
// teclas rápidas (Esc em sequência) se perdem — mesma classe de bug
|
|
128
|
+
// corrigida no TextInput.
|
|
129
|
+
const exitRef = useRef(exit);
|
|
130
|
+
exitRef.current = exit;
|
|
131
|
+
const popRef = useRef(pop);
|
|
132
|
+
popRef.current = pop;
|
|
133
|
+
const popToRef = useRef(popTo);
|
|
134
|
+
popToRef.current = popTo;
|
|
135
|
+
const currentRef = useRef(current);
|
|
136
|
+
currentRef.current = current;
|
|
137
|
+
const reAuthRef = useRef(reAuth);
|
|
138
|
+
reAuthRef.current = reAuth;
|
|
139
|
+
const abortReAuthRef = useRef(abortReAuth);
|
|
140
|
+
abortReAuthRef.current = abortReAuth;
|
|
141
|
+
const handleGlobalInput = useCallback((input, key) => {
|
|
142
|
+
if (input === 'q') {
|
|
143
|
+
exitRef.current();
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (key.escape) {
|
|
147
|
+
// M2-07 (#30): a busca da home intercepta Esc para fechar o campo em
|
|
148
|
+
// vez de desempilhar a tela — consultado ANTES de qualquer outra
|
|
149
|
+
// semântica de Esc (pop/abandono de re-auth).
|
|
150
|
+
if (consumeEscapeInterceptor()) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Fix do review #119: Esc numa tela de onboarding com um re-auth ativo
|
|
154
|
+
// é ABANDONO explícito — aborta as pendências (rejeita o(s) `guard()`
|
|
155
|
+
// original(is) com `ReAuthAbortedError`) e volta direto à tela de
|
|
156
|
+
// origem do 401, em vez do `pop()` simples de sempre.
|
|
157
|
+
const action = resolveEscapeAction(currentRef.current, reAuthRef.current);
|
|
158
|
+
if (action.kind === 'abort-reauth') {
|
|
159
|
+
abortReAuthRef.current(new ReAuthAbortedError());
|
|
160
|
+
popToRef.current(action.origin);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
popRef.current();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (input === '/') {
|
|
167
|
+
// Reason: reservado para a busca (M2-07, ainda sem tela) — no-op
|
|
168
|
+
// proposital para já travar a tecla e evitar que outra tela reaproveite
|
|
169
|
+
// "/" para algo diferente antes da tela de busca existir.
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}, []);
|
|
173
|
+
useInput(handleGlobalInput);
|
|
174
|
+
const Screen = SCREENS[current].component;
|
|
175
|
+
// Full-screen = alt-screen (runTui) + fundo/texto via OSC (cobrem a tela toda)
|
|
176
|
+
// + este container com a ALTURA do terminal (`minHeight`). O breadcrumb fica no
|
|
177
|
+
// topo e a tela ocupa o resto (`flexGrow`); cada tela ancora seus atalhos no
|
|
178
|
+
// rodapé com um espaçador `flexGrow` (estilo nano/nvim/tmux).
|
|
179
|
+
return (_jsxs(Box, { flexDirection: "column", minHeight: rows, children: [_jsx(Breadcrumb, { stack: stack }), armed ? (_jsx(Box, { paddingX: 1, marginBottom: 1, children: _jsxs(Text, { color: theme.warning, children: [symbols.warning, " Pressione Ctrl+C de novo para sair."] }) })) : null, _jsx(Box, { flexGrow: 1, flexDirection: "column", children: _jsx(Screen, {}) })] }));
|
|
180
|
+
}
|