commit 65e7c864245871ba3d64a82b22a8102a91a64e3c Author: Schmidt Peter <117939077+jokerz5575@users.noreply.github.com> Date: Thu May 21 14:27:40 2026 +0200 Project is published. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2c21f21 --- /dev/null +++ b/Makefile @@ -0,0 +1,173 @@ +# ============================================================ +# gbuild — C rewrite build system +# ============================================================ + +CC = gcc +CFLAGS = -Wall -Wextra -O2 -Iinclude +LDLIBS_GBUILD = -lncurses +LDLIBS_GCONFIG = + +# ---- package metadata -------------------------------------- + +NAME = gbuild +VERSION = 1.0.0 +ARCH := $(shell uname -m) +DEB_ARCH := $(shell dpkg --print-architecture 2>/dev/null || \ + echo $(ARCH) | sed 's/x86_64/amd64/;s/aarch64/arm64/') +RPM_DATE := $(shell date "+%a %b %d %Y") +PREFIX = /usr/local + +# ---- release output dirs ----------------------------------- + +DIST = dist +DEB_ROOT = $(DIST)/deb +RPM_ROOT = $(DIST)/rpmbuild + +# ---- sources ----------------------------------------------- + +COMMON_SRCS = src/config.c + +GBUILD_SRCS = \ + src/gbuild.c \ + src/git_ops.c \ + src/logger.c \ + src/make_ops.c \ + src/tui.c \ + $(COMMON_SRCS) + +GCONFIG_SRCS = \ + src/gconfig.c \ + $(COMMON_SRCS) + +HEADERS = $(wildcard include/*.h) + +# ============================================================ +# Standard targets +# ============================================================ + +.PHONY: all clean install uninstall \ + release release-src release-bin release-deb release-rpm + +all: bin/gbuild bin/gconfig + +bin/gbuild: $(GBUILD_SRCS) $(HEADERS) | bin + $(CC) $(CFLAGS) -o $@ $(GBUILD_SRCS) $(LDLIBS_GBUILD) + +bin/gconfig: $(GCONFIG_SRCS) $(HEADERS) | bin + $(CC) $(CFLAGS) -o $@ $(GCONFIG_SRCS) $(LDLIBS_GCONFIG) + +bin: + mkdir -p bin + +clean: + rm -rf bin/ dist/ + +install: all + install -Dm755 bin/gbuild $(DESTDIR)$(PREFIX)/bin/gbuild + install -Dm755 bin/gconfig $(DESTDIR)$(PREFIX)/bin/gconfig + +uninstall: + rm -f $(DESTDIR)$(PREFIX)/bin/gbuild + rm -f $(DESTDIR)$(PREFIX)/bin/gconfig + +# ============================================================ +# release — build all four artefacts into dist/ +# ============================================================ +# +# dist/ +# ├── gbuild-1.0.0.tar.gz ← source tarball +# ├── gbuild-1.0.0-x86_64-bin.tar.gz ← stripped binaries +# ├── gbuild_1.0.0_amd64.deb ← Debian package +# └── gbuild-1.0.0-1.x86_64.rpm ← RPM package +# +release: release-src release-bin release-deb release-rpm + @echo "" + @echo "Release artefacts:" + @ls -1sh $(DIST)/*.tar.gz $(DIST)/*.deb $(DIST)/*.rpm 2>/dev/null \ + | awk '{print " " $$0}' + +# ============================================================ +# release-src — source tarball +# ============================================================ + +release-src: + @echo ">>> [1/4] Source tarball ..." + mkdir -p $(DIST) + mkdir -p $(DIST)/$(NAME)-$(VERSION) + cp -r src include pkg Makefile README.md $(DIST)/$(NAME)-$(VERSION)/ + tar -C $(DIST) -czf $(DIST)/$(NAME)-$(VERSION).tar.gz \ + $(NAME)-$(VERSION) + rm -rf $(DIST)/$(NAME)-$(VERSION) + @echo " OK $(DIST)/$(NAME)-$(VERSION).tar.gz" + +# ============================================================ +# release-bin — stripped binary tarball +# ============================================================ + +release-bin: all + @echo ">>> [2/4] Binary tarball ..." + mkdir -p $(DIST) + mkdir -p $(DIST)/$(NAME)-$(VERSION)-$(ARCH)-bin/usr/local/bin + cp bin/gbuild $(DIST)/$(NAME)-$(VERSION)-$(ARCH)-bin/usr/local/bin/ + cp bin/gconfig $(DIST)/$(NAME)-$(VERSION)-$(ARCH)-bin/usr/local/bin/ + strip $(DIST)/$(NAME)-$(VERSION)-$(ARCH)-bin/usr/local/bin/gbuild + strip $(DIST)/$(NAME)-$(VERSION)-$(ARCH)-bin/usr/local/bin/gconfig + tar -C $(DIST) -czf \ + $(DIST)/$(NAME)-$(VERSION)-$(ARCH)-bin.tar.gz \ + $(NAME)-$(VERSION)-$(ARCH)-bin + rm -rf $(DIST)/$(NAME)-$(VERSION)-$(ARCH)-bin + @echo " OK $(DIST)/$(NAME)-$(VERSION)-$(ARCH)-bin.tar.gz" + +# ============================================================ +# release-deb — Debian .deb package +# ============================================================ + +release-deb: all + @echo ">>> [3/4] .deb package ..." + mkdir -p $(DIST) + rm -rf $(DEB_ROOT) + + install -Dm755 bin/gbuild $(DEB_ROOT)/usr/local/bin/gbuild + install -Dm755 bin/gconfig $(DEB_ROOT)/usr/local/bin/gconfig + strip $(DEB_ROOT)/usr/local/bin/gbuild + strip $(DEB_ROOT)/usr/local/bin/gconfig + + install -dm755 $(DEB_ROOT)/usr/share/doc/$(NAME) + cp README.md $(DEB_ROOT)/usr/share/doc/$(NAME)/ + + install -dm755 $(DEB_ROOT)/DEBIAN + sed -e 's/@VERSION@/$(VERSION)/g' \ + -e 's/@DEB_ARCH@/$(DEB_ARCH)/g' \ + pkg/deb/DEBIAN/control.in > $(DEB_ROOT)/DEBIAN/control + sed -e 's/@VERSION@/$(VERSION)/g' \ + pkg/deb/DEBIAN/postinst.in > $(DEB_ROOT)/DEBIAN/postinst + chmod 755 $(DEB_ROOT)/DEBIAN/postinst + + dpkg-deb --build --root-owner-group $(DEB_ROOT) \ + $(DIST)/$(NAME)_$(VERSION)_$(DEB_ARCH).deb + rm -rf $(DEB_ROOT) + @echo " OK $(DIST)/$(NAME)_$(VERSION)_$(DEB_ARCH).deb" + +# ============================================================ +# release-rpm — RPM package +# ============================================================ + +release-rpm: release-src + @echo ">>> [4/4] .rpm package ..." + rm -rf $(RPM_ROOT) + mkdir -p $(RPM_ROOT)/BUILD $(RPM_ROOT)/BUILDROOT $(RPM_ROOT)/RPMS $(RPM_ROOT)/SOURCES $(RPM_ROOT)/SPECS $(RPM_ROOT)/SRPMS + + cp $(DIST)/$(NAME)-$(VERSION).tar.gz \ + $(RPM_ROOT)/SOURCES/ + + sed -e 's/@VERSION@/$(VERSION)/g' \ + -e 's/@RPM_DATE@/$(RPM_DATE)/g' \ + pkg/rpm/gbuild.spec.in > $(RPM_ROOT)/SPECS/$(NAME).spec + + rpmbuild -bb --nodeps \ + --define "_topdir $(CURDIR)/$(RPM_ROOT)" \ + $(RPM_ROOT)/SPECS/$(NAME).spec + + find $(RPM_ROOT)/RPMS -name "*.rpm" -exec cp {} $(DIST)/ \; + rm -rf $(RPM_ROOT) + @echo " OK $(DIST)/$(NAME)-$(VERSION)-1.$(ARCH).rpm" diff --git a/README.md b/README.md new file mode 100644 index 0000000..7a0cde6 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# gbuild — C rewrite + +A C rewrite of the original [gbuild](https://spdlab.hu/gbuild) bash tool. +Clone, pull, pick a target, build — all from one command. + +--- + +## Features + +- **Clone or pull** — automatically clones a fresh repo on first run; pulls on subsequent runs +- **Interactive target picker** — reads your `Makefile` and presents an ncurses TUI to select the build target; pass `--target` to skip it +- **Timestamped log files** — every run writes a log to `~/.local/log/gbuild/` named `_.log` +- **Log browser** — built-in two-pane TUI: file list on the left, live preview on the right; open in `less`, delete, or quit +- **Configurable** — reads `~/.gconfig`; override with `--url` and `--user` at runtime +- **`gconfig` companion** — manages `~/.gconfig` with `init`, `show`, and `help` commands + +--- + +## Dependencies + +| Library | Purpose | Install (Debian/Ubuntu) | +|----------|------------------------------|------------------------------| +| ncurses | TUI for picker & log browser | `sudo apt install libncurses-dev` | +| git | Clone / pull | `sudo apt install git` | +| make | Build projects | `sudo apt install make` | +| less | Open logs from browser | `sudo apt install less` | + +--- + +## Build + +```sh +# Install ncurses dev headers if needed +sudo apt install libncurses-dev + +# Build both binaries into bin/ +make + +# Optionally install system-wide +sudo make install +``` + +--- + +## Quick setup + +```sh +# 1. Initialise ~/.gconfig with defaults +gconfig init + +# 2. Fill in your values +$EDITOR ~/.gconfig + +# 3. Verify +gconfig show + +# 4. Build a project +gbuild myproject +``` + +### `~/.gconfig` format + +```ini +# ~/.gconfig — managed by gconfig + +[git] +GIT_URL = http://localhost:3000 +GIT_USER = jokerz + +[auth] +# Use token-based OR password-based auth; leave the other blank +GIT_TOKEN = +GIT_PASSWORD = + +[build] +DEFAULT_TARGET = +CLONE_DIR = ~/projects + +[log] +LOG_ENABLED = true +LOG_DIR = ~/.local/log/gbuild +``` + +--- + +## Usage + +``` +gbuild [options] +gbuild --logs + +Options: + --url Override Git base URL (default: ~/.gconfig) + --user Override Git username (default: ~/.gconfig) + --target Run target directly, skip interactive picker + --logs Open the interactive log browser + -h, --help Print this help and exit +``` + +### Examples + +```sh +# Build with defaults +gbuild myproject + +# Skip the target picker +gbuild --target clean myproject + +# Different server and user for this run +gbuild --url http://10.0.0.5:3000 --user alice myproject + +# Browse past build logs +gbuild --logs +``` + +--- + +## `gconfig` commands + +| Command | Description | +|---------------------|-------------------------------------------------------------| +| `gconfig init` | Create `~/.gconfig` with defaults (skips if already exists) | +| `gconfig init --force` | Overwrite; backs up old file as `~/.gconfig.bak.` | +| `gconfig show` | Print current config (auth fields masked) | +| `gconfig help` | Print usage | + +--- + +## Project layout + +``` +gbuild/ +├── Makefile +├── README.md +├── include/ +│ ├── config.h — GConfig struct, INI load/save/init/show +│ ├── git_ops.h — clone / pull +│ ├── logger.h — timestamped coloured logging +│ ├── make_ops.h — Makefile target parser + build runner +│ └── tui.h — ncurses target picker + log browser +└── src/ + ├── config.c + ├── gbuild.c — gbuild main() + ├── gconfig.c — gconfig main() + ├── git_ops.c + ├── logger.c + ├── make_ops.c + └── tui.c +``` + +--- + +MIT License — maintained by jokerz / spdlab.hu diff --git a/bin/gbuild b/bin/gbuild new file mode 100644 index 0000000..6c3664f Binary files /dev/null and b/bin/gbuild differ diff --git a/bin/gconfig b/bin/gconfig new file mode 100644 index 0000000..2b1ee61 Binary files /dev/null and b/bin/gconfig differ diff --git a/dist/gbuild-1.0.0-1.x86_64.rpm b/dist/gbuild-1.0.0-1.x86_64.rpm new file mode 100644 index 0000000..68a8eeb Binary files /dev/null and b/dist/gbuild-1.0.0-1.x86_64.rpm differ diff --git a/dist/gbuild-1.0.0-x86_64-bin.tar.gz b/dist/gbuild-1.0.0-x86_64-bin.tar.gz new file mode 100644 index 0000000..53d9c4f Binary files /dev/null and b/dist/gbuild-1.0.0-x86_64-bin.tar.gz differ diff --git a/dist/gbuild-1.0.0.tar.gz b/dist/gbuild-1.0.0.tar.gz new file mode 100644 index 0000000..6f8538d Binary files /dev/null and b/dist/gbuild-1.0.0.tar.gz differ diff --git a/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm b/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm new file mode 100644 index 0000000..ea443d5 Binary files /dev/null and b/dist/gbuild-debuginfo-1.0.0-1.x86_64.rpm differ diff --git a/dist/gbuild_1.0.0_amd64.deb b/dist/gbuild_1.0.0_amd64.deb new file mode 100644 index 0000000..bb426e2 Binary files /dev/null and b/dist/gbuild_1.0.0_amd64.deb differ diff --git a/include/config.h b/include/config.h new file mode 100644 index 0000000..c3a911d --- /dev/null +++ b/include/config.h @@ -0,0 +1,28 @@ +#ifndef CONFIG_H +#define CONFIG_H + +#include +#include + +#define CFG_MAX 512 + +typedef struct { + char git_url[CFG_MAX]; + char git_user[256]; + char git_token[256]; + char git_password[256]; + char default_target[128]; + char clone_dir[CFG_MAX]; + bool log_enabled; + char log_dir[CFG_MAX]; +} GConfig; + +void config_defaults(GConfig *cfg); +int config_load(GConfig *cfg, const char *path); +int config_save(const GConfig *cfg, const char *path); +int config_init(const char *path, bool force); +void config_show(const GConfig *cfg); +void config_get_path(char *out, size_t n); +char *expand_tilde(const char *in, char *out, size_t n); + +#endif /* CONFIG_H */ diff --git a/include/git_ops.h b/include/git_ops.h new file mode 100644 index 0000000..bf22f63 --- /dev/null +++ b/include/git_ops.h @@ -0,0 +1,18 @@ +#ifndef GIT_OPS_H +#define GIT_OPS_H + +#include "logger.h" + +/* Returns 1 if clone_dir/project/.git exists, 0 otherwise */ +int git_repo_exists(const char *clone_dir, const char *project); + +/* Clone repo under clone_dir/project using token or password auth */ +int git_clone(const char *base_url, const char *user, + const char *token, const char *password, + const char *project, const char *clone_dir, + Logger *log); + +/* Pull latest in repo_path */ +int git_pull(const char *repo_path, Logger *log); + +#endif /* GIT_OPS_H */ diff --git a/include/logger.h b/include/logger.h new file mode 100644 index 0000000..f5c37e4 --- /dev/null +++ b/include/logger.h @@ -0,0 +1,22 @@ +#ifndef LOGGER_H +#define LOGGER_H + +#include +#include + +typedef struct { + FILE *fp; + char path[512]; + bool enabled; +} Logger; + +int logger_open (Logger *log, const char *log_dir, const char *project); +void logger_close(Logger *log); + +void logger_info (Logger *log, const char *fmt, ...); +void logger_ok (Logger *log, const char *fmt, ...); +void logger_warn (Logger *log, const char *fmt, ...); +void logger_error(Logger *log, const char *fmt, ...); +void logger_raw (Logger *log, const char *fmt, ...); + +#endif /* LOGGER_H */ diff --git a/include/make_ops.h b/include/make_ops.h new file mode 100644 index 0000000..9c4f598 --- /dev/null +++ b/include/make_ops.h @@ -0,0 +1,17 @@ +#ifndef MAKE_OPS_H +#define MAKE_OPS_H + +#include "logger.h" + +#define MAX_TARGETS 256 +#define TARGET_NAME 128 + +typedef struct { + char names[MAX_TARGETS][TARGET_NAME]; + int count; +} MakeTargets; + +int make_parse_targets(const char *repo_path, MakeTargets *out); +int make_build(const char *repo_path, const char *target, Logger *log); + +#endif /* MAKE_OPS_H */ diff --git a/include/tui.h b/include/tui.h new file mode 100644 index 0000000..a2c035f --- /dev/null +++ b/include/tui.h @@ -0,0 +1,16 @@ +#ifndef TUI_H +#define TUI_H + +#include "make_ops.h" +#include + +/* Arrow-key driven target picker. + * Fills `selected` with the chosen target name. + * Returns 0 on selection, -1 if the user cancelled (q / ESC). */ +int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size); + +/* Two-pane log browser: list on left, preview on right. + * Keys: ↑↓ navigate Enter open in less d delete q quit */ +void tui_log_browser(const char *log_dir); + +#endif /* TUI_H */ diff --git a/pkg/deb/DEBIAN/control.in b/pkg/deb/DEBIAN/control.in new file mode 100644 index 0000000..8f03668 --- /dev/null +++ b/pkg/deb/DEBIAN/control.in @@ -0,0 +1,10 @@ +Package: gbuild +Version: @VERSION@ +Architecture: @DEB_ARCH@ +Maintainer: jokerz +Depends: git, make, less, libncurses6 +Section: devel +Priority: optional +Description: Build tool for your Linux distro + Clone, pull, pick a target, build — all from one command. + Includes the gconfig companion for managing ~/.gconfig. diff --git a/pkg/deb/DEBIAN/postinst.in b/pkg/deb/DEBIAN/postinst.in new file mode 100644 index 0000000..3178fec --- /dev/null +++ b/pkg/deb/DEBIAN/postinst.in @@ -0,0 +1,2 @@ +#!/bin/sh +echo "gbuild @VERSION@ installed. Run: gconfig init" diff --git a/pkg/rpm/gbuild.spec.in b/pkg/rpm/gbuild.spec.in new file mode 100644 index 0000000..403fd5e --- /dev/null +++ b/pkg/rpm/gbuild.spec.in @@ -0,0 +1,34 @@ +Name: gbuild +Version: @VERSION@ +Release: 1%{?dist} +Summary: Build tool for your Linux distro +License: MIT +URL: https://spdlab.hu/gbuild +Source0: gbuild-@VERSION@.tar.gz + +BuildRequires: gcc make ncurses-devel +Requires: git make less ncurses-libs + +%description +Clone, pull, pick a target, build — all from one command. +Includes the gconfig companion for managing ~/.gconfig. + +%prep +%autosetup + +%build +make all + +%install +make install DESTDIR=%{buildroot} PREFIX=/usr/local + +%files +/usr/local/bin/gbuild +/usr/local/bin/gconfig + +%post +echo "gbuild @VERSION@ installed. Run: gconfig init" + +%changelog +* @RPM_DATE@ jokerz - @VERSION@-1 +- Initial C rewrite release diff --git a/src/config.c b/src/config.c new file mode 100644 index 0000000..46b8b5f --- /dev/null +++ b/src/config.c @@ -0,0 +1,195 @@ +#include "config.h" + +#include +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------------ helpers */ + +char *expand_tilde(const char *in, char *out, size_t n) +{ + if (!in || in[0] != '~') { + strncpy(out, in ? in : "", n - 1); + out[n - 1] = '\0'; + return out; + } + const char *home = getenv("HOME"); + if (!home) home = "/tmp"; + snprintf(out, n, "%s%s", home, in + 1); + return out; +} + +void config_get_path(char *out, size_t n) +{ + const char *home = getenv("HOME"); + if (!home) home = "/tmp"; + snprintf(out, n, "%s/.gconfig", home); +} + +/* ---------------------------------------------------------------- defaults */ + +void config_defaults(GConfig *cfg) +{ + memset(cfg, 0, sizeof(*cfg)); + strncpy(cfg->git_url, "http://localhost:3000", CFG_MAX - 1); + strncpy(cfg->git_user, "", sizeof(cfg->git_user) - 1); + cfg->log_enabled = true; + + const char *home = getenv("HOME"); + if (!home) home = "/tmp"; + snprintf(cfg->clone_dir, CFG_MAX, "%s/projects", home); + snprintf(cfg->log_dir, CFG_MAX, "%s/.local/log/gbuild", home); +} + +/* ------------------------------------------------------------------ loader */ + +static void strip(char *s) +{ + /* strip leading whitespace */ + char *p = s; + while (*p == ' ' || *p == '\t') p++; + if (p != s) memmove(s, p, strlen(p) + 1); + /* strip trailing whitespace / newline */ + size_t l = strlen(s); + while (l > 0 && (s[l-1] == '\n' || s[l-1] == '\r' || + s[l-1] == ' ' || s[l-1] == '\t')) + s[--l] = '\0'; +} + +int config_load(GConfig *cfg, const char *path) +{ + config_defaults(cfg); + + FILE *f = fopen(path, "r"); + if (!f) return -1; + + char line[512]; + char section[64] = ""; + + while (fgets(line, sizeof(line), f)) { + strip(line); + if (line[0] == '\0' || line[0] == '#') continue; + + /* section header */ + if (line[0] == '[') { + char *end = strchr(line, ']'); + if (end) { + *end = '\0'; + strncpy(section, line + 1, sizeof(section) - 1); + } + continue; + } + + /* key = value */ + char *eq = strchr(line, '='); + if (!eq) continue; + *eq = '\0'; + char *key = line; + char *val = eq + 1; + strip(key); + strip(val); + + char expanded[CFG_MAX]; + expand_tilde(val, expanded, sizeof(expanded)); + + if (!strcmp(key, "GIT_URL")) strncpy(cfg->git_url, expanded, CFG_MAX - 1); + else if (!strcmp(key, "GIT_USER")) strncpy(cfg->git_user, expanded, sizeof(cfg->git_user) - 1); + else if (!strcmp(key, "GIT_TOKEN")) strncpy(cfg->git_token, expanded, sizeof(cfg->git_token) - 1); + else if (!strcmp(key, "GIT_PASSWORD")) strncpy(cfg->git_password, expanded, sizeof(cfg->git_password) - 1); + else if (!strcmp(key, "DEFAULT_TARGET")) strncpy(cfg->default_target, expanded, sizeof(cfg->default_target) - 1); + else if (!strcmp(key, "CLONE_DIR")) strncpy(cfg->clone_dir, expanded, CFG_MAX - 1); + else if (!strcmp(key, "LOG_DIR")) strncpy(cfg->log_dir, expanded, CFG_MAX - 1); + else if (!strcmp(key, "LOG_ENABLED")) cfg->log_enabled = (strcmp(expanded, "false") != 0 && + strcmp(expanded, "0") != 0); + (void)section; + } + + fclose(f); + return 0; +} + +/* ------------------------------------------------------------------ saver */ + +int config_save(const GConfig *cfg, const char *path) +{ + FILE *f = fopen(path, "w"); + if (!f) return -1; + + fprintf(f, + "# ~/.gconfig — managed by gconfig\n\n" + "[git]\n" + "GIT_URL = %s\n" + "GIT_USER = %s\n\n" + "[auth]\n" + "# Use token-based OR password-based auth; leave the other blank\n" + "GIT_TOKEN = %s\n" + "GIT_PASSWORD = %s\n\n" + "[build]\n" + "DEFAULT_TARGET = %s\n" + "CLONE_DIR = %s\n\n" + "[log]\n" + "LOG_ENABLED = %s\n" + "LOG_DIR = %s\n", + cfg->git_url, + cfg->git_user, + cfg->git_token, + cfg->git_password, + cfg->default_target, + cfg->clone_dir, + cfg->log_enabled ? "true" : "false", + cfg->log_dir); + + fclose(f); + return 0; +} + +/* ------------------------------------------------------------------ init */ + +int config_init(const char *path, bool force) +{ + struct stat st; + if (!force && stat(path, &st) == 0) { + /* file exists and we're not forcing */ + return 1; /* already exists */ + } + + if (force && stat(path, &st) == 0) { + /* back up existing file */ + time_t now = time(NULL); + char backup[CFG_MAX + 32]; + snprintf(backup, sizeof(backup), "%s.bak.%ld", path, (long)now); + rename(path, backup); + printf("Backed up existing config to: %s\n", backup); + } + + GConfig cfg; + config_defaults(&cfg); + return config_save(&cfg, path); +} + +/* ------------------------------------------------------------------ show */ + +void config_show(const GConfig *cfg) +{ + const char *tok = cfg->git_token[0] ? "****" : "(not set)"; + const char *pass = cfg->git_password[0] ? "****" : "(not set)"; + + printf("\n [git]\n"); + printf(" GIT_URL = %s\n", cfg->git_url[0] ? cfg->git_url : "(not set)"); + printf(" GIT_USER = %s\n", cfg->git_user[0] ? cfg->git_user : "(not set)"); + + printf("\n [auth]\n"); + printf(" GIT_TOKEN = %s\n", tok); + printf(" GIT_PASSWORD = %s\n", pass); + + printf("\n [build]\n"); + printf(" DEFAULT_TARGET = %s\n", cfg->default_target[0] ? cfg->default_target : "(not set)"); + printf(" CLONE_DIR = %s\n", cfg->clone_dir); + + printf("\n [log]\n"); + printf(" LOG_ENABLED = %s\n", cfg->log_enabled ? "true" : "false"); + printf(" LOG_DIR = %s\n\n", cfg->log_dir); +} diff --git a/src/gbuild.c b/src/gbuild.c new file mode 100644 index 0000000..9bc8b8a --- /dev/null +++ b/src/gbuild.c @@ -0,0 +1,194 @@ +#include "config.h" +#include "git_ops.h" +#include "logger.h" +#include "make_ops.h" +#include "tui.h" + +#include +#include +#include +#include +#include + +#define VERSION "1.0.0" + +/* ----------------------------------------------------------------- usage */ + +static void print_usage(const char *prog) +{ + printf( + "gbuild %s — A build tool for your Linux distro\n\n" + "Usage:\n" + " %s [options] \n" + " %s --logs\n\n" + "Options:\n" + " --url Override Git base URL (default: ~/.gconfig)\n" + " --user Override Git username (default: ~/.gconfig)\n" + " --target Run target directly, skip interactive picker\n" + " --logs Open the interactive log browser\n" + " -h, --help Print this help and exit\n\n" + "Examples:\n" + " gbuild myproject\n" + " gbuild --target clean myproject\n" + " gbuild --url http://10.0.0.5:3000 --user alice myproject\n" + " gbuild --logs\n\n" + "Configuration is read from ~/.gconfig. Run 'gconfig init' to set it up.\n", + VERSION, prog, prog); +} + +/* ----------------------------------------------------------------- main */ + +int main(int argc, char *argv[]) +{ + /* ---- parse arguments ---- */ + const char *arg_url = NULL; + const char *arg_user = NULL; + const char *arg_target = NULL; + const char *arg_project = NULL; + int flag_logs = 0; + + for (int i = 1; i < argc; i++) { + if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")) { + print_usage(argv[0]); + return 0; + } else if (!strcmp(argv[i], "--url")) { + if (++i >= argc) { fprintf(stderr, "error: --url requires a value\n"); return 1; } + arg_url = argv[i]; + } else if (!strcmp(argv[i], "--user")) { + if (++i >= argc) { fprintf(stderr, "error: --user requires a value\n"); return 1; } + arg_user = argv[i]; + } else if (!strcmp(argv[i], "--target")) { + if (++i >= argc) { fprintf(stderr, "error: --target requires a value\n"); return 1; } + arg_target = argv[i]; + } else if (!strcmp(argv[i], "--logs")) { + flag_logs = 1; + } else if (argv[i][0] == '-') { + fprintf(stderr, "error: unknown flag '%s'\n", argv[i]); + return 1; + } else { + if (arg_project) { + fprintf(stderr, "error: unexpected argument '%s'\n", argv[i]); + return 1; + } + arg_project = argv[i]; + } + } + + /* ---- load config ---- */ + GConfig cfg; + char cfg_path[512]; + config_get_path(cfg_path, sizeof(cfg_path)); + + if (config_load(&cfg, cfg_path) != 0) { + fprintf(stderr, + "[WARN ] ~/.gconfig not found — using built-in defaults.\n" + " Run 'gconfig init' to create one.\n"); + config_defaults(&cfg); + } + + /* apply overrides */ + if (arg_url) strncpy(cfg.git_url, arg_url, CFG_MAX - 1); + if (arg_user) strncpy(cfg.git_user, arg_user, sizeof(cfg.git_user) - 1); + + /* expand paths that might contain ~ */ + char clone_dir[CFG_MAX], log_dir[CFG_MAX]; + expand_tilde(cfg.clone_dir, clone_dir, sizeof(clone_dir)); + expand_tilde(cfg.log_dir, log_dir, sizeof(log_dir)); + + /* ---- log browser mode ---- */ + if (flag_logs) { + tui_log_browser(log_dir); + return 0; + } + + /* ---- need a project name ---- */ + if (!arg_project) { + fprintf(stderr, "error: no project specified.\n\n"); + print_usage(argv[0]); + return 1; + } + + /* ---- open logger ---- */ + Logger log; + memset(&log, 0, sizeof(log)); + if (cfg.log_enabled) { + if (logger_open(&log, log_dir, arg_project) == 0) + logger_info(&log, "Logging to: %s", log.path); + else + fprintf(stderr, "[WARN ] Could not open log file in %s\n", log_dir); + } + + logger_info(&log, "gbuild started for project: %s", arg_project); + + /* ---- clone or pull ---- */ + char repo_path[CFG_MAX * 2]; + snprintf(repo_path, sizeof(repo_path), "%s/%s", clone_dir, arg_project); + + if (git_repo_exists(clone_dir, arg_project)) { + logger_info(&log, "Repo already cloned — pulling latest changes ..."); + int rc = git_pull(repo_path, &log); + if (rc != 0) { + logger_error(&log, "git pull failed (exit %d).", rc); + logger_close(&log); + return 1; + } + logger_ok(&log, "Repository updated."); + } else { + logger_info(&log, "Cloning %s/%s/%s ...", + cfg.git_url, cfg.git_user, arg_project); + int rc = git_clone(cfg.git_url, cfg.git_user, + cfg.git_token, cfg.git_password, + arg_project, clone_dir, &log); + if (rc != 0) { + logger_error(&log, "git clone failed (exit %d).", rc); + logger_close(&log); + return 1; + } + logger_ok(&log, "Repository cloned."); + } + + /* ---- resolve make target ---- */ + char target[TARGET_NAME]; + memset(target, 0, sizeof(target)); + + if (arg_target) { + /* explicit --target flag */ + strncpy(target, arg_target, sizeof(target) - 1); + logger_info(&log, "Selected target: %s", target); + } else if (cfg.default_target[0]) { + /* default from config */ + strncpy(target, cfg.default_target, sizeof(target) - 1); + logger_info(&log, "Using default target: %s", target); + } else { + /* interactive TUI picker */ + MakeTargets targets; + if (make_parse_targets(repo_path, &targets) != 0) { + logger_warn(&log, + "No targets found in Makefile — running 'make' with no target."); + target[0] = '\0'; + } else { + if (tui_pick_target(&targets, target, sizeof(target)) < 0) { + logger_warn(&log, "No target selected — aborting."); + logger_close(&log); + return 0; + } + } + logger_info(&log, "Selected target: %s", + target[0] ? target : "(default)"); + } + + /* ---- build ---- */ + logger_info(&log, "gbuild: building '%s' target '%s' ...", + arg_project, target[0] ? target : "(default)"); + + int rc = make_build(repo_path, target, &log); + if (rc != 0) { + logger_error(&log, "Build failed (exit %d).", rc); + logger_close(&log); + return 1; + } + + logger_ok(&log, "gbuild complete."); + logger_close(&log); + return 0; +} diff --git a/src/gconfig.c b/src/gconfig.c new file mode 100644 index 0000000..eb548ce --- /dev/null +++ b/src/gconfig.c @@ -0,0 +1,91 @@ +#include "config.h" + +#include +#include +#include +#include + +#define VERSION "1.0.0" + +/* ----------------------------------------------------------------- usage */ + +static void print_usage(void) +{ + printf( + "gconfig %s — Manage ~/.gconfig for gbuild\n\n" + "Usage:\n" + " gconfig [flags]\n\n" + "Commands:\n" + " init Create ~/.gconfig with defaults (skips if already exists)\n" + " init --force Overwrite existing config; backs up the old file first\n" + " show Print current config values (auth fields masked)\n" + " help Print this help and exit\n\n" + "Quick setup:\n" + " gconfig init\n" + " $EDITOR ~/.gconfig\n" + " gconfig show\n", + VERSION); +} + +/* ----------------------------------------------------------------- main */ + +int main(int argc, char *argv[]) +{ + if (argc < 2) { + print_usage(); + return 1; + } + + char cfg_path[512]; + config_get_path(cfg_path, sizeof(cfg_path)); + + const char *cmd = argv[1]; + + /* ---- help ---- */ + if (!strcmp(cmd, "help") || !strcmp(cmd, "-h") || !strcmp(cmd, "--help")) { + print_usage(); + return 0; + } + + /* ---- init ---- */ + if (!strcmp(cmd, "init")) { + int force = 0; + for (int i = 2; i < argc; i++) { + if (!strcmp(argv[i], "--force") || !strcmp(argv[i], "-f")) + force = 1; + } + + int rc = config_init(cfg_path, force); + if (rc < 0) { + fprintf(stderr, "error: could not write %s: ", cfg_path); + perror(NULL); + return 1; + } + if (rc == 1) { + printf("~/.gconfig already exists. Use 'gconfig init --force' to overwrite.\n"); + return 0; + } + printf("Created %s with default values.\n", cfg_path); + printf("Edit it with: $EDITOR %s\n", cfg_path); + printf("Verify with: gconfig show\n"); + return 0; + } + + /* ---- show ---- */ + if (!strcmp(cmd, "show")) { + GConfig cfg; + if (config_load(&cfg, cfg_path) != 0) { + fprintf(stderr, + "error: could not read %s\n" + "Run 'gconfig init' to create it.\n", cfg_path); + return 1; + } + printf("\n%s\n", cfg_path); + config_show(&cfg); + return 0; + } + + fprintf(stderr, "error: unknown command '%s'\n\n", cmd); + print_usage(); + return 1; +} diff --git a/src/git_ops.c b/src/git_ops.c new file mode 100644 index 0000000..4923c3d --- /dev/null +++ b/src/git_ops.c @@ -0,0 +1,125 @@ +#include "git_ops.h" +#include "config.h" + +#include +#include +#include +#include +#include +#include + +/* ---------------------------------------------------------------- helpers */ + +/* + * Run `cmd`, streaming output to stdout and log simultaneously. + * Returns the exit status of the child process. + */ +static int run_stream(const char *cmd, Logger *log) +{ + FILE *pipe = popen(cmd, "r"); + if (!pipe) { + logger_error(log, "popen failed: %s", strerror(errno)); + return -1; + } + + char line[1024]; + while (fgets(line, sizeof(line), pipe)) { + /* strip trailing newline for logger_raw so it adds its own */ + size_t l = strlen(line); + if (l > 0 && line[l-1] == '\n') line[l-1] = '\0'; + logger_raw(log, "%s\n", line); + } + + int status = pclose(pipe); + return (status == -1) ? -1 : WEXITSTATUS(status); +} + +/* + * Inject credentials into a URL of the form scheme://host/path + * producing scheme://user:auth@host/path. + * + * If both token and password are empty the URL is left unmodified + * (useful for public repos). + */ +static void build_auth_url(char *out, size_t n, + const char *base_url, const char *user, + const char *token, const char *password, + const char *project) +{ + const char *auth = (token && token[0]) ? token : + (password && password[0]) ? password : NULL; + + /* find end of scheme:// */ + const char *sep = strstr(base_url, "://"); + if (!sep) { + /* fallback: treat whole thing as host */ + if (auth) + snprintf(out, n, "http://%s:%s@%s/%s/%s", + user, auth, base_url, user, project); + else + snprintf(out, n, "http://%s/%s/%s", base_url, user, project); + return; + } + + /* scheme = base_url[0 .. sep+3) */ + char scheme[16] = {0}; + size_t slen = (size_t)(sep + 3 - base_url); + if (slen >= sizeof(scheme)) slen = sizeof(scheme) - 1; + strncpy(scheme, base_url, slen); + + const char *host = base_url + slen; + + /* strip trailing slash from host fragment */ + char hostbuf[512]; + strncpy(hostbuf, host, sizeof(hostbuf) - 1); + hostbuf[sizeof(hostbuf) - 1] = '\0'; + size_t hl = strlen(hostbuf); + while (hl > 0 && hostbuf[hl-1] == '/') hostbuf[--hl] = '\0'; + + if (auth && user && user[0]) + snprintf(out, n, "%s%s:%s@%s/%s/%s", + scheme, user, auth, hostbuf, user, project); + else if (user && user[0]) + snprintf(out, n, "%s%s@%s/%s/%s", + scheme, user, hostbuf, user, project); + else + snprintf(out, n, "%s%s/%s/%s", scheme, hostbuf, user, project); +} + +/* --------------------------------------------------------- public API */ + +int git_repo_exists(const char *clone_dir, const char *project) +{ + char git_path[CFG_MAX * 2]; + snprintf(git_path, sizeof(git_path), "%s/%s/.git", clone_dir, project); + struct stat st; + return (stat(git_path, &st) == 0 && S_ISDIR(st.st_mode)) ? 1 : 0; +} + +int git_clone(const char *base_url, const char *user, + const char *token, const char *password, + const char *project, const char *clone_dir, + Logger *log) +{ + char url[CFG_MAX * 2]; + build_auth_url(url, sizeof(url), + base_url, user, token, password, project); + + /* NOTE: credentials appear in the URL which git masks in its own output, + * but they may briefly be visible in /proc//cmdline. + * For a higher-security setup, consider GIT_ASKPASS instead. */ + char cmd[CFG_MAX * 4]; + snprintf(cmd, sizeof(cmd), + "git clone \"%s\" \"%s/%s\" 2>&1", + url, clone_dir, project); + + return run_stream(cmd, log); +} + +int git_pull(const char *repo_path, Logger *log) +{ + char cmd[CFG_MAX * 2]; + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" pull 2>&1", repo_path); + return run_stream(cmd, log); +} diff --git a/src/logger.c b/src/logger.c new file mode 100644 index 0000000..de2eead --- /dev/null +++ b/src/logger.c @@ -0,0 +1,158 @@ +#include "logger.h" + +#include +#include +#include +#include +#include +#include +#include + +/* ANSI colours */ +#define COL_RESET "\033[0m" +#define COL_CYAN "\033[36m" +#define COL_GREEN "\033[32m" +#define COL_YELLOW "\033[33m" +#define COL_RED "\033[31m" +#define COL_BOLD "\033[1m" + +/* ---------------------------------------------------------------- helpers */ + +static void mkdirs(const char *path) +{ + char tmp[512]; + strncpy(tmp, path, sizeof(tmp) - 1); + tmp[sizeof(tmp) - 1] = '\0'; + for (char *p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + mkdir(tmp, 0755); + *p = '/'; + } + } + mkdir(tmp, 0755); +} + +/* ------------------------------------------------------------------ open */ + +int logger_open(Logger *log, const char *log_dir, const char *project) +{ + memset(log, 0, sizeof(*log)); + + mkdirs(log_dir); + + time_t now = time(NULL); + struct tm *t = localtime(&now); + char ts[32]; + strftime(ts, sizeof(ts), "%Y%m%d_%H%M%S", t); + + snprintf(log->path, sizeof(log->path), + "%s/%s_%s.log", log_dir, ts, project); + + log->fp = fopen(log->path, "w"); + if (!log->fp) return -1; + + log->enabled = true; + return 0; +} + +void logger_close(Logger *log) +{ + if (log->fp) { + fclose(log->fp); + log->fp = NULL; + } +} + +/* ----------------------------------------------------------------- write */ + +typedef enum { LVL_INFO, LVL_OK, LVL_WARN, LVL_ERROR } LogLevel; + +static const char *level_tag(LogLevel lv) +{ + switch (lv) { + case LVL_INFO: return "[INFO ]"; + case LVL_OK: return "[OK ]"; + case LVL_WARN: return "[WARN ]"; + case LVL_ERROR: return "[ERROR]"; + } + return "[ ]"; +} + +static const char *level_color(LogLevel lv) +{ + switch (lv) { + case LVL_INFO: return COL_CYAN; + case LVL_OK: return COL_GREEN COL_BOLD; + case LVL_WARN: return COL_YELLOW; + case LVL_ERROR: return COL_RED COL_BOLD; + } + return ""; +} + +static void write_level(Logger *log, LogLevel lv, const char *fmt, va_list ap) +{ + char msg[2048]; + vsnprintf(msg, sizeof(msg), fmt, ap); + + /* stdout — coloured */ + printf("%s%s%s %s\n", level_color(lv), level_tag(lv), COL_RESET, msg); + fflush(stdout); + + /* log file — plain */ + if (log && log->fp) { + /* prepend a timestamp in the file */ + time_t now = time(NULL); + struct tm *t = localtime(&now); + char ts[32]; + strftime(ts, sizeof(ts), "%H:%M:%S", t); + fprintf(log->fp, "%s %s %s\n", ts, level_tag(lv), msg); + fflush(log->fp); + } +} + +void logger_info(Logger *log, const char *fmt, ...) +{ + va_list ap; va_start(ap, fmt); + write_level(log, LVL_INFO, fmt, ap); + va_end(ap); +} + +void logger_ok(Logger *log, const char *fmt, ...) +{ + va_list ap; va_start(ap, fmt); + write_level(log, LVL_OK, fmt, ap); + va_end(ap); +} + +void logger_warn(Logger *log, const char *fmt, ...) +{ + va_list ap; va_start(ap, fmt); + write_level(log, LVL_WARN, fmt, ap); + va_end(ap); +} + +void logger_error(Logger *log, const char *fmt, ...) +{ + va_list ap; va_start(ap, fmt); + write_level(log, LVL_ERROR, fmt, ap); + va_end(ap); +} + +void logger_raw(Logger *log, const char *fmt, ...) +{ + va_list ap; + + va_start(ap, fmt); + vprintf(fmt, ap); + va_end(ap); + + fflush(stdout); + + if (log && log->fp) { + va_start(ap, fmt); + vfprintf(log->fp, fmt, ap); + va_end(ap); + fflush(log->fp); + } +} diff --git a/src/make_ops.c b/src/make_ops.c new file mode 100644 index 0000000..156228e --- /dev/null +++ b/src/make_ops.c @@ -0,0 +1,128 @@ +#include "make_ops.h" + +#include +#include +#include +#include +#include + +/* ------------------------------------------------------------ helpers */ + +/* Special make targets we want to skip in the picker */ +static const char *SKIP_TARGETS[] = { + ".PHONY", ".SUFFIXES", ".DEFAULT", ".PRECIOUS", + ".INTERMEDIATE", ".SECONDARY", ".NOTPARALLEL", + ".DELETE_ON_ERROR", ".IGNORE", ".LOW_RESOLUTION_TIME", + ".SILENT", ".EXPORT_ALL_VARIABLES", ".ONESHELL", + NULL +}; + +static int is_special(const char *name) +{ + for (int i = 0; SKIP_TARGETS[i]; i++) + if (!strcmp(name, SKIP_TARGETS[i])) return 1; + return 0; +} + +static int is_valid_target_char(char c) +{ + return isalnum((unsigned char)c) || c == '_' || c == '-' || c == '.'; +} + +/* -------------------------------------------------------- parser */ + +int make_parse_targets(const char *repo_path, MakeTargets *out) +{ + /* Try Makefile then makefile */ + char mf_path[1024]; + FILE *f = NULL; + + snprintf(mf_path, sizeof(mf_path), "%s/Makefile", repo_path); + f = fopen(mf_path, "r"); + if (!f) { + snprintf(mf_path, sizeof(mf_path), "%s/makefile", repo_path); + f = fopen(mf_path, "r"); + } + if (!f) return -1; + + out->count = 0; + + char line[2048]; + while (fgets(line, sizeof(line), f) && out->count < MAX_TARGETS) { + /* skip comment lines and recipe lines (start with tab) */ + if (line[0] == '#' || line[0] == '\t' || line[0] == '\n') continue; + + /* find first ':' */ + char *colon = strchr(line, ':'); + if (!colon) continue; + + /* must not be := += ?= (variable assignment) */ + if (colon > line && (*(colon-1) == ':' || + *(colon-1) == '+' || + *(colon-1) == '?')) continue; + + /* extract the name part before ':' */ + size_t name_len = (size_t)(colon - line); + if (name_len == 0 || name_len >= TARGET_NAME) continue; + + char name[TARGET_NAME]; + strncpy(name, line, name_len); + name[name_len] = '\0'; + + /* strip surrounding whitespace */ + /* (shouldn't have leading whitespace since we skipped tabs, + * but strip trailing) */ + while (name_len > 0 && isspace((unsigned char)name[name_len - 1])) + name[--name_len] = '\0'; + + if (name_len == 0) continue; + + /* skip if name contains non-target chars (%, =, space) */ + int valid = 1; + for (size_t i = 0; i < name_len; i++) { + if (!is_valid_target_char(name[i])) { valid = 0; break; } + } + if (!valid) continue; + + /* skip special targets */ + if (is_special(name)) continue; + + /* skip duplicates */ + int dup = 0; + for (int i = 0; i < out->count; i++) + if (!strcmp(out->names[i], name)) { dup = 1; break; } + if (dup) continue; + + strncpy(out->names[out->count], name, TARGET_NAME - 1); + out->names[out->count][TARGET_NAME - 1] = '\0'; + out->count++; + } + + fclose(f); + return (out->count > 0) ? 0 : -1; +} + +/* -------------------------------------------------------- build */ + +int make_build(const char *repo_path, const char *target, Logger *log) +{ + char cmd[2048]; + snprintf(cmd, sizeof(cmd), + "make -C \"%s\" %s 2>&1", repo_path, target); + + FILE *pipe = popen(cmd, "r"); + if (!pipe) { + logger_error(log, "popen failed: %s", strerror(errno)); + return -1; + } + + char line[1024]; + while (fgets(line, sizeof(line), pipe)) { + size_t l = strlen(line); + if (l > 0 && line[l-1] == '\n') line[l-1] = '\0'; + logger_raw(log, "%s\n", line); + } + + int status = pclose(pipe); + return (status == -1) ? -1 : WEXITSTATUS(status); +} diff --git a/src/tui.c b/src/tui.c new file mode 100644 index 0000000..2498c6f --- /dev/null +++ b/src/tui.c @@ -0,0 +1,429 @@ +#include "tui.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +/* ================================================================ + * SECTION 1 — TARGET PICKER + * ================================================================ */ + +#define PICKER_MIN_W 40 +#define PICKER_MIN_H 8 + +int tui_pick_target(const MakeTargets *targets, char *selected, size_t sel_size) +{ + if (!targets || targets->count == 0) return -1; + + initscr(); + noecho(); + cbreak(); + keypad(stdscr, TRUE); + curs_set(0); + + if (has_colors()) { + start_color(); + use_default_colors(); + init_pair(1, COLOR_BLACK, COLOR_CYAN); /* selected row */ + init_pair(2, COLOR_CYAN, -1); /* border colour */ + init_pair(3, COLOR_WHITE, -1); /* normal row */ + } + + int rows, cols; + getmaxyx(stdscr, rows, cols); + + int list_h = targets->count + 2; /* +2 for border */ + int list_w = PICKER_MIN_W; + + /* find longest target name and widen accordingly */ + for (int i = 0; i < targets->count; i++) { + int l = (int)strlen(targets->names[i]) + 4; + if (l > list_w) list_w = l; + } + if (list_w > cols - 4) list_w = cols - 4; + if (list_h > rows - 4) list_h = rows - 4; + + int win_y = (rows - list_h) / 2; + int win_x = (cols - list_w) / 2; + + /* instruction line above the box */ + int cur = 0; + int scroll_offset = 0; + int visible = list_h - 2; /* rows inside border */ + + WINDOW *win = newwin(list_h, list_w, win_y, win_x); + + /* header hint */ + attron(A_DIM); + mvprintw(win_y - 2, win_x, + "Select make target K - UP J - DOWN to navigate Enter - select \u00b7 q - cancel"); + attroff(A_DIM); + refresh(); + + int done = 0; + int result = -1; + + while (!done) { + werase(win); + if (has_colors()) wattron(win, COLOR_PAIR(2)); + box(win, 0, 0); + if (has_colors()) wattroff(win, COLOR_PAIR(2)); + + for (int i = 0; i < visible && (i + scroll_offset) < targets->count; i++) { + int idx = i + scroll_offset; + if (idx == cur) { + if (has_colors()) wattron(win, COLOR_PAIR(1) | A_BOLD); + else wattron(win, A_REVERSE); + mvwprintw(win, i + 1, 1, " %-*s ", + list_w - 4, targets->names[idx]); + if (has_colors()) wattroff(win, COLOR_PAIR(1) | A_BOLD); + else wattroff(win, A_REVERSE); + } else { + if (has_colors()) wattron(win, COLOR_PAIR(3)); + mvwprintw(win, i + 1, 1, " %-*s", + list_w - 4, targets->names[idx]); + if (has_colors()) wattroff(win, COLOR_PAIR(3)); + } + } + + wrefresh(win); + + int ch = wgetch(win); + switch (ch) { + case KEY_UP: + case 'k': + if (cur > 0) { + cur--; + if (cur < scroll_offset) scroll_offset = cur; + } + break; + case KEY_DOWN: + case 'j': + if (cur < targets->count - 1) { + cur++; + if (cur >= scroll_offset + visible) + scroll_offset = cur - visible + 1; + } + break; + case '\n': + case '\r': + case KEY_ENTER: + strncpy(selected, targets->names[cur], sel_size - 1); + selected[sel_size - 1] = '\0'; + result = cur; + done = 1; + break; + case 'q': + case 27: /* ESC */ + done = 1; + break; + } + } + + delwin(win); + endwin(); + return result; +} + +/* ================================================================ + * SECTION 2 — LOG BROWSER + * ================================================================ */ + +#define MAX_LOGS 512 + +typedef struct { + char name[256]; /* filename only */ + char path[768]; /* full path */ + time_t mtime; +} LogEntry; + +static int log_entry_cmp(const void *a, const void *b) +{ + /* newest first */ + const LogEntry *la = (const LogEntry *)a; + const LogEntry *lb = (const LogEntry *)b; + if (lb->mtime > la->mtime) return 1; + if (lb->mtime < la->mtime) return -1; + return 0; +} + +static int load_log_entries(const char *log_dir, + LogEntry *entries, int max) +{ + DIR *d = opendir(log_dir); + if (!d) return 0; + + int count = 0; + struct dirent *de; + while ((de = readdir(d)) && count < max) { + if (de->d_name[0] == '.') continue; + /* only .log files */ + const char *dot = strrchr(de->d_name, '.'); + if (!dot || strcmp(dot, ".log")) continue; + + strncpy(entries[count].name, de->d_name, + sizeof(entries[count].name) - 1); + snprintf(entries[count].path, sizeof(entries[count].path), + "%s/%s", log_dir, de->d_name); + + struct stat st; + if (stat(entries[count].path, &st) == 0) + entries[count].mtime = st.st_mtime; + else + entries[count].mtime = 0; + count++; + } + closedir(d); + + qsort(entries, count, sizeof(LogEntry), log_entry_cmp); + return count; +} + +/* + * Read up to `max_lines` trailing lines from `path` into `lines`. + * Returns the number of lines read. + * Caller owns the memory and should free each pointer. + */ +#define PREVIEW_MAX 128 + +static int read_tail(const char *path, char **lines, int max_lines) +{ + FILE *f = fopen(path, "r"); + if (!f) return 0; + + /* Circular buffer approach */ + char **buf = calloc(max_lines, sizeof(char *)); + if (!buf) { fclose(f); return 0; } + + char tmp[1024]; + int idx = 0, total = 0; + while (fgets(tmp, sizeof(tmp), f)) { + /* strip newline */ + size_t l = strlen(tmp); + if (l > 0 && tmp[l-1] == '\n') tmp[l-1] = '\0'; + + free(buf[idx % max_lines]); + buf[idx % max_lines] = strdup(tmp); + idx++; + total++; + } + fclose(f); + + int have = (total < max_lines) ? total : max_lines; + int start = (total >= max_lines) ? (idx % max_lines) : 0; + + for (int i = 0; i < have; i++) + lines[i] = buf[(start + i) % max_lines]; + + /* free slots that weren't returned */ + for (int i = have; i < max_lines; i++) { + free(buf[(start + i) % max_lines]); + buf[(start + i) % max_lines] = NULL; + } + free(buf); + return have; +} + +/* ---------------------------------------------------------------- browser */ + +void tui_log_browser(const char *log_dir) +{ + LogEntry *entries = calloc(MAX_LOGS, sizeof(LogEntry)); + if (!entries) return; + + int count = load_log_entries(log_dir, entries, MAX_LOGS); + + initscr(); + noecho(); + cbreak(); + keypad(stdscr, TRUE); + curs_set(0); + + if (has_colors()) { + start_color(); + use_default_colors(); + init_pair(1, COLOR_BLACK, COLOR_CYAN); /* selected row */ + init_pair(2, COLOR_CYAN, -1); /* border */ + init_pair(3, COLOR_WHITE, -1); /* normal row */ + init_pair(4, COLOR_YELLOW,-1); /* preview text */ + init_pair(5, COLOR_RED, -1); /* status/warn */ + } + + int rows, cols; + getmaxyx(stdscr, rows, cols); + + /* layout: left pane = 40% width, right pane = rest */ + int left_w = cols * 40 / 100; + if (left_w < 30) left_w = 30; + int right_w = cols - left_w - 1; /* -1 for divider */ + int pane_h = rows - 3; /* -3 for title + hint bar */ + + WINDOW *wleft = newwin(pane_h, left_w, 1, 0); + WINDOW *wright = newwin(pane_h, right_w, 1, left_w + 1); + + int cur = 0, scroll = 0; + int done = 0; + + while (!done) { + getmaxyx(stdscr, rows, cols); + pane_h = rows - 3; + + /* ---- title bar ---- */ + attron(A_BOLD); + mvprintw(0, 2, "gbuild log browser"); + attroff(A_BOLD); + clrtoeol(); + + /* ---- hint bar ---- */ + if (has_colors()) attron(COLOR_PAIR(2)); + mvprintw(rows - 2, 0, + " \u2191\u2193 navigate Enter open in less d delete q quit " + " "); + if (has_colors()) attroff(COLOR_PAIR(2)); + + /* ---- vertical divider ---- */ + for (int y = 1; y < rows - 2; y++) + mvaddch(y, left_w, ACS_VLINE); + + /* ---- left pane: file list ---- */ + werase(wleft); + if (has_colors()) wattron(wleft, COLOR_PAIR(2)); + box(wleft, 0, 0); + if (has_colors()) wattroff(wleft, COLOR_PAIR(2)); + + int visible = pane_h - 2; + if (visible < 1) visible = 1; + + if (count == 0) { + mvwprintw(wleft, 2, 2, "(no logs found)"); + } else { + for (int i = 0; i < visible && (i + scroll) < count; i++) { + int idx = i + scroll; + /* trim the .log extension for display */ + char disp[256]; + strncpy(disp, entries[idx].name, sizeof(disp) - 1); + char *dot = strrchr(disp, '.'); + if (dot) *dot = '\0'; + + if (idx == cur) { + if (has_colors()) wattron(wleft, COLOR_PAIR(1) | A_BOLD); + else wattron(wleft, A_REVERSE); + mvwprintw(wleft, i + 1, 1, " %-*s ", + left_w - 4, disp); + if (has_colors()) wattroff(wleft, COLOR_PAIR(1) | A_BOLD); + else wattroff(wleft, A_REVERSE); + } else { + mvwprintw(wleft, i + 1, 2, "%-*s", + left_w - 4, disp); + } + } + } + wrefresh(wleft); + + /* ---- right pane: preview ---- */ + werase(wright); + if (has_colors()) wattron(wright, COLOR_PAIR(2)); + box(wright, 0, 0); + if (has_colors()) wattroff(wright, COLOR_PAIR(2)); + + if (count > 0 && cur < count) { + int preview_lines = pane_h - 2; + char **lines = calloc(preview_lines, sizeof(char *)); + if (lines) { + int n = read_tail(entries[cur].path, lines, preview_lines); + if (has_colors()) wattron(wright, COLOR_PAIR(4)); + for (int i = 0; i < n; i++) { + if (lines[i]) { + mvwprintw(wright, i + 1, 1, "%-*.*s", + right_w - 2, right_w - 2, lines[i]); + free(lines[i]); + } + } + if (has_colors()) wattroff(wright, COLOR_PAIR(4)); + free(lines); + } + } else { + mvwprintw(wright, 2, 2, "(no log selected)"); + } + wrefresh(wright); + refresh(); + + /* ---- input ---- */ + int ch = getch(); + switch (ch) { + case KEY_UP: + case 'k': + if (cur > 0) { + cur--; + if (cur < scroll) scroll = cur; + } + break; + case KEY_DOWN: + case 'j': + if (cur < count - 1) { + cur++; + if (cur >= scroll + visible) + scroll = cur - visible + 1; + } + break; + case '\n': + case '\r': + case KEY_ENTER: + if (count > 0 && cur < count) { + endwin(); + char cmd[1024]; + snprintf(cmd, sizeof(cmd), + "less \"%s\"", entries[cur].path); + system(cmd); + /* re-init after less exits */ + initscr(); + noecho(); + cbreak(); + keypad(stdscr, TRUE); + curs_set(0); + if (has_colors()) { + start_color(); + use_default_colors(); + init_pair(1, COLOR_BLACK, COLOR_CYAN); + init_pair(2, COLOR_CYAN, -1); + init_pair(3, COLOR_WHITE, -1); + init_pair(4, COLOR_YELLOW,-1); + init_pair(5, COLOR_RED, -1); + } + clear(); + } + break; + case 'd': + if (count > 0 && cur < count) { + /* confirm */ + mvprintw(rows - 1, 0, + "Delete %s? [y/N] ", entries[cur].name); + refresh(); + int confirm = getch(); + if (confirm == 'y' || confirm == 'Y') { + remove(entries[cur].path); + /* reload list */ + count = load_log_entries(log_dir, entries, MAX_LOGS); + if (cur >= count) cur = count > 0 ? count - 1 : 0; + if (scroll > cur) scroll = cur; + } + clear(); + } + break; + case 'q': + case 27: /* ESC */ + done = 1; + break; + } + } + + delwin(wleft); + delwin(wright); + endwin(); + free(entries); +}