fconvert 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +54 -0
  2. package/assets/manifest.json +15 -0
  3. package/bun.lock +26 -0
  4. package/dist/convert +0 -0
  5. package/package.json +25 -0
  6. package/src/artifacts/artifact.ts +21 -0
  7. package/src/artifacts/file.ts +11 -0
  8. package/src/bundle/manifest.ts +10 -0
  9. package/src/bundle/platform.ts +10 -0
  10. package/src/bundle/resolve.ts +88 -0
  11. package/src/cli/commands/convert.ts +105 -0
  12. package/src/cli/commands/doctor.ts +16 -0
  13. package/src/cli/commands/formats.ts +20 -0
  14. package/src/cli/commands/handlers.ts +37 -0
  15. package/src/cli/commands/route.ts +91 -0
  16. package/src/cli/main.ts +69 -0
  17. package/src/cli/parse.ts +127 -0
  18. package/src/core/config.ts +16 -0
  19. package/src/core/errors.ts +18 -0
  20. package/src/core/logger.ts +38 -0
  21. package/src/core/types.ts +77 -0
  22. package/src/diagnostics/doctor.ts +43 -0
  23. package/src/executor/executor.ts +177 -0
  24. package/src/executor/workspace.ts +47 -0
  25. package/src/formats/aliases.ts +8 -0
  26. package/src/formats/common.ts +69 -0
  27. package/src/formats/detect.ts +62 -0
  28. package/src/formats/registry.ts +64 -0
  29. package/src/handlers/base.ts +61 -0
  30. package/src/handlers/bridges/binary.ts +22 -0
  31. package/src/handlers/bridges/text.ts +127 -0
  32. package/src/handlers/exec.ts +47 -0
  33. package/src/handlers/native/ffmpeg.ts +77 -0
  34. package/src/handlers/native/imagemagick.ts +65 -0
  35. package/src/handlers/native/pandoc.ts +66 -0
  36. package/src/handlers/native/sevenzip.ts +115 -0
  37. package/src/handlers/registry.ts +68 -0
  38. package/src/planner/costs.ts +111 -0
  39. package/src/planner/deadends.ts +31 -0
  40. package/src/planner/explain.ts +12 -0
  41. package/src/planner/graph.ts +68 -0
  42. package/src/planner/search.ts +77 -0
  43. package/test/e2e/engine-bridge.test.ts +52 -0
  44. package/test/unit/detect.test.ts +15 -0
  45. package/test/unit/planner.test.ts +46 -0
  46. package/tsconfig.json +31 -0
@@ -0,0 +1,64 @@
1
+ import type { FileFormat, FormatDefinition } from "../core/types.ts";
2
+ import { COMMON_FORMATS } from "./common.ts";
3
+ import { FORMAT_ALIASES } from "./aliases.ts";
4
+
5
+ export class FormatRegistry {
6
+ private readonly byId = new Map<string, FileFormat>();
7
+ private readonly byAlias = new Map<string, string>();
8
+ private readonly byExtension = new Map<string, string>();
9
+
10
+ constructor(definitions: FormatDefinition[] = COMMON_FORMATS) {
11
+ for (const definition of definitions) {
12
+ this.register(definition);
13
+ }
14
+
15
+ for (const [alias, id] of Object.entries(FORMAT_ALIASES)) {
16
+ this.byAlias.set(alias.toLowerCase(), id.toLowerCase());
17
+ }
18
+ }
19
+
20
+ private register(definition: FormatDefinition): void {
21
+ const id = definition.id.toLowerCase();
22
+ const aliases = (definition.aliases ?? []).map((value) => value.toLowerCase());
23
+
24
+ const fileFormat: FileFormat = {
25
+ id,
26
+ name: definition.name,
27
+ extension: definition.extension.toLowerCase(),
28
+ mime: definition.mime[0] ?? "application/octet-stream",
29
+ category: [...definition.category],
30
+ aliases,
31
+ };
32
+
33
+ this.byId.set(id, fileFormat);
34
+ this.byExtension.set(fileFormat.extension, id);
35
+ this.byAlias.set(id, id);
36
+
37
+ for (const extension of definition.extensions) {
38
+ this.byExtension.set(extension.toLowerCase(), id);
39
+ }
40
+
41
+ for (const alias of aliases) {
42
+ this.byAlias.set(alias, id);
43
+ }
44
+ }
45
+
46
+ getById(idOrAlias: string): FileFormat | undefined {
47
+ const normalized = idOrAlias.toLowerCase();
48
+ const resolved = this.byAlias.get(normalized) ?? normalized;
49
+ return this.byId.get(resolved);
50
+ }
51
+
52
+ getByExtension(extension: string): FileFormat | undefined {
53
+ const normalized = extension.toLowerCase();
54
+ const id = this.byExtension.get(normalized);
55
+ if (!id) {
56
+ return undefined;
57
+ }
58
+ return this.byId.get(id);
59
+ }
60
+
61
+ all(): FileFormat[] {
62
+ return [...this.byId.values()].sort((a, b) => a.id.localeCompare(b.id));
63
+ }
64
+ }
@@ -0,0 +1,61 @@
1
+ import type { ArtifactRef } from "../artifacts/artifact.ts";
2
+ import type { BundleResolver } from "../bundle/resolve.ts";
3
+ import type { Logger } from "../core/logger.ts";
4
+ import type { FileFormat } from "../core/types.ts";
5
+ import type { Workspace } from "../executor/workspace.ts";
6
+
7
+ export interface HandlerRule {
8
+ from: string | "*";
9
+ to: string | "*";
10
+ cost?: number;
11
+ lossless?: boolean;
12
+ }
13
+
14
+ export interface HandlerCapabilities {
15
+ supportsAnyInput?: boolean;
16
+ startupCost?: number;
17
+ priority?: number;
18
+ deterministic?: boolean;
19
+ }
20
+
21
+ export interface HandlerContext {
22
+ workspace: Workspace;
23
+ bundle: BundleResolver;
24
+ logger: Logger;
25
+ timeoutMs?: number;
26
+ }
27
+
28
+ export interface ConvertRequest {
29
+ input: ArtifactRef;
30
+ outputPath: string;
31
+ inputFormat: FileFormat;
32
+ outputFormat: FileFormat;
33
+ }
34
+
35
+ export interface HandlerResult {
36
+ output: ArtifactRef;
37
+ warnings?: string[];
38
+ }
39
+
40
+ export interface ConversionHandler {
41
+ readonly name: string;
42
+ readonly capabilities: HandlerCapabilities;
43
+ readonly rules: HandlerRule[];
44
+
45
+ init?(ctx: HandlerContext): Promise<void>;
46
+ isAvailable?(ctx: HandlerContext): Promise<boolean>;
47
+ convert(ctx: HandlerContext, request: ConvertRequest): Promise<HandlerResult>;
48
+ }
49
+
50
+ export interface PlannedEdge {
51
+ handler: ConversionHandler;
52
+ from: FileFormat;
53
+ to: FileFormat;
54
+ rule: HandlerRule;
55
+ cost: number;
56
+ }
57
+
58
+ export interface PlannedRoute {
59
+ edges: PlannedEdge[];
60
+ totalCost: number;
61
+ }
@@ -0,0 +1,22 @@
1
+ import { copyFile, mkdir } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import type { ConversionHandler, ConvertRequest, HandlerContext, HandlerResult } from "../base.ts";
4
+ import { toFileArtifact } from "../../artifacts/file.ts";
5
+
6
+ export class BinaryBridgeHandler implements ConversionHandler {
7
+ readonly name = "binary-bridge";
8
+ readonly capabilities = {
9
+ startupCost: 1,
10
+ priority: 5,
11
+ deterministic: true,
12
+ supportsAnyInput: true,
13
+ };
14
+
15
+ readonly rules = [{ from: "*", to: "*", cost: 420, lossless: false }];
16
+
17
+ async convert(_ctx: HandlerContext, request: ConvertRequest): Promise<HandlerResult> {
18
+ await mkdir(dirname(request.outputPath), { recursive: true });
19
+ await copyFile(request.input.path, request.outputPath);
20
+ return { output: await toFileArtifact(request.outputPath) };
21
+ }
22
+ }
@@ -0,0 +1,127 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import type { ConversionHandler, ConvertRequest, HandlerContext, HandlerResult, HandlerRule } from "../base.ts";
4
+ import { toFileArtifact } from "../../artifacts/file.ts";
5
+
6
+ const TEXTISH = ["txt", "md", "html", "json", "xml", "yaml", "csv", "base64", "hex", "bin"];
7
+
8
+ function textRules(): HandlerRule[] {
9
+ const rules: HandlerRule[] = [];
10
+ for (const from of TEXTISH) {
11
+ for (const to of TEXTISH) {
12
+ if (from === to) {
13
+ continue;
14
+ }
15
+ rules.push({ from, to, cost: 60, lossless: false });
16
+ }
17
+ }
18
+ rules.push({ from: "*", to: "base64", cost: 110, lossless: false });
19
+ rules.push({ from: "*", to: "hex", cost: 110, lossless: false });
20
+ rules.push({ from: "base64", to: "*", cost: 130, lossless: false });
21
+ rules.push({ from: "hex", to: "*", cost: 130, lossless: false });
22
+ return rules;
23
+ }
24
+
25
+ function decodeIfEncoded(inputFormat: string, bytes: Buffer): Buffer {
26
+ if (inputFormat === "base64") {
27
+ try {
28
+ return Buffer.from(bytes.toString("utf8").trim(), "base64");
29
+ } catch {
30
+ return bytes;
31
+ }
32
+ }
33
+ if (inputFormat === "hex") {
34
+ const normalized = bytes.toString("utf8").replace(/\s+/g, "").trim();
35
+ if (normalized.length % 2 !== 0) {
36
+ return bytes;
37
+ }
38
+ try {
39
+ return Buffer.from(normalized, "hex");
40
+ } catch {
41
+ return bytes;
42
+ }
43
+ }
44
+ return bytes;
45
+ }
46
+
47
+ function htmlEscape(value: string): string {
48
+ return value
49
+ .replaceAll("&", "&amp;")
50
+ .replaceAll("<", "&lt;")
51
+ .replaceAll(">", "&gt;")
52
+ .replaceAll('"', "&quot;")
53
+ .replaceAll("'", "&#39;");
54
+ }
55
+
56
+ function fromStructuredToText(inputFormat: string, text: string): string {
57
+ if (inputFormat === "json") {
58
+ try {
59
+ const parsed = JSON.parse(text) as Record<string, unknown>;
60
+ const data = parsed.data;
61
+ if (typeof data === "string") {
62
+ return data;
63
+ }
64
+ } catch {
65
+ return text;
66
+ }
67
+ }
68
+
69
+ if (inputFormat === "html" || inputFormat === "xml") {
70
+ return text.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
71
+ }
72
+
73
+ return text;
74
+ }
75
+
76
+ export class TextBridgeHandler implements ConversionHandler {
77
+ readonly name = "text-bridge";
78
+ readonly capabilities = {
79
+ startupCost: 1,
80
+ priority: 20,
81
+ deterministic: true,
82
+ };
83
+
84
+ readonly rules: HandlerRule[] = textRules();
85
+
86
+ async convert(_ctx: HandlerContext, request: ConvertRequest): Promise<HandlerResult> {
87
+ const raw = Buffer.from(await readFile(request.input.path));
88
+ const decoded = decodeIfEncoded(request.inputFormat.id, raw);
89
+ const sourceText = fromStructuredToText(request.inputFormat.id, decoded.toString("utf8"));
90
+
91
+ await mkdir(dirname(request.outputPath), { recursive: true });
92
+
93
+ const target = request.outputFormat.id;
94
+ if (target === "bin") {
95
+ await writeFile(request.outputPath, decoded);
96
+ return { output: await toFileArtifact(request.outputPath) };
97
+ }
98
+
99
+ let outputText: string;
100
+ if (target === "base64") {
101
+ outputText = decoded.toString("base64");
102
+ } else if (target === "hex") {
103
+ outputText = decoded.toString("hex");
104
+ } else if (target === "txt") {
105
+ outputText = sourceText;
106
+ } else if (target === "json") {
107
+ outputText = JSON.stringify({ data: sourceText }, null, 2);
108
+ } else if (target === "xml") {
109
+ outputText = `<root><data><![CDATA[${sourceText}]]></data></root>`;
110
+ } else if (target === "html") {
111
+ outputText = `<html><body><pre>${htmlEscape(sourceText)}</pre></body></html>`;
112
+ } else if (target === "md") {
113
+ outputText = `\`\`\`text\n${sourceText}\n\`\`\``;
114
+ } else if (target === "yaml") {
115
+ const lines = sourceText.split("\n").map((line) => ` ${line}`);
116
+ outputText = `data: |\n${lines.join("\n")}`;
117
+ } else if (target === "csv") {
118
+ const escaped = sourceText.replaceAll('"', '""').replaceAll("\n", " ");
119
+ outputText = `data\n"${escaped}"\n`;
120
+ } else {
121
+ outputText = sourceText;
122
+ }
123
+
124
+ await writeFile(request.outputPath, outputText, "utf8");
125
+ return { output: await toFileArtifact(request.outputPath) };
126
+ }
127
+ }
@@ -0,0 +1,47 @@
1
+ import { CliError, ExitCode } from "../core/errors.ts";
2
+
3
+ export interface ExecResult {
4
+ stdout: string;
5
+ stderr: string;
6
+ exitCode: number;
7
+ }
8
+
9
+ export async function runCommand(
10
+ command: string,
11
+ args: string[],
12
+ timeoutMs?: number,
13
+ ): Promise<ExecResult> {
14
+ const proc = Bun.spawn([command, ...args], {
15
+ stdout: "pipe",
16
+ stderr: "pipe",
17
+ });
18
+
19
+ let timedOut = false;
20
+ let timer: ReturnType<typeof setTimeout> | undefined;
21
+ if (timeoutMs && timeoutMs > 0) {
22
+ timer = setTimeout(() => {
23
+ timedOut = true;
24
+ proc.kill();
25
+ }, timeoutMs);
26
+ }
27
+
28
+ const [stdoutBuf, stderrBuf, exitCode] = await Promise.all([
29
+ new Response(proc.stdout).arrayBuffer(),
30
+ new Response(proc.stderr).arrayBuffer(),
31
+ proc.exited,
32
+ ]);
33
+
34
+ if (timer) {
35
+ clearTimeout(timer);
36
+ }
37
+
38
+ if (timedOut) {
39
+ throw new CliError(`Command timed out: ${command}`, ExitCode.ConversionFailed);
40
+ }
41
+
42
+ return {
43
+ stdout: Buffer.from(stdoutBuf).toString("utf8"),
44
+ stderr: Buffer.from(stderrBuf).toString("utf8"),
45
+ exitCode,
46
+ };
47
+ }
@@ -0,0 +1,77 @@
1
+ import { dirname } from "node:path";
2
+ import { mkdir } from "node:fs/promises";
3
+ import type { HandlerContext, HandlerResult, ConversionHandler, ConvertRequest, HandlerRule } from "../base.ts";
4
+ import { runCommand } from "../exec.ts";
5
+ import { CliError, ExitCode } from "../../core/errors.ts";
6
+ import { toFileArtifact } from "../../artifacts/file.ts";
7
+
8
+ const AUDIO = ["wav", "mp3", "flac", "ogg"];
9
+ const VIDEO = ["mp4", "mov", "webm", "wmv", "gif"];
10
+ const IMAGE = ["png", "jpeg", "webp", "bmp", "tiff", "gif"];
11
+
12
+ function pairRules(from: string[], to: string[], cost: number): HandlerRule[] {
13
+ const rules: HandlerRule[] = [];
14
+ for (const src of from) {
15
+ for (const dst of to) {
16
+ if (src === dst) {
17
+ continue;
18
+ }
19
+ rules.push({ from: src, to: dst, cost, lossless: false });
20
+ }
21
+ }
22
+ return rules;
23
+ }
24
+
25
+ export class FfmpegHandler implements ConversionHandler {
26
+ readonly name = "ffmpeg";
27
+ readonly capabilities = {
28
+ startupCost: 30,
29
+ priority: 90,
30
+ deterministic: true,
31
+ };
32
+
33
+ readonly rules: HandlerRule[] = [
34
+ ...pairRules(AUDIO, AUDIO, 15),
35
+ ...pairRules(VIDEO, VIDEO, 20),
36
+ ...pairRules(IMAGE, IMAGE, 10),
37
+ ...pairRules(VIDEO, AUDIO, 25),
38
+ ...pairRules(AUDIO, VIDEO, 70),
39
+ ...pairRules(VIDEO, IMAGE, 30),
40
+ ...pairRules(IMAGE, VIDEO, 40),
41
+ ];
42
+
43
+ async isAvailable(ctx: HandlerContext): Promise<boolean> {
44
+ const resolved = await ctx.bundle.resolveBinary("ffmpeg");
45
+ return Boolean(resolved);
46
+ }
47
+
48
+ async convert(ctx: HandlerContext, request: ConvertRequest): Promise<HandlerResult> {
49
+ const ffmpeg = await ctx.bundle.mustResolveBinary("ffmpeg");
50
+ await mkdir(dirname(request.outputPath), { recursive: true });
51
+
52
+ const args = [
53
+ "-y",
54
+ "-hide_banner",
55
+ "-loglevel",
56
+ "error",
57
+ "-i",
58
+ request.input.path,
59
+ ];
60
+
61
+ if (request.outputFormat.category.includes("image")) {
62
+ args.push("-frames:v", "1");
63
+ }
64
+
65
+ args.push(request.outputPath);
66
+ const result = await runCommand(ffmpeg, args, ctx.timeoutMs);
67
+
68
+ if (result.exitCode !== 0) {
69
+ throw new CliError(
70
+ `ffmpeg failed (${request.inputFormat.id} -> ${request.outputFormat.id}): ${result.stderr || result.stdout}`,
71
+ ExitCode.ConversionFailed,
72
+ );
73
+ }
74
+
75
+ return { output: await toFileArtifact(request.outputPath) };
76
+ }
77
+ }
@@ -0,0 +1,65 @@
1
+ import { dirname } from "node:path";
2
+ import { mkdir } from "node:fs/promises";
3
+ import type { ConversionHandler, ConvertRequest, HandlerContext, HandlerResult, HandlerRule } from "../base.ts";
4
+ import { runCommand } from "../exec.ts";
5
+ import { CliError, ExitCode } from "../../core/errors.ts";
6
+ import { toFileArtifact } from "../../artifacts/file.ts";
7
+
8
+ const IMAGE_DOC = ["png", "jpeg", "webp", "bmp", "tiff", "gif", "svg", "pdf"];
9
+
10
+ function imageRules(): HandlerRule[] {
11
+ const rules: HandlerRule[] = [];
12
+ for (const from of IMAGE_DOC) {
13
+ for (const to of IMAGE_DOC) {
14
+ if (from === to) {
15
+ continue;
16
+ }
17
+ rules.push({ from, to, cost: 18, lossless: false });
18
+ }
19
+ }
20
+ return rules;
21
+ }
22
+
23
+ export class ImageMagickHandler implements ConversionHandler {
24
+ readonly name = "imagemagick";
25
+ readonly capabilities = {
26
+ startupCost: 20,
27
+ priority: 80,
28
+ deterministic: true,
29
+ };
30
+
31
+ readonly rules: HandlerRule[] = imageRules();
32
+
33
+ private async resolveMagick(ctx: HandlerContext): Promise<string | undefined> {
34
+ const magick = await ctx.bundle.resolveBinary("magick");
35
+ if (magick) {
36
+ return magick;
37
+ }
38
+ return ctx.bundle.resolveBinary("convert");
39
+ }
40
+
41
+ async isAvailable(ctx: HandlerContext): Promise<boolean> {
42
+ const executable = await this.resolveMagick(ctx);
43
+ return Boolean(executable);
44
+ }
45
+
46
+ async convert(ctx: HandlerContext, request: ConvertRequest): Promise<HandlerResult> {
47
+ const magick = await this.resolveMagick(ctx);
48
+ if (!magick) {
49
+ throw new CliError("ImageMagick executable not found", ExitCode.EnvironmentError);
50
+ }
51
+
52
+ await mkdir(dirname(request.outputPath), { recursive: true });
53
+
54
+ const args = [request.input.path, request.outputPath];
55
+ const result = await runCommand(magick, args, ctx.timeoutMs);
56
+ if (result.exitCode !== 0) {
57
+ throw new CliError(
58
+ `ImageMagick failed (${request.inputFormat.id} -> ${request.outputFormat.id}): ${result.stderr || result.stdout}`,
59
+ ExitCode.ConversionFailed,
60
+ );
61
+ }
62
+
63
+ return { output: await toFileArtifact(request.outputPath) };
64
+ }
65
+ }
@@ -0,0 +1,66 @@
1
+ import { dirname } from "node:path";
2
+ import { mkdir } from "node:fs/promises";
3
+ import type { ConversionHandler, ConvertRequest, HandlerContext, HandlerResult, HandlerRule } from "../base.ts";
4
+ import { runCommand } from "../exec.ts";
5
+ import { CliError, ExitCode } from "../../core/errors.ts";
6
+ import { toFileArtifact } from "../../artifacts/file.ts";
7
+
8
+ const DOC_TEXT_DATA = [
9
+ "txt",
10
+ "md",
11
+ "html",
12
+ "json",
13
+ "xml",
14
+ "yaml",
15
+ "csv",
16
+ "pdf",
17
+ "docx",
18
+ "pptx",
19
+ "xlsx",
20
+ ];
21
+
22
+ function pandocRules(): HandlerRule[] {
23
+ const rules: HandlerRule[] = [];
24
+ for (const from of DOC_TEXT_DATA) {
25
+ for (const to of DOC_TEXT_DATA) {
26
+ if (from === to) {
27
+ continue;
28
+ }
29
+ rules.push({ from, to, cost: 26, lossless: false });
30
+ }
31
+ }
32
+ return rules;
33
+ }
34
+
35
+ export class PandocHandler implements ConversionHandler {
36
+ readonly name = "pandoc";
37
+ readonly capabilities = {
38
+ startupCost: 40,
39
+ priority: 85,
40
+ deterministic: true,
41
+ };
42
+
43
+ readonly rules: HandlerRule[] = pandocRules();
44
+
45
+ async isAvailable(ctx: HandlerContext): Promise<boolean> {
46
+ const resolved = await ctx.bundle.resolveBinary("pandoc");
47
+ return Boolean(resolved);
48
+ }
49
+
50
+ async convert(ctx: HandlerContext, request: ConvertRequest): Promise<HandlerResult> {
51
+ const pandoc = await ctx.bundle.mustResolveBinary("pandoc");
52
+ await mkdir(dirname(request.outputPath), { recursive: true });
53
+
54
+ const args = [request.input.path, "-o", request.outputPath];
55
+ const result = await runCommand(pandoc, args, ctx.timeoutMs);
56
+
57
+ if (result.exitCode !== 0) {
58
+ throw new CliError(
59
+ `pandoc failed (${request.inputFormat.id} -> ${request.outputFormat.id}): ${result.stderr || result.stdout}`,
60
+ ExitCode.ConversionFailed,
61
+ );
62
+ }
63
+
64
+ return { output: await toFileArtifact(request.outputPath) };
65
+ }
66
+ }
@@ -0,0 +1,115 @@
1
+ import { copyFile, mkdir, readdir, rm, stat, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { ConversionHandler, ConvertRequest, HandlerContext, HandlerResult, HandlerRule } from "../base.ts";
4
+ import { runCommand } from "../exec.ts";
5
+ import { CliError, ExitCode } from "../../core/errors.ts";
6
+ import { toFileArtifact } from "../../artifacts/file.ts";
7
+
8
+ const ARCHIVES = new Set(["zip", "tar", "7z"]);
9
+
10
+ async function findFirstFile(root: string): Promise<string | undefined> {
11
+ const entries = await readdir(root, { withFileTypes: true });
12
+ for (const entry of entries) {
13
+ const candidate = join(root, entry.name);
14
+ if (entry.isFile()) {
15
+ return candidate;
16
+ }
17
+ if (entry.isDirectory()) {
18
+ const nested = await findFirstFile(candidate);
19
+ if (nested) {
20
+ return nested;
21
+ }
22
+ }
23
+ }
24
+ return undefined;
25
+ }
26
+
27
+ function sevenZipRules(): HandlerRule[] {
28
+ return [
29
+ { from: "*", to: "zip", cost: 90, lossless: false },
30
+ { from: "*", to: "tar", cost: 90, lossless: false },
31
+ { from: "*", to: "7z", cost: 95, lossless: false },
32
+ { from: "zip", to: "*", cost: 140, lossless: false },
33
+ { from: "tar", to: "*", cost: 140, lossless: false },
34
+ { from: "7z", to: "*", cost: 145, lossless: false },
35
+ ];
36
+ }
37
+
38
+ export class SevenZipHandler implements ConversionHandler {
39
+ readonly name = "7zip";
40
+ readonly capabilities = {
41
+ startupCost: 15,
42
+ priority: 60,
43
+ deterministic: true,
44
+ supportsAnyInput: true,
45
+ };
46
+
47
+ readonly rules: HandlerRule[] = sevenZipRules();
48
+
49
+ private async resolve7z(ctx: HandlerContext): Promise<string | undefined> {
50
+ const candidates = ["7zz", "7z"];
51
+ for (const candidate of candidates) {
52
+ const resolved = await ctx.bundle.resolveBinary(candidate);
53
+ if (resolved) {
54
+ return resolved;
55
+ }
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ async isAvailable(ctx: HandlerContext): Promise<boolean> {
61
+ const binary = await this.resolve7z(ctx);
62
+ return Boolean(binary);
63
+ }
64
+
65
+ async convert(ctx: HandlerContext, request: ConvertRequest): Promise<HandlerResult> {
66
+ const sevenZip = await this.resolve7z(ctx);
67
+ if (!sevenZip) {
68
+ throw new CliError("7zip executable not found", ExitCode.EnvironmentError);
69
+ }
70
+
71
+ await mkdir(dirname(request.outputPath), { recursive: true });
72
+ await rm(request.outputPath, { force: true });
73
+
74
+ const toArchive = ARCHIVES.has(request.outputFormat.id);
75
+ const fromArchive = ARCHIVES.has(request.inputFormat.id);
76
+
77
+ if (toArchive) {
78
+ const archiveType = request.outputFormat.id;
79
+ const args = ["a", "-y", `-t${archiveType}`, request.outputPath, request.input.path];
80
+ const result = await runCommand(sevenZip, args, ctx.timeoutMs);
81
+ if (result.exitCode !== 0) {
82
+ throw new CliError(`7zip archive creation failed: ${result.stderr || result.stdout}`, ExitCode.ConversionFailed);
83
+ }
84
+ return { output: await toFileArtifact(request.outputPath) };
85
+ }
86
+
87
+ if (fromArchive) {
88
+ const extractDir = join(ctx.workspace.outputDir, `extract-${Date.now()}`);
89
+ await mkdir(extractDir, { recursive: true });
90
+ const args = ["x", "-y", request.input.path, `-o${extractDir}`];
91
+ const result = await runCommand(sevenZip, args, ctx.timeoutMs);
92
+ if (result.exitCode !== 0) {
93
+ throw new CliError(`7zip extraction failed: ${result.stderr || result.stdout}`, ExitCode.ConversionFailed);
94
+ }
95
+
96
+ const first = await findFirstFile(extractDir);
97
+ if (!first) {
98
+ await writeFile(request.outputPath, new Uint8Array());
99
+ } else {
100
+ const stats = await stat(first);
101
+ if (stats.isFile()) {
102
+ await copyFile(first, request.outputPath);
103
+ } else {
104
+ await writeFile(request.outputPath, new Uint8Array());
105
+ }
106
+ }
107
+ return { output: await toFileArtifact(request.outputPath) };
108
+ }
109
+
110
+ throw new CliError(
111
+ `7zip cannot convert ${request.inputFormat.id} -> ${request.outputFormat.id}`,
112
+ ExitCode.ConversionFailed,
113
+ );
114
+ }
115
+ }