From e0cf0916ddb9a84345f23d59d4b3188fe996ee8d Mon Sep 17 00:00:00 2001 From: Wayne Yao Date: Tue, 8 Jul 2025 23:13:00 +0800 Subject: [PATCH 1/8] Add a few targets to the Makefile to ease local development --- install/Makefile | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/install/Makefile b/install/Makefile index 644e4a35..8b65cadd 100644 --- a/install/Makefile +++ b/install/Makefile @@ -1,4 +1,5 @@ all: update-versions go-build-release put-back +dev-all: dev-update-versions dev-build dev-clean go-build-release: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64 @@ -11,6 +12,12 @@ clean: update-versions: @echo "Fetching latest versions..." cp main.go main.go.bak && \ + $(MAKE) dev-update-versions + +put-back: + mv main.go.bak main.go + +dev-update-versions: PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name') && \ GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \ BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \ @@ -20,5 +27,11 @@ update-versions: sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \ echo "Updated main.go with latest versions" -put-back: - mv main.go.bak main.go +dev-build: go-build-release + +dev-clean: + @echo "Restoring version values ..." + sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"replaceme\"/" main.go && \ + sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"replaceme\"/" main.go && \ + sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"replaceme\"/" main.go + @echo "Restored version strings in main.go" From 607b168b5649530788156a1dc392c58d43f102d0 Mon Sep 17 00:00:00 2001 From: Wayne Yao Date: Tue, 8 Jul 2025 23:14:23 +0800 Subject: [PATCH 2/8] Use explicity FQDN image path because Podman by default doesn't have unqualified-search, and we don't bother configuring it for users. Being explicit is also a good practice --- install/config/crowdsec/docker-compose.yml | 2 +- install/config/docker-compose.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/install/config/crowdsec/docker-compose.yml b/install/config/crowdsec/docker-compose.yml index 28470d14..17289ef2 100644 --- a/install/config/crowdsec/docker-compose.yml +++ b/install/config/crowdsec/docker-compose.yml @@ -1,6 +1,6 @@ services: crowdsec: - image: crowdsecurity/crowdsec:latest + image: docker.io/crowdsecurity/crowdsec:latest container_name: crowdsec environment: GID: "1000" diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index 90349b7a..29c70af0 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -1,7 +1,7 @@ name: pangolin services: pangolin: - image: fosrl/pangolin:{{.PangolinVersion}} + image: docker.io/fosrl/pangolin:{{.PangolinVersion}} container_name: pangolin restart: unless-stopped volumes: @@ -13,7 +13,7 @@ services: retries: 15 {{if .InstallGerbil}} gerbil: - image: fosrl/gerbil:{{.GerbilVersion}} + image: docker.io/fosrl/gerbil:{{.GerbilVersion}} container_name: gerbil restart: unless-stopped depends_on: @@ -35,7 +35,7 @@ services: - 80:80 # Port for traefik because of the network_mode {{end}} traefik: - image: traefik:v3.4.1 + image: docker.io/traefik:v3.4.1 container_name: traefik restart: unless-stopped {{if .InstallGerbil}} From e83e8c2ee4389841408770f5ec583d97e4fa1a5c Mon Sep 17 00:00:00 2001 From: Wayne Yao Date: Tue, 8 Jul 2025 23:14:42 +0800 Subject: [PATCH 3/8] Add podman support to the installer. --- install/crowdsec.go | 12 +-- install/input.txt | 1 + install/main.go | 231 +++++++++++++++++++++++++++++++++----------- 3 files changed, 183 insertions(+), 61 deletions(-) diff --git a/install/crowdsec.go b/install/crowdsec.go index c17bf540..2e388e92 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -13,7 +13,7 @@ import ( func installCrowdsec(config Config) error { - if err := stopContainers(); err != nil { + if err := stopContainers(config.InstallationContainerType); err != nil { return fmt.Errorf("failed to stop containers: %v", err) } @@ -72,12 +72,12 @@ func installCrowdsec(config Config) error { os.Exit(1) } - if err := startContainers(); err != nil { + if err := startContainers(config.InstallationContainerType); err != nil { return fmt.Errorf("failed to start containers: %v", err) } // get API key - apiKey, err := GetCrowdSecAPIKey() + apiKey, err := GetCrowdSecAPIKey(config.InstallationContainerType) if err != nil { return fmt.Errorf("failed to get API key: %v", err) } @@ -87,7 +87,7 @@ func installCrowdsec(config Config) error { return fmt.Errorf("failed to replace bouncer key: %v", err) } - if err := restartContainer("traefik"); err != nil { + if err := restartContainer("traefik", config.InstallationContainerType); err != nil { return fmt.Errorf("failed to restart containers: %v", err) } @@ -110,9 +110,9 @@ func checkIsCrowdsecInstalledInCompose() bool { return bytes.Contains(content, []byte("crowdsec:")) } -func GetCrowdSecAPIKey() (string, error) { +func GetCrowdSecAPIKey(containerType SupportedContainer) (string, error) { // First, ensure the container is running - if err := waitForContainer("crowdsec"); err != nil { + if err := waitForContainer("crowdsec", containerType); err != nil { return "", fmt.Errorf("waiting for container: %w", err) } diff --git a/install/input.txt b/install/input.txt index 9bca8081..9ecf0d4d 100644 --- a/install/input.txt +++ b/install/input.txt @@ -1,3 +1,4 @@ +docker example.com pangolin.example.com admin@example.com diff --git a/install/main.go b/install/main.go index 38aa6f63..1545640f 100644 --- a/install/main.go +++ b/install/main.go @@ -7,17 +7,17 @@ import ( "fmt" "io" "io/fs" + "math/rand" "os" "os/exec" "os/user" "path/filepath" "runtime" + "strconv" "strings" "syscall" "text/template" "time" - "math/rand" - "strconv" "golang.org/x/term" ) @@ -33,43 +33,99 @@ func loadVersions(config *Config) { var configFiles embed.FS type Config struct { - PangolinVersion string - GerbilVersion string - BadgerVersion string - BaseDomain string - DashboardDomain string - LetsEncryptEmail string - EnableEmail bool - EmailSMTPHost string - EmailSMTPPort int - EmailSMTPUser string - EmailSMTPPass string - EmailNoReply string - InstallGerbil bool - TraefikBouncerKey string - DoCrowdsecInstall bool - Secret string + InstallationContainerType SupportedContainer + PangolinVersion string + GerbilVersion string + BadgerVersion string + BaseDomain string + DashboardDomain string + LetsEncryptEmail string + EnableEmail bool + EmailSMTPHost string + EmailSMTPPort int + EmailSMTPUser string + EmailSMTPPass string + EmailNoReply string + InstallGerbil bool + TraefikBouncerKey string + DoCrowdsecInstall bool + Secret string } +type SupportedContainer string + +const ( + Docker SupportedContainer = "docker" + Podman = "podman" +) + func main() { reader := bufio.NewReader(os.Stdin) + inputContainer := readString(reader, "Would you like to run pangolin as docker or podman container?", "docker") - // check if docker is not installed and the user is root - if !isDockerInstalled() { - if os.Geteuid() != 0 { - fmt.Println("Docker is not installed. Please install Docker manually or run this installer as root.") - os.Exit(1) - } + chosenContainer := Docker + if strings.EqualFold(inputContainer, "docker") { + chosenContainer = Docker + } else if strings.EqualFold(inputContainer, "podman") { + chosenContainer = Podman + } else { + fmt.Printf("Unrecognized container type: %s. Valid options are 'docker' or 'podman'.\n", inputContainer) + os.Exit(1) } - // check if the user is in the docker group (linux only) - if !isUserInDockerGroup() { - fmt.Println("You are not in the docker group.") - fmt.Println("The installer will not be able to run docker commands without running it as root.") + if chosenContainer == Podman { + if !isPodmanInstalled() { + fmt.Println("Podman or podman-compose is not installed. Please install both manually. Automated installation will be available in a later release.") + os.Exit(1) + } + + if err := exec.Command("bash", "-c", "cat /etc/sysctl.conf | grep 'net.ipv4.ip_unprivileged_port_start='"); err == nil { + fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.") + fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.") + approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p\". Approve?", true) + if approved { + if os.Geteuid() != 0 { + fmt.Println("You need to run the installer as root for such a configuration.") + os.Exit(1) + } + + // Podman containers are not able to listen on privileged ports. The official recommendation is to + // container low-range ports as unprivileged ports. + // Linux only. + + if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p"); err != nil { + fmt.Sprintf("failed to configure unprivileged ports: %v.\n", err) + os.Exit(1) + } + } else { + fmt.Println("You need to configure port forwarding or adjust the listening ports before running pangolin.") + } + } else { + fmt.Println("Unprivileged ports have been configured.") + } + + } else if chosenContainer == Docker { + // check if docker is not installed and the user is root + if !isDockerInstalled() { + if os.Geteuid() != 0 { + fmt.Println("Docker is not installed. Please install Docker manually or run this installer as root.") + os.Exit(1) + } + } + + // check if the user is in the docker group (linux only) + if !isUserInDockerGroup() { + fmt.Println("You are not in the docker group.") + fmt.Println("The installer will not be able to run docker commands without running it as root.") + os.Exit(1) + } + } else { + // This shouldn't happen unless there's a third container runtime. os.Exit(1) } var config Config + config.InstallationContainerType = chosenContainer // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { @@ -86,7 +142,7 @@ func main() { moveFile("config/docker-compose.yml", "docker-compose.yml") - if !isDockerInstalled() && runtime.GOOS == "linux" { + if !isDockerInstalled() && runtime.GOOS == "linux" && chosenContainer == Docker { if readBool(reader, "Docker is not installed. Would you like to install it?", true) { installDocker() // try to start docker service but ignore errors @@ -115,14 +171,15 @@ func main() { fmt.Println("\n=== Starting installation ===") - if isDockerInstalled() { + if (isDockerInstalled() && chosenContainer == Docker) || + (isPodmanInstalled() && chosenContainer == Podman) { if readBool(reader, "Would you like to install and start the containers?", true) { - if err := pullContainers(); err != nil { + if err := pullContainers(chosenContainer); err != nil { fmt.Println("Error: ", err) return } - if err := startContainers(); err != nil { + if err := startContainers(chosenContainer); err != nil { fmt.Println("Error: ", err) return } @@ -137,6 +194,8 @@ func main() { // check if crowdsec is installed if readBool(reader, "Would you like to install CrowdSec?", false) { fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.") + + // BUG: crowdsec installation will be skipped if the user chooses to install on the first installation. if readBool(reader, "Are you willing to manage CrowdSec?", false) { if config.DashboardDomain == "" { traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml", "config/traefik/dynamic_config.yml") @@ -240,7 +299,7 @@ func collectUserInput(reader *bufio.Reader) Config { config.EmailSMTPHost = readString(reader, "Enter SMTP host", "") config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587) config.EmailSMTPUser = readString(reader, "Enter SMTP username", "") - config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") + config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword? config.EmailNoReply = readString(reader, "Enter no-reply email address", "") } @@ -456,7 +515,15 @@ func startDockerService() error { } func isDockerInstalled() bool { - cmd := exec.Command("docker", "--version") + return isContainerInstalled("docker") +} + +func isPodmanInstalled() bool { + return isContainerInstalled("podman") && isContainerInstalled("podman-compose") +} + +func isContainerInstalled(container string) bool { + cmd := exec.Command(container, "--version") if err := cmd.Run(); err != nil { return false } @@ -527,52 +594,98 @@ func executeDockerComposeCommandWithArgs(args ...string) error { cmd = exec.Command("docker-compose", args...) } - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() } // pullContainers pulls the containers using the appropriate command. -func pullContainers() error { +func pullContainers(containerType SupportedContainer) error { fmt.Println("Pulling the container images...") + if containerType == Podman { + if err := run("podman-compose", "-f", "docker-compose.yml", "pull"); err != nil { + return fmt.Errorf("failed to pull the containers: %v", err) + } - if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "pull", "--policy", "always"); err != nil { - return fmt.Errorf("failed to pull the containers: %v", err) + return nil } - return nil + if containerType == Docker { + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "pull", "--policy", "always"); err != nil { + return fmt.Errorf("failed to pull the containers: %v", err) + } + + return nil + } + + return fmt.Errorf("Unsupported container type: %s", containerType) } // startContainers starts the containers using the appropriate command. -func startContainers() error { +func startContainers(containerType SupportedContainer) error { fmt.Println("Starting containers...") - if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil { - return fmt.Errorf("failed to start containers: %v", err) + + if containerType == Podman { + if err := run("podman-compose", "-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil { + return fmt.Errorf("failed start containers: %v", err) + } + + return nil } - return nil + if containerType == Docker { + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "up", "-d", "--force-recreate"); err != nil { + return fmt.Errorf("failed to start containers: %v", err) + } + + return nil + } + + return fmt.Errorf("Unsupported container type: %s", containerType) } // stopContainers stops the containers using the appropriate command. -func stopContainers() error { +func stopContainers(containerType SupportedContainer) error { fmt.Println("Stopping containers...") + if containerType == Podman { + if err := run("podman-compose", "-f", "docker-compose.yml", "down"); err != nil { + return fmt.Errorf("failed to stop containers: %v", err) + } - if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "down"); err != nil { - return fmt.Errorf("failed to stop containers: %v", err) + return nil } - return nil + if containerType == Docker { + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "down"); err != nil { + return fmt.Errorf("failed to stop containers: %v", err) + } + + return nil + } + + return fmt.Errorf("Unsupported container type: %s", containerType) } // restartContainer restarts a specific container using the appropriate command. -func restartContainer(container string) error { +func restartContainer(container string, containerType SupportedContainer) error { fmt.Println("Restarting containers...") + if containerType == Podman { + if err := run("podman-compose", "-f", "docker-compose.yml", "restart"); err != nil { + return fmt.Errorf("failed to stop the container \"%s\": %v", container, err) + } - if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "restart", container); err != nil { - return fmt.Errorf("failed to stop the container \"%s\": %v", container, err) + return nil } - return nil + if containerType == Docker { + if err := executeDockerComposeCommandWithArgs("-f", "docker-compose.yml", "restart", container); err != nil { + return fmt.Errorf("failed to stop the container \"%s\": %v", container, err) + } + + return nil + } + + return fmt.Errorf("Unsupported container type: %s", containerType) } func copyFile(src, dst string) error { @@ -600,13 +713,13 @@ func moveFile(src, dst string) error { return os.Remove(src) } -func waitForContainer(containerName string) error { +func waitForContainer(containerName string, containerType SupportedContainer) error { maxAttempts := 30 retryInterval := time.Second * 2 for attempt := 0; attempt < maxAttempts; attempt++ { // Check if container is running - cmd := exec.Command("docker", "container", "inspect", "-f", "{{.State.Running}}", containerName) + cmd := exec.Command(string(containerType), "container", "inspect", "-f", "{{.State.Running}}", containerName) var out bytes.Buffer cmd.Stdout = &out @@ -641,3 +754,11 @@ func generateRandomSecretKey() string { } return string(b) } + +// Run external commands with stdio/stderr attached. +func run(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} From 342675276bd11c980293bf226d500a77dd23db3b Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 13 Jul 2025 15:58:58 -0700 Subject: [PATCH 4/8] Add type & cap --- install/main.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install/main.go b/install/main.go index 1545640f..d3f30f26 100644 --- a/install/main.go +++ b/install/main.go @@ -56,12 +56,12 @@ type SupportedContainer string const ( Docker SupportedContainer = "docker" - Podman = "podman" + Podman SupportedContainer = "podman" ) func main() { reader := bufio.NewReader(os.Stdin) - inputContainer := readString(reader, "Would you like to run pangolin as docker or podman container?", "docker") + inputContainer := readString(reader, "Would you like to run Pangolin as docker or podman container?", "docker") chosenContainer := Docker if strings.EqualFold(inputContainer, "docker") { From 4443dda0f6979c8d34020ecdfd786a962af04ff4 Mon Sep 17 00:00:00 2001 From: Wayne Yao Date: Mon, 21 Jul 2025 22:48:10 +0800 Subject: [PATCH 5/8] Fix a bug that error check prevents port configuration --- install/main.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/install/main.go b/install/main.go index d3f30f26..8160f2e9 100644 --- a/install/main.go +++ b/install/main.go @@ -79,7 +79,7 @@ func main() { os.Exit(1) } - if err := exec.Command("bash", "-c", "cat /etc/sysctl.conf | grep 'net.ipv4.ip_unprivileged_port_start='"); err == nil { + if err := exec.Command("bash", "-c", "cat /etc/sysctl.conf | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil { fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.") fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.") approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p\". Approve?", true) @@ -389,7 +389,6 @@ func createConfigFiles(config Config) error { return nil }) - if err != nil { return fmt.Errorf("error walking config files: %v", err) } From 7c12b8ae25452cc4173b58eba42d1ac711d2dc14 Mon Sep 17 00:00:00 2001 From: Sebastian Felber Date: Tue, 22 Jul 2025 16:20:02 +0200 Subject: [PATCH 6/8] add IPv6 support for docker network --- docker-compose.example.yml | 3 ++- install/config/docker-compose.yml | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index e6c78453..5a1b0a4e 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -51,4 +51,5 @@ services: networks: default: driver: bridge - name: pangolin \ No newline at end of file + name: pangolin + enable_ipv6: true \ No newline at end of file diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index 90349b7a..dc680f2b 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -59,3 +59,4 @@ networks: default: driver: bridge name: pangolin + enable_ipv6: true From 0b52cd002ede24bac9d9ef6104f8d5e1b6d57b49 Mon Sep 17 00:00:00 2001 From: Fernando Rodrigues Date: Sat, 26 Jul 2025 18:47:50 +1000 Subject: [PATCH 7/8] add an environment variable for the smtp_pass config option The password for secure authentication may be sensitive, so it is best to not leave it lying around in a config file. This commit introduces the EMAIL_SMTP_PASS environment variable, which can be set to configure the SMTP password without writing it to the configuration file. Signed-off-by: Fernando Rodrigues --- server/lib/readConfigFile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index f738b986..da1e1649 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -213,7 +213,7 @@ export const configSchema = z smtp_host: z.string().optional(), smtp_port: portSchema.optional(), smtp_user: z.string().optional(), - smtp_pass: z.string().optional(), + smtp_pass: z.string().optional().transform(getEnvOrYaml("EMAIL_SMTP_PASS")), smtp_secure: z.boolean().optional(), smtp_tls_reject_unauthorized: z.boolean().optional(), no_reply: z.string().email().optional() From 9e87c42d0cabc241ee13b3769ca75ce3d7a1259f Mon Sep 17 00:00:00 2001 From: Fernando Rodrigues Date: Sun, 27 Jul 2025 13:07:00 +1000 Subject: [PATCH 8/8] add shebangs to migration and server scripts In NixOS, we wrap these files in a bash script to allow users to just run them as normal executables, instead of calling them as arguments to Node.JS. In our build scripts, we just add the shebang after the files have been compiled, but adding it upstream will allow all Pangolin users to just run ./server.mjs to start their Pangolin instances. Signed-off-by: Fernando Rodrigues --- server/index.ts | 1 + server/setup/migrationsPg.ts | 1 + server/setup/migrationsSqlite.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/server/index.ts b/server/index.ts index 55b34543..d3f90281 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,3 +1,4 @@ +#! /usr/bin/env node import "./extendZod.ts"; import { runSetupFunctions } from "./setup"; diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index d5e2f46d..6996999c 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -1,3 +1,4 @@ +#! /usr/bin/env node import { migrate } from "drizzle-orm/node-postgres/migrator"; import { db } from "../db/pg"; import semver from "semver"; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 68da0a27..9fd5a470 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -1,3 +1,4 @@ +#! /usr/bin/env node import { migrate } from "drizzle-orm/better-sqlite3/migrator"; import { db, exists } from "../db/sqlite"; import path from "path";