@payconductor/houndsman 0.12.0 → 0.13.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 (45) hide show
  1. package/README.md +17 -1
  2. package/package.json +1 -1
  3. package/src/cli/commands/doctor-command.ts +22 -0
  4. package/src/cli/commands/hunt-command.ts +462 -0
  5. package/src/cli/commands/review-command.ts +20 -2
  6. package/src/cli/help.ts +17 -2
  7. package/src/cli/index.ts +17 -2
  8. package/src/cli/progress.ts +45 -0
  9. package/src/core/ai/ai-sdk-model-provider.ts +39 -0
  10. package/src/core/ai/index.ts +6 -1
  11. package/src/core/ai/types.ts +2 -0
  12. package/src/core/commands/scaffold.ts +11 -0
  13. package/src/core/config.ts +28 -0
  14. package/src/core/defaults.ts +15 -0
  15. package/src/core/engine/review-scope.ts +92 -25
  16. package/src/core/engine/review-session.ts +7 -3
  17. package/src/core/engine/review-workspace.ts +14 -0
  18. package/src/core/env.ts +14 -0
  19. package/src/core/hunt/hunt-runner.ts +328 -0
  20. package/src/core/hunt/index.ts +59 -0
  21. package/src/core/hunt/maki-install.ts +331 -0
  22. package/src/core/hunt/maki-model.ts +101 -0
  23. package/src/core/hunt/maki-prompt.ts +71 -0
  24. package/src/core/hunt/maki-release.ts +104 -0
  25. package/src/core/hunt/maki-runner.ts +276 -0
  26. package/src/core/hunt/pool.ts +30 -0
  27. package/src/core/logging/index.ts +2 -0
  28. package/src/core/logging/logger.ts +19 -0
  29. package/src/core/logging/progress.ts +282 -0
  30. package/src/core/orchestrator.ts +158 -49
  31. package/src/core/project-config.ts +38 -0
  32. package/src/core/storage/types.ts +1 -0
  33. package/src/index.ts +49 -0
  34. package/src/skill/README.md +12 -0
  35. package/src/skill/SETUP.md +21 -1
  36. package/src/skill/SKILL.md +4 -0
  37. package/src/skill/hunt.md +36 -0
  38. package/src/tests/__snapshots__/baseline.test.ts.snap +22 -3
  39. package/src/tests/ai-core.test.ts +38 -1
  40. package/src/tests/cli.test.ts +1 -0
  41. package/src/tests/hunt-cli.test.ts +185 -0
  42. package/src/tests/hunt-install.test.ts +153 -0
  43. package/src/tests/hunt-maki.test.ts +467 -0
  44. package/src/tests/progress.test.ts +117 -0
  45. package/src/tests/review-scope.test.ts +62 -0
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Orquestração do harness local (`houndsman hunt`).
3
+ *
4
+ * Recebe os briefs já preparados e despacha um processo maki por watchdog. O
5
+ * watchdog devolve o JSON na resposta final; validamos com o mesmo parser do
6
+ * `--finalize` e gravamos em `findings/<id>.json`. Falhas são reportadas por
7
+ * watchdog — a política de finalizar ou não fica com o chamador (CLI).
8
+ */
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { parseWatchdogReport } from "../report-builder";
12
+ import { systemClock } from "../services/clock";
13
+ import type { ReviewWorkArea } from "../storage/work-area";
14
+ import { renderHuntPrompt, renderRetrySuffix } from "./maki-prompt";
15
+ import {
16
+ extractJsonObject,
17
+ type MakiProcessRunner,
18
+ type MakiRunMetrics,
19
+ type MakiUsage,
20
+ } from "./maki-runner";
21
+ import { mapPool } from "./pool";
22
+
23
+ /** Evento de progresso (CLI loga; core não escreve saída). */
24
+ export type HuntEvent =
25
+ | { type: "start"; watchdogId: string }
26
+ | {
27
+ type: "retry";
28
+ watchdogId: string;
29
+ attempt: number;
30
+ maxAttempts: number;
31
+ error: string;
32
+ }
33
+ | {
34
+ type: "done";
35
+ watchdogId: string;
36
+ findings: number;
37
+ reviewedFiles: number;
38
+ attempts: number;
39
+ metrics: MakiRunMetrics;
40
+ }
41
+ | {
42
+ type: "failed";
43
+ watchdogId: string;
44
+ error: string;
45
+ attempts: number;
46
+ metrics: MakiRunMetrics;
47
+ };
48
+
49
+ /** Resultado de um watchdog. */
50
+ export type HuntWatchdogResult = {
51
+ watchdogId: string;
52
+ status: "done" | "failed";
53
+ findings: number;
54
+ reviewedFiles: number;
55
+ attempts: number;
56
+ durationMs: number;
57
+ sessionId?: string;
58
+ numTurns?: number;
59
+ costUsd?: number;
60
+ usage?: MakiUsage;
61
+ error?: string;
62
+ };
63
+
64
+ /** Agregados do relatório. */
65
+ export type HuntTotals = {
66
+ watchdogs: number;
67
+ done: number;
68
+ failed: number;
69
+ findings: number;
70
+ costUsd: number;
71
+ inputTokens: number;
72
+ outputTokens: number;
73
+ totalTokens: number;
74
+ };
75
+
76
+ /** Relatório persistido como `hunt-report.json`. */
77
+ export type HuntReport = {
78
+ version: 1;
79
+ engine: "maki";
80
+ model: string;
81
+ startedAt: string;
82
+ finishedAt: string;
83
+ durationMs: number;
84
+ watchdogs: HuntWatchdogResult[];
85
+ totals: HuntTotals;
86
+ };
87
+
88
+ /** Dependências do runner. */
89
+ export type HuntRunnerOptions = {
90
+ /** Staging da review (briefs/findings). */
91
+ work: ReviewWorkArea;
92
+ /** Raiz de leitura dos arquivos da review. */
93
+ root: string;
94
+ projectName: string;
95
+ /** Spec `provider/model-id`. */
96
+ model: string;
97
+ apiKeyEnv: string;
98
+ apiKey: string;
99
+ concurrency: number;
100
+ timeoutMs: number;
101
+ retries: number;
102
+ runner: MakiProcessRunner;
103
+ onEvent?: (event: HuntEvent) => void;
104
+ };
105
+
106
+ /** Despacha os briefs via maki e devolve o relatório consolidado. */
107
+ export class HuntRunner {
108
+ private readonly options: HuntRunnerOptions;
109
+
110
+ constructor(options: HuntRunnerOptions) {
111
+ this.options = options;
112
+ }
113
+
114
+ async run(activeIds: readonly string[]): Promise<HuntReport> {
115
+ const startedAt = systemClock();
116
+ const startedMs = Date.now();
117
+ const watchdogs = await mapPool(activeIds, this.options.concurrency, (id) =>
118
+ this.runOne(id),
119
+ );
120
+ const finishedAt = systemClock();
121
+ return {
122
+ version: 1,
123
+ engine: "maki",
124
+ model: this.options.model,
125
+ startedAt,
126
+ finishedAt,
127
+ durationMs: Date.now() - startedMs,
128
+ watchdogs,
129
+ totals: totalsOf(watchdogs),
130
+ };
131
+ }
132
+
133
+ private async runOne(watchdogId: string): Promise<HuntWatchdogResult> {
134
+ const briefPath = this.options.work.briefFile(watchdogId);
135
+ let brief: string;
136
+ try {
137
+ brief = readFileSync(briefPath, "utf8");
138
+ } catch {
139
+ const error = `Brief ausente: ${briefPath}`;
140
+ this.emit({
141
+ type: "failed",
142
+ watchdogId,
143
+ error,
144
+ attempts: 0,
145
+ metrics: { durationMs: 0 },
146
+ });
147
+ return failedResult(watchdogId, error, 0, { durationMs: 0 });
148
+ }
149
+
150
+ const basePrompt = renderHuntPrompt({
151
+ projectName: this.options.projectName,
152
+ watchdogId,
153
+ brief,
154
+ });
155
+
156
+ this.emit({ type: "start", watchdogId });
157
+ let attempts = 0;
158
+ let lastError = "";
159
+ const accumulated: MakiRunMetrics = { durationMs: 0 };
160
+
161
+ while (attempts <= this.options.retries) {
162
+ attempts++;
163
+ const prompt =
164
+ attempts === 1
165
+ ? basePrompt
166
+ : `${basePrompt}\n${renderRetrySuffix(lastError)}`;
167
+ const result = await this.options.runner.run({
168
+ prompt,
169
+ cwd: this.options.root,
170
+ model: this.options.model,
171
+ apiKeyEnv: this.options.apiKeyEnv,
172
+ apiKey: this.options.apiKey,
173
+ timeoutMs: this.options.timeoutMs,
174
+ });
175
+ mergeMetrics(accumulated, result.metrics);
176
+
177
+ if (!result.ok) {
178
+ lastError = result.error;
179
+ this.emitRetry(watchdogId, attempts, lastError);
180
+ continue;
181
+ }
182
+
183
+ const raw = extractJsonObject(result.text);
184
+ if (raw === null) {
185
+ lastError = "a resposta final não contém um objeto JSON.";
186
+ this.emitRetry(watchdogId, attempts, lastError);
187
+ continue;
188
+ }
189
+
190
+ const warnings: string[] = [];
191
+ const report = parseWatchdogReport(raw, `maki:${watchdogId}`, (message) =>
192
+ warnings.push(message),
193
+ );
194
+ if (!report) {
195
+ lastError = warnings[0] ?? "JSON fora do schema do watchdog.";
196
+ this.emitRetry(watchdogId, attempts, lastError);
197
+ continue;
198
+ }
199
+ if (report.watchdogId !== watchdogId) {
200
+ lastError = `watchdogId '${report.watchdogId}' não corresponde a '${watchdogId}'.`;
201
+ this.emitRetry(watchdogId, attempts, lastError);
202
+ continue;
203
+ }
204
+
205
+ this.options.work.writeFinding(
206
+ watchdogId,
207
+ `${JSON.stringify(report, null, 2)}\n`,
208
+ );
209
+ const done: HuntWatchdogResult = {
210
+ watchdogId,
211
+ status: "done",
212
+ findings: report.findings.length,
213
+ reviewedFiles: report.reviewedFiles.length,
214
+ attempts,
215
+ durationMs: accumulated.durationMs,
216
+ sessionId: accumulated.sessionId,
217
+ numTurns: accumulated.numTurns,
218
+ costUsd: accumulated.costUsd,
219
+ usage: accumulated.usage,
220
+ };
221
+ this.emit({
222
+ type: "done",
223
+ watchdogId,
224
+ findings: done.findings,
225
+ reviewedFiles: done.reviewedFiles,
226
+ attempts,
227
+ metrics: accumulated,
228
+ });
229
+ return done;
230
+ }
231
+
232
+ const error = lastError || "falha desconhecida.";
233
+ this.emit({ type: "failed", watchdogId, error, attempts, metrics: accumulated });
234
+ return failedResult(watchdogId, error, attempts, accumulated);
235
+ }
236
+
237
+ private emit(event: HuntEvent): void {
238
+ try {
239
+ this.options.onEvent?.(event);
240
+ } catch {
241
+ // Progresso nunca derruba a revisão.
242
+ }
243
+ }
244
+
245
+ /** Avisa a próxima tentativa (só quando ainda há retry). */
246
+ private emitRetry(watchdogId: string, attempt: number, error: string): void {
247
+ if (attempt > this.options.retries) return;
248
+ this.emit({
249
+ type: "retry",
250
+ watchdogId,
251
+ attempt,
252
+ maxAttempts: this.options.retries + 1,
253
+ error,
254
+ });
255
+ }
256
+ }
257
+
258
+ /** Soma métricas de várias tentativas no acumulado. */
259
+ function mergeMetrics(target: MakiRunMetrics, source: MakiRunMetrics): void {
260
+ target.durationMs += source.durationMs;
261
+ target.sessionId = source.sessionId ?? target.sessionId;
262
+ if (source.numTurns !== undefined) {
263
+ target.numTurns = (target.numTurns ?? 0) + source.numTurns;
264
+ }
265
+ if (source.costUsd !== undefined) {
266
+ target.costUsd = (target.costUsd ?? 0) + source.costUsd;
267
+ }
268
+ if (source.usage) {
269
+ target.usage = {
270
+ inputTokens:
271
+ (target.usage?.inputTokens ?? 0) + (source.usage.inputTokens ?? 0),
272
+ outputTokens:
273
+ (target.usage?.outputTokens ?? 0) + (source.usage.outputTokens ?? 0),
274
+ totalTokens:
275
+ (target.usage?.totalTokens ?? 0) + (source.usage.totalTokens ?? 0),
276
+ cacheReadInputTokens:
277
+ (target.usage?.cacheReadInputTokens ?? 0) +
278
+ (source.usage.cacheReadInputTokens ?? 0),
279
+ };
280
+ }
281
+ }
282
+
283
+ /** Resultado de falha de um watchdog. */
284
+ function failedResult(
285
+ watchdogId: string,
286
+ error: string,
287
+ attempts: number,
288
+ metrics: MakiRunMetrics,
289
+ ): HuntWatchdogResult {
290
+ return {
291
+ watchdogId,
292
+ status: "failed",
293
+ findings: 0,
294
+ reviewedFiles: 0,
295
+ attempts,
296
+ durationMs: metrics.durationMs,
297
+ sessionId: metrics.sessionId,
298
+ numTurns: metrics.numTurns,
299
+ costUsd: metrics.costUsd,
300
+ usage: metrics.usage,
301
+ error,
302
+ };
303
+ }
304
+
305
+ /** Agrega os resultados por watchdog. */
306
+ function totalsOf(watchdogs: readonly HuntWatchdogResult[]): HuntTotals {
307
+ const totals: HuntTotals = {
308
+ watchdogs: watchdogs.length,
309
+ done: 0,
310
+ failed: 0,
311
+ findings: 0,
312
+ costUsd: 0,
313
+ inputTokens: 0,
314
+ outputTokens: 0,
315
+ totalTokens: 0,
316
+ };
317
+ for (const watchdog of watchdogs) {
318
+ if (watchdog.status === "done") totals.done++;
319
+ else totals.failed++;
320
+ totals.findings += watchdog.findings;
321
+ totals.costUsd += watchdog.costUsd ?? 0;
322
+ totals.inputTokens += watchdog.usage?.inputTokens ?? 0;
323
+ totals.outputTokens += watchdog.usage?.outputTokens ?? 0;
324
+ totals.totalTokens += watchdog.usage?.totalTokens ?? 0;
325
+ }
326
+ totals.costUsd = Number(totals.costUsd.toFixed(6));
327
+ return totals;
328
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Fachada do harness local (`core/hunt`).
3
+ *
4
+ * A skill `/houndsman hunt` não importa nada daqui: este módulo é exclusivo do
5
+ * caminho CLI (`houndsman hunt`), que usa o maki para executar os briefs.
6
+ */
7
+
8
+ export type {
9
+ HuntEvent,
10
+ HuntReport,
11
+ HuntRunnerOptions,
12
+ HuntTotals,
13
+ HuntWatchdogResult,
14
+ } from "./hunt-runner";
15
+ export { HuntRunner } from "./hunt-runner";
16
+ export type {
17
+ InstallMakiOptions,
18
+ InstallMakiResult,
19
+ MakiBinary,
20
+ MakiBinarySource,
21
+ MakiInstallManifest,
22
+ } from "./maki-install";
23
+ export {
24
+ installMaki,
25
+ makiCurrentFile,
26
+ makiInstallDir,
27
+ makiSourceLabel,
28
+ makiToolsRoot,
29
+ probeMakiVersion,
30
+ readMakiInstall,
31
+ requireMakiBinary,
32
+ resolveMakiBinary,
33
+ uninstallMaki,
34
+ } from "./maki-install";
35
+ export type { MakiModel } from "./maki-model";
36
+ export {
37
+ houndsmanKeyEnvFor,
38
+ MAKI_API_KEY_ENV,
39
+ requireMakiModel,
40
+ resolveMakiModel,
41
+ } from "./maki-model";
42
+ export { renderHuntPrompt, renderRetrySuffix } from "./maki-prompt";
43
+ export type {
44
+ MakiJsonOutput,
45
+ MakiProcessRunner,
46
+ MakiRunMetrics,
47
+ MakiRunRequest,
48
+ MakiRunResult,
49
+ MakiUsage,
50
+ } from "./maki-runner";
51
+ export {
52
+ extractJsonObject,
53
+ MAKI_DISALLOWED_TOOLS,
54
+ makiArgs,
55
+ metricsFrom,
56
+ parseMakiOutput,
57
+ SpawnMakiRunner,
58
+ } from "./maki-runner";
59
+ export { mapPool } from "./pool";
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Instalação e descoberta do binário do maki (harness local).
3
+ *
4
+ * O binário é opcional e fica fora do projeto: `<HOUNDSMAN_HOME>/tools/maki`.
5
+ * `houndsman hunt install` baixa o release fixado e verifica o sha256; o
6
+ * `houndsman update`/`setup` nunca toca nisso. A skill `/houndsman hunt`
7
+ * (harness externo) não usa este módulo.
8
+ */
9
+
10
+ import { createHash } from "node:crypto";
11
+ import {
12
+ chmodSync,
13
+ copyFileSync,
14
+ existsSync,
15
+ mkdirSync,
16
+ mkdtempSync,
17
+ readdirSync,
18
+ readFileSync,
19
+ rmSync,
20
+ writeFileSync,
21
+ } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { basename, dirname, join, resolve } from "node:path";
24
+ import { env } from "../env";
25
+ import { displayPath } from "../paths";
26
+ import { systemClock } from "../services/clock";
27
+ import { houndsmanHome } from "../storage/paths";
28
+ import {
29
+ checksumFor,
30
+ effectiveMakiVersion,
31
+ makiAssetFor,
32
+ makiBinaryName,
33
+ makiChecksumsUrl,
34
+ makiDownloadUrl,
35
+ normalizeMakiVersion,
36
+ } from "./maki-release";
37
+
38
+ /** Origem do binário resolvido. */
39
+ export type MakiBinarySource = "env" | "config" | "managed" | "path";
40
+
41
+ /** Binário do maki disponível para execução. */
42
+ export type MakiBinary = {
43
+ path: string;
44
+ source: MakiBinarySource;
45
+ /** Versão conhecida (instalação gerenciada). */
46
+ version?: string;
47
+ };
48
+
49
+ /** Manifesto da instalação gerenciada (`current.json`). */
50
+ export type MakiInstallManifest = {
51
+ version: string;
52
+ path: string;
53
+ asset?: string;
54
+ sha256?: string;
55
+ installedAt: string;
56
+ };
57
+
58
+ /** Raiz das versões gerenciadas do maki. */
59
+ export function makiToolsRoot(): string {
60
+ return join(houndsmanHome(), "tools", "maki");
61
+ }
62
+
63
+ /** Diretório de uma versão instalada. */
64
+ export function makiInstallDir(version: string): string {
65
+ return join(makiToolsRoot(), normalizeMakiVersion(version));
66
+ }
67
+
68
+ /** Caminho do ponteiro para a instalação ativa. */
69
+ export function makiCurrentFile(): string {
70
+ return join(makiToolsRoot(), "current.json");
71
+ }
72
+
73
+ /** Lê o manifesto da instalação gerenciada (null se ausente/inválido). */
74
+ export function readMakiInstall(): MakiInstallManifest | null {
75
+ const file = makiCurrentFile();
76
+ if (!existsSync(file)) return null;
77
+ try {
78
+ const parsed = JSON.parse(
79
+ readFileSync(file, "utf8"),
80
+ ) as Partial<MakiInstallManifest>;
81
+ if (typeof parsed.version !== "string" || typeof parsed.path !== "string") {
82
+ return null;
83
+ }
84
+ return {
85
+ version: parsed.version,
86
+ path: parsed.path,
87
+ asset: parsed.asset,
88
+ sha256: parsed.sha256,
89
+ installedAt: parsed.installedAt ?? systemClock(),
90
+ };
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ /** Grava o ponteiro da instalação ativa. */
97
+ function writeInstallManifest(manifest: MakiInstallManifest): void {
98
+ const file = makiCurrentFile();
99
+ mkdirSync(dirname(file), { recursive: true });
100
+ writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
101
+ }
102
+
103
+ /**
104
+ * Resolve o binário: `HOUNDSMAN_MAKI_PATH` > `hunt.binaryPath` > instalação
105
+ * gerenciada > `maki` no PATH. Não valida existência (use `requireMakiBinary`).
106
+ */
107
+ export function resolveMakiBinary(configPath?: string): MakiBinary | null {
108
+ const explicit = env.makiPath ?? configPath?.trim();
109
+ if (explicit && explicit.length > 0) {
110
+ return { path: resolve(explicit), source: env.makiPath ? "env" : "config" };
111
+ }
112
+ const managed = readMakiInstall();
113
+ if (managed)
114
+ return { path: managed.path, source: "managed", version: managed.version };
115
+ const which = Bun.which("maki");
116
+ if (which) return { path: which, source: "path" };
117
+ return null;
118
+ }
119
+
120
+ /** Rótulo legível da origem (diagnóstico). */
121
+ export function makiSourceLabel(source: MakiBinarySource): string {
122
+ switch (source) {
123
+ case "env":
124
+ return "HOUNDSMAN_MAKI_PATH";
125
+ case "config":
126
+ return "hunt.binaryPath";
127
+ case "managed":
128
+ return "instalação gerenciada";
129
+ default:
130
+ return "PATH";
131
+ }
132
+ }
133
+
134
+ /** Resolve e exige um binário existente; lança erro acionável quando ausente. */
135
+ export function requireMakiBinary(configPath?: string): MakiBinary {
136
+ const binary = resolveMakiBinary(configPath);
137
+ if (!binary) {
138
+ throw new Error(
139
+ "Capability `hunt` não instalada: rode `houndsman hunt install` (ou defina HOUNDSMAN_MAKI_PATH).",
140
+ );
141
+ }
142
+ if (!existsSync(binary.path)) {
143
+ throw new Error(
144
+ `Binário do maki não encontrado em ${displayPath(binary.path)} (${makiSourceLabel(binary.source)}). ` +
145
+ "Rode `houndsman hunt install --force` ou ajuste HOUNDSMAN_MAKI_PATH.",
146
+ );
147
+ }
148
+ return binary;
149
+ }
150
+
151
+ /** Lê a versão do binário (`maki --version`); `undefined` se indisponível. */
152
+ export function probeMakiVersion(binaryPath: string): string | undefined {
153
+ try {
154
+ const result = Bun.spawnSync([binaryPath, "--version"], {
155
+ stdout: "pipe",
156
+ stderr: "pipe",
157
+ });
158
+ if (result.exitCode !== 0) return undefined;
159
+ const output = result.stdout.toString().trim();
160
+ return output.match(/\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?/)?.[0];
161
+ } catch {
162
+ return undefined;
163
+ }
164
+ }
165
+
166
+ /** Opções de instalação. */
167
+ export type InstallMakiOptions = {
168
+ /** Versão alvo (`vX.Y.Z` ou `X.Y.Z`). */
169
+ version?: string;
170
+ /** Reinstala mesmo se o binário já existir. */
171
+ force?: boolean;
172
+ /** Arquivo local (`.tar.gz`/`.zip`) para ambientes offline. */
173
+ from?: string;
174
+ /** Progresso textual (CLI). */
175
+ log?: (message: string) => void;
176
+ /** `fetch` injetável (testes). */
177
+ fetchImpl?: typeof fetch;
178
+ };
179
+
180
+ /** Resultado da instalação. */
181
+ export type InstallMakiResult = {
182
+ version: string;
183
+ path: string;
184
+ skipped: boolean;
185
+ };
186
+
187
+ /** Baixa (ou extrai) o maki para `<HOUNDSMAN_HOME>/tools/maki/<versão>`. */
188
+ export async function installMaki(
189
+ options: InstallMakiOptions = {},
190
+ ): Promise<InstallMakiResult> {
191
+ const version = effectiveMakiVersion(options.version);
192
+ const binaryName = makiBinaryName();
193
+ const target = join(makiInstallDir(version), binaryName);
194
+ const log = options.log ?? (() => {});
195
+
196
+ if (existsSync(target) && !options.force) {
197
+ writeInstallManifest({ version, path: target, installedAt: systemClock() });
198
+ return { version, path: target, skipped: true };
199
+ }
200
+
201
+ const asset = makiAssetFor(process.platform, process.arch, version);
202
+ if (!asset) {
203
+ throw new Error(
204
+ `Sem release do maki para ${process.platform}/${process.arch}. ` +
205
+ "Instale manualmente (https://github.com/tontinton/maki/releases) e defina HOUNDSMAN_MAKI_PATH.",
206
+ );
207
+ }
208
+
209
+ const temp = mkdtempSync(join(tmpdir(), "houndsman-maki-"));
210
+ try {
211
+ let archivePath: string;
212
+ let checksum: string | undefined;
213
+
214
+ if (options.from) {
215
+ archivePath = resolve(options.from);
216
+ if (!existsSync(archivePath)) {
217
+ throw new Error(
218
+ `Arquivo do maki não encontrado: ${displayPath(archivePath)}.`,
219
+ );
220
+ }
221
+ log(`Extraindo ${displayPath(archivePath)}...`);
222
+ } else {
223
+ const fetchImpl = options.fetchImpl ?? fetch;
224
+ const url = makiDownloadUrl(version, asset.name);
225
+ log(`Baixando maki v${version} (${asset.name})...`);
226
+ const response = await fetchImpl(url);
227
+ if (!response.ok) {
228
+ throw new Error(
229
+ `Falha ao baixar o maki (HTTP ${response.status}): ${url}`,
230
+ );
231
+ }
232
+ const bytes = new Uint8Array(await response.arrayBuffer());
233
+
234
+ const checksumsResponse = await fetchImpl(makiChecksumsUrl(version));
235
+ if (checksumsResponse.ok) {
236
+ const expected = checksumFor(await checksumsResponse.text(), asset.name);
237
+ if (expected) {
238
+ const actual = createHash("sha256").update(bytes).digest("hex");
239
+ if (actual !== expected) {
240
+ throw new Error(
241
+ `Checksum inválido para ${asset.name} (esperado ${expected.slice(0, 12)}…, obtido ${actual.slice(0, 12)}…).`,
242
+ );
243
+ }
244
+ checksum = expected;
245
+ log("Checksum sha256 verificado.");
246
+ } else {
247
+ log(
248
+ "Aviso: release sem checksum para o asset; seguindo sem verificação.",
249
+ );
250
+ }
251
+ } else {
252
+ log(
253
+ `Aviso: não foi possível obter sha256sums.txt (HTTP ${checksumsResponse.status}).`,
254
+ );
255
+ }
256
+
257
+ archivePath = join(temp, asset.name);
258
+ writeFileSync(archivePath, bytes);
259
+ }
260
+
261
+ const extractDir = join(temp, "extract");
262
+ mkdirSync(extractDir, { recursive: true });
263
+ extractArchive(archivePath, extractDir);
264
+ const found = findFile(extractDir, binaryName);
265
+ if (!found) {
266
+ throw new Error(
267
+ `O pacote do maki não contém '${binaryName}'. Conteúdo inesperado em ${basename(archivePath)}.`,
268
+ );
269
+ }
270
+
271
+ const installDir = makiInstallDir(version);
272
+ mkdirSync(installDir, { recursive: true });
273
+ copyFileSync(found, target);
274
+ chmodSync(target, 0o755);
275
+ writeInstallManifest({
276
+ version,
277
+ path: target,
278
+ asset: asset.name,
279
+ sha256: checksum,
280
+ installedAt: systemClock(),
281
+ });
282
+ return { version, path: target, skipped: false };
283
+ } finally {
284
+ rmSync(temp, { recursive: true, force: true });
285
+ }
286
+ }
287
+
288
+ /** Remove todas as versões gerenciadas; devolve o caminho removido (ou null). */
289
+ export function uninstallMaki(): string | null {
290
+ const root = makiToolsRoot();
291
+ if (!existsSync(root)) return null;
292
+ rmSync(root, { recursive: true, force: true });
293
+ return root;
294
+ }
295
+
296
+ /** Extrai `.tar.gz`/`.zip` (Windows usa o `tar.exe`/bsdtar do sistema). */
297
+ function extractArchive(archive: string, directory: string): void {
298
+ try {
299
+ const result = Bun.spawnSync(["tar", "-xf", archive, "-C", directory], {
300
+ stdout: "pipe",
301
+ stderr: "pipe",
302
+ });
303
+ if (result.exitCode !== 0) {
304
+ const detail = result.stderr.toString().trim();
305
+ throw new Error(
306
+ `Falha ao extrair ${basename(archive)}${detail ? `: ${detail}` : "."}`,
307
+ );
308
+ }
309
+ } catch (error) {
310
+ if (error instanceof Error && error.message.startsWith("Falha ao extrair")) {
311
+ throw error;
312
+ }
313
+ throw new Error(
314
+ `Falha ao extrair ${basename(archive)}: 'tar' indisponível no sistema.`,
315
+ );
316
+ }
317
+ }
318
+
319
+ /** Procura um arquivo por nome, com profundidade limitada. */
320
+ function findFile(root: string, name: string, depth = 0): string | null {
321
+ if (depth > 4) return null;
322
+ for (const entry of readdirSync(root, { withFileTypes: true })) {
323
+ const full = join(root, entry.name);
324
+ if (entry.isFile() && entry.name === name) return full;
325
+ if (entry.isDirectory()) {
326
+ const found = findFile(full, name, depth + 1);
327
+ if (found) return found;
328
+ }
329
+ }
330
+ return null;
331
+ }