diff --git a/vext-tools/src/Mod.affine b/vext-tools/src/Mod.affine index c2ae175..ebac79b 100644 --- a/vext-tools/src/Mod.affine +++ b/vext-tools/src/Mod.affine @@ -1,7 +1,14 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Mod; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 OR MPL-2.0 +// vext-tools - CLI utilities and hooks for vext +// +// @module + +// Re-export hooks +module Git = Git +module Install = Install + diff --git a/vext-tools/src/bindings/Deno.affine b/vext-tools/src/bindings/Deno.affine index 0d05f97..4524b17 100644 --- a/vext-tools/src/bindings/Deno.affine +++ b/vext-tools/src/bindings/Deno.affine @@ -1,7 +1,87 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Deno; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 OR MPL-2.0 +// ReScript bindings for Deno runtime APIs + +// Deno.Command for executing commands +module Command = { + struct t + + struct commandOptions { { + args: array, + stdout: string, + stderr: string, + } + + struct output { { + success: bool, + stdout: Js.TypedArray2.Uint8Array.t, + stderr: Js.TypedArray2.Uint8Array.t, + } + + @new @module("Deno") external make: (string, commandOptions) => t = "Command" + @send external output: t => promise = "output" +} + +// Deno.connect for network connections +module Net = { + struct conn + + struct connectOptions { { + hostname: string, + port: int, + } + + @module("Deno") external connect: connectOptions => promise = "connect" + @send external write: (conn, Js.TypedArray2.Uint8Array.t) => promise = "write" + @send external close: conn => unit = "close" +} + +// Deno.stdin +module Stdin = { + struct t + + @module("Deno") @val external stdin: t = "stdin" + @send external read: (t, Js.TypedArray2.Uint8Array.t) => promise> = "read" +} + +// Deno.env +module Env = { + @module("Deno") @scope("env") external get: string => option = "get" +} + +// Deno.args +@module("Deno") @val external args: array = "args" + +// Deno.exit +@module("Deno") external exit: int => unit = "exit" + +// Deno file operations +@module("Deno") external stat: string => promise<{..}> = "stat" +@module("Deno") external writeTextFile: (string, string) => promise = "writeTextFile" +@module("Deno") external chmod: (string, int) => promise = "chmod" + +// TextEncoder/TextDecoder +module TextEncoder = { + struct t + + @new external make: unit => t = "TextEncoder" + @send external encode: (t, string) => Js.TypedArray2.Uint8Array.t = "encode" +} + +module TextDecoder = { + struct t + + @new external make: unit => t = "TextDecoder" + @send external decode: (t, Js.TypedArray2.Uint8Array.t) => string = "decode" +} + +// Console +module Console = { + @val external log: string => unit = "console.log" + @val external error: string => unit = "console.error" +} + diff --git a/vext-tools/src/bindings/Std.affine b/vext-tools/src/bindings/Std.affine index 78c522f..e1e10ba 100644 --- a/vext-tools/src/bindings/Std.affine +++ b/vext-tools/src/bindings/Std.affine @@ -1,7 +1,48 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Std; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 OR MPL-2.0 +// ReScript bindings for Deno standard library (@std/*) + +// @std/cli/parse-args +module ParseArgs = { + struct parseArgsOptions { { + string: array, + boolean: array, + alias: Js.Dict.t, + } + + struct args { { + _: array, + } + + @module("@std/cli/parse-args") + external parseArgs: (array, parseArgsOptions) => args = "parseArgs" + + // Helper to get string value from parsed args + fn getString: (args, string) => option = (args, key) => { + fn obj = args->Obj.magic + Js.Dict.get(obj, key) + } + + // Helper to get bool value from parsed args + fn getBool: (args, string) => bool = (args, key) => { + fn obj: Js.Dict.t = args->Obj.magic + Js.Dict.get(obj, key)->Option.getOr(false) + } +} + +// @std/fs +module Fs = { + @module("@std/fs") external ensureDir: string => promise = "ensureDir" +} + +// @std/path +module Path = { + @module("@std/path") external join: (string, string) => string = "join" + @module("@std/path") external join3: (string, string, string) => string = "join" + @module("@std/path") external dirname: string => string = "dirname" +} + diff --git a/vext-tools/src/hooks/Git.affine b/vext-tools/src/hooks/Git.affine index 658cfa3..fd89bb3 100644 --- a/vext-tools/src/hooks/Git.affine +++ b/vext-tools/src/hooks/Git.affine @@ -1,7 +1,400 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Git; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 OR MPL-2.0 +// vext Git post-receive hook +// Reads pushed refs from stdin and sends notifications to vextd. + +open Deno + +// Types +struct commitInfo { { + hash: string, + shortHash: string, + author: string, + authorEmail: string, + subject: string, + body: string, + timestamp: string, +} + +struct refUpdate { { + oldRef: string, + newRef: string, + refName: string, +} + +struct notification { { + to: array, + privmsg: string, + project: option, + branch: option, + commit: option, + author: option, + url: option, +} + +// Configuration from environment +struct config { { + mutable server: string, + mutable targets: array, + mutable project: option, + mutable baseUrl: option, + mutable maxCommits: int, + mutable colors: bool, +} + +fn defaultConfig = (): config => { + fn server = Env.get("VEXT_SERVER")->Option.getOr("127.0.0.1:6659") + fn targetsStr = Env.get("VEXT_TARGETS")->Option.getOr("") + fn targets = targetsStr->String.split(",")->Array.filter(s => s != "") + fn project = switch Env.get("VEXT_PROJECT") { + | Some(p) => Some(p) + | None => Env.get("GL_PROJECT_PATH") + } + fn baseUrl = Env.get("VEXT_URL") + fn maxCommits = Env.get("VEXT_MAX_COMMITS") + ->Option.flatMap(s => Int.fromString(s)) + ->Option.getOr(5) + fn colors = Env.get("VEXT_COLORS") != Some("false") + + {server, targets, project, baseUrl, maxCommits, colors} +} + +// Run git command and get output +fn runGit = async (args: array): string => { + fn cmd = Command.make("git", { + args, + stdout: "piped", + stderr: "piped", + }) + fn output = await cmd->Command.output + if !output.success { + Js.Exn.raiseError(`git ${args->Array.join(" ")} failed`) + } + fn decoder = TextDecoder.make() + decoder->TextDecoder.decode(output.stdout)->String.trim +} + +// Get commit info for a hash +fn getCommitInfo = async (hash: string): commitInfo => { + fn format = "%H%n%h%n%an%n%ae%n%s%n%b%n%aI" + fn output = await runGit(["log", "-1", `--format=${format}`, hash]) + fn lines = output->String.split("\n") + + { + hash: lines->Array.getUnsafe(0), + shortHash: lines->Array.getUnsafe(1), + author: lines->Array.getUnsafe(2), + authorEmail: lines->Array.getUnsafe(3), + subject: lines->Array.getUnsafe(4), + body: lines->Array.slice(~start=5, ~end=-1)->Array.join("\n"), + timestamp: lines->Array.getUnsafe(Array.length(lines) - 1), + } +} + +// Get commits between two refs +fn getCommitsBetween = async (config: config, oldRef: string, newRef: string): array => { + fn nullRef = "0000000000000000000000000000000000000000" + + // Handle new branch + if oldRef == nullRef { + fn output = await runGit([ + "rev-list", + `--max-count=${config.maxCommits->Int.toString}`, + newRef, + ]) + output->String.split("\n")->Array.filter(s => s != "") + } else if newRef == nullRef { + // Handle deleted branch + [] + } else { + fn output = await runGit([ + "rev-list", + `--max-count=${config.maxCommits->Int.toString}`, + `${oldRef}..${newRef}`, + ]) + output->String.split("\n")->Array.filter(s => s != "") + } +} + +// Extract branch name from ref +fn extractBranchName = (refName: string): string => { + if refName->String.startsWith("refs/heads/") { + refName->String.sliceToEnd(~start=11) + } else if refName->String.startsWith("refs/tags/") { + "tag/" ++ refName->String.sliceToEnd(~start=10) + } else { + refName + } +} + +// Format commit message +fn formatCommitMessage = (commit: commitInfo, branch: string, project: option): string => { + fn parts = [] + + switch project { + | Some(p) => parts->Array.push(`[${p}]`)->ignore + | None => () + } + + parts->Array.push(branch)->ignore + parts->Array.push(commit.shortHash)->ignore + parts->Array.push(commit.author ++ ":")->ignore + parts->Array.push(commit.subject)->ignore + + parts->Array.join(" ") +} + +// Send notification to vextd +fn sendNotification = async (config: config, notification: notification): unit => { + fn payload = notification->Obj.magic->JSON.stringify ++ "\n" + + try { + fn serverParts = config.server->String.split(":") + fn hostname = serverParts->Array.getUnsafe(0) + fn port = serverParts->Array.get(1) + ->Option.flatMap(Int.fromString) + ->Option.getOr(6659) + + fn conn = await Net.connect({hostname, port}) + fn encoder = TextEncoder.make() + fn _ = await conn->Net.write(encoder->TextEncoder.encode(payload)) + conn->Net.close + + Console.error(`[vext] Sent notification to ${config.server}`) + } catch { + | Js.Exn.Error(err) => + fn msg = Js.Exn.message(err)->Option.getOr("unknown error") + Console.error(`[vext] Failed to send notification: ${msg}`) + } +} + +// Process a ref update +fn processRefUpdate = async (config: config, update: refUpdate): unit => { + if Array.length(config.targets) == 0 { + Console.error("[vext] No targets configured (set VEXT_TARGETS)") + return + } + + fn branch = extractBranchName(update.refName) + fn nullRef = "0000000000000000000000000000000000000000" + + // Handle branch deletion + if update.newRef == nullRef { + await sendNotification(config, { + to: config.targets, + privmsg: `Branch ${branch} deleted`, + project: config.project, + branch: Some(branch), + commit: None, + author: None, + url: None, + }) + return + } + + // Handle new branch + if update.oldRef == nullRef { + await sendNotification(config, { + to: config.targets, + privmsg: `New branch ${branch} created`, + project: config.project, + branch: Some(branch), + commit: None, + author: None, + url: None, + }) + } + + // Get commits + fn commits = await getCommitsBetween(config, update.oldRef, update.newRef) + + if Array.length(commits) == 0 { + return + } + + // Process each commit (most recent first) + fn reversed = commits->Array.toReversed + for i in 0 to Array.length(reversed) - 1 { + fn hash = reversed->Array.getUnsafe(i) + try { + fn commit = await getCommitInfo(hash) + fn message = formatCommitMessage(commit, branch, config.project) + + fn url = switch config.baseUrl { + | Some(base) => Some(`${base}/commit/${commit.hash}`) + | None => None + } + + await sendNotification(config, { + to: config.targets, + privmsg: message, + project: config.project, + branch: Some(branch), + commit: Some(commit.shortHash), + author: Some(commit.author), + url, + }) + } catch { + | Js.Exn.Error(err) => + fn msg = Js.Exn.message(err)->Option.getOr("unknown") + Console.error(`[vext] Failed to process commit ${hash}: ${msg}`) + } + } + + // If there were more commits, note that + if Array.length(commits) >= config.maxCommits { + await sendNotification(config, { + to: config.targets, + privmsg: `... and more commits (showing last ${config.maxCommits->Int.toString})`, + project: config.project, + branch: Some(branch), + commit: None, + author: None, + url: None, + }) + } +} + +// Read ref updates from stdin +fn readStdin = async (): array => { + fn updates: array = [] + fn decoder = TextDecoder.make() + fn buffer = Js.TypedArray2.Uint8Array.fromLength(1024) + fn input = ref("") + + try { + fn continue = ref(true) + while continue.contents { + fn n = await Stdin.stdin->Stdin.read(buffer) + switch n->Js.Nullable.toOption { + | None => continue := false + | Some(bytesRead) => + fn slice = buffer->Js.TypedArray2.Uint8Array.slice(~start=0, ~end_=bytesRead) + input := input.contents ++ decoder->TextDecoder.decode(slice) + } + } + } catch { + | _ => () // stdin might not be available + } + + // Parse ref updates + fn lines = input.contents->String.split("\n") + lines->Array.forEach(line => { + fn trimmed = line->String.trim + if trimmed != "" { + fn parts = trimmed->String.split(" ") + if Array.length(parts) >= 3 { + updates->Array.push({ + oldRef: parts->Array.getUnsafe(0), + newRef: parts->Array.getUnsafe(1), + refName: parts->Array.getUnsafe(2), + })->ignore + } + } + }) + + updates +} + +// Show help +fn showHelp = () => { + Console.log(`vext git hook - Send commit notifications to IRC + +Usage: Git.res.mjs [options] + +When run as a git post-receive hook, reads ref updates from stdin. + +Options: + --server vextd server address (default: 127.0.0.1:6659) + --to IRC target URL (can specify multiple) + --project Project name + --url Base URL for commit links + --help Show this help + --version Show version + +Environment variables: + VEXT_SERVER vextd server address + VEXT_TARGETS Comma-separated IRC target URLs + VEXT_PROJECT Project name + VEXT_URL Base URL for commit links + VEXT_MAX_COMMITS Maximum commits to report (default: 5) +`) +} + +// Main entry point +fn main = async () => { + fn config = defaultConfig() + + // Simple arg parsing + fn args = Deno.args + fn showHelpFlag = args->Array.some(a => a == "--help" || a == "-h") + fn showVersion = args->Array.some(a => a == "--version" || a == "-V") + + if showHelpFlag { + showHelp() + exit(0) + } + + if showVersion { + Console.log("vext-hook 1.0.0") + exit(0) + } + + // Parse CLI args for overrides + args->Array.forEachWithIndex((arg, i) => { + if arg == "--server" { + switch args->Array.get(i + 1) { + | Some(v) => config.server = v + | None => () + } + } + if arg == "--to" { + switch args->Array.get(i + 1) { + | Some(v) => config.targets = [v] + | None => () + } + } + if arg == "--project" { + switch args->Array.get(i + 1) { + | Some(v) => config.project = Some(v) + | None => () + } + } + if arg == "--url" { + switch args->Array.get(i + 1) { + | Some(v) => config.baseUrl = Some(v) + | None => () + } + } + }) + + // Read ref updates from stdin + fn updates = await readStdin() + + if Array.length(updates) == 0 { + Console.error("[vext] No ref updates received from stdin") + exit(0) + } + + // Process each ref update + for i in 0 to Array.length(updates) - 1 { + fn update = updates->Array.getUnsafe(i) + await processRefUpdate(config, update) + } +} + +// Run main +fn _ = main()->Promise.catch(err => { + fn msg = switch err { + | Js.Exn.Error(e) => Js.Exn.message(e)->Option.getOr("unknown") + | _ => "unknown error" + } + Console.error(`[vext] Fatal error: ${msg}`) + exit(1) + Promise.resolve() +}) + diff --git a/vext-tools/src/hooks/Install.affine b/vext-tools/src/hooks/Install.affine index 2a332df..5bf135f 100644 --- a/vext-tools/src/hooks/Install.affine +++ b/vext-tools/src/hooks/Install.affine @@ -1,7 +1,172 @@ // SPDX-License-Identifier: MPL-2.0 -// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell -// Ported via Harvard Engine bulk-processor +// Ported via Harvard Engine (Semantic pass) module Install; -// TODO: Complete semantic implementation +// SPDX-License-Identifier: MPL-2.0 OR MPL-2.0 +// vext hook installer +// Installs git hooks for vext notifications. + +open Deno +open Std + +fn hookTemplate = `#!/bin/sh +# vext post-receive hook +# Sends commit notifications to IRC via vextd +# +# Configuration (set in environment or uncomment below): +# export VEXT_SERVER="127.0.0.1:6659" +# export VEXT_TARGETS="irc://irc.libera.chat/your-channel" +# export VEXT_PROJECT="your-project" +# export VEXT_URL="https://github.com/you/repo" + +# Run the hook +exec deno run --allow-net --allow-env --allow-read \\ + "HOOK_PATH" "$@" +` + +// Find git directory +fn findGitDir = async (): option => { + fn cmd = Command.make("git", { + args: ["rev-parse", "--git-dir"], + stdout: "piped", + stderr: "null", + }) + fn output = await cmd->Command.output + if !output.success { + None + } else { + fn decoder = TextDecoder.make() + Some(decoder->TextDecoder.decode(output.stdout)->String.trim) + } +} + +// Install the hook +fn installHook = async (gitDir: string, hookPath: string, force: bool): bool => { + fn hooksDir = Path.join(gitDir, "hooks") + fn targetPath = Path.join(hooksDir, "post-receive") + + // Check if hook already exists + try { + fn _ = await stat(targetPath) + if !force { + Console.error(`Hook already exists: ${targetPath}`) + Console.error("Use --force to overwrite") + return false + } + } catch { + | _ => () // File doesn't exist, that's fine + } + + await Fs.ensureDir(hooksDir) + + // Generate hook content + fn content = hookTemplate->String.replaceAll("HOOK_PATH", hookPath) + + await writeTextFile(targetPath, content) + await chmod(targetPath, 0o755) + + Console.log(`Installed hook: ${targetPath}`) + true +} + +// Show help +fn showHelp = () => { + Console.log(`vext hook installer + +Usage: Install.res.mjs [options] + +Options: + --git-dir Path to .git directory (auto-detected if not specified) + --hook-path Path to the Git.res.mjs hook script + --force, -f Overwrite existing hook + --help, -h Show this help + +Example: + deno run --allow-read --allow-write Install.res.mjs --force +`) +} + +// Main entry point +fn main = async () => { + fn args = Deno.args + + // Check for help flag + fn showHelpFlag = args->Array.some(a => a == "--help" || a == "-h") + if showHelpFlag { + showHelp() + exit(0) + } + + // Parse arguments + fn gitDirArg = ref(None) + fn hookPathArg = ref(None) + fn forceFlag = ref(false) + + args->Array.forEachWithIndex((arg, i) => { + if arg == "--git-dir" { + gitDirArg := args->Array.get(i + 1) + } + if arg == "--hook-path" { + hookPathArg := args->Array.get(i + 1) + } + if arg == "--force" || arg == "-f" { + forceFlag := true + } + }) + + // Find git directory + fn gitDir = switch gitDirArg.contents { + | Some(d) => d + | None => + switch await findGitDir() { + | Some(d) => d + | None => + Console.error("Not in a git repository. Use --git-dir to specify.") + exit(1) + "" // unreachable + } + } + + // Find hook script path + fn hookPath = switch hookPathArg.contents { + | Some(p) => p + | None => + // Default to Git.res.mjs in the same directory + // This assumes the compiled ReScript output + Path.join(Path.dirname("./"), "Git.res.mjs") + } + + // Install the hook + fn success = await installHook(gitDir, hookPath, forceFlag.contents) + + if success { + fn postReceivePath = Path.join3(gitDir, "hooks", "post-receive") + Console.log(` +Hook installed successfully! + +Configure by setting environment variables: + VEXT_SERVER vextd server (default: 127.0.0.1:6659) + VEXT_TARGETS IRC targets, comma-separated + VEXT_PROJECT Project name + VEXT_URL Base URL for commit links + +Or edit the hook file directly: + ${postReceivePath} +`) + } else { + exit(1) + } +} + +// Run main +fn _ = main()->Promise.catch(err => { + fn msg = switch err { + | Js.Exn.Error(e) => Js.Exn.message(e)->Option.getOr("unknown") + | _ => "unknown error" + } + Console.error(`Error: ${msg}`) + exit(1) + Promise.resolve() +}) +