@ethicdevs/gitfoss | Show object: 5d688ff33fae8821b08a2c5c6f28b278bd2f1473 ∙ GitFOSS
refactor(ssh_command): make it stable
+ 1010
- 72
@@ -14,7 +14,6 @@ stages:
           - git checkout master
           - yarn
           - yarn build
-          -
         cached:
           - /home/node/build/gitfoss/dist/
           - /home/node/build/gitfoss/public/

@@ -18,12 +18,23 @@ COPY ./tsconfig.json       /usr/src/app/tsconfig.json
 COPY ./yarn.lock           /usr/src/app/yarn.lock
 
 # Install dependencies
-RUN apk upgrade --no-cache openssl
+RUN apk add --no-cache curl libcurl curl-dev crystal shards && apk upgrade --no-cache openssl
 RUN npm install --global yarn
 RUN yarn install
 
 #    [local tree]          [builder tree]
 
+COPY ./packages            /usr/src/app/packages
+
+# Install Crystal dependencies
+RUN export TRUST_CERT_PATH=`curl-config --ca` && echo -n | openssl s_client -showcerts -connect gitfoss.dev:443 -servername gitfoss.dev 2>/dev/null | sed -ne '/-BEGIN CERTIFICATE-/,/-END CERTIFICATE-/p' >> $TRUST_CERT_PATH
+RUN cd /usr/src/app/packages/gitfoss-ci && shards install
+# RUN cd /usr/src/app/packages/gitfoss-ci-runner && shards install
+# RUN GIT_CURL_VERBOSE=1 GIT_TRACE=1 git clone https://gitfoss.dev/ethicdevs/crystal-docker-api.git
+RUN cd /usr/src/app/packages/gitfoss-ssh-command && shards install
+
+#    [local tree]          [builder tree]
+
 COPY ./.git                /usr/src/app/.git
 COPY ./app                 /usr/src/app/app
 COPY ./db                  /usr/src/app/db

...
@@ -47,10 +58,16 @@ RUN yarn test              # Test code to ensure it is regression-free (jest)
 RUN yarn build:ts          # Transpile TypeScript to JavaScript (node)
 RUN yarn bundle:islands    # Bundle Islands (react-monolith) to ESM/CJS/UMD
 
+# Crystal build
+RUN crystal build --release ./packages/gitfoss-ci/src/gitfoss-ci.cr
+# RUN crystal build --release ./packages/gitfoss-ci-runner/src/gitfoss-ci-runner.cr
+RUN crystal build --release ./packages/gitfoss-ssh-command/src/ssh-command.cr
+
 #    [local tree]          [builder tree]
 
 COPY ./public              /usr/src/app/public
 COPY ./app.manifest.json   /usr/src/app/app.manifest.json
+COPY ./packages/gitfoss-ssh-command/ssh-command /usr/src/app/bin/ssh-command
 # COPY ./dist                /usr/src/app/dist
 
 FROM node:slim AS base

...
@@ -66,7 +83,9 @@ ENV HOST=${HOST}
 # Install required dependencies
 RUN apt-get update -y && \
     apt-get install --no-install-recommends \
-        git-core git openssl openssh-server gnupg sudo curl jq ca-certificates wget -y && \
+        git-core git openssl openssh-server \
+        gnupg sudo curl jq ca-certificates \
+        wget -y && \
     rm -rf /var/lib/apt/lists/*
 
 # Install Node 20.0.0 from nodesource

...
@@ -156,10 +175,11 @@ RUN chown git:git git-shell-commands/no-interactive-login
 RUN chmod +x git-shell-commands/no-interactive-login
 
 # Add ssh command to force client command
-COPY ./data/ssh_command /usr/bin/
+COPY --from=builder /usr/src/app/bin/ssh-command /usr/bin/ssh_command
+# COPY ./data/ssh_command /usr/bin/
 COPY ./data/ssh_command_node /usr/bin/
 COPY ./data/http_client.js /usr/bin/
-RUN chmod +x /usr/bin/ssh_command
+# RUN chmod +x /usr/bin/ssh_command
 RUN chmod +x /usr/bin/ssh_command_node
 
 RUN touch /opt/ssh_commands.log

@@ -66,9 +66,18 @@ $ docker exec -it <container_id_here> yarn migrate:deploy
 ```sh
 $ docker-compose up -d
 $ docker-compose exec web yarn migrate:deploy
-$ docker-compose exec traefik cat /var/log/traefik/traefik.log
 ```
 
+We also have a `docker-compose.caddy.yml` and `docker-compose.traefik.yml` for running GitFOSS using Caddy and Traefik reverse-proxies respectively.  
+
+### Logs
+
+- `/var/log/gitfoss/git_ssh.log`: Whatever happens on git over ssh
+- `/var/log/gitfoss/git_http.log`: Whatever happens on git over http(s)
+- `/var/log/gitfoss/web.log`: Whatever happens over web ui 
+- `/var/log/gitfoss/ci.log`: Whatever happens over CI
+- `/var/log/gitfoss/ci-runner-$n.log`: Whatever happens over CI runner $n
+
 ## License
 
 This project is licensed under the [MIT](LICENSE) license.

app/components/Layout.tsx
@@ -1,4 +1,4 @@
-// 3rd-party
+// 3rd-party !
 import React, { FC, useState } from "react";
 import styled, { css } from "styled-components";
 // app

app/controllers/repository/getRepositoryDetailsView.ts
@@ -21,7 +21,8 @@ const getRepositoryDetailsView: ReqHandler<
   AppRouteParams,
   AppRoute.REPOSITORY_DETAILS
 > = async (request, reply) => {
-  const { orgSlug, repoSlug } = request.params;
+  let { orgSlug, repoSlug } = request.params;
+  repoSlug = repoSlug.replace(/\.git$/, "");
 
   const orgService = makeOrganizationService({ request });
   const repoService = makeRepositoryService({ request });

app/controllers/ssh-auth.ts
@@ -5,6 +5,7 @@ import { GitServer } from "@ethicdevs/fastify-git-server";
 import { AppRoute, AppRouteParams } from "../routes.defs";
 import { makeGitServerService } from "../services/gitServer";
 
+// POST /_ssh/auth - SSH auth request handler
 const onSSHAuth: ReqHandler<AppRouteParams, AppRoute.SSH_AUTH> = async (
   request,
   reply,

@@ -1,3 +1,3 @@
-command="ssh_command wnemencha",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDGo9GDFyJ8RJ4F+Y4Vxl2bCl8sskRYbiSWItmoBZoYFVR2qAvUyowDY2xlDa9JaG/+zbBB6sUwUr8oC/GdSaV+zp5CTP2RxcXDW2Aup6w1/a4qiilSKORMXBWvIgjyvVvHjG0TiCfeC0PfBbXCO8FhLxP5lgTrl1kVUduM0LR4/gcH3vIrrKbORWtfAC7Bw6z3qc/X9CysPxtQZYu6+AknJ1vwUtLAH2H9cKS8uwaJ5N/k0n8Sc8ANozdpp7EyodA12nFFwvf5oPakTdm5cBnnnEIe2p+GA4nP2DyybmtIR/wttJGMs6Bmz0bXO6AfFdhcGKbzwT2qEGRX5drQj0qUI+gLSZ42/9DsGN7kr2gRDpXG2ATx+c4H2XvR3fqS1cyFq+ZmezK4l32BH/KjQMR1zfgeX2Ky46YxOLQn84PvWILmpzYPLTJ02kXFr0pjofraX2h0E/0Ke2ZBPlOUcaNZOU2dJDYn/B0GVrJ0niopvseYpVXHoTzMPYpr+ReCfAc= admin@Admins-MacBook-Pro.local
-command="ssh_command willi.wonka38",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCbpfyW60ydd3p0xQAozzB2lecE4NG9KyUbd/mRl+EPEOm4pnh3oIqbGLs42QYgKc1H8bkEEddFvnayN+JHJIwsRBSv3YMMsbTRHp6onHgsVHMaO+nNyubONwb7eLfR+5PvZ4eeOwbJpgfLwW2tVpoqLiTRpF8DFijYQePqGqQzFwWXx9niHHb3s1pqDPGjiqlEqbJPT4MyIdHo66M8SJMFL12uIUq33Wm3PRCtPDMs4FdW0fpvQpaBqu6cb71WrH8MDRC5IGT2KC3MD7flVfayUoSsdo3I/zzkom8t34Ee0b7NkQ0jvcsVves/Vz36YCZ/W7pNiKYi18B3sLIxpwI9a9k+kYt5a/5kz5izmeVbiVK2PDk45GN2b0RCfMb3uT3v5MwcJjKf5h52WYVjh0Uzj8AC4ZOtW84BAVrnX4hkBirN6oOYJB8/8pHvtAq5WSFtJnPzz1rL/mOFSsr/HS5Jo8KSsrH8uZHz2QWr6T6PDDGOjiEc44JTPOxqywUXo7Cs5TvicLhqWXdPokTmVftoG0pRpVWi9ctvGc1GRXviEWgvJZyqx/yDdtvrjKWDNjIM3jww1Blrb0R1o/0wvvHFfTf8flvW4SC7POXG0bXp/ANSGnTXIiAQk9JGmU6ntCqVebtNis60Xvbfj5gMGsEv1rhM1cyMcvIfT20ASZo6hw== aremy.dev@gmail.com
-command="ssh_command willi.wonka38",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC2gwbtNQtNTLbvyd8/B7k+dzOiYMYKfjbmr5x8DQUZr+K9367D3TzQ6wDMQBWnDu5FIKoKvAFTP3tHhV5p+3GMXqzS/2PCtzV7WHlUtJ7vVxsREoQVrHCS0KZ7+KT5F+daV4VKmnCqr6qfeirvCc1XS/feBnp+P9ctGjMj+PEdkaAv3JaOVpH3bROaokTiTdr4NPF73hM2jAhEo8V7IJwd6e/GlWM4X6rp1QL5cB38utbxIBqq8GYUeX021OLoQoRazXTC27Xwn3i1+w9uqA97KYfSk5pKwrv1+V9JfiHUmaQRB08+nd9hi0dDARJaiar5ipJZ6fioWTM+lqDhEFxBTz9Xfioa1HkpBfh8gW3nsQAJBYUaqUifn5ucJJCu3zZJwTynP4/CGe6g1K7uNjKCHXUx4Lnmw5wcQGCLsSEFc6Pc8QUxjuJ7R5pzvNOTC2omAAoZvHtinj+pHCG6mM4FmL0Wic8qDOLDCeyFS8zHMa2bzulFpwr2tyNcX3xGFT8= xana@blackstorm
+command="/usr/local/bin/git-upload-pack-wrapper wnemencha",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDGo9GDFyJ8RJ4F+Y4Vxl2bCl8sskRYbiSWItmoBZoYFVR2qAvUyowDY2xlDa9JaG/+zbBB6sUwUr8oC/GdSaV+zp5CTP2RxcXDW2Aup6w1/a4qiilSKORMXBWvIgjyvVvHjG0TiCfeC0PfBbXCO8FhLxP5lgTrl1kVUduM0LR4/gcH3vIrrKbORWtfAC7Bw6z3qc/X9CysPxtQZYu6+AknJ1vwUtLAH2H9cKS8uwaJ5N/k0n8Sc8ANozdpp7EyodA12nFFwvf5oPakTdm5cBnnnEIe2p+GA4nP2DyybmtIR/wttJGMs6Bmz0bXO6AfFdhcGKbzwT2qEGRX5drQj0qUI+gLSZ42/9DsGN7kr2gRDpXG2ATx+c4H2XvR3fqS1cyFq+ZmezK4l32BH/KjQMR1zfgeX2Ky46YxOLQn84PvWILmpzYPLTJ02kXFr0pjofraX2h0E/0Ke2ZBPlOUcaNZOU2dJDYn/B0GVrJ0niopvseYpVXHoTzMPYpr+ReCfAc= admin@Admins-MacBook-Pro.local
+command="/usr/local/bin/git-upload-pack-wrapper willi.wonka38",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCbpfyW60ydd3p0xQAozzB2lecE4NG9KyUbd/mRl+EPEOm4pnh3oIqbGLs42QYgKc1H8bkEEddFvnayN+JHJIwsRBSv3YMMsbTRHp6onHgsVHMaO+nNyubONwb7eLfR+5PvZ4eeOwbJpgfLwW2tVpoqLiTRpF8DFijYQePqGqQzFwWXx9niHHb3s1pqDPGjiqlEqbJPT4MyIdHo66M8SJMFL12uIUq33Wm3PRCtPDMs4FdW0fpvQpaBqu6cb71WrH8MDRC5IGT2KC3MD7flVfayUoSsdo3I/zzkom8t34Ee0b7NkQ0jvcsVves/Vz36YCZ/W7pNiKYi18B3sLIxpwI9a9k+kYt5a/5kz5izmeVbiVK2PDk45GN2b0RCfMb3uT3v5MwcJjKf5h52WYVjh0Uzj8AC4ZOtW84BAVrnX4hkBirN6oOYJB8/8pHvtAq5WSFtJnPzz1rL/mOFSsr/HS5Jo8KSsrH8uZHz2QWr6T6PDDGOjiEc44JTPOxqywUXo7Cs5TvicLhqWXdPokTmVftoG0pRpVWi9ctvGc1GRXviEWgvJZyqx/yDdtvrjKWDNjIM3jww1Blrb0R1o/0wvvHFfTf8flvW4SC7POXG0bXp/ANSGnTXIiAQk9JGmU6ntCqVebtNis60Xvbfj5gMGsEv1rhM1cyMcvIfT20ASZo6hw== aremy.dev@gmail.com
+command="/usr/local/bin/git-upload-pack-wrapper willi.wonka38",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC2gwbtNQtNTLbvyd8/B7k+dzOiYMYKfjbmr5x8DQUZr+K9367D3TzQ6wDMQBWnDu5FIKoKvAFTP3tHhV5p+3GMXqzS/2PCtzV7WHlUtJ7vVxsREoQVrHCS0KZ7+KT5F+daV4VKmnCqr6qfeirvCc1XS/feBnp+P9ctGjMj+PEdkaAv3JaOVpH3bROaokTiTdr4NPF73hM2jAhEo8V7IJwd6e/GlWM4X6rp1QL5cB38utbxIBqq8GYUeX021OLoQoRazXTC27Xwn3i1+w9uqA97KYfSk5pKwrv1+V9JfiHUmaQRB08+nd9hi0dDARJaiar5ipJZ6fioWTM+lqDhEFxBTz9Xfioa1HkpBfh8gW3nsQAJBYUaqUifn5ucJJCu3zZJwTynP4/CGe6g1K7uNjKCHXUx4Lnmw5wcQGCLsSEFc6Pc8QUxjuJ7R5pzvNOTC2omAAoZvHtinj+pHCG6mM4FmL0Wic8qDOLDCeyFS8zHMa2bzulFpwr2tyNcX3xGFT8= xana@blackstorm

new file
db/migrations/20260517001331_add_ssh_key_fingerprint_n_last_used/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable
+ALTER TABLE "UserSSHKey" ADD COLUMN     "key_fingerprint" TEXT NOT NULL DEFAULT '',
+ADD COLUMN     "lastUsedAt" TIMESTAMP(3);

@@ -150,24 +150,25 @@ model User {
 }
 
 model UserSSHKey {
-  id String @id @default(cuid())
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
-  // lastUsedAt DateTime?
+  id               String     @id @default(cuid())
+  createdAt        DateTime   @default(now())
+  updatedAt        DateTime   @updatedAt
+  lastUsedAt       DateTime?
 
-  user   User   @relation("ManyUserSSHKeyToOneUser", fields: [userId], references: [id])
-  userId String
+  user             User       @relation("ManyUserSSHKeyToOneUser", fields: [userId], references: [id])
+  userId           String
 
-  name String
-  key String
+  name             String
+  key              String
+  key_fingerprint  String     @default("")
 
-  revoked Boolean @default(false)
+  revoked          Boolean    @default(false)
 }
 
 model Pipeline {
   id        String      @id @default(cuid())
-  createdAt DateTime @default(now())
-  updatedAt DateTime @updatedAt
+  createdAt DateTime    @default(now())
+  updatedAt DateTime    @updatedAt
 
   name      String
   status    PipelineStatus @default(PENDING)

docker-compose.caddy.yml
@@ -14,6 +14,13 @@ services:
       - ./Caddyfile:/etc/caddy/Caddyfile
       - ./data/caddy/data:/data
       - ./data/caddy/config:/config
+  # anti-bot/spam (cloudflare-like open-source alternative)
+  anubis:
+    image: ghcr.io/techarohq/anubis:latest
+    # pull_policy: always
+    environment:
+      BIND: ":1337"
+      TARGET: http://gitfoss_web:1337
   db:
     restart: always
     container_name: gitfoss_db

@@ -15,24 +15,24 @@ services:
       - 5432:5432
     volumes:
       - ./data/postgres_data:/var/lib/postgresql/data
-  # web:
-  #   container_name: gitfoss_web
-  #   build:
-  #     context: .
-  #     dockerfile: Dockerfile
-  #     args:
-  #       - HOST=0.0.0.0
-  #       - PORT=1337
-  #   depends_on:
-  #     - db
-  #   ports:
-  #     - 1337:1337
-  #     - 22:22
-  #   volumes:
-  #     - ./data/gitfoss_repos:/var/lib/gitfoss/repos
-  #     - ./data/gitfoss_repos:/home/git/repos
-  #     - ./data/authorized_keys:/home/git/.ssh/authorized_keys
-  #   env_file: .env.docker
+  web:
+    container_name: gitfoss_web
+    build:
+      context: .
+      dockerfile: Dockerfile
+      args:
+        - HOST=0.0.0.0
+        - PORT=1337
+    depends_on:
+      - db
+    ports:
+      - 1337:1337
+      - 22:22
+    volumes:
+      - ./data/gitfoss_repos:/var/lib/gitfoss/repos
+      - ./data/gitfoss_repos:/home/git/repos
+      - ./data/authorized_keys:/home/git/.ssh/authorized_keys
+    env_file: .env.docker
   # environment:
   #   - COOKIE_NAME=gitfoss_ssid
   #   - COOKIE_SECRET=gitfoss-cookie-secret

@@ -55,7 +55,7 @@
     "markdown-to-jsx": "^7.1.7",
     "markdown-toc": "^1.2.0",
     "mime-db": "^1.52.0",
-    "patch-package": "^6.4.7",
+    "patch-package": "^7.0.0",
     "pg": "^8.7.3",
     "postinstall-postinstall": "^2.1.0",
     "prismjs": "^1.29.0",

new file
packages/gitfoss-ssh-command/bin/ssh_command
new file
packages/gitfoss-ssh-command/invoke-from-fake-ssh.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+SSH_ORIGINAL_COMMAND="git-receive-pack aaaa" ./ssh-command skz aaaa ffff

new file
packages/gitfoss-ssh-command/shard.lock
@@ -0,0 +1,2 @@
+version: 2.0
+shards: {}

new file
packages/gitfoss-ssh-command/shard.yml
@@ -0,0 +1,21 @@
+name: gitfoss-ssh-command
+version: 0.1.0
+
+authors:
+  - Willi Wonka <willi.wonka38@proton.me>
+
+targets:
+  ssh_command:
+    main: src/ssh-command.cr
+
+crystal: ">= 1.20.0"
+
+license: MIT
+
+# dependencies:
+#   crystar:
+#     github: naqvis/crystar
+#   docker:
+#     # github: aca-labs/crystal-docker
+#     git: https://gitfoss.dev/ethicdevs/crystal-docker-api.git
+#     tag: 0.4.5

new file
packages/gitfoss-ssh-command/src/fetch.cr
@@ -0,0 +1,354 @@
+require "http"
+require "http/client"
+require "openssl"
+
+module Fetch
+  private def self.fetch(
+    method : String,
+    url : String,
+    query : URI::Params? = URI::Params.new,
+    headers : HTTP::Headers? = HTTP::Headers.new,
+    body : String? = nil,
+    max_hops : Int32? = 10,
+  ) : HTTP::Client::Response
+    context = OpenSSL::SSL::Context::Client.new
+    context.verify_mode = OpenSSL::SSL::VerifyMode::NONE # re-enable in prod
+    uri = URI.parse(url)
+    uri.query_params = query unless query.nil?
+    body = "" if body.nil?
+    headers_hash = HTTP::Headers.new if headers.nil?
+    headers_hash = headers.clone unless headers.nil?
+    hops = 0
+    max_hops = max_hops.nil? ? 10 : max_hops
+    loop do
+      raise "too many redirects" if hops >= (max_hops.to_i || 10)
+      hops += 1
+      begin
+        headers ||= HTTP::Headers.new
+        headers_hash ||= HTTP::Headers.new
+        headers_hash.each { |k, v| headers[k] = v }
+        headers["Host"] = uri.host.not_nil! unless uri.host.nil?
+        headers["Content-Type"] = "application/json" if method == "POST"
+        resp = case method.upcase
+        when "POST"
+          HTTP::Client.post(uri.to_s, headers: headers, body: body, tls: context)
+        when "PUT"
+          HTTP::Client.put(uri.to_s, headers: headers, body: body, tls: context)
+        when "DELETE"
+          HTTP::Client.delete(uri.to_s, headers: headers, tls: context)
+        when "OPTIONS"
+          HTTP::Client.options(uri.to_s, headers: headers, tls: context)
+        when "HEAD"
+          HTTP::Client.head(uri.to_s, headers: headers, tls: context)
+        else
+          HTTP::Client.get(uri.to_s, headers: headers, tls: context)
+        end
+        # pp uri.to_s
+        # pp headers
+        # pp body
+        # puts "[redirect #{hops}] #{method} #{uri.to_s} -> #{resp.status_code} Location=#{resp.headers["Location"]?}"
+        # pp resp
+        # pp resp.headers.to_h
+        # not a redirect -> return
+        if resp && !(300..399).includes?(resp.status_code)
+          return resp
+        end
+        # redirect handling
+        loc = resp.headers["Location"]? || resp.headers["location"]? || ""
+        raise "redirect with no Location header" if loc.empty?
+        loc = self.normalize_location(uri, loc)
+        new_uri = URI.parse(loc)
+        if self.same_origin?(self.canonical_uri(new_uri), self.canonical_uri(uri))
+          # treat as final to avoid loop
+          return resp
+        end
+        # decide method change: RFC practice -> 303 always GET;
+        # many clients also convert 301/302 for non-GET/HEAD to GET
+        if resp.status_code == 303 || (
+          (resp.status_code == 301 || resp.status_code == 302) &&
+          method != "GET" && method != "HEAD"
+        )
+          method = "GET"
+          body = nil
+          # drop content headers
+          headers_hash.delete("Content-Length")
+          headers_hash.delete("Content-Type")
+        end
+        # drop Authorization on cross-origin redirects
+        unless same_origin?(uri, new_uri)
+          headers_hash.delete("Authorization")
+        end
+        uri = new_uri
+        return resp
+      rescue ex : Exception
+        puts ex.message
+        puts ex.backtrace
+        raise ex
+      end
+    end
+  end
+
+  private def self.normalize_location(base : URI, loc : String) : String
+    begin
+      # absolute URI
+      URI.parse(loc).to_s
+    rescue
+      # relative -> build a new URI by resolving path/query against base
+      # ensure base.path ends with '/' if needed for relative resolution
+      base_path = base.path.empty? ? "/" : base.path
+      # If loc starts with '/', it's absolute path on same origin
+      resolved_path = if loc.starts_with?("/")
+        loc
+      else
+        # join base path directory with relative segment
+        dir = base_path.ends_with?("/") ? base_path : File.dirname(base_path)
+        File.join(dir, loc)
+      end
+
+      # construct new URI preserving scheme, host, port
+      uri = URI.new("#{base.scheme}://#{base.host}#{base.port && base.port != 80 && base.port != 443 ? ":#{base.port}" : ""}#{resolved_path}")
+      uri.to_s
+    end
+  end
+
+  private def self.same_origin?(a : URI, b : URI) : Bool
+    a.scheme == b.scheme && a.host == b.host && a.port == b.port
+  end
+
+  private def self.canonical(u : URI) : String
+    scheme = u.scheme.not_nil!.downcase
+    host = u.host.not_nil!.downcase
+    port = u.port
+    if (scheme == "http" && port == 80) || (scheme == "https" && port == 443)
+      port = nil
+    end
+    path = u.path.empty? ? "/" : File.expand_path(u.path, "/")
+    "#{scheme}://#{host}#{port ? ":#{port}" : ""}#{path}#{u.query ? "?#{u.query}" : ""}"
+  end
+
+  private def self.canonical_uri(u : URI) : URI
+    URI.parse(self.canonical(u))
+  end
+
+  {% for method in %w(GET POST PUT DELETE OPTIONS HEAD) %}
+  def self.{{method.id.downcase}}(
+    url : String,
+    query : URI::Params? = URI::Params.new,
+    headers : HTTP::Headers? = HTTP::Headers.new,
+    body : String? = nil,
+    max_redirects : Int32? = 10,
+  )
+    self.fetch(
+      method: {{method.downcase}},
+      url: url,
+      query: query,
+      headers: headers,
+      body: body,
+      max_redirects: max_redirects,
+    )
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      query: URI::Params,
+      headers: HTTP::Headers,
+      body: String,
+      max_redirects: Int32,
+    ),
+  )
+    self.fetch(
+      method: {{method.downcase}},
+      url: url,
+      query: args[:query],
+      headers: args[:headers],
+      body: args[:body],
+      max_redirects: args[:max_redirects],
+    )
+  end
+
+  def self.{{method.id.downcase}}(url : String)
+    self.fetch({{method.downcase}}, url, nil, nil, nil, nil)
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      query: URI::Params,
+    ),
+  )
+    self.fetch({{method.downcase}}, url, args[:query], nil, nil, nil)
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      headers: HTTP::Headers,
+    ),
+  )
+    self.fetch({{method.downcase}}, url, nil, args[:headers], nil, nil)
+  end
+
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      query: URI::Params,
+      headers: HTTP::Headers,
+    ),
+  )
+    self.fetch({{method.downcase}}, url, args[:query], args[:headers], nil, nil)
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      query: URI::Params,
+      body: String,
+    ),
+  )
+    self.fetch({{method.downcase}}, url, args[:query], nil, args[:body], nil)
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      query: URI::Params,
+      headers: HTTP::Headers,
+      body: String,
+    ),
+  )
+    self.fetch({{method.downcase}}, url, args[:query], args[:headers], args[:body], nil)
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      headers: HTTP::Headers,
+      body: String,
+    ),
+  )
+    self.fetch({{method.downcase}}, url, nil, args[:headers], args[:body], nil)
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      query: URI::Params,
+      headers: HTTP::Headers,
+      body: String,
+      max_redirects: Int32,
+    ),
+  )
+    self.fetch({{method.downcase}}, url, args[:query], args[:headers], args[:body], args[:max_redirects])
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      query: Hash(String, String),
+      headers: Hash(String, String),
+      body: Hash(String, String),
+    ),
+  )
+    query = URI::Params.build do |p|
+      args[:query].each do |k, v|
+        p[k] = v
+      end
+    end
+    headers = HTTP::Headers.new
+    args[:headers].each do |k, v|
+      headers[k] = v
+    end
+    body = args[:body].to_json.to_s
+    self.fetch({{method.downcase}}, url, query, headers, body, nil)
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      headers: Hash(String, String),
+      body: Hash(String, String),
+    ),
+  )
+    headers = HTTP::Headers.new
+    args[:headers].each do |k, v|
+      headers[k] = v
+    end
+    body = args[:body].to_json.to_s
+    self.fetch({{method.downcase}}, url, nil, headers, body, nil)
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      query: Hash(String, String),
+      body: Hash(String, String),
+    ),
+  )
+    query = URI::Params.build do |p|
+      args[:query].each do |k, v|
+        p[k] = v
+      end
+    end
+    body = args[:body].to_json.to_s
+    self.fetch({{method.downcase}}, url, query, nil, body, nil)
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      query: Hash(String, String),
+      headers: Hash(String, String),
+    ),
+  )
+    query = URI::Params.build do |p|
+      args[:query].each do |k, v|
+        p[k] = v
+      end
+    end
+    headers = HTTP::Headers.new
+    args[:headers].each do |k, v|
+      headers[k] = v
+    end
+    self.fetch({{method.downcase}}, url, query, headers, nil, nil)
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      query: Hash(String, String),
+    ),
+  )
+    query = URI::Params.build do |p|
+      args[:query].each do |k, v|
+        p[k] = v
+      end
+    end
+    self.fetch({{method.downcase}}, url, query, nil, nil, nil)
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      headers: Hash(String, String),
+    ),
+  )
+    headers = HTTP::Headers.new
+    args[:headers].each do |k, v|
+      headers[k] = v
+    end
+    self.fetch({{method.downcase}}, url, nil, headers, nil, nil)
+  end
+
+  def self.{{method.id.downcase}}(
+    url : String,
+    args : NamedTuple(
+      body: Hash(String, String),
+    ),
+  )
+    body = args[:body].to_json.to_s
+    self.fetch({{method.downcase}}, url, nil, nil, body, nil)
+  end
+  {% end %}
+end

new file
packages/gitfoss-ssh-command/src/ssh-command.cr
@@ -0,0 +1,459 @@
+#!/usr/bin/env crystal
+# ssh-key-wrapper-crystal
+#
+# - Uses only stdlib
+# - Args: argv[0]=user argv[1]=key_blob argv[2]=fingerprint (fingerprint optional)
+# - Validates key via HTTP POST to /auth/ssh
+# - For git-receive-pack (push): scans incoming pack for secrets, triggers pipelines, enforces policy
+# - For git-upload-pack / git-upload-archive (fetch/pull/clone): enforces auth and allows fetch (no server-side pack scanning)
+# - Build: crystal build --release ssh-key-wrapper-crystal.cr -o /usr/local/bin/ssh-key-wrapper-crystal
+require "file_utils"
+require "json"
+require "process"
+require "uri"
+require "io"
+
+require "./fetch"
+
+# ---------- Config ----------
+MAX_PACK_BYTES = 50 * 1024 * 1024  # 50 MiB
+CHUNK_SIZE = 64 * 1024             # 64 KiB
+OVERLAP = 4096                     # 4 KiB overlap
+MAX_MATCHES = 500
+LOG_FILE = "/var/log/gitfoss/git_ssh.log"
+
+PATTERNS = [
+  /AKIA[0-9A-Z]{16}/,
+  /AIza[0-9A-Za-z\-_]{35}/,
+  /-----BEGIN PRIVATE KEY-----/,
+  /password\s*[:=]\s*[^ \t\r\n]{6,}/i
+]
+
+# prefix
+SIDEBAND_PREFIX = "remote: "
+
+# ---------- Helpers ----------
+def write_to_file(msg : String)
+  File.write(filename: LOG_FILE, content: "#{msg}\n", encoding: "utf-8")
+end
+
+def die_with_message(msg : String, code = 1)
+  txt = "#{SIDEBAND_PREFIX}#{msg}"
+  STDOUT.puts txt
+  write_to_file txt
+  exit(code)
+end
+
+def sideband_println(msg : String)
+  txt = "#{SIDEBAND_PREFIX}#{msg}"
+  STDOUT.puts txt
+  write_to_file txt
+end
+
+def bytes_to_utf8_str(buf : Bytes) : String
+  String.new(buf) rescue buf.map(&.chr).join
+end
+
+def compute_line_col(text : String, index_in_bytes : Int32)
+  line = 1
+  col = 1
+  bcount = 0
+  text.each_codepoint do |cp|
+    ch = cp.chr  # returns a one-char String
+    bytes = ch.bytesize
+    break if bcount + bytes > index_in_bytes
+    if cp == 10
+      line += 1
+      col = 1
+    else
+      col += 1
+    end
+    bcount += bytes
+  end
+  {line: line, column: col}
+end
+
+def run_cmd_capture(cmd : Array(String), cwd = nil)
+  output_io = IO::Memory.new
+  error_io = IO::Memory.new
+  process = Process.run(
+    command: cmd[0],
+    args: cmd[1..-1],
+    chdir: cwd,
+    output: output_io,
+    error: error_io,
+  )
+  out = output_io.gets_to_end
+  err = error_io.gets_to_end
+  code = process.exit_code
+  { code: code, out: out, err: err }
+end
+
+def exec_replace(cmd : Array(String))
+  Process.exec(cmd[0], cmd[1..-1])
+end
+
+# Extract repo path (org/repo.git) from SSH_ORIGINAL_COMMAND argument
+def extract_org_repo(original_cmd : String)
+  arg = nil
+  if original_cmd.includes?(" ")
+    parts = original_cmd.split(" ", 2)
+    arg = parts[1].strip
+    if (arg.starts_with?("'") && arg.ends_with?("'")) || (arg.starts_with?("\"") && arg.ends_with?("\""))
+      arg = arg[1..-2]
+    end
+  end
+  return {org: nil, repo: nil, host: nil} if arg.nil? || arg.empty?
+
+  # If URL
+  begin
+    if arg.starts_with?("http://") || arg.starts_with?("https://")
+      u = URI.parse(arg)
+      segs = u.path.split("/").reject &.empty?
+      if segs.size >= 2
+        org = segs[-2]
+        repo = segs[-1]
+        repo = repo[0, repo.size - 4] if repo.ends_with?(".git")
+        host = "#{u.scheme}://#{u.host}"
+        return {org: org, repo: repo, host: host}
+      end
+    end
+  rescue
+  end
+
+  # If filesystem or bare "org/repo.git" or "/srv/git/org/repo.git"
+  segs = arg.split("/").reject &.empty?
+  if segs.size >= 2
+    org = segs[-2]
+    repo = segs[-1]
+    repo = repo[0, repo.size - 4] if repo.ends_with?(".git")
+    return {org: org, repo: repo, host: nil}
+  end
+
+  {org: nil, repo: nil, host: nil}
+end
+
+# Parse pkt-line data into lines (simple)
+def parse_pkt_lines(data : Bytes) : Array(String)
+  lines = [] of String
+  i = 0
+  while i < data.size
+    break if i + 4 > data.size
+    len_hex = data[i,4].to_s
+    if len_hex == "0000"
+      i += 4
+      next
+    end
+    len = len_hex.to_i(16)
+    break if len < 4
+    payload_len = len - 4
+    break if i + 4 + payload_len > data.size
+    payload = data[i+4, payload_len].to_s
+    lines << payload
+    i += 4 + payload_len
+  end
+  lines
+end
+
+# ---------- Step 0: get params ----------
+# Endpoint base for auth and pipeline trigger (adjust as needed)
+AUTH_URL = "https://gitfoss.dev/_ssh/auth"
+TRIGGER_BASE = "https://gitfoss.dev"  # base host for pipeline triggers
+
+# ---------- Step 1: gather inputs ----------
+if ARGV.size < 3
+  die_with_message "usage: ssh-command <user> <key_blob> [fingerprint]\n", 2
+end
+
+user = ARGV[0]
+key_blob = ARGV[1]
+fingerprint = ARGV[2] rescue nil
+
+if ENV["SSH_ORIGINAL_COMMAND"]?.nil?
+  die_with_message "Script has not been invoked by ssh. Missing environment variable SSH_ORIGINAL_COMMAND.\n", 2
+end
+
+SSH_ORIGINAL_COMMAND = ENV["SSH_ORIGINAL_COMMAND"]? || ""
+
+if user.nil? || key_blob.nil?
+  die_with_message "usage: ssh-key-wrapper <user> <key_blob> [fingerprint]", 2
+end
+
+# ---------- Step 2: validate via HTTP POST (auth) ----------
+begin
+  parsed = URI.parse(AUTH_URL)
+  info = extract_org_repo(SSH_ORIGINAL_COMMAND)
+  resp = Fetch.post(AUTH_URL.to_s, {
+    headers: {
+      "Content-Type" => "application/json",
+      "Accept" => "application/json",
+    },
+    body: {
+      "command" => SSH_ORIGINAL_COMMAND,
+      "username" => user,
+      "repoSlug" => "#{info[:org]}/#{info[:repo]}.git",
+      "publicKey" => key_blob,
+      "publicKeyFingerprint" => fingerprint || "",
+    },
+  })
+
+  if resp.status != HTTP::Status::OK
+    die_with_message("Key validation failed (auth service returned #{resp.status_code})")
+  # end
+  # if j.is_a?(Hash) && j["success"]?.try(&.as_bool) == true
+    # authorized
+  else
+    # j = JSON.parse(resp.body) rescue nil
+    # pp j
+    # authorized
+    # todo: handle authMode
+    # todo: set repoDir => j["gitRepositoryDir"]
+    # todo: check command hasn't been tampered
+  end
+rescue ex
+  die_with_message("Key validation error: #{ex.message}")
+end
+
+# ---------- Step 3: dispatch by SSH_ORIGINAL_COMMAND ----------
+if SSH_ORIGINAL_COMMAND.starts_with?("git-receive-pack")
+  mode = :receive
+elsif SSH_ORIGINAL_COMMAND.starts_with?("git-upload-pack") || SSH_ORIGINAL_COMMAND.starts_with?("git-upload-archive")
+  mode = :upload
+else
+  die_with_message("Unsupported command: only git-receive-pack, git-upload-pack, git-upload-archive are allowed")
+end
+
+# For upload (fetch/pull), just exec the original command (auth already done).
+# if mode == :upload
+sideband_println("Auth OK; allowing command (#{SSH_ORIGINAL_COMMAND.split(' ')[0]})")
+exec_replace(["/bin/sh", "-c", SSH_ORIGINAL_COMMAND])
+# end
+
+# ---------- From here: mode == :receive (push) ----------
+# Read stdin fully into memory (preamble + pack) but enforce size limit
+tmpdir = Dir.tempdir #("gitpack-")
+raw = "".to_slice # Bytes.new
+total = 0
+begin
+  loop do
+    buf = Bytes.new(CHUNK_SIZE)
+    slice = buf[0, CHUNK_SIZE]
+    read = STDIN.read(slice) rescue 0
+    break if read == 0
+    total += read
+    if total > MAX_PACK_BYTES
+      FileUtils.rm_rf(tmpdir) rescue nil
+      die_with_message("Pack exceeds limit of #{MAX_PACK_BYTES} bytes; rejecting push")
+    end
+    raw += buf[0, read]
+  end
+rescue ex
+  FileUtils.rm_rf(tmpdir) rescue nil
+  die_with_message("Error reading stdin: #{ex.message}")
+end
+
+# Split preamble (pkt-lines) and pack data by locating "PACK" header
+pack_sig = "PACK".to_slice.as(Bytes)
+pack_index = raw.index(pack_sig) || -1
+pre_bytes = raw[0, pack_index] if pack_index >= 0
+pre_bytes ||= raw
+pack_bytes = raw[pack_index, raw.size - pack_index] if pack_index >= 0
+pack_bytes ||= "".to_slice
+
+# Parse pkt-lines to get ref updates
+pkt_lines = parse_pkt_lines(pre_bytes)
+ref_updates = [] of Hash(String, String)
+pkt_lines.each do |line|
+  next if line.nil? || line.empty?
+  if line.includes?("\0")
+    line = line.split("\0", 2)[0]
+  end
+  parts = line.split(" ")
+  if parts.size >= 3 && parts[0] =~ /^[0-9a-f]{40}$/ && parts[1] =~ /^[0-9a-f]{40}$/
+    old_sha = parts[0]
+    new_sha = parts[1]
+    refname = parts[2].strip
+    ref_updates << {"old" => old_sha, "new" => new_sha, "ref" => refname}
+  end
+end
+
+pushed_refs = [] of Hash(String, String)
+ref_updates.each do |r|
+  ref = r["ref"].as(String)
+  if ref.starts_with?("refs/heads/")
+    pushed_refs << {
+      "type" => "branch",
+      "name" => ref["refs/heads/".size .. -1],
+      "new" => r["new"],
+    }
+  elsif ref.starts_with?("refs/tags/")
+    pushed_refs << {
+      "type" => "tag",
+      "name" => ref["refs/tags/".size .. -1],
+      "new" => r["new"],
+    }
+  else
+    pushed_refs << {
+      "type" => "other",
+      "name" => ref,
+      "new" => r["new"],
+    }
+  end
+end
+
+# Write pack bytes to file (if any)
+pack_path = File.join(tmpdir, "incoming.pack")
+File.open(pack_path, "w") do |f|
+  f.write(pack_bytes) if pack_bytes.size > 0
+end
+
+# If pack exists, index/unpack into temp bare repo and scan blobs
+detections = [] of Hash(String, String | Int32)
+
+if File.size(pack_path).to_i > 0
+  repo_dir = File.join(tmpdir, "repo.git")
+  if !Dir.exists?(repo_dir)
+    run_cmd_capture(["git", "init", "--bare", repo_dir])
+  end
+
+  pack_dest_dir = File.join(repo_dir, "objects", "pack")
+  FileUtils.mkdir_p(pack_dest_dir)
+  pack_dest = File.join(pack_dest_dir, "incoming.pack")
+  FileUtils.mv(pack_path, pack_dest) rescue FileUtils.cp(pack_path, pack_dest)
+
+  index_res = run_cmd_capture(["git", "index-pack", "--stdin"], repo_dir)
+  if index_res[:code] != 0
+    index_res = run_cmd_capture(["git", "index-pack", pack_dest], repo_dir)
+    if index_res[:code] != 0
+      FileUtils.rm_rf(tmpdir) rescue nil
+      die_with_message("git index-pack failed: #{index_res[:err]}")
+    end
+  end
+
+  idx_file = Dir.glob(File.join(pack_dest_dir, "*.idx"))[0] rescue nil
+  unless idx_file
+    FileUtils.rm_rf(tmpdir) rescue nil
+    die_with_message("git index-pack did not produce .idx; aborting")
+  end
+
+  verify = run_cmd_capture(["git", "verify-pack", "-v", idx_file], repo_dir)
+  if verify[:code] != 0
+    FileUtils.rm_rf(tmpdir) rescue nil
+    die_with_message("git verify-pack failed: #{verify[:err]}")
+  end
+
+  blob_shas = [] of String
+  verify[:out].each_line do |l|
+    l = l.chomp
+    next if l.empty?
+    parts = l.split
+    sha = parts[0]
+    next unless sha && sha =~ /^[0-9a-f]{40}$/
+    t = run_cmd_capture(["git", "cat-file", "-t", sha], repo_dir)
+    next if t[:code] != 0
+    typ = t[:out].strip
+    if typ == "blob"
+      blob_shas << sha
+    end
+  end
+
+  blob_shas.each do |sha|
+    break if detections.size >= MAX_MATCHES
+    cat = run_cmd_capture(["git", "cat-file", "-p", sha], repo_dir)
+    next if cat[:code] != 0
+    blob_text = bytes_to_utf8_str(cat[:out].to_slice)
+
+    PATTERNS.each do |pat|
+      start = 0
+      while m = pat.match(blob_text, start)
+        match_str = m[0]
+        prefix = blob_text[0, m.begin(0)]
+        byte_offset = prefix.bytesize
+        lc = compute_line_col(blob_text, byte_offset)
+
+        paths = [] of String
+        revs = run_cmd_capture(["git", "rev-list", "--all"], repo_dir)
+        if revs[:code] == 0
+          revs[:out].each_line do |commit|
+            commit = commit.strip
+            next if commit.empty?
+            ls = run_cmd_capture(["git", "ls-tree", "-r", "--full-tree", commit], repo_dir)
+            next if ls[:code] != 0
+            ls[:out].each_line do |line|
+              if m2 = line.match(/^\d+\s+blob\s+([0-9a-f]{40})\t(.+)$/)
+                if m2[1] == sha
+                  paths << m2[2]
+                end
+              end
+            end
+            break if !paths.empty?
+          end
+        end
+
+        detections << {
+          "pattern" => pat.to_s,
+          "blob" => sha,
+          "paths" => paths.join(", "),
+          "line" => lc[:line],
+          "column" => lc[:column],
+          "excerpt" => match_str
+        }
+
+        start = m.end(0)
+        break if detections.size >= MAX_MATCHES
+      end
+      break if detections.size >= MAX_MATCHES
+    end
+  end
+end
+
+# ---------- Decision ----------
+if detections.size > 0
+  sideband_println("Push rejected: #{detections.size} secret(s) detected in incoming pack")
+  detections.each_with_index do |d, i|
+    sideband_println("Match #{i+1}: pattern=#{d["pattern"]} blob=#{d["blob"]} paths=#{d["paths"]} line=#{d["line"]} col=#{d["column"]}")
+    sideband_println("Excerpt: #{d["excerpt"]}")
+  end
+  FileUtils.rm_rf(tmpdir) rescue nil
+  exit 1
+end
+
+# ---------- Trigger pipelines (only when auth OK and no detections) ----------
+orgrepo = extract_org_repo(SSH_ORIGINAL_COMMAND)
+org = orgrepo[:org]
+repo = orgrepo[:repo]
+host = orgrepo[:host]
+
+if org.nil? || repo.nil?
+  sideband_println("Warning: could not determine org/repo from SSH_ORIGINAL_COMMAND; skipping pipeline triggers")
+else
+  pushed_refs.each do |pr|
+    next unless pr["type"] == "branch" || pr["type"] == "tag"
+    commit = pr["new"].as(String)
+    branch = pr["type"] == "branch" ? (pr["name"].as(String)) : nil
+    tag = pr["type"] == "tag" ? (pr["name"].as(String)) : nil
+    next if commit.nil? || commit.strip.empty? || commit == ("0" * 40)
+
+    path = "/#{org}/#{repo}/pipeline/trigger"
+    params = URI::Params.build do |p|
+      p.add "commit", URI.encode_path(commit)
+      p.add "branch", URI.encode_path(branch) if branch
+      p.add "tag", URI.encode_path(tag) if tag
+      p.add "trigger_username", URI.encode_path(user)
+      p.add "trigger_pubkey", URI.encode_path(key_blob)
+      p.add "trigger_fingerprint", URI.encode_path(fingerprint || "")
+    end
+    url = "#{(host || TRIGGER_BASE)}#{path}?#{params.to_s}"
+
+    begin
+      res = Fetch.post(url)
+      sideband_println("Pipeline trigger for #{pr["type"]}: HTTP #{res.status_code}")
+    rescue ex
+      sideband_println("Pipeline trigger error: #{ex.message}")
+    end
+  end
+end
+
+FileUtils.rm_rf(tmpdir) rescue nil
+sideband_println("No secrets detected; allowing push")
+exec_replace(["/bin/sh", "-c", SSH_ORIGINAL_COMMAND])

new file
packages/gitfoss-ssh-command/ssh-command
new file
patches/@ethicdevs+react-monolith+1.10.0-dev.05.patch
@@ -0,0 +1,30 @@
+diff --git a/node_modules/@ethicdevs/react-monolith/dist/components/Router.js b/node_modules/@ethicdevs/react-monolith/dist/components/Router.js
+index 3bb6f12..c26dc2a 100644
+--- a/node_modules/@ethicdevs/react-monolith/dist/components/Router.js
++++ b/node_modules/@ethicdevs/react-monolith/dist/components/Router.js
+@@ -54,8 +54,8 @@ function Root({ children, }) {
+     setLocation;
+     const pathPattern = new routing_1.Path("/:orgSlug/:repoSlug/:currentRef/tree/*");
+     const pathRealUrl = new routing_1.Path("/ethicdevs/gitfoss/main/tree/app/views/HomeView.tsx");
+-    console.log("pathPattern:", pathPattern);
+-    console.log("pathRealUrl:", pathRealUrl);
++    // console.log("pathPattern:", pathPattern);
++    // console.log("pathRealUrl:", pathRealUrl);
+     const addRoute = (route) => {
+         // console.log("addRoute called", route);
+         if (route.path == null)
+diff --git a/node_modules/@ethicdevs/react-monolith/src/components/Router.tsx b/node_modules/@ethicdevs/react-monolith/src/components/Router.tsx
+index 26c710f..2d3c232 100644
+--- a/node_modules/@ethicdevs/react-monolith/src/components/Router.tsx
++++ b/node_modules/@ethicdevs/react-monolith/src/components/Router.tsx
+@@ -83,8 +83,8 @@ function Root<RoutesParams extends IRouteParams>({
+     "/ethicdevs/gitfoss/main/tree/app/views/HomeView.tsx",
+   );
+ 
+-  console.log("pathPattern:", pathPattern);
+-  console.log("pathRealUrl:", pathRealUrl);
++  // console.log("pathPattern:", pathPattern);
++  // console.log("pathRealUrl:", pathRealUrl);
+ 
+   const addRoute: RouterContext["addRoute"] = (route) => {
+     // console.log("addRoute called", route);

@@ -1290,6 +1290,11 @@ asynckit@^0.4.0:
   version "0.4.0"
   resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
 
+at-least-node@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2"
+  integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==
+
 atomic-sleep@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz#eb85b77a601fc932cfe432c5acd364a9e2c9075b"

...
@@ -1485,7 +1490,7 @@ caniuse-lite@^1.0.30001359:
   version "1.0.30001361"
   resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001361.tgz#ba2adb2527566fb96f3ac7c67698ae7fc495a28d"
 
-chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2:
+chalk@^2.0.0, chalk@^2.4.1:
   version "2.4.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424"
   dependencies:

...
@@ -1493,9 +1498,10 @@ chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2:
     escape-string-regexp "^1.0.5"
     supports-color "^5.3.0"
 
-chalk@^4.0.0:
+chalk@^4.0.0, chalk@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
+  integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
   dependencies:
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"

...
@@ -1518,14 +1524,15 @@ chokidar@^3.5.1:
   optionalDependencies:
     fsevents "~2.3.2"
 
-ci-info@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46"
-
 ci-info@^3.2.0:
   version "3.3.2"
   resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.3.2.tgz#6d2967ffa407466481c6c90b6e16b3098f080128"
 
+ci-info@^3.7.0:
+  version "3.9.0"
+  resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4"
+  integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==
+
 cjs-module-lexer@^1.0.0:
   version "1.2.2"
   resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40"

...
@@ -2368,13 +2375,15 @@ fresh@0.5.2:
   version "0.5.2"
   resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
 
-fs-extra@^7.0.1:
-  version "7.0.1"
-  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-7.0.1.tgz#4f189c44aa123b895f722804f55ea23eadc348e9"
+fs-extra@^9.0.0:
+  version "9.1.0"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d"
+  integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==
   dependencies:
-    graceful-fs "^4.1.2"
-    jsonfile "^4.0.0"
-    universalify "^0.1.0"
+    at-least-node "^1.0.0"
+    graceful-fs "^4.2.0"
+    jsonfile "^6.0.1"
+    universalify "^2.0.0"
 
 fs.realpath@^1.0.0:
   version "1.0.0"

...
@@ -2567,6 +2576,11 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.9:
   version "4.2.10"
   resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c"
 
+graceful-fs@^4.2.0:
+  version "4.2.11"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
+  integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
+
 gray-matter@^2.1.0:
   version "2.1.1"
   resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-2.1.1.tgz#3042d9adec2a1ded6a7707a9ed2380f8a17a430e"

...
@@ -2789,12 +2803,6 @@ is-callable@^1.1.4, is-callable@^1.2.4:
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945"
 
-is-ci@^2.0.0:
-  version "2.0.0"
-  resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c"
-  dependencies:
-    ci-info "^2.0.0"
-
 is-core-module@^2.9.0:
   version "2.9.0"
   resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69"

...
@@ -3483,9 +3491,12 @@ json5@2.x, json5@^2.2.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c"
 
-jsonfile@^4.0.0:
-  version "4.0.0"
-  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
+jsonfile@^6.0.1:
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.2.1.tgz#b6e31717f22cc37330b081ce0051ed5de53af2f6"
+  integrity sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==
+  dependencies:
+    universalify "^2.0.0"
   optionalDependencies:
     graceful-fs "^4.1.6"
 

...
@@ -4055,23 +4066,25 @@ parse5@6.0.1:
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
 
-patch-package@^6.4.7:
-  version "6.4.7"
-  resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-6.4.7.tgz#2282d53c397909a0d9ef92dae3fdeb558382b148"
+patch-package@^7.0.0:
+  version "7.0.2"
+  resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-7.0.2.tgz#c01589bb6964854b5210506a5845d47900641f5a"
+  integrity sha512-PMYfL8LXxGIRmxXLqlEaBxzKPu7/SdP13ld6GSfAUJUZRmBDPp8chZs0dpzaAFn9TSPnFiMwkC6PJt6pBiAl8Q==
   dependencies:
     "@yarnpkg/lockfile" "^1.1.0"
-    chalk "^2.4.2"
-    cross-spawn "^6.0.5"
+    chalk "^4.1.2"
+    ci-info "^3.7.0"
+    cross-spawn "^7.0.3"
     find-yarn-workspace-root "^2.0.0"
-    fs-extra "^7.0.1"
-    is-ci "^2.0.0"
+    fs-extra "^9.0.0"
     klaw-sync "^6.0.0"
-    minimist "^1.2.0"
+    minimist "^1.2.6"
     open "^7.4.2"
     rimraf "^2.6.3"
-    semver "^5.6.0"
+    semver "^7.5.3"
     slash "^2.0.0"
     tmp "^0.0.33"
+    yaml "^2.2.2"
 
 path-exists@^4.0.0:
   version "4.0.0"

...
@@ -4580,6 +4593,11 @@ semver@^7.3.2:
   dependencies:
     lru-cache "^6.0.0"
 
+semver@^7.5.3:
+  version "7.8.0"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.0.tgz#ed0661039fcbcda2ce71f01fa6adbefaa77040df"
+  integrity sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==
+
 send@^0.17.1:
   version "0.17.2"
   resolved "https://registry.yarnpkg.com/send/-/send-0.17.2.tgz#926622f76601c41808012c8bf1688fe3906f7820"

...
@@ -5145,10 +5163,15 @@ underscore@>=1.1.0:
   version "1.13.4"
   resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.4.tgz#7886b46bbdf07f768e0052f1828e1dcab40c0dee"
 
-universalify@^0.1.0, universalify@^0.1.2:
+universalify@^0.1.2:
   version "0.1.2"
   resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
 
+universalify@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d"
+  integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==
+
 update-browserslist-db@^1.0.4:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.4.tgz#dbfc5a789caa26b1db8990796c2c8ebbce304824"

...
@@ -5338,6 +5361,11 @@ yallist@^2.0.0:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
 
+yaml@^2.2.2:
+  version "2.9.0"
+  resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.9.0.tgz#78274afd93598a1dfdd6130df6a566defcbf9aa4"
+  integrity sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==
+
 yargs-parser@20.x, yargs-parser@^20.2.2:
   version "20.2.9"
   resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee"