diff options
| author | huker667 <huker@tuta.io> | 2026-05-10 09:51:20 +0300 |
|---|---|---|
| committer | huker667 <huker@tuta.io> | 2026-05-10 09:51:20 +0300 |
| commit | e9046a96f1edd2d52594785998d90a14d10a5803 (patch) | |
| tree | b2a0119be219839dff66d07e638a6775a568b0eb | |
| download | qulay-master.tar.gz qulay-master.tar.bz2 qulay-master.zip | |
init commit v0.7master
| -rw-r--r-- | README.md | 13 | ||||
| -rw-r--r-- | database.py | 177 | ||||
| -rw-r--r-- | docs/adding-repositories.md | 14 | ||||
| -rw-r--r-- | docs/creating-repositories.md | 57 | ||||
| -rw-r--r-- | logs.py | 31 | ||||
| -rw-r--r-- | package.py | 249 | ||||
| -rw-r--r-- | paths.py | 9 | ||||
| -rwxr-xr-x | qulay.py | 126 | ||||
| -rw-r--r-- | uzbekdb/__init__.py | 3 | ||||
| -rw-r--r-- | uzbekdb/database.py | 56 |
10 files changed, 735 insertions, 0 deletions
diff --git a/README.md b/README.md new file mode 100644 index 0000000..2ecc4aa --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Qulay (Qulay Package Manager) 🕌 + +Qulay is a minimal, fast, and halal package manager for Uzbek Linux. + +- no haram dependencies +- no unnecessary bloat +- pure control and barakah + +## documentation + +documentation is available in the [docs](docs/) directory. +- [adding repositories](docs/adding-repositories.md) +- [creating repositories](docs/creating-repositories.md) diff --git a/database.py b/database.py new file mode 100644 index 0000000..2ebe97a --- /dev/null +++ b/database.py @@ -0,0 +1,177 @@ +import os +import sys +import dbm +import ssl +import shutil +import shelve +import urllib.request +import tarfile + +import compression.zstd as zstd +from paths import * +from uzbekdb import database +import urllib.request +import urllib.error +from urllib.parse import urlsplit, urlunsplit, urlparse + + +TIMEOUT = 10 +_NAME = os.path.basename(sys.argv[0]) + + +def get_file_repos(): + if not os.path.exists(REPOS_FILE): + print(f"!! can't get repos database.\n" + f"|- please init Qulay PM with '{_NAME} i'\n" + f"|- and update repos with '{_NAME} u'") + return {} + + try: + with shelve.open(REPOS_FILE, flag="r") as db: + return db["repos"] or {} + except Exception: + print(f"!! can't get repos database.\n" + f"|- please init Qulay PM with '{_NAME} i'\n" + f"|- and update repos with '{_NAME} u'") + return {} + + +def get_repos_urls(): + if not os.path.exists(REPOS_URLS_FILE): + return [] + with open(REPOS_URLS_FILE, "r") as f: + try: + return f.readlines() + except: + return [] + + +def download_repos(urls, dest_dir="/"): + valid_urls = [u.strip() for u in urls if u.strip()] + total = len(valid_urls) + + for idx, url in enumerate(valid_urls, 1): + file_name = os.path.basename(urlparse(url).path) + repo_name = file_name.removesuffix(".tar.zst") + try: + bar_len = 20 + + print(f"\r:: {repo_name:<10} [{'#' * 0}{'-' * bar_len}] {idx-1}/{total}", end="", flush=True) + + with urllib.request.urlopen(url, timeout=10) as response: + with open(f"/tmp/{file_name}", "wb") as f: + shutil.copyfileobj(response, f) + + print(f"\r:: {repo_name:<10} [{'#' * 10}{'-' * 10}] {idx-1}/{total}", end="", flush=True) + + repos_dir = os.path.join(dest_dir, REPOS_DIR.lstrip(os.sep)) + + with zstd.open(f"/tmp/{file_name}", mode='rb') as zf: + with tarfile.open(fileobj=zf, mode='r|') as tar: + tar.extractall(path=repos_dir) + except Exception as e: + print(f"\r:: {repo_name:<10} fail [{'x' * 20}] {idx}/{total}") + else: + print(f"\r:: {repo_name:<10} done [{'#' * 20}] {idx}/{total}") + finally: + if os.path.exists(f"/tmp/{file_name}"): + os.remove(f"/tmp/{file_name}") + + +def _read_installed(dest_dir="/"): + installed_full_path = os.path.join(dest_dir, INSTALLED_FILE.lstrip(os.sep)) + if not os.path.exists(installed_full_path): + return {} + + with open(installed_full_path, "r", encoding="utf-8") as f: + content = f.read().strip() + data = database.loads(content) + if isinstance(data, list) and len(data) == 2: + data = {data[0]: [data[1]]} + return data if data else {} + + +def _write_installed(data: dict, dest_dir="/"): + installed_full_path = os.path.join(dest_dir, INSTALLED_FILE.lstrip(os.sep)) + with open(installed_full_path, "w", encoding="utf-8") as f: + f.write(database.dumps(data)) + + +def is_installed(name, dest_dir="/"): + return name in _read_installed(dest_dir) + + +def install_package(name, version, dest_dir="/"): + data = _read_installed(dest_dir) + + action = "added" + + if isinstance(data, dict): + if name in data: + if data[name][0] == version: + action = "already installed" + else: + action = f"upgraded from {data[name][0]} to {version}" + data[name][0] = version + else: + data[name] = [version] + else: + data = {name: [version]} + + _write_installed(data, dest_dir) + return action + + +def remove_package(name, dest_dir="/"): + data = _read_installed(dest_dir) + + if not isinstance(data, dict): + raise ValueError("!! installed database is corrupted") + + if name not in data: + raise ValueError(f"!! cant remove '{name}': package not installed") + + del data[name] + _write_installed(data, dest_dir) + + +def get_version_package(name, dest_dir="/"): + return _read_installed(dest_dir).get(name) + + +def ensure_database(verbose, ask, dest_dir="/"): + to_create = [INSTALLED_FILE, REPOS_URLS_FILE, LOG_FILE, CACHE_DIR, TMP_DIR, REPOS_DIR] + + if ask: + for path in to_create: + if path.startswith("/"): + path = path.lstrip("/") + path = os.path.join(dest_dir, path.lstrip(os.sep)) + print(f" * {path}") + answer = input("-> Do you want to create the following directories and files? [Y/n]: ").strip().lower() + if not answer in ("", "y", "yes"): + sys.exit(0) + + + for name in to_create: + if name.startswith("/"): + name = name.lstrip("/") + name_full_path = os.path.join(dest_dir, name) + try: + if name.endswith("/"): + os.makedirs(name_full_path, exist_ok=True) + if verbose: + print(f"[+] dir {name_full_path}") + continue + + os.makedirs(os.path.dirname(name_full_path), exist_ok=True) + + if not os.path.exists(name_full_path): + open(name_full_path, "w").close() + + if verbose: + print(f"[+] file {name_full_path}") + + except Exception as e: + if verbose: + print(f"[f] failed to create {name_full_path}: {e}") diff --git a/docs/adding-repositories.md b/docs/adding-repositories.md new file mode 100644 index 0000000..3274507 --- /dev/null +++ b/docs/adding-repositories.md @@ -0,0 +1,14 @@ +# adding repositories + +the file with the list of repository links is located in `/etc/qulay/repositories.uz` (it's not UzbekDB file). to add a repository, you need to open this file and add a line with a link, for example: +``` +http://127.0.0.1:8000/releases/core.tar.zst +https://codeberg.org/UzbekLinux/qulay-pkgs/releases/download/latest/halal.tar.zst +``` +you must provide a link specifically to the release Zstd archive. + +update in qulay after adding repositories urls: +``` +qulay u +``` +qulay will download tar.zst file and extract it to `/var/lib/qulay/repos/` folder. diff --git a/docs/creating-repositories.md b/docs/creating-repositories.md new file mode 100644 index 0000000..f7bbd2f --- /dev/null +++ b/docs/creating-repositories.md @@ -0,0 +1,57 @@ +# creating repositories + +your repository should be packed in Zstd archive and your repository should have this structure: +``` +repo-name.tar.zst/ +| manifest.uz +| pkgs.uz +| packages/ +|| example/ +||| install.sh +||| remove.sh +||| depends.uz +|| halal/ +||| ... +|| ... +``` + +*manifest.uz*: +``` +repo-name | Repository Description | maintainer | not required... +``` + +*pkgs.uz*: +``` +example | Example package for Da | 1.0.0 +halal | most halal package in the world | 78fd004609 +``` +*packages/example/*: +``` +example/ +| install.sh +| remove.sh +| depends.uz +``` +*packages/example/install.sh*: +```bash +#!/bin/sh +set -e + +curl -# -O http://example.org/example/bin/example.sh +mkdir -p $DESTDIR/usr/bin +cp -v example.sh $DESTDIR/usr/bin/example +chmod +x $DESTDIR/usr/bin/example +``` +*packages/example/remove.sh*: +```bash +#!/bin/sh +set -e + +rm -fv $DESTDIR/usr/bin/example +``` +*packages/example/depends.uz*: +``` +python-uzbekdb +example-lib +procps-ng +``` @@ -0,0 +1,31 @@ +# logs.py -- logging "lib" for qulay cli + +import time +from paths import LOG_FILE + +def _write_log(level, text, output=False): + timestamp = time.strftime("%Y-%m-%d %H:%M:%S") + line = f"{timestamp} {level[0]} {text}\n" + if output: + print(text) + with open(LOG_FILE, "a") as f: + f.write(line) + +def info(text, output=False, write=True): + if write: + _write_log("info", text, output) + else: + if output: + print(text) +def warn(text, output=False, write=True): + if write: + _write_log("warn", text, output) + else: + if output: + print(text) +def error(text, output=False, write=True): + if write: + _write_log("error", text, output) + else: + if output: + print(text) diff --git a/package.py b/package.py new file mode 100644 index 0000000..2667c7d --- /dev/null +++ b/package.py @@ -0,0 +1,249 @@ +import os +import sys +import tarfile +import shutil +import tempfile +import subprocess +import urllib.request +from urllib.parse import urlsplit, urlunsplit + +from logs import info, warn, error +from paths import TMP_DIR, CACHE_DIR, REPOS_DIR +from pathlib import Path +from uzbekdb import database +from database import ( + download_repos, + get_repos_urls, + install_package, + remove_package, + is_installed, +) + + +_NAME = os.path.basename(sys.argv[0]) + + +def _random_tmp_dir(tmp_dir=TMP_DIR): + return tempfile.mkdtemp(prefix="qulay_", dir=tmp_dir) + + +def _download(url, dest): + try: + urllib.request.urlretrieve(url, dest) + except Exception as e: + return e + else: + return 1 + return 0 + + +def _extract(archive, dest): + with tarfile.open(archive) as tar: + tar.extractall(dest) + + +def _read_depends(path): + depends_file = os.path.join(path, "depends.uz") + if not os.path.exists(depends_file): + return [] + with open(depends_file) as f: + return [line.strip() for line in f if line.strip()] + + +def _check_pkg_structure(path): + required = ["install.sh", "remove.sh"] + for file in required: + if not os.path.exists(os.path.join(path, file)): + error("!! package is corrupted", True) + + +def _get_packages(dest_dir="/"): + found = [] + packages_path = os.path.join(dest_dir, REPOS_DIR.lstrip(os.sep)) + for repo_name in os.listdir(packages_path): + repo_path = os.path.join(packages_path, repo_name) + if os.path.isdir(repo_path): + pkgs_file = os.path.join(repo_path, "pkgs.uz") + if os.path.exists(pkgs_file): + try: + with open(pkgs_file, "r", encoding="utf-8") as f: + pkgs_data = database.loads(f.read()) + for name, data in pkgs_data.items(): + found.append({ + "name": name, + "repo": repo_name, + "data": data + }) + except Exception: + continue + return found + + +def _find_package(name, dest_dir="/"): + found = [] + packages_path = os.path.join(dest_dir, REPOS_DIR.lstrip(os.sep)) + + if not os.path.exists(packages_path): + return "-n" + + for repo_name in os.listdir(packages_path): + repo_path = os.path.join(packages_path, repo_name) + if os.path.isdir(repo_path): + pkgs_file = os.path.join(repo_path, "pkgs.uz") + if os.path.exists(pkgs_file): + try: + with open(pkgs_file, "r", encoding="utf-8") as f: + pkgs_data = database.loads(f.read()) + + if isinstance(pkgs_data, dict) and name in pkgs_data: + found.append({ + "name": name, + "repo": repo_name, + "data": pkgs_data[name] + }) + except Exception: + continue + + if not found: + return None + # if len(found) > 1: + # return "-r" + + return found + + +def _find_repo_package(result, repo, name): + for pkg in result: + if pkg["repo"] == repo: + return pkg + return None + + +def install(name, args=[], dest_dir="/"): + # verbose=False, ask=False, reinstall=False, disable_logs=False, disable_deps=False + verbose = ":v" in args + ask = ":a" in args + reinstall = ":r" in args + disable_logs = ":nl" in args + disable_deps = ":nd" in args + + if "/" in name: + parts = name.split("/", 1) + repo_name = parts[0] + name = parts[1] + + pkg = _find_repo_package(_find_package(name), repo_name, name) + + if not pkg: + error(f"!! package {name} not found in {repo_name}", True, disable_logs) + return 1 + + pkg_data = pkg["data"] + version = pkg_data[1] + else: + result = _find_package(name) + if not result: + error(f"!! package {name} not found", True, disable_logs) + return 1 + + if len(result) > 1: + error(f"!! {len(result)} same pkgs found in different repos:", True, disable_logs) + for pkg in result: + print(f" {pkg['repo']}/{pkg['name']} - {pkg['data'][0]} - {pkg['data'][1]}") + return 1 + + repo_name = result[0]["repo"] + pkg_data = result[0]["data"] + + version = pkg_data[1] + + pkg_path = os.path.join(dest_dir, REPOS_DIR.lstrip(os.sep), f"{repo_name}/packages/{name}") + + if ask: + download_answer = input( + f"-> confirm installing '{name}' with version {version} [Y/n]: " + ).strip().lower() + + if download_answer not in ("y", "yes", ""): + sys.exit(0) + + # -- install depends -- + depends = _read_depends(pkg_path) + if not disable_deps: + for dep in depends: + if not is_installed(dep) or reinstall: + # -- copy args to dep install -- + install(dep, args, dest_dir=dest_dir) + + # -- run install.sh -- + try: + subprocess.run( + ["sh", "install.sh"], + cwd=pkg_path, + check=True, + env=os.environ + ) + except Exception as e: + error(f"!! {e}", True, disable_logs) + return 0 + + if not disable_logs: + install_package(f"{repo_name}/{name}", version, dest_dir=dest_dir) + + info(f":: {name} installed successfully", True, disable_logs) + + +def remove(name, args=[], dest_dir="/"): + verbose = ":v" in args + ask = ":a" in args + disable_logs = ":nl" in args + # disable_deps = "/nd" in args + + if "/" in name: + parts = name.split("/", 1) + repo_name = parts[0] + name = parts[1] + + pkg = _find_repo_package(_find_package(name), repo_name, name) + + if not pkg: + error(f"!! package {name} not found in {repo_name}", True, disable_logs) + return 1 + + pkg_data = pkg["data"] + version = pkg_data[1] + else: + result = _find_package(name) + if not result: + error(f"!! package {name} not found", True, disable_logs) + return 1 + + if len(result) > 1: + error(f"!! {len(result)} same pkgs found in different repos:", True, disable_logs) + for pkg in result: + print(f" {pkg['repo']}/{pkg['name']} - {pkg['data'][0]} - {pkg['data'][1]}") + return 1 + + repo_name = result[0]["repo"] + pkg_data = result[0]["data"] + + version = pkg_data[1] + + # -- super uzbek package remover da -- + pkg_path = os.path.join(dest_dir, REPOS_DIR.lstrip(os.sep), f"{repo_name}/packages/{name}") + + try: + subprocess.run( + ["sh", "remove.sh"], + cwd=pkg_path, + check=True, + env=os.environ + ) + except Exception as e: + error(f"!! {e}", True, disable_logs) + return 0 + + if not disable_logs: + remove_package(f"{repo_name}/{name}", dest_dir=dest_dir) + + info(f":: {name} removed successfully", True, disable_logs) diff --git a/paths.py b/paths.py new file mode 100644 index 0000000..8d231ef --- /dev/null +++ b/paths.py @@ -0,0 +1,9 @@ +# paths to files, dirs for Qulay PM + +REPOS_URLS_FILE = "/etc/qulay/repositories.uz" +INSTALLED_FILE = "/var/lib/qulay/installed.uz" +REPOS_FILE = "/var/lib/qulay/shelve_repos.db" +REPOS_DIR = "/var/lib/qulay/repos/" +LOG_FILE = "/var/log/qulay/qulay.logs" +CACHE_DIR = "/var/cache/qulay/" +TMP_DIR = "/tmp/qulay/" diff --git a/qulay.py b/qulay.py new file mode 100755 index 0000000..86ce457 --- /dev/null +++ b/qulay.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 + +import package +import database +import signal +import sys +import os + +from pathlib import Path +from logs import info, warn, error + + +_NAME = os.path.basename(sys.argv[0]) +_VERSION = "0.4" +_HELP_TEXT = f"""{_NAME}: command-line interface to the Qulay Package Manager +Usage: + {_NAME} h -> help menu + {_NAME} + pkg -> download and install package + {_NAME} - pkg -> remove package + {_NAME} g pkg -> get package version + {_NAME} s pkg -> search packages in repos + {_NAME} u -> update all repositories + {_NAME} i -> init Qulay PM + {_NAME} r -> get all repositories URLs + {_NAME} q -> get all installed packages + {_NAME} v -> get {_NAME} version +Options: + :v -> verbose output + :a -> ask before doing + :nl -> disable recording in databases and logs + :nd -> do not download deps + :r -> reinstall already installed deps +Vars: + DESTDIR -> path where the package will be installed/removed""" + +all_commands_list = ["h", "+", "-", "g", "u", "w", "i", "r", "q", "v", "s"] +allowed_args = [":v", ":a", ":nl", ":nd", ":r"] +def handle_ctrl_c(signum, frame): + warn("\nw! canceled by the user", True, ":nl" in sys.argv) + sys.exit(130) + +signal.signal(signal.SIGINT, handle_ctrl_c) + +def parse_args(args, mode): + return_args = [] + for arg in args: + if mode == "c": + if not arg.startswith(":"): + return_args += [arg] + elif mode == "a": + if arg.startswith(":"): + return_args += [arg] + elif mode == "o": + in_cmds = arg in all_commands_list + is_name = arg == sys.argv[0] + if not arg.startswith(":") and not in_cmds and not is_name: + return_args += [arg] + return return_args + +if __name__ == "__main__": + args = parse_args(sys.argv, "a") + cmds = parse_args(sys.argv, "c") + oths = parse_args(sys.argv, "o") + dest_dir = Path(os.environ.get("DESTDIR") or "/").resolve() + root_cmds = ["+", "-", "u", "i", "w"] + + if len(cmds) < 2: + print(_HELP_TEXT) + sys.exit(1) + + if cmds[1] in root_cmds: + if os.geteuid() != 0: + error("!! not enough rights. run this with root privileges.", True, ":nl" in args) + sys.exit(1) + + if cmds[1] == "+": + for pkg in oths: + package.install(pkg, args=args, dest_dir=dest_dir) + elif cmds[1] == "-": + for pkg in oths: + package.remove(pkg, args=args, dest_dir=dest_dir) + elif cmds[1] == "u": + database.download_repos(database.get_repos_urls()) + elif cmds[1] == "g": + if len(cmds) > 2: + print(database.get_version_package(cmds[2])[0]) + else: + error("!! package is not specified", True, ":nl" in args) + elif cmds[1] == "i": + database.ensure_database(verbose=":v" in args, ask=":a" in args, dest_dir=dest_dir) + elif cmds[1] == "r": + for url in database.get_repos_urls(): + print(url.strip()) + elif cmds[1] == "q": + for name, data in database._read_installed(dest_dir=dest_dir).items(): + print(f"{name} - {data[0]}") + elif cmds[1] == "s": + if len(cmds) > 2: + for name in oths: + if "/" in name: + parts = name.split("/", 1) + if parts[0] == "" or parts[1] == "": + continue + repo_name, name = parts[0], parts[1] + pkgs = package._find_package(name, dest_dir=dest_dir) + pkgs = [package._find_repo_package(pkgs, repo_name, name)] + else: + pkgs = package._find_package(name, dest_dir=dest_dir) + if not pkgs: + continue + for pkg in pkgs: + if database.is_installed(f"{pkg['repo']}/{pkg['name']}", dest_dir=dest_dir): + print(f"[+] {pkg['repo']}/{pkg['name']} - {pkg['data'][0]} - {pkg['data'][1]}") + else: + print(f" {pkg['repo']}/{pkg['name']} - {pkg['data'][0]} - {pkg['data'][1]}") + else: + pkgs = package._get_packages(dest_dir=dest_dir) + for pkg in pkgs: + if database.is_installed(f"{pkg['repo']}/{pkg['name']}", dest_dir=dest_dir): + print(f"[+] {pkg['repo']}/{pkg['name']} - {pkg['data'][0]} - {pkg['data'][1]}") + else: + print(f" {pkg['repo']}/{pkg['name']} - {pkg['data'][0]} - {pkg['data'][1]}") + elif cmds[1] == "v": + print(f"{_NAME}: {_VERSION}") + else: + print(_HELP_TEXT) diff --git a/uzbekdb/__init__.py b/uzbekdb/__init__.py new file mode 100644 index 0000000..001a104 --- /dev/null +++ b/uzbekdb/__init__.py @@ -0,0 +1,3 @@ +""" + UzbekDB Python library - made by msh356. +""" diff --git a/uzbekdb/database.py b/uzbekdb/database.py new file mode 100644 index 0000000..f27d107 --- /dev/null +++ b/uzbekdb/database.py @@ -0,0 +1,56 @@ +""" + UzbekDB database +""" + +def _is_correct(dbstr: str): + try: + db = [x for x in dbstr.split("\n") if x] + except Exception: + return False + if len(db) == 0: + return False + else: + return True + +def _is_multiline(dbstr: str): + if _is_correct(dbstr): + db = [x for x in dbstr.split("\n") if x] + if len(db) == 1: + return False + else: + return True + +def _return_correct_type(s): + for t in (int, float, complex): + try: + return t(s) + except ValueError: + continue + return s + +def loads(dbstr: str): + if _is_correct(dbstr): + db = [x for x in dbstr.split("\n") if x] + multiline = _is_multiline(dbstr) + if multiline: + dbobj = {} + for i in db: + dbel = i.split(" | ") + dbelc = [] + for i in dbel: + dbelc.append(_return_correct_type(i)) + dbobj[dbelc[0]] = dbelc[1:] + else: + dbobj = [] + for i in db[0].split(" | "): + dbobj.append(_return_correct_type(i)) + return dbobj + +def dumps(dbobj): + dbstr = "" + if isinstance(dbobj, dict): + for k,v in dbobj.items(): + dbstr = dbstr + f"{k} | {' | '.join(map(str, v))}\n" + elif isinstance(dbobj, list): + dbstr = " | ".join(map(str, dbobj)) + return dbstr |