Compare commits

..

3 Commits

Author SHA1 Message Date
burkkyy 3468df3782 removing unessesary files
Build and Push Docker Image / build (push) Successful in 1m40s
2026-07-14 23:22:44 -07:00
burkkyy 4a675f7da4 rewrote dev command to python, and added prod 2026-07-14 23:21:24 -07:00
burkkyy 5b31972b0f adding in production compose file 2026-07-14 23:08:03 -07:00
5 changed files with 297 additions and 277 deletions
-2
View File
@@ -1,2 +0,0 @@
nodejs 20.10.0
ruby 3.2.2
+2 -33
View File
@@ -16,44 +16,13 @@ Note that the `dev` command uses the `db` service, and so only has access to fol
## Set up `dev` command ## Set up `dev` command
The `dev` command vastly simplifies development using docker compose. It only requires `ruby`; however, `direnv` and `asdf` will make it easier to use. The `dev` command vastly simplifies development using docker compose. It only requires `python`; however, `direnv` and `asdf` will make it easier to use.
It's simply a wrapper around docker compose with the ability to quickly add custom helpers. It's simply a wrapper around docker compose with the ability to quickly add custom helpers.
All commands are just strings joined together, so it's easy to add new commmands. `dev` prints out each command that it runs, so that you can run the command manually to debug it, or just so you learn some docker compose syntax as you go. All commands are just strings joined together, so it's easy to add new commmands. `dev` prints out each command that it runs, so that you can run the command manually to debug it, or just so you learn some docker compose syntax as you go.
1. (optional) Install `asdf` as seen in <https://asdf-vm.com/guide/getting-started.html>. 1. (optional) Install [direnv](https://direnv.net/) and create an `.envrc` with
e.g. for Linux
```bash
apt install curl git
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.12.0
echo '
# asdf
. "$HOME/.asdf/asdf.sh"
. "$HOME/.asdf/completions/asdf.bash"
' >> ~/.bashrc
```
2. Install `ruby` via `asdf` as seen here <https://github.com/asdf-vm/asdf-ruby>, or using whatever custom Ruby install method works for your platform.
e.g. for Linux
```bash
asdf plugin add ruby https://github.com/asdf-vm/asdf-ruby.git
# install version from .tool-versions file
asdf install ruby
asdf reshim ruby
```
You will now be able to run the `./bin/dev` command.
3. (optional) Install [direnv](https://direnv.net/) and create an `.envrc` with
```bash ```bash
#!/usr/bin/env bash #!/usr/bin/env bash
+200 -242
View File
@@ -1,295 +1,253 @@
#!/usr/bin/env ruby #!/usr/bin/env python3
class DevHelper import os
# Support dashes in command names import re
COMMAND_TO_METHOD = { import subprocess
"ts-node" => :ts_node, import sys
"check-types" => :check_types,
"bash-completions" => :bash_completions,
"plantuml-to-png" => :plantuml_to_png,
}
METHOD_TO_COMMAND = COMMAND_TO_METHOD.invert
REPLACE_PROCESS = "replace_process" # Support dashes in command names
WAIT_FOR_PROCESS = "wait_for_process" COMMAND_TO_METHOD = {
"ts-node": "ts_node",
"check-types": "check_types",
"bash-completions": "bash_completions",
"plantuml-to-png": "plantuml_to_png",
}
METHOD_TO_COMMAND = {method: command for command, method in COMMAND_TO_METHOD.items()}
# External Interface WAIT_FOR_PROCESS = "wait_for_process"
def self.call(*args)
new.call(*args)
end
# Core logic
def call(*args, **kwargs)
command = args[0]
method = COMMAND_TO_METHOD.fetch(command, command)
if args.length.positive? && respond_to?(method)
public_send(method, *args.drop(1), **kwargs)
else
compose(*args, **kwargs)
end
end
def compose(*args, **kwargs) class DevHelper:
command = compose_command(*args, **kwargs) def __init__(self):
puts "Running: #{command}" unless kwargs[:slient] self.last_exit_status = 0
case kwargs[:execution_mode] # Core logic
when WAIT_FOR_PROCESS def call(self, *args, **kwargs):
wait_for_process_with_logging(command) if not args:
else return self.compose(*args, **kwargs)
exec(command)
end
end
# Primary command wrappers command, rest = args[0], args[1:]
def build(*args, **kwargs) method_name = COMMAND_TO_METHOD.get(command, command)
compose(%w[build], *args, **kwargs) method = getattr(self, method_name, None)
end if callable(method) and not method_name.startswith("_") and method_name != "call":
return method(*rest, **kwargs)
return self.compose(*args, **kwargs)
def compile(*args, **kwargs) def compose(self, *args, **kwargs):
run(*%w[api npm run build], execution_mode: WAIT_FOR_PROCESS) command = self._compose_command(*args, **kwargs)
exit($?.exitstatus) unless $?.success? if not kwargs.get("silent"):
run(*%w[web npm run build]) print(f"Running: {command}")
end
def up(*args, **kwargs) if kwargs.get("execution_mode") == WAIT_FOR_PROCESS:
compose(*%w[up --remove-orphans], *args, **kwargs) self._wait_for_process_with_logging(command)
end else:
os.execv("/bin/sh", ["/bin/sh", "-c", command])
def down(*args, **kwargs) # Primary command wrappers
compose(*%w[down --remove-orphans], *args, **kwargs) def build(self, *args, **kwargs):
end self.compose("build", *args, **kwargs)
def logs(*args, **kwargs) def compile(self, *args, **kwargs):
compose(*%w[logs -f], *args, **kwargs) self.run("api", "npm", "run", "build", execution_mode=WAIT_FOR_PROCESS)
end if self.last_exit_status != 0:
sys.exit(self.last_exit_status)
self.run("web", "npm", "run", "build")
def run(*args, **kwargs) def up(self, *args, **kwargs):
compose(*%w[run --rm], *args, **kwargs) self.compose("up", "--remove-orphans", *args, **kwargs)
end
def ps(*args, **kwargs) def down(self, *args, **kwargs):
compose(*%w[ps], *args, **kwargs) self.compose("down", "--remove-orphans", *args, **kwargs)
end
# Custom helpers def logs(self, *args, **kwargs):
def api(*args, **kwargs) self.compose("logs", "-f", *args, **kwargs)
run(*%w[api], *args, **kwargs)
end
def web(*args, **kwargs) def run(self, *args, **kwargs):
run(*%w[web], *args, **kwargs) self.compose("run", "--rm", *args, **kwargs)
end
def check_types(*args, **kwargs) def ps(self, *args, **kwargs):
run(*%w[api npm run check-types], *args, **kwargs) self.compose("ps", *args, **kwargs)
end
def test(*args, **kwargs) # Custom helpers
service = args[0] def api(self, *args, **kwargs):
if service == "api" self.run("api", *args, **kwargs)
test_api(*args.drop(1), **kwargs)
elsif service == "web"
test_web(*args.drop(1), **kwargs)
else
test_api(*args, **kwargs)
end
end
def test_api(*args, **kwargs) def web(self, *args, **kwargs):
reformat_project_relative_path_filter_for_vitest!(args, "api/") self.run("web", *args, **kwargs)
run(*%w[test_api npm run test], *args, **kwargs)
end
def test_web(*args, **kwargs) def check_types(self, *args, **kwargs):
reformat_project_relative_path_filter_for_vitest!(args, "web/") self.run("api", "npm", "run", "check-types", *args, **kwargs)
run(*%w[test_web npm run test], *args, **kwargs)
end
def sqlcmd(*args, **kwargs) def test(self, *args, **kwargs):
db_host = ENV.fetch('DB_HOST', 'localhost') service = args[0] if args else None
db_user = ENV.fetch('DB_USER', 'sa') if service == "api":
db_pass = ENV.fetch('DB_PASS', '1m5ecure!') self.test_api(*args[1:], **kwargs)
db_name = ENV.fetch('DB_NAME', 'YHSI') elif service == "web":
compose( self.test_web(*args[1:], **kwargs)
*%w[exec db /opt/mssql-tools/bin/sqlcmd], else:
*%W[-U #{db_user}], self.test_api(*args, **kwargs)
*%W[-P #{db_pass}],
*%W[-H #{db_host}],
*%W[-d #{db_name}],
'-I', # enable quoted identifiers, e.g. "table"."column"
*args,
**kwargs
)
end
def db(*args, **kwargs) def test_api(self, *args, **kwargs):
compose(*%w[exec db], *args, **kwargs) args = self._reformat_project_relative_path_filter_for_vitest(list(args), "api/")
end self.run("test_api", "npm", "run", "test", *args, **kwargs)
def debug def test_web(self, *args, **kwargs):
api_container_id = container_id("api") args = self._reformat_project_relative_path_filter_for_vitest(list(args), "web/")
puts "Waiting for breakpoint to trigger..." self.run("test_web", "npm", "run", "test", *args, **kwargs)
puts "'ctrl-c' to exit."
command = "docker attach --detach-keys ctrl-c #{api_container_id}"
puts "Running: #{command}"
exec(command)
exit 0
end
def npm(*args, **kwargs) def sqlcmd(self, *args, **kwargs):
run(*%w[api npm], *args, **kwargs) db_host = os.environ.get("DB_HOST", "localhost")
end db_user = os.environ.get("DB_USER", "sa")
db_pass = os.environ.get("DB_PASS", "1m5ecure!")
db_name = os.environ.get("DB_NAME", "YHSI")
self.compose(
"exec",
"db",
"/opt/mssql-tools/bin/sqlcmd",
"-U",
db_user,
"-P",
db_pass,
"-H",
db_host,
"-d",
db_name,
"-I", # enable quoted identifiers, e.g. "table"."column"
*args,
**kwargs,
)
def ts_node(*args, **kwargs) def db(self, *args, **kwargs):
run(*%w[api npm run ts-node], *args, **kwargs) self.compose("exec", "db", *args, **kwargs)
end
def knex(*args, **kwargs) def debug(self, *args, **kwargs):
if RUBY_PLATFORM =~ /linux/ container_id = self._container_id("api")
run(*%w[api npm run knex], *args, execution_mode: WAIT_FOR_PROCESS, **kwargs) print("Waiting for breakpoint to trigger...")
print("'ctrl-c' to exit.")
command = f"docker attach --detach-keys ctrl-c {container_id}"
print(f"Running: {command}")
os.execv("/bin/sh", ["/bin/sh", "-c", command])
file_or_directory = "#{project_root}/api/src/db/migrations" def npm(self, *args, **kwargs):
exit(0) unless take_over_needed?(file_or_directory) self.run("api", "npm", *args, **kwargs)
ownit file_or_directory def ts_node(self, *args, **kwargs):
else self.run("api", "npm", "run", "ts-node", *args, **kwargs)
run(*%w[api npm run knex], *args, **kwargs)
end
end
def migrate(*args, **kwargs) def knex(self, *args, **kwargs):
action = args[0] if sys.platform.startswith("linux"):
knex("migrate:#{action}", *args.drop(1), **kwargs) self.run("api", "npm", "run", "knex", *args, execution_mode=WAIT_FOR_PROCESS, **kwargs)
end
def seed(*args, **kwargs) file_or_directory = os.path.join(self._project_root(), "api/src/db/migrations")
action = args[0] if not self._take_over_needed(file_or_directory):
knex("seed:#{action}", *args.drop(1), **kwargs) sys.exit(0)
end
def ownit(*args, **kwargs) self.ownit(file_or_directory)
file_or_directory = args[0] else:
raise ScriptError, "Must provide a file or directory path." if file_or_directory.nil? self.run("api", "npm", "run", "knex", *args, **kwargs)
if RUBY_PLATFORM =~ /linux/ def migrate(self, *args, **kwargs):
puts "Take ownership of the file or directory? #{file_or_directory}" action = args[0] if args else None
exec("sudo chown -R #{user_id}:#{group_id} #{file_or_directory}") self.knex(f"migrate:{action}", *args[1:], **kwargs)
else
raise NotImplementedError, "Not implement for platform #{RUBY_PLATFORM}"
end
end
def plantuml_to_png(*args, **kwargs) def seed(self, *args, **kwargs):
file_path = args.pop action = args[0] if args else None
raise ScriptError, "Must provide a file path." if file_path.nil? self.knex(f"seed:{action}", *args[1:], **kwargs)
png_path = file_path.gsub(/\.(wsd|pu|puml|plantuml|uml)$/, ".png") def ownit(self, *args, **kwargs):
file_or_directory = args[0] if args else None
if file_or_directory is None:
raise ValueError("Must provide a file or directory path.")
command = <<~BASH if sys.platform.startswith("linux"):
curl #{args.join(" ")} \ print(f"Take ownership of the file or directory? {file_or_directory}")
--data-binary @'#{file_path}' \ command = f"sudo chown -R {self._user_id()}:{self._group_id()} {file_or_directory}"
http://localhost:9999/png > '#{png_path}' os.execv("/bin/sh", ["/bin/sh", "-c", command])
BASH else:
raise NotImplementedError(f"Not implemented for platform {sys.platform}")
puts "Running: #{command}" def plantuml_to_png(self, *args, **kwargs):
exec(command) args = list(args)
end if not args:
raise ValueError("Must provide a file path.")
file_path = args.pop()
def bash_completions png_path = re.sub(r"\.(wsd|pu|puml|plantuml|uml)$", ".png", file_path)
completions =
public_methods(false)
.reject { |word| %i[call].include?(word) }
.map { |word| METHOD_TO_COMMAND.fetch(word, word) }
puts completions
end
private command = f"curl {' '.join(args)} --data-binary @'{file_path}' http://localhost:9999/png > '{png_path}'"
def wait_for_process_with_logging(command) print(f"Running: {command}")
IO.popen("#{command} 2>&1") do |io| os.execv("/bin/sh", ["/bin/sh", "-c", command])
until io.eof?
line = io.gets
puts line
end
end
end
def container_id(container_name, *args, **kwargs) def bash_completions(self, *args, **kwargs):
command = compose_command(*%w[ps -q], container_name, *args, **kwargs) completions = sorted(
puts "Running: #{command}" METHOD_TO_COMMAND.get(name, name)
id_of_container = `#{command}`.chomp for name in vars(DevHelper)
puts "Container id is: #{id_of_container}" if not name.startswith("_") and name != "call" and callable(getattr(DevHelper, name))
id_of_container )
end print(" ".join(completions))
def service_running?(container_name) # Private helpers
ps(*%w[-q --status=running], execution_mode: WAIT_FOR_PROCESS, slient: true) != "" def _wait_for_process_with_logging(self, command):
end process = subprocess.Popen(
["/bin/sh", "-c", f"{command} 2>&1"],
stdout=subprocess.PIPE,
text=True,
)
for line in process.stdout:
print(line, end="")
process.wait()
self.last_exit_status = process.returncode
def compose_command(*args, **kwargs) def _container_id(self, container_name, *args, **kwargs):
environment = kwargs.fetch(:environment, "development") command = self._compose_command("ps", "-q", container_name, *args, **kwargs)
"cd #{project_root} && docker compose -f docker-compose.#{environment}.yml #{args.join(" ")}" print(f"Running: {command}")
end result = subprocess.run(["/bin/sh", "-c", command], stdout=subprocess.PIPE, text=True)
container_id = result.stdout.strip()
print(f"Container id is: {container_id}")
return container_id
def project_root def _service_running(self, container_name):
@project_root ||= File.absolute_path("#{__dir__}/..") self.ps("-q", "--status=running", execution_mode=WAIT_FOR_PROCESS, silent=True)
end return self.last_exit_status == 0
def take_over_needed?(file_or_directory) def _compose_command(self, *args, **kwargs):
files_owned_by_others = environment = kwargs.get("environment", "development")
system("find #{file_or_directory} -not -user #{user_id} -print -quit | grep -q .") return f"cd {self._project_root()} && docker compose -f docker-compose.{environment}.yml {' '.join(args)}"
files_owned_by_others
end
def user_id def _project_root(self):
unless RUBY_PLATFORM =~ /linux/ return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
raise NotImplementedError, "Not implement for platform #{RUBY_PLATFORM}"
end
`id -u`.strip def _take_over_needed(self, file_or_directory):
end result = subprocess.run(
["/bin/sh", "-c", f"find {file_or_directory} -not -user {self._user_id()} -print -quit | grep -q ."],
)
return result.returncode == 0
def group_id def _user_id(self):
unless RUBY_PLATFORM =~ /linux/ if not sys.platform.startswith("linux"):
raise NotImplementedError, "Not implement for platform #{RUBY_PLATFORM}" raise NotImplementedError(f"Not implemented for platform {sys.platform}")
end return subprocess.run(["id", "-u"], stdout=subprocess.PIPE, text=True).stdout.strip()
`id -g`.strip def _group_id(self):
end if not sys.platform.startswith("linux"):
raise NotImplementedError(f"Not implemented for platform {sys.platform}")
return subprocess.run(["id", "-g"], stdout=subprocess.PIPE, text=True).stdout.strip()
def reformat_project_relative_path_filter_for_vitest!(args, prefix) def _reformat_project_relative_path_filter_for_vitest(self, args, prefix):
if args.length.positive? && args[0].start_with?(prefix) if args and args[0].startswith(prefix):
src_path_prefix = "#{prefix}src/" src_path_prefix = f"{prefix}src/"
test_path_regex = Regexp.escape(prefix)
src_path_regex = Regexp.escape(src_path_prefix)
if args[0].start_with?(src_path_prefix) if args[0].startswith(src_path_prefix):
# TODO: handle other file types # TODO: handle other file types
args[0] = args[0].gsub(/^#{src_path_regex}/, "tests/").gsub(/\.ts$/, ".test.ts") args[0] = re.sub(r"\.ts$", ".test.ts", re.sub(f"^{re.escape(src_path_prefix)}", "tests/", args[0]))
else else:
args[0] = args[0].gsub(/^#{test_path_regex}/, "") args[0] = re.sub(f"^{re.escape(prefix)}", "", args[0])
end
puts "Reformatted path filter from project relative to service relative for vitest." print("Reformatted path filter from project relative to service relative for vitest.")
end
end
end
# Only execute main function when file is executed return args
DevHelper.call(*ARGV) if $PROGRAM_NAME == __FILE__
## Dev completions
# https://iridakos.com/programming/2018/03/01/bash-programmable-completion-tutorial
# _dev_completions () {
# local dev_command_path="$(which dev)"
# local dev_function_names
# dev_function_names="$(ruby "$dev_command_path" bash_completions)"
# # COMP_WORDS: an array of all the words typed after the name of the program the compspec belongs to
# # COMP_CWORD: an index of the COMP_WORDS array pointing to the word the current cursor is at - in other words, the index of the word the cursor was when the tab key was pressed
# # COMP_LINE: the current command line
# COMPREPLY=($(compgen -W "$dev_function_names" "${COMP_WORDS[$COMP_CWORD]}"))
# }
# complete -F _dev_completions dev if __name__ == "__main__":
# complete -W "allow" direnv DevHelper().call(*sys.argv[1:])
Executable
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
import os
import subprocess
import sys
# Support dashes in command names
COMMAND_TO_METHOD = {}
METHOD_TO_COMMAND = {method: command for command, method in COMMAND_TO_METHOD.items()}
WAIT_FOR_PROCESS = "wait_for_process"
class ProdHelper:
def __init__(self):
self.last_exit_status = 0
# Core logic
def call(self, *args, **kwargs):
if not args:
return self.compose(*args, **kwargs)
command, rest = args[0], args[1:]
method_name = COMMAND_TO_METHOD.get(command, command)
method = getattr(self, method_name, None)
if callable(method) and not method_name.startswith("_") and method_name != "call":
return method(*rest, **kwargs)
return self.compose(*args, **kwargs)
def compose(self, *args, **kwargs):
command = self._compose_command(*args, **kwargs)
if not kwargs.get("silent"):
print(f"Running: {command}")
if kwargs.get("execution_mode") == WAIT_FOR_PROCESS:
self._wait_for_process_with_logging(command)
else:
os.execv("/bin/sh", ["/bin/sh", "-c", command])
# Primary command wrappers
def up(self, *args, **kwargs):
self.compose("up", "--remove-orphans", *args, **kwargs)
def down(self, *args, **kwargs):
self.compose("down", "--remove-orphans", *args, **kwargs)
def logs(self, *args, **kwargs):
self.compose("logs", "-f", *args, **kwargs)
def ps(self, *args, **kwargs):
self.compose("ps", *args, **kwargs)
def bash_completions(self, *args, **kwargs):
completions = sorted(
METHOD_TO_COMMAND.get(name, name)
for name in vars(ProdHelper)
if not name.startswith("_") and name != "call" and callable(getattr(ProdHelper, name))
)
print(" ".join(completions))
# Private helpers
def _wait_for_process_with_logging(self, command):
process = subprocess.Popen(
["/bin/sh", "-c", f"{command} 2>&1"],
stdout=subprocess.PIPE,
text=True,
)
for line in process.stdout:
print(line, end="")
process.wait()
self.last_exit_status = process.returncode
def _compose_command(self, *args, **kwargs):
environment = kwargs.get("environment", "production")
return f"cd {self._project_root()} && docker compose -f docker-compose.{environment}.yml {' '.join(args)}"
def _project_root(self):
return os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if __name__ == "__main__":
ProdHelper().call(*sys.argv[1:])
+13
View File
@@ -0,0 +1,13 @@
services:
app:
image: gitea.burke.host/burkkyy/calebburke.dev:latest
pull_policy: always
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
ports:
- "${HOST_PORT:-3000}:${HOST_PORT:-3000}"
volumes:
- ./.env:/home/node/app/.env.production