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.
Files changed (229) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/dist/bundle/index.d.ts +5 -0
  4. package/dist/bundle/index.js +5 -0
  5. package/dist/bundle/json.d.ts +90 -0
  6. package/dist/bundle/json.js +266 -0
  7. package/dist/bundle/markdown.d.ts +75 -0
  8. package/dist/bundle/markdown.js +294 -0
  9. package/dist/bundle/search-list.d.ts +43 -0
  10. package/dist/bundle/search-list.js +53 -0
  11. package/dist/bundle/stable-stringify.d.ts +26 -0
  12. package/dist/bundle/stable-stringify.js +50 -0
  13. package/dist/cache/contract.d.ts +157 -0
  14. package/dist/cache/contract.js +0 -0
  15. package/dist/cache/disk-index.d.ts +82 -0
  16. package/dist/cache/disk-index.js +220 -0
  17. package/dist/cache/disk.d.ts +133 -0
  18. package/dist/cache/disk.js +313 -0
  19. package/dist/cache/gc.d.ts +78 -0
  20. package/dist/cache/gc.js +123 -0
  21. package/dist/cache/get-or-compute.d.ts +36 -0
  22. package/dist/cache/get-or-compute.js +52 -0
  23. package/dist/cache/index.d.ts +9 -0
  24. package/dist/cache/index.js +8 -0
  25. package/dist/cache/keys.d.ts +76 -0
  26. package/dist/cache/keys.js +78 -0
  27. package/dist/cache/memory.d.ts +48 -0
  28. package/dist/cache/memory.js +110 -0
  29. package/dist/cache-first.d.ts +127 -0
  30. package/dist/cache-first.js +227 -0
  31. package/dist/client/errors.d.ts +33 -0
  32. package/dist/client/errors.js +49 -0
  33. package/dist/client/http.d.ts +110 -0
  34. package/dist/client/http.js +207 -0
  35. package/dist/client/index.d.ts +5 -0
  36. package/dist/client/index.js +5 -0
  37. package/dist/client/issues.d.ts +71 -0
  38. package/dist/client/issues.js +100 -0
  39. package/dist/client/search.d.ts +58 -0
  40. package/dist/client/search.js +81 -0
  41. package/dist/config/credentials.d.ts +247 -0
  42. package/dist/config/credentials.js +427 -0
  43. package/dist/config/doctor.d.ts +123 -0
  44. package/dist/config/doctor.js +260 -0
  45. package/dist/config/index.d.ts +6 -0
  46. package/dist/config/index.js +6 -0
  47. package/dist/config/keyring.d.ts +96 -0
  48. package/dist/config/keyring.js +158 -0
  49. package/dist/config/login.d.ts +97 -0
  50. package/dist/config/login.js +189 -0
  51. package/dist/config/settings.d.ts +94 -0
  52. package/dist/config/settings.js +140 -0
  53. package/dist/contract.d.ts +173 -0
  54. package/dist/contract.js +27 -0
  55. package/dist/core.d.ts +1 -0
  56. package/dist/core.js +8 -0
  57. package/dist/extract/audio-extractor.d.ts +105 -0
  58. package/dist/extract/audio-extractor.js +156 -0
  59. package/dist/extract/audio.d.ts +126 -0
  60. package/dist/extract/audio.js +184 -0
  61. package/dist/extract/dispatcher.d.ts +132 -0
  62. package/dist/extract/dispatcher.js +115 -0
  63. package/dist/extract/download.d.ts +111 -0
  64. package/dist/extract/download.js +261 -0
  65. package/dist/extract/duration.d.ts +106 -0
  66. package/dist/extract/duration.js +148 -0
  67. package/dist/extract/ffmpeg.d.ts +56 -0
  68. package/dist/extract/ffmpeg.js +95 -0
  69. package/dist/extract/gguf.d.ts +137 -0
  70. package/dist/extract/gguf.js +215 -0
  71. package/dist/extract/index.d.ts +19 -0
  72. package/dist/extract/index.js +19 -0
  73. package/dist/extract/magic.d.ts +80 -0
  74. package/dist/extract/magic.js +282 -0
  75. package/dist/extract/ooxml.d.ts +131 -0
  76. package/dist/extract/ooxml.js +336 -0
  77. package/dist/extract/pdf.d.ts +147 -0
  78. package/dist/extract/pdf.js +322 -0
  79. package/dist/extract/queue.d.ts +167 -0
  80. package/dist/extract/queue.js +217 -0
  81. package/dist/extract/subprocess.d.ts +145 -0
  82. package/dist/extract/subprocess.js +181 -0
  83. package/dist/extract/tesseract.d.ts +153 -0
  84. package/dist/extract/tesseract.js +321 -0
  85. package/dist/extract/video-extractor.d.ts +84 -0
  86. package/dist/extract/video-extractor.js +89 -0
  87. package/dist/extract/video.d.ts +198 -0
  88. package/dist/extract/video.js +418 -0
  89. package/dist/extract/which.d.ts +63 -0
  90. package/dist/extract/which.js +72 -0
  91. package/dist/extract/whisper-extract.d.ts +211 -0
  92. package/dist/extract/whisper-extract.js +323 -0
  93. package/dist/extract/whisper.d.ts +50 -0
  94. package/dist/extract/whisper.js +67 -0
  95. package/dist/extract/zip.d.ts +50 -0
  96. package/dist/extract/zip.js +165 -0
  97. package/dist/extract-issue-attachments.d.ts +82 -0
  98. package/dist/extract-issue-attachments.js +156 -0
  99. package/dist/fetch-attachment-text.d.ts +113 -0
  100. package/dist/fetch-attachment-text.js +155 -0
  101. package/dist/fetch-issue-bundle.d.ts +81 -0
  102. package/dist/fetch-issue-bundle.js +93 -0
  103. package/dist/fetch-issue-search.d.ts +74 -0
  104. package/dist/fetch-issue-search.js +120 -0
  105. package/dist/index.d.ts +21 -0
  106. package/dist/index.js +67 -0
  107. package/dist/normalize/collections.d.ts +52 -0
  108. package/dist/normalize/collections.js +170 -0
  109. package/dist/normalize/helpers.d.ts +35 -0
  110. package/dist/normalize/helpers.js +59 -0
  111. package/dist/normalize/index.d.ts +2 -0
  112. package/dist/normalize/index.js +2 -0
  113. package/dist/normalize/issue.d.ts +34 -0
  114. package/dist/normalize/issue.js +154 -0
  115. package/dist/surfaces/cli/commands.d.ts +61 -0
  116. package/dist/surfaces/cli/commands.js +262 -0
  117. package/dist/surfaces/cli/main.d.ts +32 -0
  118. package/dist/surfaces/cli/main.js +210 -0
  119. package/dist/surfaces/cli/prompts.d.ts +66 -0
  120. package/dist/surfaces/cli/prompts.js +147 -0
  121. package/dist/surfaces/cli/tty.d.ts +27 -0
  122. package/dist/surfaces/cli/tty.js +35 -0
  123. package/dist/surfaces/cli/types.d.ts +39 -0
  124. package/dist/surfaces/cli/types.js +7 -0
  125. package/dist/surfaces/mcp/server.d.ts +171 -0
  126. package/dist/surfaces/mcp/server.js +427 -0
  127. package/dist/surfaces/tui/app.d.ts +55 -0
  128. package/dist/surfaces/tui/app.js +180 -0
  129. package/dist/surfaces/tui/attachment-status.d.ts +79 -0
  130. package/dist/surfaces/tui/attachment-status.js +113 -0
  131. package/dist/surfaces/tui/components/breadcrumb.d.ts +7 -0
  132. package/dist/surfaces/tui/components/breadcrumb.js +31 -0
  133. package/dist/surfaces/tui/components/gradient-text.d.ts +23 -0
  134. package/dist/surfaces/tui/components/gradient-text.js +75 -0
  135. package/dist/surfaces/tui/components/scroll-view.d.ts +25 -0
  136. package/dist/surfaces/tui/components/scroll-view.js +77 -0
  137. package/dist/surfaces/tui/components/spinner.d.ts +12 -0
  138. package/dist/surfaces/tui/components/spinner.js +39 -0
  139. package/dist/surfaces/tui/components/text-input.d.ts +45 -0
  140. package/dist/surfaces/tui/components/text-input.js +114 -0
  141. package/dist/surfaces/tui/format-file-size.d.ts +24 -0
  142. package/dist/surfaces/tui/format-file-size.js +43 -0
  143. package/dist/surfaces/tui/glyphs.d.ts +47 -0
  144. package/dist/surfaces/tui/glyphs.js +84 -0
  145. package/dist/surfaces/tui/hooks/use-auth-guard.d.ts +39 -0
  146. package/dist/surfaces/tui/hooks/use-auth-guard.js +135 -0
  147. package/dist/surfaces/tui/hooks/use-doctor-status.d.ts +64 -0
  148. package/dist/surfaces/tui/hooks/use-doctor-status.js +123 -0
  149. package/dist/surfaces/tui/hooks/use-escape-interceptor.d.ts +25 -0
  150. package/dist/surfaces/tui/hooks/use-escape-interceptor.js +65 -0
  151. package/dist/surfaces/tui/hooks/use-exit-guard.d.ts +18 -0
  152. package/dist/surfaces/tui/hooks/use-exit-guard.js +66 -0
  153. package/dist/surfaces/tui/hooks/use-export-bundle.d.ts +62 -0
  154. package/dist/surfaces/tui/hooks/use-export-bundle.js +100 -0
  155. package/dist/surfaces/tui/hooks/use-issue-detail.d.ts +61 -0
  156. package/dist/surfaces/tui/hooks/use-issue-detail.js +132 -0
  157. package/dist/surfaces/tui/hooks/use-issue-search.d.ts +71 -0
  158. package/dist/surfaces/tui/hooks/use-issue-search.js +168 -0
  159. package/dist/surfaces/tui/hooks/use-list-navigation.d.ts +44 -0
  160. package/dist/surfaces/tui/hooks/use-list-navigation.js +82 -0
  161. package/dist/surfaces/tui/hooks/use-media-binaries.d.ts +24 -0
  162. package/dist/surfaces/tui/hooks/use-media-binaries.js +44 -0
  163. package/dist/surfaces/tui/hooks/use-my-issues.d.ts +66 -0
  164. package/dist/surfaces/tui/hooks/use-my-issues.js +151 -0
  165. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.d.ts +11 -0
  166. package/dist/surfaces/tui/hooks/use-onboarding-callbacks.js +104 -0
  167. package/dist/surfaces/tui/hooks/use-terminal-width.d.ts +52 -0
  168. package/dist/surfaces/tui/hooks/use-terminal-width.js +90 -0
  169. package/dist/surfaces/tui/index.d.ts +50 -0
  170. package/dist/surfaces/tui/index.js +158 -0
  171. package/dist/surfaces/tui/instance.d.ts +37 -0
  172. package/dist/surfaces/tui/instance.js +36 -0
  173. package/dist/surfaces/tui/job-registry.d.ts +113 -0
  174. package/dist/surfaces/tui/job-registry.js +123 -0
  175. package/dist/surfaces/tui/job-status.d.ts +45 -0
  176. package/dist/surfaces/tui/job-status.js +81 -0
  177. package/dist/surfaces/tui/navigation.d.ts +74 -0
  178. package/dist/surfaces/tui/navigation.js +87 -0
  179. package/dist/surfaces/tui/palettes.d.ts +38 -0
  180. package/dist/surfaces/tui/palettes.js +244 -0
  181. package/dist/surfaces/tui/screen.d.ts +30 -0
  182. package/dist/surfaces/tui/screen.js +51 -0
  183. package/dist/surfaces/tui/screens/about.d.ts +2 -0
  184. package/dist/surfaces/tui/screens/about.js +35 -0
  185. package/dist/surfaces/tui/screens/appearance.d.ts +2 -0
  186. package/dist/surfaces/tui/screens/appearance.js +74 -0
  187. package/dist/surfaces/tui/screens/config.d.ts +2 -0
  188. package/dist/surfaces/tui/screens/config.js +82 -0
  189. package/dist/surfaces/tui/screens/doctor.d.ts +2 -0
  190. package/dist/surfaces/tui/screens/doctor.js +109 -0
  191. package/dist/surfaces/tui/screens/export.d.ts +2 -0
  192. package/dist/surfaces/tui/screens/export.js +168 -0
  193. package/dist/surfaces/tui/screens/home-selection.d.ts +70 -0
  194. package/dist/surfaces/tui/screens/home-selection.js +80 -0
  195. package/dist/surfaces/tui/screens/home.d.ts +2 -0
  196. package/dist/surfaces/tui/screens/home.js +200 -0
  197. package/dist/surfaces/tui/screens/issue-detail.d.ts +6 -0
  198. package/dist/surfaces/tui/screens/issue-detail.js +182 -0
  199. package/dist/surfaces/tui/screens/jobs.d.ts +7 -0
  200. package/dist/surfaces/tui/screens/jobs.js +89 -0
  201. package/dist/surfaces/tui/screens/loaded-issue-context.d.ts +49 -0
  202. package/dist/surfaces/tui/screens/loaded-issue-context.js +57 -0
  203. package/dist/surfaces/tui/screens/onboarding/api-key.d.ts +2 -0
  204. package/dist/surfaces/tui/screens/onboarding/api-key.js +69 -0
  205. package/dist/surfaces/tui/screens/onboarding/login.d.ts +2 -0
  206. package/dist/surfaces/tui/screens/onboarding/login.js +50 -0
  207. package/dist/surfaces/tui/screens/onboarding/mode.d.ts +2 -0
  208. package/dist/surfaces/tui/screens/onboarding/mode.js +42 -0
  209. package/dist/surfaces/tui/screens/onboarding/onboarding-context.d.ts +221 -0
  210. package/dist/surfaces/tui/screens/onboarding/onboarding-context.js +131 -0
  211. package/dist/surfaces/tui/screens/onboarding/success.d.ts +2 -0
  212. package/dist/surfaces/tui/screens/onboarding/success.js +41 -0
  213. package/dist/surfaces/tui/screens/onboarding/url.d.ts +24 -0
  214. package/dist/surfaces/tui/screens/onboarding/url.js +84 -0
  215. package/dist/surfaces/tui/screens/onboarding/validating.d.ts +1 -0
  216. package/dist/surfaces/tui/screens/onboarding/validating.js +86 -0
  217. package/dist/surfaces/tui/screens/welcome.d.ts +6 -0
  218. package/dist/surfaces/tui/screens/welcome.js +95 -0
  219. package/dist/surfaces/tui/status-color.d.ts +21 -0
  220. package/dist/surfaces/tui/status-color.js +23 -0
  221. package/dist/surfaces/tui/symbols.d.ts +231 -0
  222. package/dist/surfaces/tui/symbols.js +14 -0
  223. package/dist/surfaces/tui/terminal-colors.d.ts +29 -0
  224. package/dist/surfaces/tui/terminal-colors.js +39 -0
  225. package/dist/surfaces/tui/theme.d.ts +156 -0
  226. package/dist/surfaces/tui/theme.js +86 -0
  227. package/dist/surfaces/tui/truncate.d.ts +31 -0
  228. package/dist/surfaces/tui/truncate.js +81 -0
  229. package/package.json +93 -0
@@ -0,0 +1,427 @@
1
+ /**
2
+ * Credential store da api_key por instância (M1-08).
3
+ *
4
+ * Persiste a api_key em um arquivo JSON `{ "<instance>": { "apiKey": "..." } }`
5
+ * com permissão `0600` dentro de um diretório `0700` — uma entrada por URL base
6
+ * normalizada, suportando múltiplas instâncias Redmine. O caminho padrão vem de
7
+ * `env-paths` (diretório de config do usuário).
8
+ *
9
+ * A resolução segue a cascata do ADR-003: keychain → arquivo → variável de
10
+ * ambiente `REDMINE_API_KEY` (o env é sempre aceito, para CI/MCP headless). A
11
+ * interface {@link CredentialStore} (get/set/delete) é comum a todas as
12
+ * implementações; a {@link MigratingCredentialCascade} as consulta em ordem e,
13
+ * quando o keychain está disponível, migra a chave do arquivo para ele.
14
+ *
15
+ * A api_key nunca é logada e nunca aparece em mensagens de erro — os erros de
16
+ * permissão carregam apenas caminhos e o comando `chmod` a ser executado. A
17
+ * verificação de permissão POSIX fica atrás de `process.platform !== 'win32'`
18
+ * porque o Windows não tem o mesmo modelo de bits `0600` (o hardening real do
19
+ * Windows é o M5-08).
20
+ */
21
+ import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
22
+ import { dirname, join } from 'node:path';
23
+ import envPaths from 'env-paths';
24
+ import { KeyringCredentialStore } from './keyring.js';
25
+ /** Logger no-op: usado quando nenhum logger é injetado. */
26
+ const noopLogger = { warn: () => undefined };
27
+ /** Permissão do diretório: `rwx` só para o dono. */
28
+ const DIR_MODE = 0o700;
29
+ /** Permissão do arquivo: `rw` só para o dono. */
30
+ const FILE_MODE = 0o600;
31
+ /** Nome do arquivo de credenciais no diretório de config. */
32
+ const CREDENTIALS_FILENAME = 'credentials.json';
33
+ /** Variável de ambiente consultada na cascata (fallback headless). */
34
+ const DEFAULT_ENV_VAR = 'REDMINE_API_KEY';
35
+ // Windows não tem o modelo POSIX de bits 0600/0700; o hardening real é o M5-08.
36
+ const PERMISSIONS_ENFORCED = process.platform !== 'win32';
37
+ /** Erro do credential store — sempre construído sem expor a api_key. */
38
+ export class CredentialStoreError extends Error {
39
+ constructor(message) {
40
+ super(message);
41
+ this.name = new.target.name;
42
+ }
43
+ }
44
+ /**
45
+ * Normaliza a URL base de uma instância para uma chave estável.
46
+ *
47
+ * Aplica minúsculas (protocolo e host), remove a porta padrão (443 p/ https, 80
48
+ * p/ http), remove a barra final e preserva o subpath. Mantém a mesma ideia do
49
+ * futuro `instance_hash`: formas equivalentes colapsam na mesma chave.
50
+ *
51
+ * @param instance - URL base da instância (ex.: `https://Redmine.Example/`).
52
+ * @returns A URL normalizada (ex.: `https://redmine.example`).
53
+ * @throws {TypeError} Se `instance` não for uma URL válida.
54
+ * @example
55
+ * normalizeInstanceUrl('HTTPS://Redmine.Example:443/'); // 'https://redmine.example'
56
+ */
57
+ export function normalizeInstanceUrl(instance) {
58
+ const parsed = new URL(instance);
59
+ const protocol = parsed.protocol.toLowerCase();
60
+ const host = parsed.hostname.toLowerCase();
61
+ const isDefaultPort = (protocol === 'https:' && parsed.port === '443') ||
62
+ (protocol === 'http:' && parsed.port === '80');
63
+ const port = parsed.port !== '' && !isDefaultPort ? `:${parsed.port}` : '';
64
+ const path = parsed.pathname.replace(/\/+$/, '');
65
+ return `${protocol}//${host}${port}${path}`;
66
+ }
67
+ /** Formata bits de permissão como octal de 3 dígitos (ex.: `644`). */
68
+ function toOctal(mode) {
69
+ return (mode & 0o777).toString(8).padStart(3, '0');
70
+ }
71
+ /** Verifica se um erro do fs é "arquivo não encontrado" (ENOENT). */
72
+ function isNotFound(cause) {
73
+ return (typeof cause === 'object' &&
74
+ cause !== null &&
75
+ cause.code === 'ENOENT');
76
+ }
77
+ /** Faz o parse defensivo do arquivo, descartando entradas malformadas. */
78
+ function parseCredentials(raw, filePath) {
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(raw);
82
+ }
83
+ catch {
84
+ // JSON inválido não pode ser ignorado em silêncio (a key "sumiria"):
85
+ // erro tipado orienta a correção sem expor o conteúdo do arquivo.
86
+ throw new CredentialStoreError(`Arquivo de credenciais corrompido (JSON inválido): ${filePath}. ` +
87
+ 'Corrija ou remova o arquivo e faça login novamente.');
88
+ }
89
+ if (typeof parsed !== 'object' || parsed === null) {
90
+ return {};
91
+ }
92
+ const result = {};
93
+ for (const [instance, entry] of Object.entries(parsed)) {
94
+ if (typeof entry === 'object' && entry !== null) {
95
+ const apiKey = entry.apiKey;
96
+ if (typeof apiKey === 'string' && apiKey.length > 0) {
97
+ result[instance] = { apiKey };
98
+ }
99
+ }
100
+ }
101
+ return result;
102
+ }
103
+ /** Caminho padrão do arquivo de credenciais (diretório de config do usuário). */
104
+ export function defaultCredentialsPath() {
105
+ return join(envPaths('redmine-context').config, CREDENTIALS_FILENAME);
106
+ }
107
+ /**
108
+ * Implementação de {@link CredentialStore} baseada em arquivo `0600` num
109
+ * diretório `0700`. Verifica as permissões ao ler (boot) e as reaplica ao
110
+ * escrever, independentemente do umask.
111
+ */
112
+ export class FileCredentialStore {
113
+ filePath;
114
+ /** @param options - Ver {@link FileCredentialStoreOptions}. */
115
+ constructor(options = {}) {
116
+ this.filePath = options.filePath ?? defaultCredentialsPath();
117
+ }
118
+ /** @inheritdoc */
119
+ async get(instance) {
120
+ const store = await this.read();
121
+ if (store === undefined) {
122
+ return undefined;
123
+ }
124
+ return store[normalizeInstanceUrl(instance)]?.apiKey;
125
+ }
126
+ /** @inheritdoc */
127
+ async set(instance, apiKey) {
128
+ const store = (await this.read()) ?? {};
129
+ store[normalizeInstanceUrl(instance)] = { apiKey };
130
+ await this.write(store);
131
+ }
132
+ /** @inheritdoc */
133
+ async delete(instance) {
134
+ const store = await this.read();
135
+ if (store === undefined) {
136
+ return;
137
+ }
138
+ const key = normalizeInstanceUrl(instance);
139
+ if (!(key in store)) {
140
+ return;
141
+ }
142
+ delete store[key];
143
+ await this.write(store);
144
+ }
145
+ /** Lê e valida o arquivo; `undefined` se ainda não existir. */
146
+ async read() {
147
+ let mode;
148
+ try {
149
+ mode = (await stat(this.filePath)).mode;
150
+ }
151
+ catch (cause) {
152
+ if (isNotFound(cause)) {
153
+ return undefined;
154
+ }
155
+ throw cause;
156
+ }
157
+ this.assertSecureMode(mode);
158
+ return parseCredentials(await readFile(this.filePath, 'utf8'), this.filePath);
159
+ }
160
+ /** Escreve o arquivo garantindo dir `0700` e arquivo `0600`. */
161
+ async write(store) {
162
+ const dir = dirname(this.filePath);
163
+ await mkdir(dir, { recursive: true, mode: DIR_MODE });
164
+ if (PERMISSIONS_ENFORCED) {
165
+ // Reaplica o modo: `mkdir`/`writeFile` sofrem influência do umask.
166
+ await chmod(dir, DIR_MODE);
167
+ }
168
+ await writeFile(this.filePath, `${JSON.stringify(store, null, 2)}\n`, { mode: FILE_MODE });
169
+ if (PERMISSIONS_ENFORCED) {
170
+ await chmod(this.filePath, FILE_MODE);
171
+ }
172
+ }
173
+ /** Falha com erro acionável se as permissões do arquivo estiverem erradas. */
174
+ assertSecureMode(mode) {
175
+ if (!PERMISSIONS_ENFORCED) {
176
+ return;
177
+ }
178
+ const actual = mode & 0o777;
179
+ if (actual !== FILE_MODE) {
180
+ throw new CredentialStoreError(`O arquivo de credenciais ${this.filePath} tem permissões ${toOctal(actual)}, ` +
181
+ `mas o esperado é 600 (somente o dono pode ler/escrever). ` +
182
+ `Corrija com: chmod 600 ${this.filePath}`);
183
+ }
184
+ }
185
+ }
186
+ /**
187
+ * Implementação de {@link CredentialStore} somente-leitura sobre uma variável
188
+ * de ambiente. É o fallback headless (CI/MCP) da cascata: `get` devolve a chave
189
+ * do env para qualquer instância; `set`/`delete` lançam.
190
+ */
191
+ export class EnvCredentialStore {
192
+ varName;
193
+ env;
194
+ /** @param options - Ver {@link EnvCredentialStoreOptions}. */
195
+ constructor(options = {}) {
196
+ this.varName = options.varName ?? DEFAULT_ENV_VAR;
197
+ this.env = options.env ?? process.env;
198
+ }
199
+ /** @inheritdoc */
200
+ async get() {
201
+ const value = this.env[this.varName];
202
+ return value !== undefined && value.length > 0 ? value : undefined;
203
+ }
204
+ /** Somente leitura: sempre lança. */
205
+ async set() {
206
+ throw new CredentialStoreError(`A variável de ambiente ${this.varName} é somente leitura e não pode ser gravada.`);
207
+ }
208
+ /** Somente leitura: sempre lança. */
209
+ async delete() {
210
+ throw new CredentialStoreError(`A variável de ambiente ${this.varName} é somente leitura e não pode ser removida.`);
211
+ }
212
+ }
213
+ /**
214
+ * Cascata de {@link CredentialStore}: `get` consulta os stores em ordem e
215
+ * devolve o primeiro valor encontrado; escritas vão para o primeiro store (o
216
+ * gravável). A ordem M1 é arquivo → env.
217
+ */
218
+ export class CascadingCredentialStore {
219
+ stores;
220
+ /**
221
+ * @param stores - Stores em ordem de prioridade (o primeiro é o gravável).
222
+ * @throws {CredentialStoreError} Se a lista estiver vazia.
223
+ */
224
+ constructor(stores) {
225
+ if (stores.length === 0) {
226
+ throw new CredentialStoreError('A cascata de credenciais precisa de ao menos um store.');
227
+ }
228
+ this.stores = stores;
229
+ }
230
+ /** @inheritdoc */
231
+ async get(instance) {
232
+ for (const store of this.stores) {
233
+ const value = await store.get(instance);
234
+ if (value !== undefined) {
235
+ return value;
236
+ }
237
+ }
238
+ return undefined;
239
+ }
240
+ /** @inheritdoc */
241
+ async set(instance, apiKey) {
242
+ await this.writableStore().set(instance, apiKey);
243
+ }
244
+ /** @inheritdoc */
245
+ async delete(instance) {
246
+ await this.writableStore().delete(instance);
247
+ }
248
+ /** O primeiro store da cascata — destino das escritas. */
249
+ writableStore() {
250
+ const first = this.stores[0];
251
+ if (first === undefined) {
252
+ throw new CredentialStoreError('A cascata de credenciais está vazia.');
253
+ }
254
+ return first;
255
+ }
256
+ }
257
+ /**
258
+ * Cascata de credenciais M2 com migração para o keychain (ADR-003).
259
+ *
260
+ * A resolução (`get`) segue a ordem keychain → arquivo `0600` → env; o primeiro
261
+ * valor encontrado vence. Quando a chave vem do ARQUIVO e o keychain está
262
+ * DISPONÍVEL, a chave é migrada — gravada no keychain e só então removida do
263
+ * arquivo (a remoção é condicionada à confirmação da gravação, evitando perda da
264
+ * credencial se o keychain degradar) — com um único aviso informativo por
265
+ * cascata. As escritas (`set`) preferem o keychain quando disponível, com
266
+ * fallback para o arquivo; o env é somente leitura. A api_key nunca é logada.
267
+ */
268
+ export class MigratingCredentialCascade {
269
+ keyring;
270
+ file;
271
+ env;
272
+ logger;
273
+ /** Garante que o aviso de migração seja emitido uma única vez por cascata. */
274
+ migrationWarned = false;
275
+ /**
276
+ * @param keyring - Store do keychain nativo (fonte prioritária e destino da migração).
277
+ * @param file - Store de arquivo `0600` (fallback e origem da migração).
278
+ * @param env - Store de ambiente somente leitura (fallback headless).
279
+ * @param logger - Logger do aviso único de migração; default no-op.
280
+ */
281
+ constructor(keyring, file, env, logger = noopLogger) {
282
+ this.keyring = keyring;
283
+ this.file = file;
284
+ this.env = env;
285
+ this.logger = logger;
286
+ }
287
+ /** @inheritdoc */
288
+ async get(instance) {
289
+ const fromKeyring = await this.keyring.get(instance);
290
+ if (fromKeyring !== undefined) {
291
+ return fromKeyring;
292
+ }
293
+ const fromFile = await this.file.get(instance);
294
+ if (fromFile !== undefined) {
295
+ await this.migrateFromFile(instance, fromFile);
296
+ return fromFile;
297
+ }
298
+ return this.env.get(instance);
299
+ }
300
+ /** @inheritdoc */
301
+ async set(instance, apiKey) {
302
+ if (await this.keyring.isAvailable()) {
303
+ await this.keyring.set(instance, apiKey);
304
+ // Só considera o keychain o dono da credencial se a gravação se confirmou;
305
+ // caso contrário (degradação de runtime) persiste no arquivo.
306
+ if ((await this.keyring.get(instance)) === apiKey) {
307
+ return;
308
+ }
309
+ }
310
+ await this.file.set(instance, apiKey);
311
+ }
312
+ /** @inheritdoc */
313
+ async delete(instance) {
314
+ // Remove de ambas as fontes graváveis para não deixar credencial órfã.
315
+ await this.keyring.delete(instance);
316
+ await this.file.delete(instance);
317
+ }
318
+ /**
319
+ * Identifica QUAL fonte resolveria a credencial da instância (keychain →
320
+ * arquivo → env), sem expor a api_key e SEM disparar a migração
321
+ * arquivo→keychain feita por {@link get} — diagnóstico somente-leitura
322
+ * usado pela tela `doctor` da TUI (#35), que só precisa mostrar o método em
323
+ * uso, nunca o segredo.
324
+ *
325
+ * @param instance - URL base da instância (será normalizada pelos stores).
326
+ * @returns A fonte em uso, ou `'none'` se nenhuma tiver a credencial.
327
+ */
328
+ async describeSource(instance) {
329
+ if ((await this.keyring.get(instance)) !== undefined) {
330
+ return 'keyring';
331
+ }
332
+ if ((await this.file.get(instance)) !== undefined) {
333
+ return 'file';
334
+ }
335
+ if ((await this.env.get(instance)) !== undefined) {
336
+ return 'env';
337
+ }
338
+ return 'none';
339
+ }
340
+ /**
341
+ * Migra a chave do arquivo para o keychain quando este está disponível. A
342
+ * remoção do arquivo só ocorre após confirmar a gravação no keychain, evitando
343
+ * perda da credencial se a escrita degradar. Emite um único aviso, sem expor a
344
+ * api_key.
345
+ *
346
+ * @param instance - URL base da instância (será normalizada pelos stores).
347
+ * @param apiKey - Chave lida do arquivo a ser migrada.
348
+ */
349
+ async migrateFromFile(instance, apiKey) {
350
+ if (!(await this.keyring.isAvailable())) {
351
+ return;
352
+ }
353
+ await this.keyring.set(instance, apiKey);
354
+ if ((await this.keyring.get(instance)) !== apiKey) {
355
+ return;
356
+ }
357
+ await this.file.delete(instance);
358
+ if (!this.migrationWarned) {
359
+ this.migrationWarned = true;
360
+ this.logger.warn(`Credencial de ${instance} migrada do arquivo local para o keychain do SO.`);
361
+ }
362
+ }
363
+ }
364
+ /**
365
+ * Monta a cascata M2 (keychain → arquivo → env `REDMINE_API_KEY`), com migração
366
+ * automática do arquivo para o keychain na resolução (ADR-003).
367
+ *
368
+ * @param options - Ver {@link CredentialCascadeOptions}.
369
+ * @returns Uma {@link MigratingCredentialCascade} pronta para uso.
370
+ */
371
+ /**
372
+ * Singleton do store default do keychain, memoizado por processo: servidores
373
+ * de longa duração (MCP) montam uma cascata por request, e recriar o store a
374
+ * cada chamada repetiria o aviso de "keychain indisponível" a cada tool call
375
+ * (o dedupe do aviso é por instância).
376
+ */
377
+ let defaultKeyringStore;
378
+ export function createCredentialCascade(options = {}) {
379
+ const logger = options.logger ?? noopLogger;
380
+ const customKeyring = options.keyringService !== undefined || options.keyringLoader !== undefined;
381
+ const keyring = customKeyring
382
+ ? new KeyringCredentialStore({
383
+ logger,
384
+ ...(options.keyringService !== undefined ? { service: options.keyringService } : {}),
385
+ ...(options.keyringLoader !== undefined ? { loader: options.keyringLoader } : {}),
386
+ })
387
+ : (defaultKeyringStore ??= new KeyringCredentialStore({ logger }));
388
+ const file = new FileCredentialStore(options.filePath !== undefined ? { filePath: options.filePath } : {});
389
+ // allowEnvFallback === false → env store sobre um ambiente VAZIO: nunca resolve
390
+ // a `REDMINE_API_KEY` (instance-agnóstica). Usado quando a URL veio de fonte
391
+ // mutável (settings.json persistido, #187) — só credencial pinada é aceita.
392
+ const envSource = options.allowEnvFallback === false ? {} : (options.env ?? process.env);
393
+ const env = new EnvCredentialStore({
394
+ ...(options.envVarName !== undefined ? { varName: options.envVarName } : {}),
395
+ env: envSource,
396
+ });
397
+ return new MigratingCredentialCascade(keyring, file, env, logger);
398
+ }
399
+ /**
400
+ * Resolve a api_key de uma instância pela cascata M2 (keychain → arquivo → env),
401
+ * migrando do arquivo para o keychain quando este estiver disponível.
402
+ *
403
+ * @param instance - URL base da instância (será normalizada).
404
+ * @param options - Ver {@link CredentialCascadeOptions}.
405
+ * @returns A api_key resolvida, ou `undefined` se nenhuma fonte a tiver.
406
+ * @example
407
+ * const apiKey = await resolveApiKey('https://redmine.example');
408
+ */
409
+ export async function resolveApiKey(instance, options = {}) {
410
+ return createCredentialCascade(options).get(instance);
411
+ }
412
+ /**
413
+ * Identifica a fonte que resolveria a credencial de uma instância na cascata
414
+ * M2 (keychain → arquivo → env), SEM expor a api_key e sem migrar do arquivo
415
+ * para o keychain — ver {@link MigratingCredentialCascade.describeSource}.
416
+ * Usada pela tela `doctor` da TUI (#35) para relatar o método de credencial
417
+ * em uso.
418
+ *
419
+ * @param instance - URL base da instância (será normalizada).
420
+ * @param options - Ver {@link CredentialCascadeOptions}.
421
+ * @returns A fonte em uso ('keyring' | 'file' | 'env'), ou 'none'.
422
+ * @example
423
+ * const source = await describeCredentialSource('https://redmine.example');
424
+ */
425
+ export async function describeCredentialSource(instance, options = {}) {
426
+ return createCredentialCascade(options).describeSource(instance);
427
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Diagnóstico dos binários de mídia (M3-11 #53, M4-01 #57, ADR-002).
3
+ *
4
+ * O núcleo do comando `doctor`: detecta a presença dos binários externos que a
5
+ * extração local de mídia precisa (`tesseract` do OCR, `ffmpeg` do vídeo,
6
+ * `whisper.cpp` da transcrição) e, quando ausentes, devolve uma instrução de
7
+ * instalação ADEQUADA AO SO. Também reporta o status do modelo GGUF do
8
+ * whisper.cpp no cache esperado.
9
+ *
10
+ * DECISÕES (todas exercitadas por testes):
11
+ * - PLATAFORMA INJETÁVEL: cada hint depende de `platform`, injetável para testar
12
+ * os três SOs num único host — o default é `process.platform`.
13
+ * - REUSO DA DETECÇÃO DOS EXTRATORES: localização e leitura de versão
14
+ * (`findTesseract`/`findFfmpeg`/`findWhisper` + `detect*Version`) vêm dos
15
+ * módulos de extração (ADR-002) — não reimplementadas, e injetáveis para manter
16
+ * os testes isolados do filesystem/binário reais.
17
+ * - DEGRADAÇÃO GRACIOSA: nunca lança; um binário ou modelo ausente é um estado
18
+ * NORMAL (`found: false`) reportado com a dica — o `doctor` é justamente o que
19
+ * orienta o usuário a instalar (ADR-002).
20
+ * - OPT-IN `--download-binaries` SÓ ONDE HÁ ARTEFATO OFICIAL: ffmpeg (BtbN) e
21
+ * whisper.cpp (releases) mencionam o download automático futuro; tesseract
22
+ * (sem artefato estático) só instrui a instalação por SO (ADR-002).
23
+ * - MODELO É UMA ENTRADA DO DIAGNÓSTICO: o status do GGUF entra na MESMA lista
24
+ * de {@link BinaryDiagnosis} que as superfícies (CLI/TUI) já iteram — nova
25
+ * entrada exibida SEM mudança de código nelas.
26
+ */
27
+ import { type FfmpegLocation } from '../extract/ffmpeg.js';
28
+ import { type PdftotextLocation } from '../extract/pdf.js';
29
+ import { type TesseractLocation } from '../extract/tesseract.js';
30
+ import { type WhisperLocation } from '../extract/whisper.js';
31
+ /** Diagnóstico de um item externo (binário ou modelo) consultado pelo `doctor`. */
32
+ export interface BinaryDiagnosis {
33
+ /** Nome do item diagnosticado (ex.: `tesseract`, `ffmpeg`, `modelo whisper (GGUF)`). */
34
+ readonly name: string;
35
+ /** `true` se localizado (binário no PATH/local convencional, ou modelo no cache). */
36
+ readonly found: boolean;
37
+ /** Caminho absoluto do executável ou do artefato, quando encontrado. */
38
+ readonly path?: string;
39
+ /** Versão detectada (ex.: `5.5.0`), quando encontrada e legível. */
40
+ readonly version?: string;
41
+ /** Instrução de instalação/obtenção adequada ao SO — sempre presente. */
42
+ readonly installHint: string;
43
+ }
44
+ /** Dependências injetáveis de {@link diagnoseBinaries} — todas com defaults de produção. */
45
+ export interface DiagnoseBinariesOptions {
46
+ /** SO usado para escolher os hints de instalação; default `process.platform`. */
47
+ readonly platform?: NodeJS.Platform;
48
+ /** Localiza o binário `tesseract`; default {@link defaultFindTesseract}. */
49
+ readonly findTesseract?: () => TesseractLocation | undefined;
50
+ /** Lê a versão do `tesseract`; default {@link defaultDetectTesseractVersion}. */
51
+ readonly detectTesseractVersion?: (bin: string) => Promise<string | undefined>;
52
+ /** Localiza o binário `pdftotext`; default {@link defaultFindPdftotext}. */
53
+ readonly findPdftotext?: () => PdftotextLocation | undefined;
54
+ /** Lê a versão do `pdftotext`; default {@link defaultDetectPdftotextVersion}. */
55
+ readonly detectPdftotextVersion?: (bin: string) => Promise<string | undefined>;
56
+ /** Localiza o binário `ffmpeg`; default {@link defaultFindFfmpeg}. */
57
+ readonly findFfmpeg?: () => FfmpegLocation | undefined;
58
+ /** Lê a versão do `ffmpeg`; default {@link defaultDetectFfmpegVersion}. */
59
+ readonly detectFfmpegVersion?: (bin: string) => Promise<string | undefined>;
60
+ /** Localiza o binário do whisper.cpp; default {@link defaultFindWhisper}. */
61
+ readonly findWhisper?: () => WhisperLocation | undefined;
62
+ /** Diretório canônico dos modelos GGUF; default {@link defaultWhisperModelDir}. */
63
+ readonly whisperModelDir?: () => string;
64
+ /** Lista os arquivos de um diretório (para achar o `.gguf`); default `readdirSync`. */
65
+ readonly listDir?: (dir: string) => readonly string[];
66
+ }
67
+ /**
68
+ * Instrução de instalação do `tesseract` para um SO. Pura e injetável — cada
69
+ * plataforma gera uma dica testável isoladamente (ADR-002). SEM menção a
70
+ * `--download-binaries`: o tesseract não tem artefato estático oficial.
71
+ *
72
+ * @param platform - Plataforma alvo (`process.platform`).
73
+ * @returns Comando/instrução de instalação adequado ao SO.
74
+ * @example
75
+ * tesseractInstallHint('darwin'); // 'brew install tesseract tesseract-lang'
76
+ */
77
+ export declare function tesseractInstallHint(platform: NodeJS.Platform): string;
78
+ /**
79
+ * Instrução de instalação do `pdftotext` (poppler) para um SO. Pura e injetável —
80
+ * cada plataforma gera uma dica testável isoladamente (ADR-002).
81
+ *
82
+ * @param platform - Plataforma alvo (`process.platform`).
83
+ * @returns Comando/instrução de instalação adequado ao SO.
84
+ * @example
85
+ * pdftotextInstallHint('darwin'); // 'brew install poppler'
86
+ */
87
+ export declare function pdftotextInstallHint(platform: NodeJS.Platform): string;
88
+ /**
89
+ * Instrução de instalação do `ffmpeg` por SO, COM menção ao opt-in
90
+ * `--download-binaries` e às builds estáticas BtbN (artefato oficial; ADR-002).
91
+ *
92
+ * @param platform - Plataforma alvo (`process.platform`).
93
+ * @returns Instrução de instalação/obtenção adequada ao SO.
94
+ * @example
95
+ * ffmpegInstallHint('darwin'); // 'brew install ffmpeg ...'
96
+ */
97
+ export declare function ffmpegInstallHint(platform: NodeJS.Platform): string;
98
+ /**
99
+ * Instrução de instalação do whisper.cpp por SO, COM menção ao opt-in
100
+ * `--download-binaries` e às releases do GitHub (artefato oficial; ADR-002).
101
+ *
102
+ * @param platform - Plataforma alvo (`process.platform`).
103
+ * @returns Instrução de instalação/obtenção adequada ao SO.
104
+ * @example
105
+ * whisperInstallHint('darwin'); // 'brew install whisper-cpp ...'
106
+ */
107
+ export declare function whisperInstallHint(platform: NodeJS.Platform): string;
108
+ /**
109
+ * Diagnostica os binários e o modelo de mídia: `tesseract`, `ffmpeg`,
110
+ * `whisper.cpp` e o modelo GGUF. Cada um localizado (PATH + locais convencionais
111
+ * / cache), com versão quando legível, sempre com a instrução de instalação do
112
+ * SO. A ordem é estável (a mesma exibida por CLI/TUI).
113
+ *
114
+ * @param options - Deps injetáveis (plataforma, localizadores, versões, modelo).
115
+ * Ver {@link DiagnoseBinariesOptions}.
116
+ * @returns A lista de {@link BinaryDiagnosis} na ordem
117
+ * `[tesseract, pdftotext, ffmpeg, whisper.cpp, modelo]`.
118
+ * @example
119
+ * for (const item of await diagnoseBinaries()) {
120
+ * if (!item.found) logger.warn(item.installHint);
121
+ * }
122
+ */
123
+ export declare function diagnoseBinaries(options?: DiagnoseBinariesOptions): Promise<BinaryDiagnosis[]>;