commit 51a186ace6f695dc277e8d8ecd831bcabbfb8325 Author: Nuno Nunes Date: Tue May 5 16:32:23 2026 +0100 add dx build system / CLI orchestrator diff --git a/README.md b/README.md new file mode 100644 index 0000000..b834481 --- /dev/null +++ b/README.md @@ -0,0 +1,321 @@ +# šŸ› ļø DX + +`dx` is a framework-agnostic bash CLI that orchestrates the full development lifecycle for PHP projects — Docker, Apache, Traefik, environment config, migrations, and tests — across multiple frameworks (Yii2, Laravel). + +The `dx` script at the project root is the entrypoint. It loads `dxkit/bootstrap.sh` and dispatches to the command and module system inside `dxkit/`. + +## Prerequisites + +- Bash (Linux/macOS native; Windows via Git Bash or WSL) +- Docker + Docker Compose +- `dx` must be executable: `chmod +x dx` + +## Quick start + +```bash +./dx setup # first time: build image + init environment +./dx up # start the stack +./dx shell # enter the app container +./dx console migrate # run database migrations +``` + +## Command reference + +### Setup & initialization + +| Command | Description | +|---|---| +| `dx build` | Build the Docker image and generate the compose stack | +| `dx init [env]` | Bring the project to a runnable state for an environment (default: `dev`) | +| `dx setup` | `build` + `init` in one step | + +**`dx build` flags** + +| Flag | Description | +|---|---| +| `--dev` / `--qly` / `--prd` | Target environment (default: `--dev`) | +| `--project=NAME` | Override the project name | +| `--port=PORT` | Override the app HTTP port | +| `--db=mysql\|mssql` | Database engine (default: `mysql`) | +| `--db-port=PORT` | Override the database port | +| `--sync-hosts` | Write the project domain to `/etc/hosts` after build | +| `--no-vhosts` | Skip Apache virtual host generation | +| `--no-interact` | Non-interactive mode (no prompts) | +| `--debug` | Enable shell trace output | + +**`dx init` flags** + +| Flag | Description | +|---|---| +| `--skip-vendors` | Skip `composer install` | +| `--skip-framework-init` | Skip framework init script | +| `--skip-framework-config` | Skip framework config generation | +| `--skip-proxy` | Skip starting the reverse proxy | +| `--debug` | Enable shell trace output | + +--- + +### Runtime + +| Command | Description | +|---|---| +| `dx up` | Start the Docker stack | +| `dx down` | Stop the Docker stack | +| `dx logs` | Tail container logs | +| `dx shell` / `dx bash` | Open an interactive shell inside the app container | +| `dx exec [args]` | Run a command inside the app container | +| `dx list` / `dx ls` | List stack containers | +| `dx test [args]` | Run PHPUnit tests inside the container | + +**`dx up` flags** + +| Flag | Description | +|---|---| +| `--sync-hosts` | Sync domain entries to `/etc/hosts` before starting | +| `--skip-proxy` | Do not ensure the reverse proxy is running | +| `--recreate` | Force container recreation | +| `--build` | Rebuild the image before starting | +| `--foreground` | Run in foreground (don't detach) | +| `--debug` | Enable shell trace output | + +**`dx down` flags** + +| Flag | Description | +|---|---| +| `--volumes` | Also remove Docker volumes | +| `--orphans` | Remove orphan containers | +| `--debug` | Enable shell trace output | + +--- + +### Framework console + +| Command | Description | +|---|---| +| `dx console [args]` | Run a framework console command | +| `dx yii ` | Alias for Yii2 (`php yii `) | +| `dx artisan ` | Alias for Laravel (`php artisan `) | + +--- + +### Migration shortcuts + +These are resolved by the active framework driver. + +**Yii2** + +| Command | Description | +|---|---| +| `dx migrate` | Run all pending migrations | +| `dx migrate-create ` | Generate a new timestamped migration file in `console/migrations/` | +| `dx migrate-down [n]` | Revert `n` migrations (default: 1) | +| `dx migrate-new` | Show pending migrations | +| `dx migrate-history` | Show migration history | + +**Laravel** + +| Command | Description | +|---|---| +| `dx migrate` | Run all pending migrations | +| `dx migrate-create ` | Create a migration via `artisan make:migration` | +| `dx migrate-rollback [--step=N]` | Revert migrations | +| `dx migrate-status` | Show migration status | +| `dx migrate-fresh` | Drop all tables and re-run all migrations | + +--- + +### Apache + +| Command | Description | +|---|---| +| `dx apache validate` | Validate vhost configuration | +| `dx apache reload` | Graceful reload (picks up config changes without downtime) | +| `dx apache restart` | Full restart | +| `dx apache vhosts` | List all loaded virtual hosts | +| `dx apache modules` | List all loaded Apache modules | +| `dx apache version` | Show Apache version | +| `dx apache logs [error\|access]` | Tail error or access logs | + +--- + +### Proxy (Traefik) + +| Command | Description | +|---|---| +| `dx proxy start` | Start the Traefik reverse proxy | +| `dx proxy stop` | Stop the proxy | +| `dx proxy restart` | Restart the proxy | +| `dx proxy status` | Show proxy status | +| `dx proxy logs` | Tail proxy logs | + +--- + +### Network & hosts + +| Command | Description | +|---|---| +| `dx network status` | Show resolved port assignments | +| `dx network resolve` | Re-run port resolution and show changes | +| `dx network hosts` | Alias for the `hosts` command | +| `dx hosts sync` | Write project domain entries to `/etc/hosts` (requires root/sudo) | +| `dx hosts remove` | Remove project entries from `/etc/hosts` | +| `dx hosts list` | Show current `/etc/hosts` entries for this project | +| `dx hosts preview` | Preview entries that would be written | + +--- + +### Workspace + +| Command | Description | +|---|---| +| `dx workspace` / `dx ws` | Drop into a subshell where all `dx` commands and framework driver commands are available without the `dx` prefix | + +--- + +## Global flags + +These flags are accepted by all commands: + +| Flag | Description | +|---|---| +| `--framework=NAME` | Override auto-detected framework | +| `--debug` | Enable shell trace output (`set -x`) | + +--- + +## Environments + +Three built-in environments: + +| Name | Use | +|---|---| +| `dev` | Development (default) +| `qly` | Quality | +| `prd` | Production | + +Environment variables are layered in order: + +1. `dxkit/env/globals.env` — shared defaults, source-controlled +2. `.project/artifacts/env/${ENVIRONMENT}.env` — local overrides, gitignored +3. `.project/artifacts/env/${ENVIRONMENT}.resolved.env` — generated resolved values, gitignored + +Resolved port assignments are written to `.project/artifacts/ports` and loaded back on subsequent runs. + +Port values in env files may use the `auto:PORT` syntax — dxkit will find the next available port starting from that number. + +--- + +## Framework support + +Framework detection reads `composer.json` at project root. + +| Framework | Detected by | Console binary | `dx console` alias | +|---|---|---|---| +| Yii2 Advanced | `yiisoft/yii2` + advanced template structure | `yii` | `dx yii` | +| Yii2 Basic | `yiisoft/yii2` + basic template structure | `yii` | `dx yii` | +| Laravel | `laravel/framework` | `artisan` | `dx artisan` | + +Use `--framework=NAME` to override detection (e.g. `--framework=yii2-advanced`). + +--- + +## Architecture + +For contributors and maintainers. + +``` +dx # project-level entrypoint; defines aliases, calls dx::dispatch() +dxkit/ +ā”œā”€ā”€ bootstrap.sh # loads core.sh, exports PROJECT_ROOT, defines dx::load_modules() +ā”œā”€ā”€ core.sh # sources all core subsystems in order +ā”œā”€ā”€ core/ # low-level infrastructure +│ ā”œā”€ā”€ platform.sh # OS detection, privilege elevation +│ ā”œā”€ā”€ context.sh # execution context management +│ ā”œā”€ā”€ utils.sh # general utilities +│ ā”œā”€ā”€ string.sh # string helpers +│ ā”œā”€ā”€ hook.sh # pre/post hook system +│ ā”œā”€ā”€ module.sh # module loader +│ ā”œā”€ā”€ command.sh # command registration framework +│ ā”œā”€ā”€ loader.sh # dynamic file loader +│ ā”œā”€ā”€ flag.sh # flag/argument parsing +│ └── runtime/runtime.sh # runtime abstraction entry +ā”œā”€ā”€ commands/ # one file per command, loaded dynamically by the dispatcher +│ ā”œā”€ā”€ build.command.sh +│ ā”œā”€ā”€ init.command.sh +│ ā”œā”€ā”€ setup.command.sh +│ ā”œā”€ā”€ console.command.sh +│ ā”œā”€ā”€ apache.command.sh +│ ā”œā”€ā”€ hosts.command.sh +│ ā”œā”€ā”€ network.command.sh +│ ā”œā”€ā”€ proxy.command.sh +│ ā”œā”€ā”€ workspace.command.sh +│ ā”œā”€ā”€ docker/ # low-level docker subcommands +│ └── runtime/ # runtime-abstracted subcommands (used by default) +ā”œā”€ā”€ modules/ # reusable function libraries sourced at startup +│ ā”œā”€ā”€ app.module.sh # framework abstraction layer (scaffold, init, config, console) +│ ā”œā”€ā”€ docker.module.sh # Docker and Docker Compose wrappers +│ ā”œā”€ā”€ env.module.sh # environment variable loading and derivation +│ ā”œā”€ā”€ log.module.sh # structured logging with icons and context prefixes +│ ā”œā”€ā”€ fs.module.sh # cross-platform file write and sed utilities +│ ā”œā”€ā”€ network.module.sh # port resolution and persistence +│ ā”œā”€ā”€ apache.module.sh # Apache management functions +│ ā”œā”€ā”€ proxy.module.sh # proxy abstraction (delegates to docker/proxy.module.sh) +│ ā”œā”€ā”€ artifact.module.sh # runtime artifact directory management +│ ā”œā”€ā”€ composer.module.sh # composer operations (install, update, require, auth) +│ ā”œā”€ā”€ template.module.sh # envsubst-based template rendering +│ ā”œā”€ā”€ yii.module.sh # Yii2 shared utilities (exec, env name mapping) +│ ā”œā”€ā”€ phpstorm.module.sh # PhpStorm datasource config generation +│ ā”œā”€ā”€ docker/ +│ │ ā”œā”€ā”€ db.module.sh # database port/path/env helpers (MySQL + MSSQL) +│ │ └── proxy.module.sh # Traefik container lifecycle and label generation +│ └── framework/ +│ ā”œā”€ā”€ yii2-advanced.module.sh +│ ā”œā”€ā”€ yii2-basic.module.sh +│ └── laravel.module.sh +ā”œā”€ā”€ drivers/ # framework-specific command registration +│ ā”œā”€ā”€ dispatcher.sh # resolves framework → driver, populates DRIVER_COMMANDS +│ ā”œā”€ā”€ yii2/ +│ │ ā”œā”€ā”€ driver.sh # registers yii, migrate-* commands +│ │ └── migrate.sh # migration file generation helpers +│ └── laravel/ +│ ā”œā”€ā”€ driver.sh # registers artisan, migrate-* commands +│ └── migrate.sh # artisan make:migration wrappers +ā”œā”€ā”€ runtime/ # runtime abstraction (Docker vs. native execution) +│ ā”œā”€ā”€ runtime.sh # selects active runtime +│ ā”œā”€ā”€ docker.runtime.sh # implements runtime::exec via docker compose exec +│ └── native.runtime.sh # implements runtime::exec directly on host +ā”œā”€ā”€ templates/ # envsubst-rendered config templates +│ ā”œā”€ā”€ docker/ +│ │ ā”œā”€ā”€ Dockerfile.template +│ │ └── docker-compose.yml.template +│ ā”œā”€ā”€ apache/ +│ │ ā”œā”€ā”€ yii2-advanced/vhosts.conf.template +│ │ └── yii2-basic/vhosts.conf.template +│ ā”œā”€ā”€ yii2-advanced/ +│ │ ā”œā”€ā”€ main-local.php.template +│ │ └── components/ # db.mysql, db.mssql, log php templates +│ └── yii2-basic/ +│ ā”œā”€ā”€ db.mysql.php.template +│ └── db.mssql.php.template +ā”œā”€ā”€ env/ # layered environment variable files +│ ā”œā”€ā”€ globals.env +│ ā”œā”€ā”€ dev.env +│ ā”œā”€ā”€ qly.env +│ └── prd.env +└── docker/ + └── entrypoint.sh # container entrypoint script +``` + +### How command dispatch works + +1. `dx [args]` calls `dx::dispatch()` +2. Aliases in `dx` are resolved first (e.g. `sh` → `runtime/shell`, `ls` → `runtime/list`) +3. Built-ins (`help`, `elevate`) are handled inline +4. Driver commands registered in `DRIVER_COMMANDS` (via `dxkit/drivers/dispatcher.sh`) are checked next +5. Remaining commands are matched to files in `dxkit/commands/` and loaded dynamically + +### Adding a new command + +1. Create `dxkit/commands/.command.sh` with a function `command::()` +2. Optionally register an alias in `dx` under the alias map +3. For framework-specific commands, register the command name in the driver file (`dxkit/drivers//driver.sh`) diff --git a/dx b/dx new file mode 100644 index 0000000..bbbe6e6 --- /dev/null +++ b/dx @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/dxkit/bootstrap.sh" + +# ============================================ +# Alias map (commands) +# ============================================ + +declare -A CMD_ALIASES=( + [up]=runtime/up + [down]=runtime/down + [logs]=runtime/logs + [list]=runtime/list + [ls]=runtime/list + [list-stack]=runtime/list + [list-containers]=runtime/list + [exec]=runtime/exec + [shell]=runtime/shell + [sh]=runtime/shell + [bash]=runtime/shell + [test]=runtime/test + + [net]=network + [ws]=workspace +) + +# ============================================ +# Modules +# ============================================ + +load_module env +load_module log +load_module fs +load_module docker +load_module network +load_module artifact + +# ============================================ +# Dispatch +# ============================================ + +function dx::resolve_alias() { + local cmd="$1" + echo "${CMD_ALIASES[$cmd]:-$cmd}" +} + +function dx::dispatch() { + local raw_cmd="${1:-help}" + shift || true + + local cmd + cmd="$(dx::resolve_alias "$raw_cmd")" + + # Special built-ins that are not file-based commands + case "$cmd" in + help) dx::help; return ;; + elevate) platform::elevate "$@"; return ;; + esac + + if driver::has_command "$cmd"; then + driver::run "$cmd" "$@" + return + fi + + # Dynamic: load command file and call ::run + if load_command "$cmd"; then + if command::exists "${cmd}"; then + command::run "${cmd}" "$@" + else + log::error "Command file for '${cmd}' loaded but '${cmd}::run' is not defined" + exit 1 + fi + else + log::error "Unknown command: '${raw_cmd}'" + echo "Run '$(basename "$0") help' to see the available commands." >&2 + exit 1 + fi +} + +# ============================================ +# Help +# ============================================ + +function dx::help() { + local console_name + console_name="$(app::console_name)" + + local framework + framework="$(app::framework)" + + local console_cmd + console_cmd="${console_name} | console" + + cat < [args...] + +Active framework: ${framework} + +Commands: + up Start stack + build Build stack + down Stop stack + logs Tail logs + list | ls List stack containers + shell | bash Enter container shell + exec Run command inside container + ${console_cmd} Run framework console command + init [env] Initialize project for environment + setup Build and init in one step + test Run tests + apache Manage Apache web server + hosts Manage /etc/hosts entries + proxy Manage reverse proxy + network | net Network utilities + +Global flags: + --framework=NAME Override active framework + --debug Enable shell trace + +Examples: + $(basename "$0") up + $(basename "$0") build + $(basename "$0") ${console_name} migrate + $(basename "$0") init prd + $(basename "$0") setup + $(basename "$0") exec ls -la + $(basename "$0") shell + $(basename "$0") --framework=laravel build +HELP +} + +# ============================================ +# Main +# ============================================ + +function main() { + local args=() + for arg in "$@"; do + case "$arg" in + --framework=*) export APP_FRAMEWORK="${arg#*=}" ;; + --debug) set -x ;; + *) args+=("$arg") ;; + esac + done + + load_module app # app::on_load loads framework/* and registers console alias + + source "$(ctx::dxkit)/drivers/dispatcher.sh" + driver::load + + env::load_env_files + dx::dispatch "${args[@]+"${args[@]}"}" +} + +main "$@" diff --git a/dxkit/bootstrap.sh b/dxkit/bootstrap.sh new file mode 100644 index 0000000..de21a03 --- /dev/null +++ b/dxkit/bootstrap.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash + +# ============================================ +# Directories +# ============================================ + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Only set PROJECT_ROOT if not already exported by parent +if [[ -z "${PROJECT_ROOT:-}" ]]; then + readonly PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +fi + +export PROJECT_ROOT + +# ============================================ +# Load Core +# ============================================ + +source "$SCRIPT_DIR/core.sh" +platform::detect + +# ============================================ +# Module Loader +# ============================================ + +function dx::load_modules() { + load_module env + load_module log + load_module fs + load_module docker + load_module network + load_module artifact +} diff --git a/dxkit/commands/apache.command.sh b/dxkit/commands/apache.command.sh new file mode 100644 index 0000000..4c3b6b0 --- /dev/null +++ b/dxkit/commands/apache.command.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# commands/apache.command.sh +# +# Manages the Apache web server. +# +# Usage: +# dx apache validate — validate vhost configuration +# dx apache reload — graceful reload (picks up vhost changes) +# dx apache restart — full restart +# dx apache vhosts — list all loaded virtual hosts +# dx apache modules — list all loaded Apache modules +# dx apache version — show Apache version +# dx apache logs — tail error or access logs + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::apache::on_load() { + load_module apache + + flag::register --debug +} + +# ============================================ +# Public entrypoint +# ============================================ + +function cmd::apache::run() { + local subcmd="${1:-help}" + shift || true + + case "$subcmd" in + validate) cmd::apache::validate ;; + reload) cmd::apache::reload ;; + restart) cmd::apache::restart ;; + vhosts) cmd::apache::vhosts ;; + modules) cmd::apache::modules ;; + version) cmd::apache::version ;; + logs) cmd::apache::logs "$@" ;; + help) cmd::apache::help ;; + *) + log::error "Unknown subcommand: '${subcmd}'" + cmd::apache::help + return 1 + ;; + esac +} + +# ============================================ +# Help +# ============================================ + +function cmd::apache::help() { + cat < + +Manages the Apache web server. + +Subcommands: + validate Validate vhost configuration + reload Graceful reload (picks up config changes) + restart Full restart + vhosts List all loaded virtual hosts + modules List all loaded Apache modules + version Show Apache version + logs [type] Tail logs (error | access, default: error) + +Examples: + dx apache validate + dx apache reload + dx apache vhosts + dx apache logs access +EOF +} + +# ============================================ +# Subcommands +# ============================================ + +function cmd::apache::validate() { + log::info "Validating Apache configuration..." + if apache::validate_config; then + log::success "Configuration is valid" + else + log::error "Configuration has errors" + return 1 + fi +} + +function cmd::apache::reload() { + log::info "Reloading Apache..." + apache::reload + log::success "Apache reloaded" +} + +function cmd::apache::restart() { + log::info "Restarting Apache..." + apache::restart + log::success "Apache restarted" +} + +function cmd::apache::vhosts() { + log::info "Loaded virtual hosts:" + apache::list_vhosts +} + +function cmd::apache::modules() { + log::info "Loaded Apache modules:" + apache::list_modules +} + +function cmd::apache::version() { + apache::version +} + +function cmd::apache::logs() { + local type="${1:-error}" + + case "$type" in + error) apache::logs::error ;; + access) apache::logs::access ;; + *) + log::error "Unknown log type: '${type}' (must be 'error' or 'access')" + return 1 + ;; + esac +} \ No newline at end of file diff --git a/dxkit/commands/build.command.sh b/dxkit/commands/build.command.sh new file mode 100644 index 0000000..842cea1 --- /dev/null +++ b/dxkit/commands/build.command.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# commands/build.command.sh +# +# Builds the Docker image and compose stack for a given environment. +# +# Usage: +# dx build [flags] +# +# Flags: +# --dev / --qly / --prd Target environment (default: dev) +# --project=NAME Override project name +# --port=PORT Override app port (e.g. 8082, auto:8080) +# --db=TYPE Override DB engine (mysql | mssql) +# --db-port=PORT Override DB port +# --sync-hosts Sync domain to /etc/hosts (requires root) +# --no-vhosts Skip Apache vhost generation +# --no-interact Non-interactive mode +# --debug Enable shell trace (set -x) + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::build::on_load() { + load_module template + load_module apache + + flag::register \ + --sync-hosts \ + --no-vhosts \ + --no-interact \ + --scaffold \ + --framework \ + --debug +} + +# ============================================ +# Public entrypoint +# ============================================ + +function cmd::build::run() { + local flags=() + + for arg in "$@"; do + flags+=("$arg") + done + + if ! flag::parse "${flags[@]+"${flags[@]}"}"; then + cmd::build::help + return 1 + fi + + # CLI overrides — applied after env files so flags win + [[ -n "${PROJECT_NAME_OVERRIDE:-}" ]] && export PROJECT_NAME="$PROJECT_NAME_OVERRIDE" + [[ -n "${APP_PORT_OVERRIDE:-}" ]] && export APP_PORT="$APP_PORT_OVERRIDE" + [[ -n "${DB_ENGINE_OVERRIDE:-}" ]] && export DB_ENGINE="$DB_ENGINE_OVERRIDE" + [[ -n "${DB_PORT_OVERRIDE:-}" ]] && export DB_PORT="$DB_PORT_OVERRIDE" + + # ============================================ + # Project scaffolding + # ============================================ + + if ! app::exists || flag::enabled --scaffold; then + app::scaffold "$(ctx::root)" + fi + + # ============================================ + # Runtime artifacts + # ============================================ + + artifact::init + + # ============================================ + # Network & Virtual Hosts + # ============================================ + + network::ports::prepare + + if flag::enabled --sync-hosts && ! flag::enabled --no-interact; then + load_command hosts + hosts::sync + fi + + if ! flag::enabled --no-vhosts; then + apache::generate_vhosts + fi + + # ============================================ + # Docker image + # ============================================ + + runtime::run_if BUILD_IMAGE docker::image::generate || { + log::error "Failed to generate Docker image (${APP_IMAGE}:${ENVIRONMENT})" + return 1 + } + + runtime::run_if BUILD_IMAGE docker::image::build || { + log::error "Failed to build Docker image (${APP_IMAGE}:${ENVIRONMENT})" + return 1 + } + + # ============================================ + # Docker compose stack + # ============================================ + + runtime::run_if BUILD_STACK docker::compose::generate || { + log::error "Failed to generate compose stack (${ENVIRONMENT})" + return 1 + } + + runtime::run_if BUILD_STACK docker::compose::build || { + log::error "Failed to build compose stack (${ENVIRONMENT})" + return 1 + } + + # ============================================ + # Done + # ============================================ + + cmd::build::_show_networking + log::success "Build complete!" + + flag::scaffold_defaults_file + cmd::build::_hint_next_steps +} + +# ============================================ +# Help +# ============================================ + +function cmd::build::help() { + cat < [args...] + dx ${console_name} [args...] + +Runs a console command inside the app container via the active framework. +Active framework: $(app::framework) + +Examples: + dx console migrate + dx console db:seed + dx ${console_name} migrate + dx ${console_name} cache/flush +EOF +} \ No newline at end of file diff --git a/dxkit/commands/docker/down.command.sh b/dxkit/commands/docker/down.command.sh new file mode 100644 index 0000000..1867466 --- /dev/null +++ b/dxkit/commands/docker/down.command.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# commands/docker/down.command.sh + +function cmd::docker::down::on_load() { + flag::register --debug +} + +function cmd::docker::down::run() { + flag::parse "$@" || { cmd::docker::down::help; return 1; } + docker::compose::down +} + +function cmd::docker::down::help() { + cat < [args...] + +Runs a command inside the app container. + +Examples: + dx exec ls -la + dx exec php --version + dx exec composer install +EOF +} diff --git a/dxkit/commands/docker/list.command.sh b/dxkit/commands/docker/list.command.sh new file mode 100644 index 0000000..79d74bf --- /dev/null +++ b/dxkit/commands/docker/list.command.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# commands/docker/list.command.sh + +function cmd::docker::list::on_load() { + flag::register --debug +} + +function cmd::docker::list::run() { + flag::parse "$@" || { cmd::docker::list::help; return 1; } + docker::list_containers +} + +function cmd::docker::list::help() { + cat < + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::hosts::on_load() { + flag::register \ + --debug +} + +# ============================================ +# Public entrypoint +# ============================================ + +function cmd::hosts::run() { + local subcmd="${1:-help}" + shift || true + + case "$subcmd" in + sync) cmd::hosts::sync "$@" ;; + remove) cmd::hosts::remove "$@" ;; + list) cmd::hosts::list "$@" ;; + preview) cmd::hosts::preview "$@" ;; + help) cmd::hosts::help ;; + *) + log::error "Unknown subcommand: '${subcmd}'" + cmd::hosts::help + return 1 + ;; + esac +} + +# ============================================ +# Help +# ============================================ + +function cmd::hosts::help() { + cat < + +Manages /etc/hosts entries for ${PROJECT_NAME}. + +Subcommands: + sync Write project entries to /etc/hosts (requires root) + remove Remove project entries from /etc/hosts (requires root) + list Show current entries in /etc/hosts for this project + preview Preview entries that would be written + +Examples: + dx hosts sync + dx hosts remove + dx hosts list + dx hosts preview +EOF +} + +# ============================================ +# Sync +# ============================================ + +function cmd::hosts::sync() { + local hosts_file + hosts_file="$(platform::hosts_file)" + + platform::require_privileges || { + log::warn "$(platform::elevation_fail)" + log::error "Could not sync hosts — permission denied" + return 1 + } + + local block + block="$(cmd::hosts::_build_block)" + + [[ -f "$hosts_file" ]] || touch "$hosts_file" || return 1 + + local tmp + tmp="$(fs::replace_block \ + "$hosts_file" \ + "$(cmd::hosts::_marker)" \ + "$(cmd::hosts::_end_marker)" \ + "$block" + )" || return 1 + + fs::write_file "$tmp" "$hosts_file" + + log::success "Hosts synced for ${PROJECT_NAME}" + cmd::hosts::_show_entries "$block" +} + +# ============================================ +# Remove +# ============================================ + +function cmd::hosts::remove() { + local hosts_file + hosts_file="$(platform::hosts_file)" + + platform::require_privileges || { + log::warn "$(platform::elevation_fail)" + log::error "Could not remove hosts — permission denied" + return 1 + } + + if ! grep -q "$(cmd::hosts::_marker)" "$hosts_file" 2>/dev/null; then + log::info "No entries found for ${PROJECT_NAME} in $(platform::hosts_file)" + return 0 + fi + + local tmp + tmp="$(fs::replace_block \ + "$hosts_file" \ + "$(cmd::hosts::_marker)" \ + "$(cmd::hosts::_end_marker)" \ + "" + )" || return 1 + + fs::write_file "$tmp" "$hosts_file" + + log::success "Hosts entries removed for ${PROJECT_NAME}" +} + +# ============================================ +# List +# ============================================ + +function cmd::hosts::list() { + local hosts_file + hosts_file="$(platform::hosts_file)" + + if ! grep -q "$(cmd::hosts::_marker)" "$hosts_file" 2>/dev/null; then + log::info "No entries found for ${PROJECT_NAME} in ${hosts_file}" + return 0 + fi + + log::info "Current entries for ${PROJECT_NAME} in ${hosts_file}:" + printf "\n" + + # Extract block between markers + awk "/$(cmd::hosts::_marker)/,/$(cmd::hosts::_end_marker)/" "$hosts_file" \ + | grep -v "^#" + printf "\n" +} + +# ============================================ +# Preview +# ============================================ + +function cmd::hosts::preview() { + log::info "Entries that would be written for ${PROJECT_NAME}:" + printf "\n" + cmd::hosts::_generate_entries + printf "\n" +} + +# ============================================ +# Private +# ============================================ + +function cmd::hosts::_marker() { echo "# >>> ${PROJECT_NAME} >>>"; } +function cmd::hosts::_end_marker() { echo "# <<< ${PROJECT_NAME} <<<"; } + +function cmd::hosts::_generate_entries() { + local ip="127.0.0.1" + + local domains=( + "$DOMAIN" + "$BACKEND_DOMAIN" + ) + + for domain in "${domains[@]}"; do + echo "${ip} ${domain}" + done +} + +function cmd::hosts::_build_block() { + local entries + entries="$(cmd::hosts::_generate_entries | tr -d '\r')" + + printf "%s\n%s\n%s" \ + "$(cmd::hosts::_marker)" \ + "$entries" \ + "$(cmd::hosts::_end_marker)" +} + +function cmd::hosts::_show_entries() { + local entries="$1" + printf "\n" + echo "$entries" + printf "\n" +} \ No newline at end of file diff --git a/dxkit/commands/init.command.sh b/dxkit/commands/init.command.sh new file mode 100644 index 0000000..420d8f3 --- /dev/null +++ b/dxkit/commands/init.command.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# commands/init.command.sh +# +# Initializes the project for a given environment. +# Brings the stack to a runnable state: containers up, vendors installed, +# proxy started, framework initialized and configured. +# +# Usage: +# dx init [env] [flags] +# +# Flags: +# --skip-vendors Skip composer install +# --skip-framework-init Skip framework init +# --skip-framework-config Skip framework config generation +# --debug Enable shell trace +# + +# ============================================ +# Public entrypoint +# ============================================ + +function cmd::init::on_load() { + flag::register \ + --skip-vendors \ + --skip-framework-init \ + --skip-framework-config \ + --skip-proxy \ + --debug +} + +function cmd::init::run() { + local env="" + local flags=() + + for arg in "$@"; do + case "$arg" in + --*) flags+=("$arg") ;; + *) [[ -z "$env" ]] && env="$arg" ;; + esac + done + + if [[ ${#flags[@]} -gt 0 ]]; then + if ! flag::parse "${flags[@]}"; then + cmd::init::help + return 1 + fi + fi + + env="${env:-${ENVIRONMENT:-dev}}" + + local framework_env + framework_env="$(app::resolve_env "$env")" + + log::env "Initializing environment: ${env} (${framework_env})..." + + # ============================================ + # Docker + # ============================================ + + docker::compose::up -d >/dev/null + + # ============================================ + # Vendors + # ============================================ + + if ! flag::enabled --skip-vendors; then + app::install_vendors + else + log::fs_warning "$(app::vendor_skip_message)" + fi + + # ============================================ + # Proxy + # ============================================ + + if ! flag::enabled --skip-proxy; then + log::run_step network info "Starting proxy..." \ + proxy::start + else + log::network_warning "Skipping Proxy initialization... (--skip-proxy)" + fi + + # ============================================ + # Framework + # ============================================ + + if ! flag::enabled --skip-framework-init; then + app::init "$env" + else + log::env_warning "$(app::init_skip_message)" + fi + + if ! flag::enabled --skip-framework-config; then + app::config + else + log::env_warning "$(app::config_skip_message)" + fi + + # ============================================ + # Done + # ============================================ + + log::env_success "Environment '${env}' is ready!" + + flag::scaffold_defaults_file + cmd::init::_hint_post_init +} + +# ============================================ +# Help +# ============================================ + +function cmd::init::help() { + cat < — manage /etc/hosts entries (delegates to hosts command) +# +# Hosts subcommands are also available directly: +# dx hosts sync +# dx hosts list +# dx hosts remove + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::network::on_load() { + flag::register \ + --debug +} + +# ============================================ +# Public entrypoint +# ============================================ + +function cmd::network::run() { + local subcmd="${1:-help}" + shift || true + + case "$subcmd" in + status) cmd::network::status::run "$@" ;; + resolve) cmd::network::resolve::run "$@" ;; + hosts) + load_command hosts + hosts::run "$@" + ;; + help) cmd::network::help ;; + *) + log::error "Unknown subcommand: '${subcmd}'" + cmd::network::help + return 1 + ;; + esac +} + +# ============================================ +# Help +# ============================================ + +function cmd::network::help() { + cat < + +Subcommands: + status Show current port assignments and their status + resolve Re-run port resolution and show what changed + hosts Manage /etc/hosts entries + +Hosts subcommands: + hosts sync Sync project domain to /etc/hosts + hosts list Show current /etc/hosts entries for this project + hosts remove Remove project entries from /etc/hosts + +Examples: + dx network status + dx network resolve + dx network hosts sync + dx hosts sync (shorthand) +EOF +} + +# ============================================ +# Status +# ============================================ + +function cmd::network::status::run() { + log::info "Port status for ${PROJECT_NAME}..." + + printf "\n" + printf "%-20s %-10s %s\n" "Service" "Port" "Status" + printf "%-20s %-10s %s\n" "-------" "----" "------" + + cmd::network::status::_report "App" "$APP_PORT" + cmd::network::status::_report "Database" "$(docker::db::external_port)" + + printf "\n" +} + +function cmd::network::status::_report() { + local label="$1" + local port="$2" + local status + + if ! network::ports::in_use "$port"; then + status="āœ“ free" + else + local container + container="$(docker ps --format '{{.Names}}' | grep "^${PROJECT_NAME}" | head -1)" + + if [[ -n "$container" ]]; then + status="āœ“ in use by ${PROJECT_NAME}" + else + status="āš ļø in use by another process — run: dx network resolve" + fi + fi + + printf "%-20s %-10s %s\n" "$label" "$port" "$status" +} + +# ============================================ +# Resolve +# ============================================ + +function cmd::network::resolve::run() { + log::info "Resolving ports for ${PROJECT_NAME}..." + + local old_app_port="$APP_PORT" + local old_db_port + old_db_port="$(docker::db::external_port)" + + network::ports::resolve_many APP_PORT DB_PORT + + local new_db_port + new_db_port="$(docker::db::external_port)" + + printf "\n" + printf "%-20s %-15s %-15s %s\n" "Service" "Current" "Resolved" "Status" + printf "%-20s %-15s %-15s %s\n" "-------" "-------" "--------" "------" + + cmd::network::resolve::_report "App" "$old_app_port" "$APP_PORT" + cmd::network::resolve::_report "Database" "$old_db_port" "$new_db_port" + + printf "\n" + + if [[ "$old_app_port" != "$APP_PORT" || "$old_db_port" != "$new_db_port" ]]; then + log::warn "Ports changed — run 'dx build' to apply" + else + log::success "No changes — all ports are available" + fi +} + +function cmd::network::resolve::_report() { + local label="$1" + local old="$2" + local resolved="$3" + local status + + if [[ "$old" == "$resolved" ]]; then + status="āœ“ unchanged" + else + status="āš ļø remapped: ${old} → ${resolved}" + fi + + printf "%-20s %-15s %-15s %s\n" "$label" "$old" "$resolved" "$status" +} \ No newline at end of file diff --git a/dxkit/commands/proxy.command.sh b/dxkit/commands/proxy.command.sh new file mode 100644 index 0000000..5a0cd5b --- /dev/null +++ b/dxkit/commands/proxy.command.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# commands/proxy.command.sh +# +# Manages the reverse proxy service. +# +# Usage: +# dx proxy start — start the proxy +# dx proxy stop — stop the proxy +# dx proxy restart — restart the proxy +# dx proxy status — show proxy status +# dx proxy logs — tail proxy logs + +# ============================================ +# Lifecycle +# ============================================ + +function cmd::proxy::on_load() { + flag::register --debug +} + +# ============================================ +# Public entrypoint +# ============================================ + +function cmd::proxy::run() { + local subcmd="${1:-help}" + shift || true + + case "$subcmd" in + start) cmd::proxy::start "$@" ;; + stop) cmd::proxy::stop "$@" ;; + restart) cmd::proxy::restart "$@" ;; + status) cmd::proxy::status "$@" ;; + logs) cmd::proxy::logs "$@" ;; + help) cmd::proxy::help ;; + *) + log::error "Unknown subcommand: '${subcmd}'" + cmd::proxy::help + return 1 + ;; + esac +} + +# ============================================ +# Help +# ============================================ + +function cmd::proxy::help() { + cat < + +Manages the reverse proxy service (${PROXY_SERVICE}). + +Subcommands: + start Start the proxy + stop Stop the proxy + restart Restart the proxy + status Show proxy status + logs Tail proxy logs + +Examples: + dx proxy start + dx proxy stop + dx proxy restart + dx proxy status + dx proxy logs +EOF +} + +# ============================================ +# Subcommands +# ============================================ + +function cmd::proxy::start() { + if proxy::running; then + log::info "Proxy (${PROXY_SERVICE}) is already running" + return 0 + fi + + log::info "Starting proxy (${PROXY_SERVICE})..." + proxy::start + log::success "Proxy started" +} + +function cmd::proxy::stop() { + if ! proxy::running; then + log::info "Proxy (${PROXY_SERVICE}) is not running" + return 0 + fi + + log::info "Stopping proxy (${PROXY_SERVICE})..." + proxy::stop + log::success "Proxy stopped" +} + +function cmd::proxy::restart() { + log::info "Restarting proxy (${PROXY_SERVICE})..." + proxy::restart + log::success "Proxy restarted" +} + +function cmd::proxy::status() { + printf "\n" + printf "%-20s %s\n" "Service:" "${PROXY_SERVICE}" + printf "%-20s %s\n" "Network:" "${PROXY_NETWORK}" + printf "%-20s %s\n" "Driver:" "$(proxy::driver)" + printf "%-20s " "Status:" + + if proxy::running; then + echo "āœ“ running" + else + echo "āš ļø stopped" + fi + + printf "\n" +} + +function cmd::proxy::logs() { + log::info "Tailing proxy logs (${PROXY_SERVICE})..." + proxy::logs +} \ No newline at end of file diff --git a/dxkit/commands/runtime/down.command.sh b/dxkit/commands/runtime/down.command.sh new file mode 100644 index 0000000..04fdcda --- /dev/null +++ b/dxkit/commands/runtime/down.command.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# commands/runtime/down.command.sh + +function cmd::runtime::down::on_load() { + flag::register \ + --volumes \ + --orphans \ + --debug +} + +function cmd::runtime::down::run() { + flag::parse "$@" || { cmd::runtime::down::help; return 1; } + runtime::down +} + +function cmd::runtime::down::help() { + cat < [args...] + +Runs a command inside the runtime. + +Examples: + dx exec ls -la + dx exec php --version + dx exec composer install +EOF +} diff --git a/dxkit/commands/runtime/list.command.sh b/dxkit/commands/runtime/list.command.sh new file mode 100644 index 0000000..de2bb9f --- /dev/null +++ b/dxkit/commands/runtime/list.command.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# commands/runtime/list.command.sh + +function cmd::runtime::list::on_load() { + flag::register --debug +} + +function cmd::runtime::list::run() { + flag::parse "$@" || { cmd::runtime::list::help; return 1; } + runtime::list +} + +function cmd::runtime::list::help() { + cat < "$rc_file" </dev/null || true +elif [[ -f "\$HOME/.bash_profile" ]]; then + source "\$HOME/.bash_profile" 2>/dev/null || true +fi + +# Load dxkit bootstrap — sources core, detects platform, +# applies MSYS2 guards, and defines dx::load_modules +source "$(ctx::dxkit)/bootstrap.sh" + +# Set framework before loading app so app::on_load +# resolves the correct framework module +export APP_FRAMEWORK="${framework}" + +# Load all standard modules (mirrors dx entrypoint) +dx::load_modules +load_module app +env::load_env_files + +# Source the active driver so its functions are in scope +source "${driver_file}" + +# ============================================ +# Driver command wrappers (run in-process) +# e.g. migrate-create → yii2::migrate::create +# ============================================ +$( + for cmd in "${!DRIVER_COMMANDS[@]}"; do + echo "function ${cmd}() { ${DRIVER_COMMANDS[$cmd]} \"\$@\"; }" + done +) + +# ============================================ +# Static dx command wrappers (subprocess) +# Uses \$PROJECT_ROOT/dx to avoid path mangling +# on Windows/MSYS2 — PROJECT_ROOT is exported +# by bootstrap so it is always available here. +# ============================================ +$( + while IFS= read -r -d '' cmd_file; do + cmd_name="$(basename "$cmd_file" .command.sh)" + [[ "$cmd_name" == "workspace" ]] && continue + echo "function ${cmd_name}() { \"\$PROJECT_ROOT/dx\" ${cmd_name} \"\$@\"; }" + done < <(find "$(ctx::dxkit)/commands" -name "*.command.sh" -print0) +) + +# ============================================ +# Prompt +# ============================================ +export PS1="\[\033[0;36m\][dx:${driver}]\[\033[0m\] \w \$ " + +# ============================================ +# Welcome +# ============================================ +echo "" +echo " dx workspace — ${framework}" +echo " Type 'exit' to return to your shell." +echo "" +echo " Framework commands:" +$( + for cmd in $(echo "${!DRIVER_COMMANDS[@]}" | tr ' ' '\n' | sort); do + echo " echo \" ${cmd}\"" + done +) +echo "" +RCEOF + + bash --rcfile "$rc_file" -i + rm -f "$rc_file" +} + +function cmd::workspace::help() { + local framework + framework="$(app::framework)" + + cat </dev/null 2>&1; } +function command::is_auto_load() { declare -F "$(command::fn "$1" on_load)" >/dev/null 2>&1; } +function command::to_namespace() { echo "${1//\//::}"; } +function command::exists() { command::has_function "$1" run; } +function command::run() { + local cmd="$1" + shift + + hook::run "$cmd" pre + + core::call_function "$(command::fn "$cmd" run)" "$@" + + local exit_code=$? + if [[ $exit_code -eq 0 ]]; then + hook::run "$cmd" post + fi + + return $exit_code +} + diff --git a/dxkit/core/context.sh b/dxkit/core/context.sh new file mode 100644 index 0000000..22524f3 --- /dev/null +++ b/dxkit/core/context.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +_CTX_ROOT="$PROJECT_ROOT" +_CTX_CONTAINER_ROOT="/app" +_CTX_DXKIT="${_CTX_ROOT}/dxkit" +_CTX_MODULES="${_CTX_DXKIT}/modules" +_CTX_COMMANDS="${_CTX_DXKIT}/commands" +_CTX_TEMPLATES="${_CTX_DXKIT}/templates" +_CTX_ENV="${_CTX_DXKIT}/env" +_CTX_PROJECT="${_CTX_ROOT}/.project" +_CTX_ARTIFACT="${_CTX_PROJECT}/artifacts" + +# ============================================ +# Wrappers +# ============================================ + +function ctx::require() { + local var="$1" + + [[ -z "${!var}" ]] && { + echo "Missing required context variable: $var" >&2 + return 1 + } +} + +# ============================================ +# Static Context +# ============================================ + +function ctx::root() { echo "$_CTX_ROOT"; } +function ctx::dxkit() { echo "$_CTX_DXKIT"; } +function ctx::modules() { echo "$_CTX_MODULES"; } +function ctx::commands() { echo "$_CTX_COMMANDS"; } +function ctx::templates() { echo "$_CTX_TEMPLATES"; } +function ctx::env() { echo "$_CTX_ENV"; } +function ctx::project() { echo "$_CTX_PROJECT"; } +function ctx::artifact() { echo "$_CTX_ARTIFACT"; } +function ctx::container::root() { echo $_CTX_CONTAINER_ROOT; } + +# ============================================ +# Derived Context +# ============================================ + +function ctx::runtime() { +# ctx::require ENVIRONMENT || return 1 + echo "$(ctx::artifact)/$ENVIRONMENT" +} + +function ctx::docker() { + echo "$(ctx::artifact)/docker" +} + +# ============================================ +# Path helpers +# ============================================ + +function ctx::artifact::path() { + local IFS="/" + echo "$(ctx::artifact)/$*" +} + +function ctx::container::path() { + local base="$(ctx::container::root)" + + if [[ $# -eq 0 ]]; then + echo "$base" + return + fi + + local IFS="/" + echo "$base/$*" +} \ No newline at end of file diff --git a/dxkit/core/flag.sh b/dxkit/core/flag.sh new file mode 100644 index 0000000..f91846b --- /dev/null +++ b/dxkit/core/flag.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# dxkit/core/flags.sh +# +# Shared CLI flag parsing for build and init commands. +# +# Parses flags into exported RUN_* and other control vars. +# Both build::run and init::run call flag::parse before doing any work, +# so --skip-* flags work identically whether passed to dx build or dx init. +# +# Usage: +# flag::parse "$@" +# flag::parse --init --skip-vendors --port=8082 + +declare -A _REGISTERED_FLAGS=() + +function flag::defaults() { + flag::set --framework yii2-advanced + flag::set --skip-vendors false + flag::set --skip-framework-init false + flag::set --skip-framework-config false +} + +function flag::register() { + for flag in "$@"; do + _REGISTERED_FLAGS["$flag"]=1 + done +} + +function flag::registered() { + [[ -n "${_REGISTERED_FLAGS["$1"]:-}" ]] +} + +function flag::set() { + local var + var="$(flag::to_var "$1")" + export "${var}"="${2:-true}" +} + +function flag::enable() { flag::set "$1" true; } +function flag::disable() { flag::set "$1" false; } + +function flag::enabled() { + local var + var="$(flag::to_var "$1")" + [[ "${!var:-false}" == true ]] +} + +function flag::to_var() { + local flag="${1#--}" + flag="${flag//-/_}" + echo "${flag^^}" +} + +function flag::value() { + local flag="$1" + local default="${2:-}" + + # Look for --flag=value in the raw args + local var + var="$(flag::to_var "$flag")" + + # Check if set as a var (from flag::parse) + if [[ -n "${!var:-}" ]]; then + echo "${!var}" + return 0 + fi + + echo "$default" +} + +function flag::load_defaults() { + local defaults_file + defaults_file="$(ctx::artifact)/.dx-flags" + + [[ -f "$defaults_file" ]] || return 0 + + # Read non-empty, non-comment lines as flags + local flags=() + while IFS= read -r line; do + [[ -z "$line" || "$line" == "#"* ]] && continue + flags+=("$line") + done < "$defaults_file" + + [[ ${#flags[@]} -eq 0 ]] && return 0 + + flag::parse "${flags[@]}" +} + +function flag::scaffold_defaults_file() { + local file="$(ctx::artifact)/.dx-flags" + + [[ -f "$file" ]] && return 0 # already exists, don't overwrite + + cat > "$file" < "$local_env" + log::info "Persisted overrides to env/local.env" +} + +# ============================================ +# Specify Defaults +# ============================================ + +flag::defaults +flag::load_defaults diff --git a/dxkit/core/hook.sh b/dxkit/core/hook.sh new file mode 100644 index 0000000..e7f3756 --- /dev/null +++ b/dxkit/core/hook.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# core/hook.sh +# +# Lifecycle hook runner. +# +# Hooks are shell scripts sourced in lexicographic order from: +# dxkit/hooks/// +# +# Timing: +# pre — runs before the command's main logic +# post — runs after the command's main logic +# +# Naming convention: +# NN-description.sh (e.g. 10-permissions.sh, 20-update-cron.sh) +# Numeric prefix controls execution order. +# +# Hooks are sourced (not executed) so they have full access to all +# loaded modules, functions, and environment variables. +# +# Usage: +# hook::run init pre +# hook::run init post +# hook::run build post +# +# Example structure: +# dxkit/hooks/ +# init/ +# pre/ +# 10-something.sh +# post/ +# 10-permissions.sh +# 20-update-cron.sh +# build/ +# post/ +# 10-something.sh + +# ============================================ +# Runner +# ============================================ + +function hook::run() { + local command="$1" + local timing="$2" + + local hook_dir + hook_dir="$(ctx::dxkit)/hooks/${command}/${timing}" + + [[ -d "$hook_dir" ]] || return 0 + + local hooks + hooks="$(hook::_list "$hook_dir")" + + [[ -z "$hooks" ]] && return 0 + + log::info "Running ${timing}-${command} hooks..." + + local hook + while IFS= read -r hook; do + [[ -f "$hook" ]] || continue + hook::_run_one "$hook" + done <<< "$hooks" +} + +# ============================================ +# Run a single hook +# ============================================ + +function hook::_run_one() { + local hook="$1" + local name + name="$(basename "$hook")" + + log::info " → ${name}" + + # shellcheck disable=SC1090 + source "$hook" || { + log::error "Hook failed: ${name}" + return 1 + } +} + +# ============================================ +# List hooks in a directory sorted by name +# ============================================ + +function hook::_list() { + local dir="$1" + + find "$dir" -maxdepth 1 -name "*.sh" -type f | sort +} + +# ============================================ +# Predicates +# ============================================ + +function hook::has() { + local command="$1" + local timing="$2" + + local hook_dir + hook_dir="$(ctx::dxkit)/hooks/${command}/${timing}" + + [[ -d "$hook_dir" ]] && [[ -n "$(hook::_list "$hook_dir")" ]] +} + +# ============================================ +# Scaffold hook directory and example file +# ============================================ + +function hook::scaffold() { + local command="$1" + local timing="${2:-post}" + + local hook_dir + hook_dir="$(ctx::dxkit)/hooks/${command}/${timing}" + + mkdir -p "$hook_dir" + + local example="${hook_dir}/10-example.sh" + [[ -f "$example" ]] && return 0 + + cat > "$example" <&2 + return 1 + fi + + # Source if file exists + if [[ -f "$file" ]]; then + source "$file" + fi +} + +# ============================================ +# load_module — Loads $(ctx::modules)/.module.sh +# Always required. +# ============================================ +function load_module() { + local name="$1" + + # Wildcard: Load all submodules + if [[ "$name" == *"/*" ]]; then + local dir="${name%/*}" + local module_dir + module_dir="$(ctx::modules)/${dir}" + + if [[ ! -d "$module_dir" ]]; then + log::error "Module directory not found: ${dir}" + return 1 + fi + + for file in "${module_dir}"/*.module.sh; do + [[ -f "$file" ]] || continue + local subname="${dir}/$(basename "${file%.module.sh}")" + load_module "$subname" + done + + return 0 + fi + + # Normal single module load + local file + file="$(ctx::modules)/${name}.module.sh" + + module::loaded "$name" && return 0 + + if [[ ! -f "$file" ]]; then + log::error "Module not found: ${name}" + return 1 + fi + + source "$file" + _LOADED_MODULES["$name"]=1 + + core::call_if_exists "$(module::to_namespace "$name")::on_load" +} + +# ============================================ +# load_command — Loads $(ctx::commands)/.command.sh +# +# Returns: +# 0 — file found and sourced +# 1 — file not found (caller decides how to handle) +# +# After sourcing, does NOT validate ::run here — +# that's the dispatcher's job, keeping this function +# a clean "did the file exist?" predicate. +# ============================================ +function load_command() { + local name="$1" + local file + file="$(ctx::commands)/${name}.command.sh" + + if [[ ! -f "$file" ]]; then + return 1 # No command file, not an error by itself + fi + + source "$file" + _LOADED_COMMANDS["$name"]=1 + + core::call_if_exists "$(command::fn "$name" on_load)" +# core::call_if_exists "$(command::to_namespace "$name")::on_load" + + return 0 +} + +# ============================================ +# load_command_strict — Load + assert ::run exists +# ============================================ +function load_command_strict() { + local name="$1" + + if ! load_command "$name"; then + echo "āŒ No command file found for: '${name}'" >&2 + return 1 + fi + + if ! core::function_exists "${name}::run"; then + echo "āŒ Command '${name}' loaded but '${name}::run' is not defined" >&2 + echo " Expected function: ${name}::run()" >&2 + return 1 + fi +} \ No newline at end of file diff --git a/dxkit/core/module.sh b/dxkit/core/module.sh new file mode 100644 index 0000000..8642764 --- /dev/null +++ b/dxkit/core/module.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +declare -A _LOADED_MODULES=() +readonly _MODULE_AUTO_LOAD_HOOK="on_load" + +function module::loaded() { [[ -n "${_LOADED_MODULES["$1"]:-}" ]]; } +function module::has_function() { declare -F "${1}::${2}" >/dev/null 2>&1; } +function module::is_auto_load() { declare -F "${1}::on_load" >/dev/null 2>&1; } +function module::to_namespace() { echo "${1//\//::}"; } \ No newline at end of file diff --git a/dxkit/core/platform.sh b/dxkit/core/platform.sh new file mode 100644 index 0000000..50edc36 --- /dev/null +++ b/dxkit/core/platform.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +# ============================================ +# Detection +# ============================================ + +function platform::detect() { + case "$(uname -s)" in + Linux*) PLATFORM="linux" ;; + Darwin*) PLATFORM="macos" ;; + CYGWIN*|MINGW*|MSYS*) PLATFORM="windows" ;; + *) PLATFORM="unknown" ;; + esac + + platform::_apply_platform_guards +} + +function platform::is_macos() { [[ "$PLATFORM" == "macos" ]]; } +function platform::is_linux() { [[ "$PLATFORM" == "linux" ]]; } +function platform::is_windows() { [[ "$PLATFORM" == "windows" ]]; } + +# ============================================ + +function platform::_apply_platform_guards() { + if platform::is_windows; then + export MSYS_NO_PATHCONV=1 + export MSYS2_ARG_CONV_EXCL="*" + fi +} + +# ============================================ + +function platform::elevation_fail() { + if platform::is_windows; then + echo "Action must be run as Administrator" + else + echo "Action must be run with sudo or as root" + fi +} + +function platform::hosts_file() { + local hostsPath='/etc/hosts' + + if platform::is_windows; then + hostsPath='/c/Windows/System32/drivers/etc/hosts' + fi + + echo $hostsPath +} + +function platform::sudo() { + if platform::is_windows; then + "$@" + else + [[ $EUID -ne 0 ]] && sudo "$1" "$@" + fi +} + +function platform::require_privileges() { + if platform::is_windows; then + if ! net session >/dev/null 2>&1; then + return 1 + fi + else + platform::sudo -n true 2>/dev/null || platform::sudo true || return 1 + fi +} + +# ============================================ +# Docker +# ============================================ + +function platform::docker_path() { + local path="$1" + + if platform::is_windows; then + cygpath -m "$path" + else + echo "$path" + fi +} + +# ============================================ +# Platform functions +# ============================================ + +# Detect GNU realpath (supports --relative-to) +function platform::has_gnu_realpath() { + realpath --help 2>&1 | grep -q -- '--relative-to' +} + +# Detect grealpath (Homebrew GNU coreutils) +function platform::has_grealpath() { + command -v grealpath >/dev/null 2>&1 +} + +# Detect python3 +function platform::has_python() { + command -v python3 >/dev/null 2>&1 +} \ No newline at end of file diff --git a/dxkit/core/string.sh b/dxkit/core/string.sh new file mode 100644 index 0000000..412164a --- /dev/null +++ b/dxkit/core/string.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +function string::trim() { + local var="$*" + var="${var#"${var%%[![:space:]]*}"}" + var="${var%"${var##*[![:space:]]}"}" + printf "%s" "$var" +} + +function string::lowercase() { + printf "%s" "$1" | tr '[:upper:]' '[:lower:]' +} + +function string::uppercase() { + printf "%s" "$1" | tr '[:lower:]' '[:upper:]' +} + +function string::replace() { + local str="$1" from="$2" to="$3" + echo "${str//$from/$to}" +} + +function string::strip_prefix() { + local str="$1" prefix="$2" + echo "${str#"$prefix"}" +} + +function string::strip_suffix() { + local str="$1" suffix="$2" + echo "${str%"$suffix"}" +} + +function string::slugify() { + echo "$1" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd '[:alnum:]-' +} + +function string::pad_right() { + local str="$1" width="$2" + printf "%-${width}s" "$str" +} + +function string::pad_left() { + local str="$1" width="$2" + printf "%${width}s" "$str" +} + +function string::repeat() { + local char="$1" count="$2" + printf "%${count}s" | tr " " "$char" +} + +function string::split() { + local str="$1" delimiter="$2" + local -n result_ref="$3" # nameref — caller passes array name + + IFS="$delimiter" read -ra result_ref <<< "$str" +} + +function string::is_empty() { [[ -z "$1" ]]; } +function string::is_not_empty() { [[ -n "$1" ]]; } +function string::starts_with() { [[ "$1" == "$2"* ]]; } +function string::ends_with() { [[ "$1" == *"$2" ]]; } +function string::contains() { [[ "$1" == *"$2"* ]]; } \ No newline at end of file diff --git a/dxkit/core/utils.sh b/dxkit/core/utils.sh new file mode 100644 index 0000000..abbd229 --- /dev/null +++ b/dxkit/core/utils.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +# =========================================== +# System +# =========================================== +function system::uuid() { + od -x /dev/urandom | head -1 | awk '{OFS="-"; print $2$3,$4,$5,$6,$7$8$9}' +} +function system::hosts() { + cat "$(platform::hosts_file)" +} +function system::stderr() { + "$1" >&2 +} + +# =========================================== +# Path +# =========================================== +function path::relative_to_root() { + local abs="$1" + local root="$(ctx::root)" + + # remove root prefix + echo "${abs#$root/}" +} + +function path::from_root() { + local path="$1" + [[ "$path" == /* ]] && echo "$path" || echo "$(ctx::root)/${path}" +} + +function path::relative_to() { + local from="$1" + local to="$2" + + # =========================================== + # 1. GNU realpath (best case) + # =========================================== + if platform::has_gnu_realpath; then + realpath --relative-to="$from" "$to" + return 0 + fi + + # =========================================== + # 2. Homebrew GNU coreutils + # =========================================== + if platform::has_grealpath; then + grealpath --relative-to="$from" "$to" + return 0 + fi + + # =========================================== + # 3. Python fallback (POSIX-safe) + # =========================================== + if platform::has_python; then + python3 -c ' + import os, sys + print(os.path.relpath(sys.argv[2], sys.argv[1])) + ' "$from" "$to" + return 0 + fi + + # =========================================== + # 4. Hard failure + # =========================================== + echo "path::relative_to: no suitable backend found" >&2 + return 1 +} + +# =========================================== +# Core +# =========================================== + +# Returns true if stdout is connected to a real TTY. +# Used to decide whether winpty or interactive flags are needed. +# False inside $(), pipes, or non-interactive subshells. +function core::has_tty() { [[ -t 1 ]]; } # stdout is a TTY +function core::is_interactive() { [[ $- == *i* ]]; } # shell is interactive mode +function core::function_exists() { declare -F "$1" >/dev/null 2>&1; } +function core::variable_exists() { declare -p "$1" &>/dev/null; } +function core::array_exists() { [[ "$(declare -p "$1" 2>/dev/null)" == "declare -a"* ]]; } + +function core::call_function() { + local fn="$1" + shift + + "$fn" "$@"; +} + +function core::call_if_exists() { + local fn="$1" + shift + + if declare -F "$fn" >/dev/null 2>&1; then + "$fn" "$@" + fi + + return 0 +} diff --git a/dxkit/docker/entrypoint.sh b/dxkit/docker/entrypoint.sh new file mode 100644 index 0000000..e9c518d --- /dev/null +++ b/dxkit/docker/entrypoint.sh @@ -0,0 +1,5 @@ +#!/bin/sh + +set -e +service ssh start +/usr/sbin/apache2ctl -D FOREGROUND diff --git a/dxkit/drivers/dispatcher.sh b/dxkit/drivers/dispatcher.sh new file mode 100644 index 0000000..e9dd73b --- /dev/null +++ b/dxkit/drivers/dispatcher.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash + +# ============================================ +# Driver Dispatcher +# ============================================ +# +# Resolves the active framework via app::framework() +# and loads the corresponding driver from dxkit/drivers/. +# +# Called in main() after load_module app, so app::framework() +# is guaranteed to be available. +# +# Each driver registers its commands into DRIVER_COMMANDS +# for dx::dispatch to pick up. +# +# Usage (in dx entrypoint, after load_module app): +# driver::load + +declare -gA DRIVER_COMMANDS=() + +# ============================================ +# Resolution +# ============================================ + +# Maps APP_FRAMEWORK values to driver directory names. +# Multiple framework variants can share one driver (e.g. yii2-advanced +# and yii2-basic both use the yii2 driver). +# Unknown frameworks fall through as-is, so new drivers +# just need a matching directory — no changes here required. +function driver::resolve() { + local framework + framework="$(app::framework)" + + case "$framework" in + yii2-advanced|yii2-basic) echo "yii2" ;; + laravel) echo "laravel" ;; + *) echo "$framework" ;; + esac +} + +# ============================================ +# Loader +# ============================================ + +function driver::load() { + local driver + driver="$(driver::resolve)" + + local driver_file + driver_file="$(ctx::dxkit)/drivers/${driver}/driver.sh" + + if [[ ! -f "$driver_file" ]]; then + log::warn "No driver found for framework '$(app::framework)' at ${driver_file}" + log::warn "Framework-specific commands (e.g. migrate-create) will not be available." + return 0 + fi + + # shellcheck source=/dev/null + source "$driver_file" + + log::debug "Loaded driver: ${driver} (framework: $(app::framework))" +} + +# ============================================ +# Runtime Helpers +# ============================================ + +# Returns true if a command is registered by the active driver. +function driver::has_command() { + local cmd="$1" + [[ -n "${DRIVER_COMMANDS[$cmd]:-}" ]] +} + +# Runs a driver command. +function driver::run() { + local cmd="$1" + shift + + if ! driver::has_command "$cmd"; then + log::error "Driver command not found: '${cmd}'" + return 1 + fi + + "${DRIVER_COMMANDS[$cmd]}" "$@" +} + +# Prints all registered driver commands (used by dx help and dx workspace). +function driver::list_commands() { + if [[ ${#DRIVER_COMMANDS[@]} -eq 0 ]]; then + return 0 + fi + + echo "" + echo " Framework commands ($(app::framework)):" + for cmd in $(echo "${!DRIVER_COMMANDS[@]}" | tr ' ' '\n' | sort); do + printf " %-28s\n" "$cmd" + done +} \ No newline at end of file diff --git a/dxkit/drivers/laravel/driver.sh b/dxkit/drivers/laravel/driver.sh new file mode 100644 index 0000000..2668929 --- /dev/null +++ b/dxkit/drivers/laravel/driver.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# ============================================ +# Laravel Driver +# ============================================ +# +# Entry point for the Laravel driver. +# Sources all Laravel command modules and registers +# them into DRIVER_COMMANDS for dx::dispatch. + +# ============================================ +# Load Modules +# ============================================ + +# shellcheck source=/dev/null +source "$(ctx::dxkit)/drivers/laravel/migrate.sh" + +# ============================================ +# Register Commands +# ============================================ + +DRIVER_COMMANDS=( + [artisan]="laravel::exec" + [migrate]="laravel::migrate::run" + [migrate-create]="laravel::migrate::create" + [migrate-rollback]="laravel::migrate::rollback" + [migrate-status]="laravel::migrate::status" + [migrate-fresh]="laravel::migrate::fresh" +) diff --git a/dxkit/drivers/laravel/migrate.sh b/dxkit/drivers/laravel/migrate.sh new file mode 100644 index 0000000..296ad70 --- /dev/null +++ b/dxkit/drivers/laravel/migrate.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +# ============================================ +# Laravel — Migration Commands +# ============================================ +# +# Thin wrappers around artisan migrate commands, +# run inside the app container via artisan::exec. + +# ============================================ +# Commands +# ============================================ + +# Creates a new migration via artisan make:migration. +# +# Usage: +# dx migrate-create create_invoices_table +function laravel::migrate::create() { + local name="${1:?Usage: dx migrate-create }" + artisan::exec make:migration "$name" +} + +# Runs all pending migrations. +# +# Usage: +# dx migrate +function laravel::migrate::run() { + artisan::exec migrate "$@" +} + +# Rolls back the last batch of migrations. +# +# Usage: +# dx migrate-rollback +# dx migrate-rollback --step=3 +function laravel::migrate::rollback() { + artisan::exec migrate:rollback "$@" +} + +# Shows the status of all migrations. +# +# Usage: +# dx migrate-status +function laravel::migrate::status() { + artisan::exec migrate:status "$@" +} + +# Drops all tables and re-runs all migrations. +# +# Usage: +# dx migrate-fresh +function laravel::migrate::fresh() { + artisan::exec migrate:fresh "$@" +} diff --git a/dxkit/drivers/yii2/driver.sh b/dxkit/drivers/yii2/driver.sh new file mode 100644 index 0000000..1cadfd8 --- /dev/null +++ b/dxkit/drivers/yii2/driver.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +# ============================================ +# Yii2 Driver +# ============================================ +# +# Entry point for the yii2-advanced driver. +# Sources all yii2 command modules and registers +# them into DRIVER_COMMANDS for dx::dispatch. + +# ============================================ +# Load Modules +# ============================================ + +# shellcheck source=/dev/null +source "$(ctx::dxkit)/drivers/yii2/migrate.sh" + +# ============================================ +# Register Commands +# ============================================ + +DRIVER_COMMANDS=( + [yii]="yii2::exec" + [migrate]="yii2::migrate::run" + [migrate-create]="yii2::migrate::create" + [migrate-down]="yii2::migrate::down" + [migrate-new]="yii2::migrate::new" + [migrate-history]="yii2::migrate::history" +) diff --git a/dxkit/drivers/yii2/migrate.sh b/dxkit/drivers/yii2/migrate.sh new file mode 100644 index 0000000..da1af42 --- /dev/null +++ b/dxkit/drivers/yii2/migrate.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +# ============================================ +# Yii2 — Commands +# ============================================ +# +# All functions run inside the app container +# via runtime::exec, so they work regardless +# of whether the stack is Docker or native. + +# ============================================ +# Helpers +# ============================================ + +# Generates a snake_case migration class name with timestamp. +# e.g. m260430_151441_create_invoice_table +function yii2::migrate::_class_name() { + local name="$1" + local timestamp + timestamp="$(date '+%y%m%d_%H%M%S')" + echo "m${timestamp}_${name}" +} + +# Resolves the migrations directory on the host. +function yii2::migrate::_dir() { + echo "$(ctx::root)/console/migrations" +} + +# ============================================ +# Yii +# ============================================ + +# Runs any yii console command inside the container. +# +# Usage: +# dx yii [args...] +# yii [args...] (inside dx workspace) +# +# Examples: +# dx yii migrate +# dx yii rbac/init +# yii cache/flush-all +function yii2::exec() { + runtime::exec php yii "$@" +} + +# ============================================ +# Migrations +# ============================================ + +# Creates a new migration file with the correct namespace, +# class name, and stub — bypassing Yii's generator entirely. +# +# Usage: +# dx migrate-create +# e.g. dx migrate-create create_invoice_table +function yii2::migrate::create() { + local name="${1:?Usage: dx migrate-create }" + local namespace="console\\migrations" + local class + class="$(yii2::migrate::_class_name "$name")" + local dir + dir="$(yii2::migrate::_dir)" + local file="${dir}/${class}.php" + + local title + title="$(echo "$name" | sed 's/_/ /g' | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) substr($i,2)}1')" + + mkdir -p "$dir" + + cat > "$file" <dropTable(\$this->tableName); + } +} +PHP + + log::success "Created: ${file}" +} + +# Runs all pending migrations. +# +# Usage: +# dx migrate +function yii2::migrate::run() { + yii2::exec migrate "$@" +} + +# Reverts the last N migrations (default: 1). +# +# Usage: +# dx migrate-down +# dx migrate-down 3 +function yii2::migrate::down() { + local n="${1:-1}" + yii2::exec migrate/down "$n" "${@:2}" +} + +# Shows new (pending) migrations. +# +# Usage: +# dx migrate-new +function yii2::migrate::new() { + yii2::exec migrate/new "$@" +} + +# Shows migration history. +# +# Usage: +# dx migrate-history +function yii2::migrate::history() { + yii2::exec migrate/history "$@" +} \ No newline at end of file diff --git a/dxkit/env/globals.env b/dxkit/env/globals.env new file mode 100644 index 0000000..dc98e71 --- /dev/null +++ b/dxkit/env/globals.env @@ -0,0 +1,39 @@ +# App Settings +export BASE_APP_IMAGE="yiisoftware/yii2-php:8.3-apache" +export APP_IMAGE="${PROJECT_NAME}-app" +export APP_PORT=auto:8080 + +# Domains +export TLD="erlog" +export DOMAIN="${PROJECT_NAME}.${TLD}" + +# Database Config +export DB_DRIVER="db" +export DB_IMAGE="mariadb:latest" +export DB_PORT=auto +export DB_HOST="${PROJECT_NAME}-${DB_DRIVER}" +export DB_NAME="${PROJECT_NAME}" +export DB_ENGINE="mysql" + +# Database Auth +export DB_USER="root" +export DB_PASS="kxpto1l" +export DB_ROOT_PASSWORD="kxpto1l" + +# Composer Auth +export GIT_DOMAIN="git.erlog.pt" +export GIT_AUTH_TOKEN_ID="gitlab+deploy-token-5" +export GIT_AUTH_TOKEN_PW="gldt-CMbMgwQ_EsxBydc13Ssv" + +export COMPOSER_AUTH="{ + \"http-basic\": { + \"${GIT_DOMAIN}\": { + \"username\": \"${GIT_AUTH_TOKEN_ID}\", + \"password\": \"${GIT_AUTH_TOKEN_PW}\" + } + } +}" + +# Proxy +export PROXY_SERVICE="traefik" +export PROXY_NETWORK="proxy" \ No newline at end of file diff --git a/dxkit/modules/apache.module.sh b/dxkit/modules/apache.module.sh new file mode 100644 index 0000000..61253bd --- /dev/null +++ b/dxkit/modules/apache.module.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# modules/apache.module.sh +# +# Apache management module. +# Handles vhost generation, config validation, reloads, and module toggling. +# +# Runs commands through runtime::exec so it works identically with +# Docker and native runtimes. + +# ============================================ +# Vhost generation +# ============================================ + +function apache::generate_vhosts() { + log::env_load "Generating Virtual Hosts (${ENVIRONMENT})..." + + template::render \ + "$(app::vhost_template_path)" \ + "$(ctx::artifact::path apache vhosts vhosts.conf)" \ + APP_PATH="$(ctx::container::root)" +} + +# ============================================ +# Config validation +# ============================================ + +function apache::validate_config() { + runtime::exec apache2ctl configtest +} + +# ============================================ +# Lifecycle +# ============================================ + +function apache::reload() { + runtime::exec apache2ctl graceful +} + +function apache::restart() { + runtime::exec apache2ctl restart +} + +# ============================================ +# Inspection +# ============================================ + +function apache::list_vhosts() { + runtime::exec apache2ctl -S +} + +function apache::list_modules() { + runtime::exec apache2ctl -M +} + +function apache::version() { + runtime::exec apache2 -v +} + +# ============================================ +# Logs +# ============================================ + +function apache::logs::error() { runtime::exec tail -f /var/log/apache2/error.log; } +function apache::logs::access() { runtime::exec tail -f /var/log/apache2/access.log; } + +# ============================================ +# Module management +# ============================================ + +function apache::module::enable() { runtime::exec a2enmod "$@"; } +function apache::module::disable() { runtime::exec a2dismod "$@"; } + +function apache::module::is_enabled() { + runtime::exec a2query -m "$1" >/dev/null 2>&1 +} \ No newline at end of file diff --git a/dxkit/modules/app.module.sh b/dxkit/modules/app.module.sh new file mode 100644 index 0000000..144ff41 --- /dev/null +++ b/dxkit/modules/app.module.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# modules/app.module.sh +# +# Framework abstraction layer. +# Dispatches app lifecycle functions to the active framework module. +# +# Active framework is determined by --framework flag (default: yii2-advanced) +# Set in .dx-flags or passed as: dx build --framework=laravel + +function app::on_load() { + load_module "framework/$(app::framework)" + load_module proxy + load_module composer + + # Register framework console name as alias for console command + # yii2-advanced → yii, laravel → artisan + # CMD_ALIASES is declared in dx + CMD_ALIASES["$(app::console_name)"]=console + CMD_ALIASES[console]=console +} + +# ============================================ +# Framework identity +# ============================================ + +function app::framework { echo "${APP_FRAMEWORK:-yii2-advanced}"; } +function app::console_name() { "framework::$(app::framework)::console_name"; } + +# ============================================ +# Interface +# ============================================ + +function app::scaffold() { "framework::$(app::framework)::scaffold" "${1:-$(ctx::root)}"; } +function app::init() { "framework::$(app::framework)::init" "$@"; } +function app::config() { "framework::$(app::framework)::config" "$@"; } +function app::console() { "framework::$(app::framework)::console" "$@"; } +function app::resolve_env() { "framework::$(app::framework)::resolve_env" "$@"; } +function app::install_vendors() { "framework::$(app::framework)::install_vendors"; } +function app::vendor_skip_message() { "framework::$(app::framework)::vendor_skip_message"; } +function app::vhost_template_path() { "framework::$(app::framework)::vhost_template_path"; } +function app::exists() { [[ -f "$(ctx::root)/composer.json" ]]; } +function app::init_skip_message() { "framework::$(app::framework)::init_skip_message" "$@"; } +function app::config_skip_message() { "framework::$(app::framework)::config_skip_message" "$@"; } +function app::post_init_hint() { "framework::$(app::framework)::post_init_hint"; } \ No newline at end of file diff --git a/dxkit/modules/artifact.module.sh b/dxkit/modules/artifact.module.sh new file mode 100644 index 0000000..049d971 --- /dev/null +++ b/dxkit/modules/artifact.module.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +function artifact::dirs() { + local dirs=( + "$(ctx::artifact)" + "$(ctx::artifact::path env)" + "$(ctx::artifact::path apache)" + "$(ctx::artifact::path database)" + "$(ctx::artifact::path docker)" + ) + printf "%s\n" "${dirs[@]}" +} + +function artifact::init() { + while IFS= read -r dir; do + mkdir -p "$dir" + done < <(artifact::dirs) +} \ No newline at end of file diff --git a/dxkit/modules/composer.module.sh b/dxkit/modules/composer.module.sh new file mode 100644 index 0000000..139b14a --- /dev/null +++ b/dxkit/modules/composer.module.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +# modules/composer.module.sh +# +# Composer wrapper for both in-runtime and bootstrap operations. +# +# Two execution modes: +# composer::run — runs inside the project's runtime +# (use when the runtime is up) +# composer::run_isolated — runs in a one-shot Docker container +# (use for bootstrap before runtime exists) + +# ============================================ +# Execution modes +# ============================================ + +# Run composer inside the project's runtime. +# Requires the runtime to be up. +function composer::run() { + runtime::exec composer "$@" +} + +# Run composer in a one-shot Docker container. +# Use for scaffold and other operations that happen before the runtime exists. +function composer::run_isolated() { + docker::raw run --rm \ + -v "$(ctx::root):/app" \ + -w /app \ + composer:latest "$@" +} + +# ============================================ +# Auth +# ============================================ + +function composer::config_auth() { + local mode="cache" + + if [[ "$1" == "cache" || "$1" == "nocache" ]]; then + mode="$1" + shift + fi + + local domain="$1" + local id="$2" + local pw="$3" + + if [[ -z "$domain" || -z "$id" || -z "$pw" ]]; then + log::error "composer::config_auth: domain, id, and password are required" + return 1 + fi + + [[ "$mode" == "nocache" ]] && \ + runtime::exec rm -f /root/.composer/auth.json + + runtime::exec composer config \ + --global \ + --auth "http-basic.${domain}" \ + "$id" \ + "$pw" +} + +# ============================================ +# Vendor management +# ============================================ + +function composer::install() { + runtime::exec composer install \ + --prefer-dist \ + --no-interaction \ + --no-progress \ + --optimize-autoloader \ + "$@" +} + +function composer::update() { + runtime::exec composer update \ + --prefer-dist \ + --no-interaction \ + --no-progress \ + "$@" +} + +function composer::require() { + runtime::exec composer require \ + --no-interaction \ + --no-progress \ + "$@" +} + +function composer::delete_lock() { + runtime::exec rm -f composer.lock +} + +# ============================================ +# Inspection +# ============================================ + +function composer::version() { runtime::exec composer --version; } +function composer::validate() { runtime::exec composer validate; } + +function composer::has_lockfile() { + [[ -f "$(ctx::root)/composer.lock" ]] +} + +function composer::has_manifest() { + [[ -f "$(ctx::root)/composer.json" ]] +} \ No newline at end of file diff --git a/dxkit/modules/docker.module.sh b/dxkit/modules/docker.module.sh new file mode 100644 index 0000000..5661729 --- /dev/null +++ b/dxkit/modules/docker.module.sh @@ -0,0 +1,305 @@ +#!/usr/bin/env bash + +# shellcheck disable=SC2120 + +# =========================================== +# Load +# =========================================== + +function docker::on_load() { load_module 'docker/*'; } + +# =========================================== +# Docker +# =========================================== +# Non-interactive docker calls — bypasses all shell aliases. +function docker::raw() { + command docker "$@" +} + +# Interactive docker calls that need a TTY. +# On Windows: prepends winpty to allocate a pseudo-terminal. +# On Unix: same as docker::raw. +function docker::tty() { + if platform::is_windows; then + winpty docker "$@" + else + command docker "$@" + fi +} + +function docker::exec() { + local container="$1" + shift + + docker::tty exec -it "$container" "$@" +} +function docker::container_running() { + local name="$1" + + docker::raw ps --format '{{.Names}}' \ + | grep -qx "$name" +} +function docker::container_exists() { + local name="$1" + + docker::raw ps -a --format '{{.Names}}' \ + | grep -qx "$name" +} +function docker::build() { + local dockerfile=$(docker::image::file) + + local tag="${APP_IMAGE}:${ENVIRONMENT}" + + require_file "$dockerfile" || { + log::docker_error "Dockerfile not found for environment $ENVIRONMENT (Dockerfile-${ENVIRONMENT})!" + return 1 + } + + log::docker_build "Building image ${tag}..." + + if docker::raw build -f "$dockerfile" -t "$tag" "$(ctx::root)"; then + log::docker_success "Image built: ${tag}" + else + log::docker_error "Failed to build image: ${tag}" + return 1 + fi +} +function docker::list_containers() { + docker::raw ps --filter "name=${PROJECT_NAME}" \ + --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" +} +function docker::status() { + local container="$1" + + if ! docker::raw inspect "$container" >/dev/null 2>&1; then + echo "missing" + return + fi + + docker::raw inspect -f '{{.State.Status}}' "$container" +} +function docker::health() { + local container="$1" + + local health + health="$(docker::raw inspect -f '{{.State.Health.Status}}' "$container" 2>/dev/null)" + + if [[ -z "$health" || "$health" == "" ]]; then + echo "unknown" + else + echo "$health" + fi +} +function docker::inside_container() { + [[ -f /.dockerenv ]] +} + +# =========================================== +# Compose +# =========================================== +function docker::compose() { + local stackName="docker-compose-${ENVIRONMENT}.yml" + + require_file "$(docker::compose::file)" || { + log::docker_error "Docker Compose Stack not found for Environment $ENVIRONMENT ($stackName)!" + return 1 + } + + if core::has_tty; then + docker::tty compose -p "${PROJECT_NAME}_${ENVIRONMENT}" -f "$(docker::compose::file)" "$@" + else + docker::raw compose -p "${PROJECT_NAME}_${ENVIRONMENT}" -f "$(docker::compose::file)" "$@" + fi +} +function docker::compose::interactive() { + local file + file="$(docker::compose::file)" + + require_file "$file" || { + log::docker_error "Missing compose file" + return 1 + } + + docker::tty compose \ + -p "${PROJECT_NAME}_${ENVIRONMENT}" \ + -f "$file" \ + "$@" +} +function docker::compose::default_service() { echo "app"; } +function docker::compose::service_running() { + local service="${1:-app}" + + local id + id="$(docker::compose ps -q "$service" 2>/dev/null)" + id="${id//$'\r'/}" # strip carriage returns using parameter expansion, no pipe + + [[ -n "$id" ]] +} +function docker::compose::ensure_running() { + local service="${1:-app}" + + if ! docker::compose::service_running "$service"; then + log::docker_start "Stack not running, starting..." + docker::compose up -d + fi +} +function docker::compose::logs() { + docker::compose logs -f +} +function docker::compose::up() { + docker::compose up -d --remove-orphans \ + && { log::docker_start "Stack started for Environment $ENVIRONMENT"; } \ + || { log::docker_error "Failed to start Stack for Environment $ENVIRONMENT"; return 1; } +} +function docker::compose::down() { + docker::compose down \ + && { log::docker_stop "Stack stopped for Environment $ENVIRONMENT"; } \ + || { log::docker_error "Failed to stop Stack for Environment $ENVIRONMENT"; return 1; } +} +function docker::compose::status() { + local service="${1:-$(docker::compose::default_service)}" + + local id + id="$(docker::compose::container "$service")" || { + echo "missing" + return + } + + docker::raw inspect -f '{{.State.Status}}' "$id" +} +function docker::compose::exec() { + local service="$1" + shift + + docker::compose::interactive exec "$service" "$@" +} +function docker::compose::container() { + local service="$1" + docker::compose ps -q "$service" +} +function docker::compose::service_exists() { + local service="${1:-$(docker::compose::default_service)}" + [[ -n "$(docker::compose::container "$service")" ]] +} +function docker::compose::list_containers() { + docker::compose ps \ + --format "table {{.Name}}\t{{.Service}}\t{{.Status}}\t{{.Ports}}" +} +function docker::compose::ports() { + local service="${1:-$(docker::compose::default_service)}" + + local id + id="$(docker::compose::container "$service")" || return 1 + + docker::raw port "$id" 2>/dev/null +} + +# =========================================== +# Paths +# =========================================== +function docker::path() { + local dir="" + local file="" + + if [[ $# -eq 1 ]]; then + file="$1" + else + dir="$1" + file="$2" + fi + + if [[ -n "$dir" ]]; then + echo "$(ctx::docker)/$dir/$file" + else + echo "$(ctx::docker)/$file" + fi +} +function docker::path::relative() { path::relative_to "$(ctx::docker)" "$1"; } +function docker::image::file() { platform::docker_path "$(ctx::docker)/Dockerfile-${ENVIRONMENT}"; } +function docker::compose::file() { platform::docker_path "$(ctx::docker)/docker-compose-${ENVIRONMENT}.yml"; } +function docker::image::path() { path::relative_to_root "$(ctx::docker)/Dockerfile-${ENVIRONMENT}"; } +function docker::compose::path() { path::relative_to_root "$(ctx::docker)/docker-compose-${ENVIRONMENT}.yml"; } +function docker::app::path() { docker::path::relative "$(ctx::root)"; } +function docker::apache::logs_path() { docker::path::relative "$(ctx::artifact)/apache/logs"; } +function docker::apache::vhosts_path() { docker::path::relative "$(ctx::artifact)/apache/vhosts"; } +function docker::apache::certs_path() { docker::path::relative "$(ctx::artifact)/apache/certificates"; } +function docker::apache::xdebug_path() { docker::path::relative "$(ctx::artifact)/php/xdebug.ini"; } + +# =========================================== +# Render +# =========================================== +function docker::compose::render_volume() { + local host="$1" + local container="$2" + + echo "- ${host}:${container}" +} +function docker::compose::db_volume() { + docker::compose::render_volume \ + "$(docker::path::relative "$(db::volume_host_path)")" \ + "$(db::volume_container_path)" +} + +function docker::compose::volume() { docker::compose::render_volume "$(docker::path::relative "$1")" "$2"; } + +# =========================================== +# Generation +# =========================================== +function docker::image::generate() { + local tag="${APP_IMAGE}:${ENVIRONMENT}" + log::docker_build "Generating Docker Image ($tag)..." + + template::render \ + "$(template::path docker Dockerfile)" \ + "$(docker::image::path)" +} +function docker::compose::generate() { + log::docker_build "Generating Docker Compose Stack (${ENVIRONMENT})..." + + template::render \ + "$(template::path docker docker-compose.yml)" \ + "$(docker::compose::path)" \ + ENVIRONMENT="$ENVIRONMENT" \ + APP_IMAGE="$APP_IMAGE" \ + CTX_BUILD="$(docker::app::path)" \ + CTX_APP="$(docker::app::path)" \ + CTX_APACHE_LOGS="$(docker::apache::logs_path)" \ + CTX_APACHE_VHOSTS="$(docker::apache::vhosts_path)" \ + CTX_APACHE_CERTS="$(docker::apache::certs_path)" \ + CTX_XDEBUG="$(docker::apache::xdebug_path)" \ + APP_PATH="$(ctx::container::root)" \ + IMAGE_PATH="$(docker::image::path)" \ + TRAEFIK_LABELS="$(docker::proxy::traefik_labels "$PROJECT_NAME" "$DOMAIN" "$BACKEND_DOMAIN" 80)" \ + DB_ENVIRONMENT="$(docker::db::environment)" \ + DB_VOLUMES="$(docker::compose::volume "$(docker::db::external_path)" "$(docker::db::internal_path)")" \ + DB_STACK_PORT="$(docker::db::external_port):$(docker::db::internal_port)" +} + +# =========================================== +# Build +# =========================================== +function docker::image::build() { + local dockerfile + dockerfile="$(path::relative_to_root "$(docker::image::path)")" + + local tag="${APP_IMAGE}:${ENVIRONMENT}" + + require_file "$dockerfile" || { + log::docker_error "Dockerfile not found for environment $ENVIRONMENT" + return 1 + } + + log::docker_build "Building image ${tag}..." + + local context + context="$(platform::docker_path "$(ctx::root)")" + + if docker::raw build -f "$dockerfile" -t "$tag" "$context"; then + log::docker_success "Image built: ${tag}" + else + return 1 + fi +} +function docker::compose::build() { + docker::compose build +} \ No newline at end of file diff --git a/dxkit/modules/docker/db.module.sh b/dxkit/modules/docker/db.module.sh new file mode 100644 index 0000000..9631398 --- /dev/null +++ b/dxkit/modules/docker/db.module.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash + +# ============================================ +# Engine config +# ============================================ + +MYSQL_INTERNAL_PORT=3306 +MSSQL_INTERNAL_PORT=1433 + +function docker::db::internal_port() { + case "$DB_ENGINE" in + mysql) echo $MYSQL_INTERNAL_PORT ;; + mssql) echo $MSSQL_INTERNAL_PORT ;; + esac +} + +function docker::db::external_port() { + # If already resolved to a plain number, use it + if [[ -n "$DB_PORT" && "$DB_PORT" != "auto" ]]; then + echo "$DB_PORT" + return + fi + + # Otherwise resolve from internal port as base + network::ports::resolve "auto:$(docker::db::internal_port)" +} + +function docker::db::internal_path() { + case "$DB_ENGINE" in + mysql) echo "/var/lib/mysql" ;; + mssql) echo "/var/opt/mssql/data" ;; + esac +} + +function docker::db::external_path() { ctx::artifact::path database; } + +function docker::db::_mysql_vars() { +cat << EOF +MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD}" + MYSQL_DATABASE: "${DB_NAME}" + MYSQL_USER: "${DB_USER}" + MYSQL_PASSWORD: "${DB_PASS}" +EOF +} + +function docker::db::_mssql_vars() { +cat << EOF +SA_PASSWORD: ${DB_PASS} + ACCEPT_EULA: 'Y' +EOF +} + +# ============================================ +# Generation +# ============================================ + +function docker::db::environment() { + case "$DB_ENGINE" in + mysql) docker::db::_mysql_vars ;; + mssql) docker::db::_mssql_vars ;; + esac +} +# ============================================ +# Load +# ============================================ + +#function docker::db::on_load() { +# +#} \ No newline at end of file diff --git a/dxkit/modules/docker/proxy.module.sh b/dxkit/modules/docker/proxy.module.sh new file mode 100644 index 0000000..2ad1564 --- /dev/null +++ b/dxkit/modules/docker/proxy.module.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# modules/docker/proxy.module.sh +# +# Proxy docker module. +# Configures proxy, generates what's needed to compose docker stack -- +# and starts/stops the proxy service. + +function docker::proxy::running() { docker::raw ps --format '{{.Names}}' | grep -qx "${PROXY_SERVICE}"; } +function docker::proxy::exists() { docker::raw ps -a --format '{{.Names}}' | grep -qx "${PROXY_SERVICE}"; } + +function docker::proxy::traefik_labels() { + local service="$1" + local domain="$2" + local backend_domain="$3" + local port="$4" + + cat < /dev/null 2>&1 + return 0 + }; + + docker::proxy::_create_container +} + +function docker::proxy::stop() { + if docker::proxy::running "${PROXY_SERVICE}"; then + docker::raw stop "${PROXY_SERVICE}" > /dev/null 2>&1 + fi +} + +function docker::proxy::restart() { + if docker::proxy::running "${PROXY_SERVICE}"; then + docker::raw restart "${PROXY_SERVICE}" > /dev/null 2>&1 + fi +} + +function docker::proxy::ensure_network() { + docker::raw network inspect "${PROXY_NETWORK}" >/dev/null 2>&1 || \ + docker::raw network create "${PROXY_NETWORK}" +} + +function docker::proxy::ensure_proxy_running() { + docker::proxy::ensure_network + docker::proxy::start +} + +# ============================================ +# Logs +# ============================================ + +function docker::proxy::logs() { + docker::raw logs -f "${PROXY_SERVICE}" +} + +# ============================================ +# Private +# ============================================ + +function docker::proxy::_create_container() { + docker::raw run -d \ + --name "$PROXY_SERVICE" \ + --restart unless-stopped \ + -p 80:80 -p 443:443 -p 8080:8080 \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + --network "$PROXY_NETWORK" \ + traefik:v3.0 \ + --api.dashboard=true --api.insecure=true \ + --providers.docker=true \ + --providers.docker.exposedbydefault=false \ + --providers.docker.network="$PROXY_NETWORK" \ + --entrypoints.web.address=:80 \ + --entrypoints.websecure.address=:443 \ + --log.level=DEBUG \ + > /dev/null 2>&1 +} \ No newline at end of file diff --git a/dxkit/modules/env.module.sh b/dxkit/modules/env.module.sh new file mode 100644 index 0000000..371cfa0 --- /dev/null +++ b/dxkit/modules/env.module.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash + +function env::on_load() { + env::defaults + env::load_globals +} + +function env::artifact::path() { path::relative_to_root "$(ctx::artifact::path env)"; } +function env::artifact::resolved() { path::relative_to_root "$(ctx::artifact::path env)/${ENVIRONMENT}.resolved.env"; } +function env::project::name() { echo "${PROJECT_NAME}"; } + +# ============================================ +# Defaults +# ============================================ + +function env::defaults() { + : "${PROJECT_NAME:=$(basename "$(ctx::root)")}" + : "${APP_FRAMEWORK:=yii2-advanced}" + : "${APP_PORT:=auto:8080}" + : "${TLD:="lan"}" + : "${DB_DRIVER:=db}" + : "${DB_IMAGE:=mariadb:latest}" + : "${DB_ENGINE:=mysql}" + : "${DB_PORT:=auto}" + : "${PROXY_SERVICE:=traefik}" + : "${PROXY_NETWORK:=proxy}" + + # Environment + : "${ENVIRONMENT:=dev}" +} + +function env::defaults::derived() { + APP_IMAGE="${APP_IMAGE:-${PROJECT_NAME}-app}" + DB_NAME="${DB_NAME:-${PROJECT_NAME}}" + DB_HOST="${DB_HOST:-${PROJECT_NAME}-${DB_DRIVER}}" + DOMAIN="${DOMAIN:-${PROJECT_NAME}.${TLD}}" + BACKEND_DOMAIN="${BACKEND_DOMAIN:-admin.${DOMAIN}}" +} + +# ============================================ + +function env::load_globals() { + load_file required "$(ctx::env)/globals.env"; + env::defaults::derived +} + +function env::load_environment() { + load_file optional "$(ctx::artifact::path env)/${ENVIRONMENT}.env" + env::defaults::derived +} + +function env::load_environment_resolved() { + load_file optional "$(env::artifact::resolved)" +} + +function env::load_env_files() { + set -a + + env::load_globals || { set +a; exit 1; } + env::load_environment + env::load_environment_resolved + + set +a +} + +function env::set_env_var() { + local key="$1" + local value="$2" + local file="${3:-$(ctx::artifact::path env)/${ENVIRONMENT}.env}" + + touch "$file" + + if grep -q "^${key}=" "$file"; then + fs::sed_inplace "s/^${key}=.*/${key}=${value}/" "$file" + else + printf "\n%s=%s" "$key" "$value" >> "$file" + fi +} + +# ============================================ +# Derived +# ============================================ + +function env::derive() { + APP_IMAGE="${PROJECT_NAME}-app" + DOMAIN="${PROJECT_NAME}.${TLD}" + BACKEND_DOMAIN="admin-${PROJECT_NAME}.${TLD}" + DB_HOST="${PROJECT_NAME}-db" + DB_NAME="${PROJECT_NAME}" + + export APP_IMAGE DOMAIN BACKEND_DOMAIN DB_HOST DB_NAME +} diff --git a/dxkit/modules/framework/laravel.module.sh b/dxkit/modules/framework/laravel.module.sh new file mode 100644 index 0000000..65fbf8b --- /dev/null +++ b/dxkit/modules/framework/laravel.module.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# framework/laravel.module.sh +# +# Laravel framework implementation. +# Implements the framework:: interface for Laravel projects. + +# ============================================ +# Skip messages +# ============================================ + +function framework::laravel::init_skip_message() { echo "Skipping Laravel bootstrap (--skip-framework-init)"; } +function framework::laravel::config_skip_message() { echo "Skipping Laravel config (--skip-framework-config)"; } +function framework::laravel::vendor_skip_message() { echo "Skipping Vendors (--skip-vendors)"; } + +# ============================================ +# Scaffold +# ============================================ + +function framework::laravel::scaffold() { + local target="$1" + + log::run_step env "Building Laravel application (${target})..." \ + composer::run_isolated create-project laravel/laravel "$target" --no-install +} + +# ============================================ +# Init +# ============================================ + +function framework::laravel::init() { + local env="$1" + local laravel_env + laravel_env="$(framework::laravel::resolve_env "$env")" + + log::run_step env "Bootstrapping Laravel (${laravel_env})..." \ + framework::laravel::_write_env_file "$env" +} + +# ============================================ +# Config +# ============================================ + +function framework::laravel::config() { + log::run_step env "Generating Laravel config cache..." \ + framework::laravel::console config:cache + + log::run_step env "Generating Laravel route cache..." \ + framework::laravel::console route:cache +} + +# ============================================ +# Console +# ============================================ + +function framework::laravel::console_name() { echo "artisan"; } +function framework::laravel::console() { runtime::exec php artisan "$@"; } + +# ============================================ +# Resolve env +# ============================================ + +function framework::laravel::resolve_env() { + case "$1" in + dev) echo "local" ;; + qly) echo "staging" ;; + prd) echo "production" ;; + *) echo "$1" ;; + esac +} + +# ============================================ +# Vendors +# ============================================ + +function framework::laravel::install_vendors() { + log::run_step auth info "Setting Composer auth for domain ${GIT_DOMAIN}..." \ + composer::config_auth nocache "$GIT_DOMAIN" "$GIT_AUTH_TOKEN_ID" "$GIT_AUTH_TOKEN_PW" + + log::run_step fs "Installing vendors..." \ + composer::install +} + +# ============================================ +# Post-init hint +# ============================================ + +function framework::laravel::post_init_hint() { + cat </dev/null < "$src" +} +function fs::sed_inplace() { + local use_sudo=false + local suffix="" + local expr + local file + + # Detect sudo flag + if [[ "$1" == "--sudo" ]]; then + use_sudo=true + shift + fi + + # Detect suffix + if [[ "$1" == .* ]]; then + suffix="$1" + shift + fi + + expr="$1" + file="$2" + + local SUDO="" + if $use_sudo && ! platform::is_windows; then + SUDO="sudo" + fi + + if platform::is_macos; then + if [[ -z "$suffix" ]]; then + $SUDO sed -i '' "$expr" "$file" + else + $SUDO sed -i "$suffix" "$expr" "$file" + fi + else + if [[ -z "$suffix" ]]; then + $SUDO sed -i "$expr" "$file" + else + $SUDO sed -i"$suffix" "$expr" "$file" + fi + fi +} +function fs::replace_block() { + local file="$1" + local start_marker="$2" + local end_marker="$3" + local content="$4" + + local tmp + tmp="$(mktemp)" + + # Remove existing block safely (no regex, no sed) + awk -v start="$start_marker" -v end="$end_marker" ' + $0 == start {skip=1; next} + $0 == end {skip=0; next} + !skip + ' "$file" > "$tmp" + + # Only add leading newline if file has content and doesn't already end with one + if [[ -s "$tmp" ]]; then + local last_char + last_char="$(tail -c1 "$tmp")" + [[ "$last_char" != "" ]] && printf "\n" >> "$tmp" + fi + + printf "%s\n" "$content" >> "$tmp" + + echo "$tmp" +} + diff --git a/dxkit/modules/log.module.sh b/dxkit/modules/log.module.sh new file mode 100644 index 0000000..8929c64 --- /dev/null +++ b/dxkit/modules/log.module.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash + +# ============================================ +# Core +# ============================================ + +LOG_LEVEL=${LOG_LEVEL:-INFO} + +# ============================================ +# Internal +# ============================================ + +function internal::get_log_priority() { + case "$1" in + DEBUG) echo 0 ;; + INFO) echo 1 ;; + WARN) echo 2 ;; + ERROR) echo 3 ;; + SUCCESS) echo 4 ;; + *) echo 1 ;; + esac +} +function internal::log() { + local level="$1" + shift + + local current_priority + local message_priority + + current_priority=$(internal::get_log_priority "$LOG_LEVEL") + message_priority=$(internal::get_log_priority "$level") + + if (( message_priority < current_priority )); then + return 0 + fi + + case "$level" in + DEBUG) color="\033[0;37m" ;; # cyan + INFO) color="\033[1;34m" ;; # blue + WARN) color="\033[1;33m" ;; # yellow + ERROR) color="\033[1;31m" ;; # red + SUCCESS)color="\033[1;32m" ;; # red + esac + + echo -e "${color}=> ${level}:\033[0m $*" +} +function internal::icon() { + local context="$1" + local action="$2" + + case "$context:$action" in + docker:log) echo "🐳 " ;; + docker:start) echo "🟢 🐳 " ;; + docker:stop) echo "šŸ”“ 🐳 " ;; + docker:success) echo "āœ… 🐳 " ;; + docker:warning) echo "āš ļø 🐳 " ;; + docker:error) echo "āŒ 🐳 " ;; + docker:logs) echo "šŸ“œ 🐳 " ;; + docker:list) echo "šŸ” 🐳 " ;; + docker:build) echo "šŸ“¦ 🐳 " ;; + + build:log) echo "šŸ—ļø " ;; + build:start) echo "🟢 šŸ—ļø " ;; + build:stop) echo "šŸ”“ šŸ—ļø " ;; + build:success) echo "āœ… šŸ—ļø " ;; + build:warning) echo "āš ļø šŸ—ļø " ;; + build:error) echo "āŒ šŸ—ļø " ;; + + network:log) echo "🌐 " ;; + network:setup) echo "āš™ļø 🌐 " ;; + network:stop) echo "šŸ”“ 🌐 " ;; + network:success) echo "āœ… 🌐 " ;; + network:warning) echo "āš ļø 🌐 " ;; + network:error) echo "āŒ 🌐 " ;; + + auth:log) echo "šŸ”‘ " ;; + auth:setup) echo "āš™ļø šŸ”‘ " ;; + auth:login) echo "šŸ” šŸ”‘ " ;; + auth:success) echo "āœ… šŸ”‘ " ;; + auth:warning) echo "āš ļø šŸ”‘ " ;; + auth:error) echo "āŒ šŸ”‘ " ;; + + env:log) echo "āš™ļø " ;; + env:load) echo "šŸ“„ āš™ļø " ;; + env:success) echo "āœ… āš™ļø " ;; + env:warning) echo "āš ļø āš™ļø " ;; + env:error) echo "āŒ āš™ļø " ;; + + fs:log) echo "šŸ“ " ;; + fs:read) echo "šŸ“„ šŸ“ " ;; + fs:write) echo "šŸ“¤ šŸ“ " ;; + fs:success) echo "āœ… šŸ“ " ;; + fs:warning) echo "āš ļø šŸ“ " ;; + fs:error) echo "āŒ šŸ“ " ;; + + db:log) echo "šŸ—„ļø " ;; + db:start) echo "🟢 šŸ—„ļø " ;; + db:migrate) echo "šŸ“œ šŸ—„ļø " ;; + db:success) echo "āœ… šŸ—„ļø " ;; + db:warning) echo "āš ļø šŸ—„ļø " ;; + db:error) echo "āŒ šŸ—„ļø " ;; + + log:info) echo "šŸ”¹ " ;; + log:warn) echo "āš ļø " ;; + log:error) echo "āŒ " ;; + log:success) echo "āœ… " ;; + log:debug) echo "šŸ” " ;; + + + *) echo "šŸ”¹" ;; + esac +} +function internal::get_context_icon() { + case "$1" in + docker) echo "🐳" ;; + build) echo "šŸ—ļø" ;; + network) echo "🌐" ;; + auth) echo "šŸ”‘" ;; + env) echo "āš™ļø" ;; + fs) echo "šŸ“" ;; + db) echo "šŸ—„ļø" ;; + *) echo "šŸ”¹" ;; + esac +} + +# ============================================ +# Loggers +# ============================================ + +function internal::log::info() { internal::log INFO "$*"; } +function internal::log::warn() { internal::log WARN "$*"; } +function internal::log::error() { internal::log ERROR "$*"; } +function internal::log::success() { internal::log SUCCESS "$*"; } +function internal::log::debug() { internal::log DEBUG "$*"; } + +# ============================================ +# Context Loggers +# ============================================ + +function log::context() { + local context="$1" + local action="$2" + shift 2 + + internal::log::info "$(internal::icon "$context" "$action") $*" +} +function log::warn_context() { + local context="$1" + local action="$2" + shift 2 + + internal::log::warn "$(internal::icon "$context" "$action") $*" +} +function log::error_context() { + local context="$1" + local action="$2" + shift 2 + + internal::log::error "$(internal::icon "$context" "$action") $*" +} +function log::success_context() { + local context="$1" + local action="$2" + shift 2 + + internal::log::success "$(internal::icon "$context" "$action") $*" +} +function log::debug_context() { + local context="$1" + local action="$2" + shift 2 + + internal::log::debug "$(internal::icon "$context" "$action") $*" +} + +# ============================================ +# Logger Helpers +# ============================================ + +function log::info() { log::context log info "$@"; } +function log::warn() { log::warn_context log warn "$@"; } +function log::error() { log::error_context log error "$@"; } +function log::success() { log::context log success "$@"; } +function log::debug() { log::debug_context log debug "$@"; } + +function log::docker() { log::context docker log "$@"; } +function log::docker_start() { log::context docker start "$@"; } +function log::docker_stop() { log::context docker stop "$@"; } +function log::docker_success() { log::context docker success "$@"; } +function log::docker_logs() { log::context docker logs "$@"; } +function log::docker_list() { log::context docker list "$@"; } +function log::docker_build() { log::context docker build "$@"; } +function log::docker_warning() { log::warn_context docker warning "$@"; } +function log::docker_error() { log::error_context docker error "$@"; } + +function log::build() { log::context build log "$@"; } +function log::build_start() { log::context build start "$@"; } +function log::build_stop() { log::context build stop "$@"; } +function log::build_success() { log::context build success "$@"; } +function log::build_warning() { log::warn_context build warning "$@"; } +function log::build_error() { log::error_context build error "$@"; } + +function log::network() { log::context network log "$@"; } +function log::network_setup() { log::context network setup "$@"; } +function log::network_stop() { log::context network stop "$@"; } +function log::network_success() { log::context network success "$@"; } +function log::network_warning() { log::warn_context network warning "$@"; } +function log::network_error() { log::error_context network error "$@"; } + +function log::auth() { log::context auth log "$@"; } +function log::auth_setup() { log::context auth setup "$@"; } +function log::auth_login() { log::context auth login "$@"; } +function log::auth_success() { log::context auth success "$@"; } +function log::auth_warning() { log::warn_context auth warning "$@"; } +function log::auth_error() { log::error_context auth error "$@"; } + +function log::env() { log::context env log "$@"; } +function log::env_load() { log::context env load "$@"; } +function log::env_success() { log::context env success "$@"; } +function log::env_warning() { log::warn_context env warning "$@"; } +function log::env_error() { log::error_context env error "$@"; } + +function log::fs() { log::context fs log "$@"; } +function log::fs_read() { log::context fs read "$@"; } +function log::fs_write() { log::context fs write "$@"; } +function log::fs_success() { log::context fs success "$@"; } +function log::fs_warning() { log::warn_context fs warning "$@"; } +function log::fs_error() { log::error_context fs error "$@"; } + +function log::db() { log::context database log "$@"; } +function log::db_start() { log::context database start "$@"; } +function log::db_migrate() { log::context database migrate "$@"; } +function log::db_success() { log::context database success "$@"; } +function log::db_warning() { log::warn_context database warning "$@"; } +function log::db_error() { log::error_context database error "$@"; } + +function log::run_step() { + local context="$1" + local mode="strict" + local description + + shift + + if [[ "$1" == "soft" || "$1" == "strict" || "$1" == "info" ]]; then + mode="$1" + shift + fi + + description="$1" + shift + + local icon=$(internal::get_context_icon "$context") + + if [[ "$mode" == "info" ]]; then + internal::log::info "$icon $description" + else + internal::log::info "šŸ”„ $icon $description" + fi + + "$@" + local status=$? + + # SUCCESS + if [[ $status -eq 0 ]]; then + if [[ "$mode" == "info" ]]; then + return 0 + fi + + internal::log::info "āœ… $icon $description" + return 0 + fi + + # FAILURE + if [[ "$mode" == "soft" || "$mode" == "info" ]]; then + internal::log::info "āš ļø $icon $description → skipped" + return 0 + fi + + internal::log::info "āŒ $icon $description → failed" + return $status +} + diff --git a/dxkit/modules/network.module.sh b/dxkit/modules/network.module.sh new file mode 100644 index 0000000..3b60a67 --- /dev/null +++ b/dxkit/modules/network.module.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash + +# ============================================ +# Ports +# ============================================ + +#function network::ports::in_use() { +# local port="$1" +# +# if command -v lsof >/dev/null 2>&1; then +# lsof -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1 +# else +# netstat -an | grep -q ":$port " +# fi +#} + +function network::ports::in_use() { + local port="$1" + + # Check Docker port bindings — reliable across platforms + if command -v docker >/dev/null 2>&1; then + docker ps --format '{{.Ports}}' | grep -q "0.0.0.0:${port}->" && return 0 + docker ps --format '{{.Ports}}' | grep -q "\[::\]:${port}->" && return 0 + fi + + # Fallback to netstat — handles both Unix and Windows output formats + netstat -an 2>/dev/null | grep -qE ":${port}[^0-9].*LISTEN" && return 0 + + return 1 +} + +function network::ports::resolve() { + local value="$1" + + # auto:X → dynamic allocation + if [[ "$value" == auto:* ]]; then + local base="${value#auto:}" + + if ! [[ "$base" =~ ^[0-9]+$ ]]; then + log::network_error "Invalid auto port syntax: $value" + return 1 + fi + + # fallback if malformed (optional safety) + [[ -z "$base" ]] && base=8000 + + local p="$base" + + while network::ports::in_use "$p"; do + ((p++)) + done + + echo "$p" + return + fi + + # static port + echo "$value" +} + +function network::ports::resolve_many() { + local var + + for var in "$@"; do + local value="${!var}" + local resolved + + resolved="$(network::ports::resolve "$value")" || return 1 + + printf -v "$var" "%s" "$resolved" + done +} + +function network::ports::persist() { + local ports_file + ports_file="$(ctx::artifact)/ports" + + cat > "$ports_file" < + + + + mariadb + true + org.mariadb.jdbc.Driver + jdbc:mariadb://localhost:3309 + + + + $ProjectFileDir$ + + + +EOF +} + +function phpstorm::generate_db_config() { + phpstorm::db_config > "$(phpstorm::config_path)/dataSources.xml" +} \ No newline at end of file diff --git a/dxkit/modules/proxy.module.sh b/dxkit/modules/proxy.module.sh new file mode 100644 index 0000000..46c3c6a --- /dev/null +++ b/dxkit/modules/proxy.module.sh @@ -0,0 +1,10 @@ +# modules/proxy.module.sh + +function proxy::driver() { echo "${PROXY_DRIVER:-docker}"; } + +function proxy::start() { "$(proxy::driver)::proxy::start"; } +function proxy::stop() { "$(proxy::driver)::proxy::stop"; } +function proxy::restart() { "$(proxy::driver)::proxy::restart"; } +function proxy::running() { "$(proxy::driver)::proxy::running"; } +function proxy::ensure() { proxy::running || proxy::start; } +function proxy::logs() { "$(proxy::driver)::proxy::logs"; } \ No newline at end of file diff --git a/dxkit/modules/template.module.sh b/dxkit/modules/template.module.sh new file mode 100644 index 0000000..9926e5c --- /dev/null +++ b/dxkit/modules/template.module.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +function template::path() { + local dir="" + local file="" + + if [[ $# -eq 1 ]]; then + file="$1" + else + dir="$1" + file="$2" + fi + + if [[ -n "$dir" ]]; then + echo "$(ctx::templates)/$dir/$file.template" + else + echo "$(ctx::templates)/$file.template" + fi +} + +function template::render() { + local template="$1" + local target="$2" + shift 2 + + require_file "$template" || { + log::fs_error "Template not found: $template" + return 1 + } + + mkdir -p "$(dirname "$target")" + + # Build env assignment list + local env_vars=() + for var in "$@"; do + env_vars+=("$var") + done + + if ! env "${env_vars[@]}" envsubst < "$template" > "$target"; then + log::fs_error "Failed rendering template: $template" + return 1 + fi +} + +#function template::render() { +# local template="$1" +# local target="$2" +# shift 2 +# +# require_file "$template" || { +# log::fs_error "Template not found: $template" +# return 1 +# } +# +# mkdir -p "$(dirname "$target")" +# +# for var in "$@"; do +# export "$var" +# done +# +# if ! envsubst < "$template" > "$target"; then +# log::fs_error "Failed rendering template: $template" +# return 1 +# fi +#} \ No newline at end of file diff --git a/dxkit/modules/yii.module.sh b/dxkit/modules/yii.module.sh new file mode 100644 index 0000000..a82bedf --- /dev/null +++ b/dxkit/modules/yii.module.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# modules/yii.module.sh +# +# Shared Yii utilities for both yii2-basic and yii2-advanced. +# Anything specific to one variant lives in its respective framework module. + +# ============================================ +# Console runner +# ============================================ + +function yii::exec() { + runtime::exec php yii "$@" +} + +# ============================================ +# Environment resolution +# ============================================ + +# Maps shorthand env names (dev/qly/prd) to Yii's expected names. +# Used by both basic and advanced framework modules. +function yii::resolve_env() { + case "$(string::lowercase "$1")" in + dev|development) echo "Development" ;; + qly|qa|quality) echo "Quality" ;; + prd|prod|production) echo "Production" ;; + *) + log::error "Unknown Yii environment: '$1'" + return 1 + ;; + esac +} diff --git a/dxkit/runtime/docker.runtime.sh b/dxkit/runtime/docker.runtime.sh new file mode 100644 index 0000000..a0579e8 --- /dev/null +++ b/dxkit/runtime/docker.runtime.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# dxkit/runtime/docker.runtime.sh +# +# Docker Compose runtime driver. +# Implements the runtime:: interface using docker compose. + +function runtime::docker::up() { + local args=() + flag::enabled --recreate && args+=(--force-recreate) + flag::enabled --build && args+=(--build) + flag::enabled --foreground || args+=(-d) + + docker::compose::up "${args[@]+"${args[@]}"}" +} + +function runtime::docker::down() { + local args=() + flag::enabled --volumes && args+=(--volumes) + flag::enabled --orphans && args+=(--remove-orphans) + + docker::compose::down "${args[@]+"${args[@]}"}" +} + +function runtime::docker::logs() { + local args=() + flag::enabled --follow && args+=(-f) + + docker::compose::logs "${args[@]+"${args[@]}"}" +} + +function runtime::docker::list() { + docker::list_containers +} + +function runtime::docker::status() { + docker::compose::ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}" +} + +function runtime::docker::build() { + docker::compose::build "$@" +} + +function runtime::docker::ensure() { + docker::compose::ensure_running +} + +function runtime::docker::is_inside() { + docker::inside_container +} + +function runtime::docker::exec() { + docker::compose::exec "$(runtime::service)" "$@" +} + +function runtime::docker::shell() { + docker::compose::exec -it "$(runtime::service)" bash 2>/dev/null \ + || docker::compose::exec -it "$(runtime::service)" sh +} diff --git a/dxkit/runtime/native.runtime.sh b/dxkit/runtime/native.runtime.sh new file mode 100644 index 0000000..aec0566 --- /dev/null +++ b/dxkit/runtime/native.runtime.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# dxkit/runtime/native.runtime.sh +# +# Native runtime driver — runs services directly on the host +# without containerization. Supports macOS (Homebrew) and Linux (systemd). +# +# To activate: +# export RUNTIME_DRIVER=native # in globals.env or local.env +# export NATIVE_WEB_SERVER=apache # apache | nginx (auto-detected if unset) +# +# Requirements: +# macOS — Homebrew, httpd or nginx, php +# Linux — systemd, apache2 or nginx, php-fpm + +# ============================================ +# Platform + web server detection +# ============================================ + +function runtime::native::_platform() { + case "$(uname -s)" in + Darwin) echo "macos" ;; + Linux) echo "linux" ;; + *) + log::error "Unsupported platform: $(uname -s)" + return 1 + ;; + esac +} + +function runtime::native::_webserver() { + if [[ -n "${NATIVE_WEB_SERVER:-}" ]]; then + echo "$NATIVE_WEB_SERVER" + return + fi + + local platform + platform="$(runtime::native::_platform)" + + if [[ "$platform" == "macos" ]]; then + brew list httpd &>/dev/null && echo "apache" && return + brew list nginx &>/dev/null && echo "nginx" && return + else + systemctl list-unit-files apache2.service &>/dev/null && echo "apache" && return + systemctl list-unit-files nginx.service &>/dev/null && echo "nginx" && return + fi + + log::error "No supported web server found (apache2 / nginx)" + log::error "Install one or set NATIVE_WEB_SERVER=apache|nginx" + return 1 +} + +# ============================================ +# Service management — platform dispatch +# ============================================ + +function runtime::native::_service_start() { + local service="$1" + case "$(runtime::native::_platform)" in + macos) brew services start "$service" ;; + linux) sudo systemctl start "$service" ;; + esac +} + +function runtime::native::_service_stop() { + local service="$1" + case "$(runtime::native::_platform)" in + macos) brew services stop "$service" ;; + linux) sudo systemctl stop "$service" ;; + esac +} + +function runtime::native::_service_running() { + local service="$1" + case "$(runtime::native::_platform)" in + macos) brew services list | awk -v s="$service" '$1==s{print $2}' | grep -q "started" ;; + linux) systemctl is-active --quiet "$service" ;; + esac +} + +function runtime::native::_service_logs() { + local service="$1" + case "$(runtime::native::_platform)" in + macos) + local log_dir + log_dir="$(brew --prefix)/var/log" + tail -f "${log_dir}/${service}/error.log" "${log_dir}/${service}/access.log" 2>/dev/null \ + || tail -f "${log_dir}/${service}.log" 2>/dev/null + ;; + linux) + sudo journalctl -u "$service" -f --no-pager + ;; + esac +} + +# ============================================ +# Service name resolution +# ============================================ + +function runtime::native::_apache_service() { + case "$(runtime::native::_platform)" in + macos) echo "httpd" ;; + linux) echo "apache2" ;; + esac +} + +function runtime::native::_nginx_service() { echo "nginx"; } + +function runtime::native::_fpm_service() { + local php_ver + php_ver="$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;' 2>/dev/null || echo "8.3")" + case "$(runtime::native::_platform)" in + macos) echo "php@${php_ver}" ;; + linux) echo "php${php_ver}-fpm" ;; + esac +} + +function runtime::native::_active_services() { + local ws + ws="$(runtime::native::_webserver)" + + case "$ws" in + apache) echo "$(runtime::native::_apache_service)" ;; + nginx) echo "$(runtime::native::_fpm_service)" + echo "$(runtime::native::_nginx_service)" ;; + esac +} + +# ============================================ +# Interface implementation +# ============================================ + +function runtime::native::up() { + log::info "Starting native runtime ($(runtime::native::_webserver))..." + + local service + while IFS= read -r service; do + runtime::native::_service_start "$service" + done < <(runtime::native::_active_services) + + log::success "Stack up" +} + +function runtime::native::down() { + log::info "Stopping native runtime..." + + # Stop in reverse order + local services=() + while IFS= read -r service; do + services+=("$service") + done < <(runtime::native::_active_services) + + for ((i=${#services[@]}-1; i>=0; i--)); do + runtime::native::_service_stop "${services[i]}" + done +} + +function runtime::native::ensure() { + local service + while IFS= read -r service; do + if ! runtime::native::_service_running "$service"; then + runtime::native::_service_start "$service" + fi + done < <(runtime::native::_active_services) +} + +function runtime::native::logs() { + local ws + ws="$(runtime::native::_webserver)" + + case "$ws" in + apache) + runtime::native::_service_logs "$(runtime::native::_apache_service)" + ;; + nginx) + runtime::native::_service_logs "$(runtime::native::_nginx_service)" + ;; + esac +} + +function runtime::native::list() { + printf "%-25s %s\n" "SERVICE" "STATUS" + printf "%-25s %s\n" "-------" "------" + + local service + while IFS= read -r service; do + if runtime::native::_service_running "$service"; then + printf "%-25s %s\n" "$service" "running" + else + printf "%-25s %s\n" "$service" "stopped" + fi + done < <(runtime::native::_active_services) +} + +function runtime::native::status() { + printf "%-20s %s\n" "Driver:" "native" + printf "%-20s %s\n" "Platform:" "$(runtime::native::_platform)" + printf "%-20s %s\n" "Web server:" "$(runtime::native::_webserver)" + printf "%-20s %s\n" "App root:" "$(ctx::root)" + echo "" + + runtime::native::list +} + +function runtime::native::build() { + # Native has no images to build — but vhost generation still needs to happen + log::info "Native runtime has no build step (services run directly)" + log::info "Reload configs with: dx proxy restart" +} + +function runtime::native::is_inside() { + # Native always runs on host — there's no "inside" concept + return 1 +} + +function runtime::native::exec() { + # No container boundary — run directly in the app root + cd "$(ctx::root)" && "$@" +} + +function runtime::native::shell() { + cd "$(ctx::root)" && exec "${SHELL:-bash}" +} \ No newline at end of file diff --git a/dxkit/runtime/runtime.sh b/dxkit/runtime/runtime.sh new file mode 100644 index 0000000..999c257 --- /dev/null +++ b/dxkit/runtime/runtime.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# dxkit/runtime/runtime.sh +# +# Runtime abstraction layer. +# Commands and modules call runtime::* — this file dispatches to +# the active driver based on RUNTIME_DRIVER (default: docker). + +# ============================================ +# Driver loader +# ============================================ + +RUNTIME_DRIVER="${RUNTIME_DRIVER:-docker}" + +function runtime::driver() { echo "${RUNTIME_DRIVER}"; } +function runtime::service() { echo "${APP_SERVICE:-app}"; } + +function runtime::_load_driver() { + local driver_file + driver_file="$(ctx::dxkit)/runtime/${RUNTIME_DRIVER}.runtime.sh" + + if [[ ! -f "$driver_file" ]]; then + log::error "Unknown runtime driver: '${RUNTIME_DRIVER}'" + log::error "Expected: ${driver_file}" + return 1 + fi + + source "$driver_file" +} + +runtime::_load_driver + +# ============================================ +# Interface +# ============================================ + +function runtime::up() { "runtime::$(runtime::driver)::up" "$@"; } +function runtime::down() { "runtime::$(runtime::driver)::down" "$@"; } +function runtime::logs() { "runtime::$(runtime::driver)::logs" "$@"; } +function runtime::list() { "runtime::$(runtime::driver)::list" "$@"; } +function runtime::status() { "runtime::$(runtime::driver)::status" "$@"; } +function runtime::build() { "runtime::$(runtime::driver)::build" "$@"; } +function runtime::ensure() { "runtime::$(runtime::driver)::ensure" "$@"; } +function runtime::is_inside() { "runtime::$(runtime::driver)::is_inside" "$@"; } + +# Exec and shell are special — they handle "running inside the runtime" +# vs "running on host" transparently +function runtime::exec() { + local args=("$@") + [[ "${#args[@]}" -eq 0 ]] && return 1 + + if runtime::is_inside; then + "${args[@]}" + else + runtime::ensure + "runtime::$(runtime::driver)::exec" "${args[@]}" + fi +} + +function runtime::shell() { + if runtime::is_inside; then + runtime::_local_shell + else + runtime::ensure + "runtime::$(runtime::driver)::shell" + fi +} + +# Conditionally run a function based on a flag value. +# Driver-agnostic — useful in build pipelines. +function runtime::run_if() { + local flag="$1" + shift + + if [[ "${!flag:-}" == true ]]; then + "$@" || return 1 + fi +} + +# ============================================ +# Internal helpers +# ============================================ + +function runtime::_local_shell() { + if command -v bash >/dev/null 2>&1; then + bash + else + sh + fi +} \ No newline at end of file diff --git a/dxkit/templates/apache/yii2-advanced/vhosts.conf.template b/dxkit/templates/apache/yii2-advanced/vhosts.conf.template new file mode 100644 index 0000000..956ed2f --- /dev/null +++ b/dxkit/templates/apache/yii2-advanced/vhosts.conf.template @@ -0,0 +1,119 @@ +# =========================================== +# Frontend — ${DOMAIN} +# =========================================== + + + ServerName ${DOMAIN} + ServerAdmin webmaster@localhost + AddDefaultCharset UTF-8 + + DocumentRoot ${APP_PATH}/frontend/web/ + + # =========================================== + # Backend + API aliases (path-based routing) + # =========================================== + Alias /admin ${APP_PATH}/backend/web/ + Alias /api ${APP_PATH}/api/web/ + + + Options FollowSymLinks + AllowOverride none + Require all granted + + DirectoryIndex index.php index.html + + RewriteEngine On + + # Don't rewrite requests headed for /admin or /api + RewriteCond %{REQUEST_URI} !^/admin + RewriteCond %{REQUEST_URI} !^/api + + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [L] + + + + Options FollowSymLinks + AllowOverride none + Require all granted + + DirectoryIndex index.php index.html + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [L] + + + + Options FollowSymLinks + AllowOverride none + Require all granted + + DirectoryIndex index.php index.html + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [L] + + + # =========================================== + # Security: block dotfiles globally + # =========================================== + RewriteEngine On + RewriteRule (^|/)\. - [F,L] + + # =========================================== + # Friendly route fixes + # =========================================== + RewriteCond %{REQUEST_URI} ^/admin$ + RewriteRule ^ /admin/ [R=301,L] + + RewriteCond %{REQUEST_URI} ^/api$ + RewriteRule ^ /api/ [R=301,L] + + # =========================================== + # Logs + # =========================================== + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + + +# =========================================== +# Backend — ${BACKEND_DOMAIN} +# =========================================== + + + ServerName ${BACKEND_DOMAIN} + ServerAdmin webmaster@localhost + AddDefaultCharset UTF-8 + + DocumentRoot ${APP_PATH}/backend/web/ + + + Options FollowSymLinks + AllowOverride none + Require all granted + + DirectoryIndex index.php index.html + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [L] + + + # =========================================== + # Security: block dotfiles globally + # =========================================== + RewriteEngine On + RewriteRule (^|/)\. - [F,L] + + # =========================================== + # Logs + # =========================================== + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + \ No newline at end of file diff --git a/dxkit/templates/apache/yii2-basic/vhosts.conf.template b/dxkit/templates/apache/yii2-basic/vhosts.conf.template new file mode 100644 index 0000000..ad308cf --- /dev/null +++ b/dxkit/templates/apache/yii2-basic/vhosts.conf.template @@ -0,0 +1,36 @@ +# =========================================== +# Frontend — ${DOMAIN} +# =========================================== + + + ServerName ${DOMAIN} + ServerAdmin webmaster@localhost + AddDefaultCharset UTF-8 + + DocumentRoot ${APP_PATH}/web/ + + + Options FollowSymLinks + AllowOverride none + Require all granted + + DirectoryIndex index.php index.html + + RewriteEngine On + RewriteCond %{REQUEST_FILENAME} !-f + RewriteCond %{REQUEST_FILENAME} !-d + RewriteRule ^ index.php [L] + + + # =========================================== + # Security: block dotfiles globally + # =========================================== + RewriteEngine On + RewriteRule (^|/)\. - [F,L] + + # =========================================== + # Logs + # =========================================== + ErrorLog ${APACHE_LOG_DIR}/error.log + CustomLog ${APACHE_LOG_DIR}/access.log combined + \ No newline at end of file diff --git a/dxkit/templates/docker/Dockerfile.template b/dxkit/templates/docker/Dockerfile.template new file mode 100644 index 0000000..71404ad --- /dev/null +++ b/dxkit/templates/docker/Dockerfile.template @@ -0,0 +1,50 @@ +FROM ${BASE_APP_IMAGE} + +RUN groupmod -g 1000 www-data && usermod -u 1000 -g www-data www-data + +RUN apt-get update && apt-get install --no-install-recommends -y \ + apt-transport-https \ + gettext \ + git \ + unzip \ + vim \ + tree \ + wget \ + gnupg2 \ + dialog \ + nano \ + libldap2-dev \ + cron \ + curl \ + openssl \ + jq \ + openssh-server + +# Install Xdebug +#RUN pecl install xdebug +#RUN docker-php-ext-enable xdebug + +# Enable Apache mod_rewrite +RUN a2enmod rewrite + +# Enable Apache SSL module +RUN a2enmod ssl +RUN a2dissite 000-default default-ssl + +# Install Composer +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +COPY dxkit/docker/entrypoint.sh /entrypoint.sh + +RUN echo "root:Docker!" | chpasswd && chmod u+x /entrypoint.sh + +RUN apt-get install -y locales && \ + echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \ + echo "pt_PT.UTF-8 UTF-8" >> /etc/locale.gen && \ + locale-gen && \ + apt-get update + +RUN rm -rf /var/lib/apt/lists/* + +EXPOSE 80 443 2222 +ENTRYPOINT [ "/entrypoint.sh" ] diff --git a/dxkit/templates/docker/docker-compose.yml.template b/dxkit/templates/docker/docker-compose.yml.template new file mode 100644 index 0000000..ecc9a66 --- /dev/null +++ b/dxkit/templates/docker/docker-compose.yml.template @@ -0,0 +1,38 @@ +services: + app: + build: + context: ${CTX_BUILD} + dockerfile: ${IMAGE_PATH} + image: ${APP_IMAGE}:${ENVIRONMENT} + restart: unless-stopped + ports: + - "${APP_PORT}:80" + volumes: + - ${CTX_APP}:${APP_PATH} + - ${CTX_APACHE_LOGS}:/var/log/apache2 + - ${CTX_APACHE_VHOSTS}:/etc/apache2/sites-enabled + - ${CTX_APACHE_CERTS}:/usr/local/share/ca-certificates/custom:ro + labels: + ${TRAEFIK_LABELS} + depends_on: + - db + networks: + - app-network + - proxy + + db: + image: ${DB_IMAGE} + restart: unless-stopped + environment: + ${DB_ENVIRONMENT} + volumes: + ${DB_VOLUMES} + ports: + - "${DB_STACK_PORT}" + networks: + - app-network + +networks: + app-network: + proxy: + external: true \ No newline at end of file diff --git a/dxkit/templates/yii2-advanced/components/db.mssql.php.template b/dxkit/templates/yii2-advanced/components/db.mssql.php.template new file mode 100644 index 0000000..2e7d2e7 --- /dev/null +++ b/dxkit/templates/yii2-advanced/components/db.mssql.php.template @@ -0,0 +1,11 @@ +'db' => [ + 'class' => \yii\db\Connection::class, + 'driverName' => 'sqlsrv', + 'dsn' => 'sqlsrv:server=db;database=${PROJECT_NAME};encrypt=false', + 'username' => '${DB_USER}', + 'password' => '${DB_PASS}', + 'charset' => 'utf8', + 'attributes' => [ + \PDO::SQLSRV_ATTR_ENCODING => \PDO::SQLSRV_ENCODING_SYSTEM + ] +], \ No newline at end of file diff --git a/dxkit/templates/yii2-advanced/components/db.mysql.php.template b/dxkit/templates/yii2-advanced/components/db.mysql.php.template new file mode 100644 index 0000000..db2ed90 --- /dev/null +++ b/dxkit/templates/yii2-advanced/components/db.mysql.php.template @@ -0,0 +1,7 @@ +'db' => [ + 'class' => \yii\db\Connection::class, + 'dsn' => 'mysql:host=db;dbname=${DB_NAME}', + 'username' => '${DB_USER}', + 'password' => '${DB_PASS}', + 'charset' => 'utf8', +], \ No newline at end of file diff --git a/dxkit/templates/yii2-advanced/components/log.php.template b/dxkit/templates/yii2-advanced/components/log.php.template new file mode 100644 index 0000000..6f74ee6 --- /dev/null +++ b/dxkit/templates/yii2-advanced/components/log.php.template @@ -0,0 +1,27 @@ +'log' => [ + 'traceLevel' => YII_DEBUG ? 3 : 0, + 'targets' => [ + [ + 'class' => 'yii\log\FileTarget', + 'levels' => ['error', 'warning', 'info'], + ], + 'sync' => [ + 'class' => 'yii\log\FileTarget', + 'levels' => ['error', 'warning'], + 'logFile' => '@runtime/logs/sync.log', + 'exportInterval' => YII_DEBUG ? 1 : 100, + 'maxLogFiles' => 100, + 'categories' => ['common\user\*'], + 'logVars' => [] + ], + 'requests' => [ + 'class' => 'yii\log\FileTarget', + 'levels' => ['info'], + 'logFile' => '@runtime/logs/requests.log', + 'exportInterval' => YII_DEBUG ? 1 : 100, + 'maxLogFiles' => 100, + //'categories' => ['common\user\*'], + 'logVars' => [] + ] + ], +], \ No newline at end of file diff --git a/dxkit/templates/yii2-advanced/main-local.php.template b/dxkit/templates/yii2-advanced/main-local.php.template new file mode 100644 index 0000000..cfa8afb --- /dev/null +++ b/dxkit/templates/yii2-advanced/main-local.php.template @@ -0,0 +1,7 @@ + [ + ${YII_MAIN_LOCAL_COMPONENTS} + ], +]; \ No newline at end of file diff --git a/dxkit/templates/yii2-basic/db.mssql.php.template b/dxkit/templates/yii2-basic/db.mssql.php.template new file mode 100644 index 0000000..22f426d --- /dev/null +++ b/dxkit/templates/yii2-basic/db.mssql.php.template @@ -0,0 +1,18 @@ + \yii\db\Connection::class, + 'driverName' => 'sqlsrv', + 'dsn' => 'sqlsrv:server=db;database=${PROJECT_NAME};encrypt=false', + 'username' => '${DB_USER}', + 'password' => '${DB_PASS}', + 'charset' => 'utf8', + 'attributes' => [ + \PDO::SQLSRV_ATTR_ENCODING => \PDO::SQLSRV_ENCODING_SYSTEM + ] + + // Schema cache options (for production environment) + //'enableSchemaCache' => true, + //'schemaCacheDuration' => 60, + //'schemaCache' => 'cache', +]; \ No newline at end of file diff --git a/dxkit/templates/yii2-basic/db.mysql.php.template b/dxkit/templates/yii2-basic/db.mysql.php.template new file mode 100644 index 0000000..0df9c6f --- /dev/null +++ b/dxkit/templates/yii2-basic/db.mysql.php.template @@ -0,0 +1,14 @@ + \yii\db\Connection::class, + 'dsn' => 'mysql:host=db;dbname=${DB_NAME}', + 'username' => '${DB_USER}', + 'password' => '${DB_PASS}', + 'charset' => 'utf8', + + // Schema cache options (for production environment) + //'enableSchemaCache' => true, + //'schemaCacheDuration' => 60, + //'schemaCache' => 'cache', +]; \ No newline at end of file