diff --git a/.circleci/config.yml b/.circleci/config.yml index 92d8cf71..a0869810 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -118,7 +118,8 @@ jobs: command: | mkdir -p /tmp/test-results docker-compose -f .circleci/compose-unit.yml exec rails-unit mkdir /tmp/test-results - docker-compose -f .circleci/compose-unit.yml exec rails-unit rails db:test:prepare + docker-compose -f .circleci/compose-unit.yml exec rails-unit rails db:test:prepare + docker-compose -f .circleci/compose-unit.yml logs rails-unit - run: name: request to start recording video diff --git a/.gitignore b/.gitignore index 18b43c9c..2caa4459 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ # Ignore master key for decrypting credentials and more. /config/master.key + +.DS_Store \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml index c1de4f67..acdedd31 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -37,3 +37,8 @@ Layout/LineLength: Style/SignalException: Exclude: - 'Dangerfile' + +# We don't want this +Rails/ApplicationRecord: + Exclude: + - 'app/models/paper_trail/version.rb' \ No newline at end of file diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index f8303ee1..c20ce29f 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,19 +1,51 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2024-05-26 05:10:14 UTC using RuboCop version 1.64.0. +# on 2024-06-12 14:52:30 UTC using RuboCop version 1.64.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. +Metrics/AbcSize: + Max: 21 + +# Offense count: 1 +# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns. +Metrics/MethodLength: + Max: 20 + +# Offense count: 4 +RSpec/AnyInstance: + Exclude: + - 'spec/redis_connections/application_redis_connection_spec.rb' + # Offense count: 1 # Configuration parameters: IgnoredMetadata. RSpec/DescribeClass: Exclude: - 'spec/browser/restricted_area_spec.rb' -# Offense count: 1 -# This cop supports unsafe autocorrection (--autocorrect-all). -Rails/ApplicationRecord: +# Offense count: 6 +# Configuration parameters: CountAsOne. +RSpec/ExampleLength: + Max: 20 + +# Offense count: 2 +# Configuration parameters: EnforcedStyle. +# SupportedStyles: have_received, receive +RSpec/MessageSpies: + Exclude: + - 'spec/redis_connections/application_redis_connection_spec.rb' + +# Offense count: 2 +RSpec/MultipleExpectations: + Max: 2 + +# Offense count: 8 +# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. +RSpec/VerifiedDoubles: Exclude: - - 'app/models/paper_trail/version.rb' + - 'spec/redis_connections/application_redis_connection_spec.rb' + - 'spec/services/async_redis_service_spec.rb' diff --git a/Dockerfile b/Dockerfile index 257aae4e..7e71240f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,4 +39,4 @@ EXPOSE 3000 ENV RAILS_ENV=development -CMD ["sh", "-c", "rails s -b 0.0.0.0 -p 3000"] +CMD ["sh", "-c", "rails s -u falcon -b 0.0.0.0 -p 3000"] diff --git a/Gemfile b/Gemfile index a3c76804..f607786a 100644 --- a/Gemfile +++ b/Gemfile @@ -7,8 +7,8 @@ git_source(:github) { |repo| "https://github.com/#{repo}.git" } gem 'rails', '~> 7' # Use postgresql as the database for Active Record gem 'pg', '>= 0.18', '< 2.0' -# Use Puma as the app server -gem 'puma', '~> 6.3' +# Use falcon as webserver +gem 'falcon' # Use SCSS for stylesheets gem 'sass-rails', '~> 6.0' # Use Uglifier as compressor for JavaScript assets @@ -50,15 +50,21 @@ gem 'ulid-rails', git: 'https://github.com/davidsiaw/ulid-rails' gem 'sidekiq' gem 'sidekiq-scheduler' +gem 'async-io' +gem 'async-redis' +gem 'async-websocket' +gem 'thread-local' + group :development, :test do + gem 'async-rspec' gem 'factory_bot_rails' gem 'pry-byebug', platforms: %i[mri mingw x64_mingw] gem 'rspec-rails' end group :test do - gem 'capybara' gem 'database_cleaner' + gem 'falcon-capybara' gem 'rspec_junit_formatter' gem 'selenium-webdriver' gem 'shoulda-matchers' diff --git a/Gemfile.lock b/Gemfile.lock index c0af8e05..1fb726d7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -88,6 +88,45 @@ GEM ar-uuid (0.2.3) activerecord ast (2.4.2) + async (2.11.0) + console (~> 1.25, >= 1.25.2) + fiber-annotation + io-event (~> 1.5, >= 1.5.1) + timers (~> 4.1) + async-container (0.18.2) + async (~> 2.10) + async-http (0.66.3) + async (>= 2.10.2) + async-pool (>= 0.6.1) + io-endpoint (~> 0.10, >= 0.10.3) + io-stream (~> 0.4) + protocol-http (~> 0.26.0) + protocol-http1 (~> 0.19.0) + protocol-http2 (~> 0.17.0) + traces (>= 0.10.0) + async-http-cache (0.4.3) + async-http (~> 0.56) + async-io (1.43.2) + async + async-pool (0.6.1) + async (>= 1.25) + async-redis (0.8.1) + async (>= 1.8, < 3.0) + async-pool (~> 0.2) + io-endpoint (~> 0.10) + io-stream (~> 0.4) + protocol-redis (~> 0.8.0) + async-rspec (1.17.0) + rspec (~> 3.0) + rspec-files (~> 1.0) + rspec-memory (~> 1.0) + async-service (0.12.0) + async + async-container (~> 0.16) + async-websocket (0.26.1) + async-http (~> 0.54) + protocol-rack (~> 0.5) + protocol-websocket (~> 0.11) base32-crockford (0.1.0) base64 (0.2.0) bcrypt (3.1.20) @@ -121,6 +160,10 @@ GEM colored2 (3.1.2) concurrent-ruby (1.3.4) connection_pool (2.4.1) + console (1.25.2) + fiber-annotation + fiber-local (~> 1.1) + json cork (0.3.0) colored2 (~> 3.1) crass (1.0.6) @@ -180,6 +223,22 @@ GEM railties (>= 5.0.0) faker (3.5.1) i18n (>= 1.8.11, < 2) + falcon (0.47.6) + async + async-container (~> 0.18) + async-http (~> 0.66, >= 0.66.3) + async-http-cache (~> 0.4.0) + async-service (~> 0.10) + bundler + localhost (~> 1.1) + openssl (~> 3.0) + process-metrics (~> 0.2.0) + protocol-rack (~> 0.5) + samovar (~> 2.3) + falcon-capybara (1.6.1) + capybara (~> 3.37) + falcon + selenium-webdriver faraday (2.12.0) faraday-net_http (>= 2.0, < 3.4) json @@ -192,6 +251,10 @@ GEM faraday (~> 2.0) ffaker (2.23.0) ffi (1.16.3) + fiber-annotation (0.2.0) + fiber-local (1.1.0) + fiber-storage + fiber-storage (0.1.1) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -223,6 +286,9 @@ GEM i18n (1.14.6) concurrent-ruby (~> 1.0) io-console (0.7.2) + io-endpoint (0.10.3) + io-event (1.5.1) + io-stream (0.4.0) irb (1.14.1) rdoc (>= 4.0.0) reline (>= 0.4.2) @@ -238,6 +304,7 @@ GEM listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) + localhost (1.3.1) logger (1.6.1) loofah (2.23.1) crass (~> 1.0.2) @@ -247,6 +314,7 @@ GEM net-imap net-pop net-smtp + mapping (1.1.1) marcel (1.0.4) matrix (0.4.2) method_source (1.1.0) @@ -271,12 +339,13 @@ GEM net-smtp (0.5.0) net-protocol nio4r (2.7.4) - nokogiri (1.16.7-x86_64-linux) + nokogiri (1.16.7-aarch64-linux) racc (~> 1.4) octokit (9.2.0) faraday (>= 1, < 3) sawyer (~> 0.9) open4 (1.3.4) + openssl (3.2.0) orm_adapter (0.5.0) paper_trail (15.2.0) activerecord (>= 6.1) @@ -286,6 +355,22 @@ GEM ast (~> 2.4.1) racc pg (1.5.9) + process-metrics (0.2.1) + console (~> 1.8) + samovar (~> 2.1) + protocol-hpack (1.4.3) + protocol-http (0.26.5) + protocol-http1 (0.19.1) + protocol-http (~> 0.22) + protocol-http2 (0.17.0) + protocol-hpack (~> 1.4) + protocol-http (~> 0.18) + protocol-rack (0.5.1) + protocol-http (~> 0.23) + rack (>= 1.0) + protocol-redis (0.8.1) + protocol-websocket (0.13.0) + protocol-http (~> 0.2) pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) @@ -296,8 +381,6 @@ GEM psych (5.1.2) stringio public_suffix (6.0.1) - puma (6.4.3) - nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) rack (3.1.8) @@ -362,11 +445,19 @@ GEM actionpack (>= 5.2) railties (>= 5.2) rexml (3.3.9) + rspec (3.13.0) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) rspec-core (3.13.2) rspec-support (~> 3.13.0) rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) + rspec-files (1.1.3) + rspec (~> 3.0) + rspec-memory (1.0.4) + rspec (~> 3.0) rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) @@ -412,6 +503,9 @@ GEM rubyzip (2.3.2) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) + samovar (2.3.0) + console (~> 1.0) + mapping (~> 1.0) sass-rails (6.0.0) sassc-rails (~> 2.1, >= 2.1.1) sassc (2.4.0) @@ -458,8 +552,11 @@ GEM terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) thor (1.3.2) + thread-local (1.1.0) tilt (2.4.0) timeout (0.4.1) + timers (4.3.5) + traces (0.11.1) trailblazer-option (0.1.2) tzinfo (2.0.6) concurrent-ruby (~> 1.0) @@ -491,20 +588,25 @@ GEM zeitwerk (2.7.1) PLATFORMS - x86_64-linux + aarch64-linux DEPENDENCIES ar-uuid + async-io + async-redis + async-rspec + async-websocket bcrypt (~> 3.1.19) bootsnap (>= 1.1.0) brakeman bundler-audit - capybara danger database_cleaner devise factory_bot_rails faker + falcon + falcon-capybara faraday-retry ffaker grape @@ -517,7 +619,6 @@ DEPENDENCIES paper_trail pg (>= 0.18, < 2.0) pry-byebug - puma (~> 6.3) rails (~> 7) redis (~> 5.3) rspec-rails @@ -535,6 +636,7 @@ DEPENDENCIES sidekiq-scheduler spring spring-watcher-listen (~> 2.1.0) + thread-local tzinfo-data uglifier (>= 1.3.0) ulid-rails! diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index d05ea0f5..7e078976 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -13,3 +13,8 @@ *= require_tree . *= require_self */ + +body { + background: #000; + color: #fff; +} diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb deleted file mode 100644 index 9aec2305..00000000 --- a/app/channels/application_cable/channel.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -module ApplicationCable - class Channel < ActionCable::Channel::Base - end -end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb deleted file mode 100644 index 8d6c2a1b..00000000 --- a/app/channels/application_cable/connection.rb +++ /dev/null @@ -1,6 +0,0 @@ -# frozen_string_literal: true - -module ApplicationCable - class Connection < ActionCable::Connection::Base - end -end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7944f9f9..9eba97f5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,8 @@ # frozen_string_literal: true +# Every controller should inherit this class ApplicationController < ActionController::Base + def logger + Rails.logger + end end diff --git a/app/jobs/README.md b/app/jobs/README.md new file mode 100644 index 00000000..b692abc7 --- /dev/null +++ b/app/jobs/README.md @@ -0,0 +1,25 @@ +Jobs +---- + +This is where you put your job classes. All workers must be named `something_job.rb`. + +To make a new kind of job just run the generator: + +`rails generate job Something` + +This will create + +``` +class SomethingJob < ApplicationJob + queue_as :default + + def perform(*args) + end +end +``` + +To run any job just run `SomethingJob.perform_later(params)`. + +More info: + +https://github.com/sidekiq/sidekiq/wiki/Active-Job diff --git a/app/redis_connections/application_redis_connection.rb b/app/redis_connections/application_redis_connection.rb new file mode 100644 index 00000000..88b089ba --- /dev/null +++ b/app/redis_connections/application_redis_connection.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Generic Redis Connection for applications to use +class ApplicationRedisConnection + def initialize(channel) + @channel = channel + async_task + end + + def on_message(msg_type, msg_name, msg_content); end + + def publish(message) + async_redis.publish(@channel, message) + end + + def close + return if @async_task.nil? + + @async_task&.stop + @async_task = nil + end + + private + + def async_task + @async_task ||= Async do + async_redis.subscribe(@channel) do |context| + loop do + msg_type, msg_name, msg_content = context.listen + on_message(msg_type, msg_name, msg_content) + end + end + end + end + + def async_redis + AsyncRedisService.default + end +end diff --git a/app/services/async_redis_service.rb b/app/services/async_redis_service.rb new file mode 100644 index 00000000..b0a8ecac --- /dev/null +++ b/app/services/async_redis_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require 'async/redis' +require 'thread/local' + +# Asynchronous Redis Client for pub sub purposes +module AsyncRedisService + extend Thread::Local + + def self.default + uri = URI.parse(default_redis_url) + endpoint = ::IO::Endpoint.tcp(uri.host, uri.port) + Async::Redis::Client.new(endpoint) + end + + def self.default_redis_url + ENV.fetch('REDIS_URL', nil) + end +end diff --git a/app/sockets/application_socket.rb b/app/sockets/application_socket.rb new file mode 100644 index 00000000..d436158d --- /dev/null +++ b/app/sockets/application_socket.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'async/websocket/adapters/rails' + +# Generic socket system for the application +class ApplicationSocket + attr_reader :connection + + def open(request) + Async::WebSocket::Adapters::Rails.open(request) do |connection| + Sync do + Rails.logger.debug { "Connection #{connection} opened" } + @connection = connection + on_open(connection) + + while (message = connection.read) + on_message(message) + end + rescue Protocol::WebSocket::ClosedError + Rails.logger.debug { "Connection #{connection} closed" } + on_closed + rescue StandardError => e + Rails.logger.debug { "Connection #{connection} errored" } + Rails.logger.debug e + Rails.logger.debug e.backtrace + on_error(e) + ensure + Rails.logger.debug { "Connection #{connection} stopped" } + end + end + end + + def on_message(message); end + + def on_open(connection); end + + def on_closed; end + + def on_error(err); end +end diff --git a/app/workers/README.md b/app/workers/README.md deleted file mode 100644 index 5da54e91..00000000 --- a/app/workers/README.md +++ /dev/null @@ -1,10 +0,0 @@ -Workers -------- - -This is where you put your workers. All workers must be named `something_worker.rb`. - -To run any workers just run `SomethingWorker.perform_async(params)`. - -More info: - -https://github.com/mperham/sidekiq/wiki/Getting-Started diff --git a/config/puma.rb b/config/puma.rb deleted file mode 100644 index 39d38e8d..00000000 --- a/config/puma.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -# Puma can serve each request in a thread from an internal thread pool. -# The `threads` method setting takes two numbers: a minimum and maximum. -# Any libraries that use thread pools should be configured to match -# the maximum value specified for Puma. Default is set to 5 threads for minimum -# and maximum; this matches the default thread size of Active Record. -# -threads_count = ENV.fetch('RAILS_MAX_THREADS', 5) -threads threads_count, threads_count - -# Specifies the `port` that Puma will listen on to receive requests; -# default is 3000. -# -port ENV.fetch('PORT', 3000) - -# Specifies the `environment` that Puma will run in. -# -environment ENV.fetch('RAILS_ENV', 'development') - -# Specifies the number of `workers` to boot in clustered mode. -# Workers are forked webserver processes. If using threads and workers together -# the concurrency of the application would be max `threads` * `workers`. -# Workers do not work on JRuby or Windows (both of which do not support -# processes). -# -# workers ENV.fetch("WEB_CONCURRENCY") { 2 } - -# Use the `preload_app!` method when specifying a `workers` number. -# This directive tells Puma to first boot the application and load code -# before forking the application. This takes advantage of Copy On Write -# process behavior so workers use less memory. -# -# preload_app! - -# Allow puma to be restarted by `rails restart` command. -plugin :tmp_restart diff --git a/config/routes.rb b/config/routes.rb index b0e6c402..a85518f4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,6 +7,7 @@ devise_for :admins, controllers: { sessions: 'admins/sessions' } + authenticate :admin do mount GrapeSwaggerRails::Engine => '/swagger' mount Sidekiq::Web => '/sidekiq' diff --git a/docker-compose.unit.yml b/docker-compose.unit.yml index b99004aa..8c89e438 100644 --- a/docker-compose.unit.yml +++ b/docker-compose.unit.yml @@ -1,4 +1,3 @@ -version: '3.7' services: rz: build: diff --git a/docker-compose.yml b/docker-compose.yml index d79e808f..9177803b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,3 @@ -version: '3.7' services: # Rails database # If you wish to clear the database contents simply run diff --git a/spec/apis/v1/health_api_spec.rb b/spec/apis/v1/health_api_spec.rb index 8445ffb3..6135c1b1 100644 --- a/spec/apis/v1/health_api_spec.rb +++ b/spec/apis/v1/health_api_spec.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require 'rails_helper' - RSpec.describe HealthApi, type: :request do it 'returns ok' do get '/api/v1/health' diff --git a/spec/browser/restricted_area_spec.rb b/spec/browser/restricted_area_spec.rb index bfddb31f..6b13f628 100644 --- a/spec/browser/restricted_area_spec.rb +++ b/spec/browser/restricted_area_spec.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require 'rails_helper' require 'browser_helper' RSpec.describe 'Restricted Area', :js, type: :browser do @@ -11,7 +10,7 @@ fill_in 'Email', with: admin.email fill_in 'Password', with: admin.password click_link_or_button 'Log in' - expect(page.body).to match 'Sidekiq' + expect(page).to have_content('Sidekiq') end it 'swagger cannot be accessed without logging in' do @@ -19,6 +18,6 @@ fill_in 'Email', with: admin.email fill_in 'Password', with: admin.password click_link_or_button 'Log in' - expect(page.body).to match 'Swagger' + expect(page).to have_content('Swagger') end end diff --git a/spec/browser_helper.rb b/spec/browser_helper.rb index c488ad0b..dee24755 100644 --- a/spec/browser_helper.rb +++ b/spec/browser_helper.rb @@ -21,7 +21,7 @@ Capybara::Selenium::Driver.new(app, browser: :chrome) end end -Capybara.server = :puma, { Silent: true } +Capybara.server = :falcon, { Silent: true } Capybara.configure do |config| config.default_max_wait_time = 10 # seconds diff --git a/spec/redis_connections/application_redis_connection_spec.rb b/spec/redis_connections/application_redis_connection_spec.rb new file mode 100644 index 00000000..b1dbfee4 --- /dev/null +++ b/spec/redis_connections/application_redis_connection_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +RSpec.describe ApplicationRedisConnection do + before do + # stub out external stuffs + async_redis_double = instance_double(Async::Redis::Client) + allow_any_instance_of(described_class).to receive(:async_redis).and_return(async_redis_double) + allow(async_redis_double).to receive(:subscribe) + + async_task_double = double('AsyncTask') + allow_any_instance_of(described_class).to receive(:async_task).and_return(async_task_double) + end + + describe '#publish' do + it 'calls publish on the async_redis' do + obj = described_class.new('test_channel_name') + + async_redis_double = instance_double(Async::Redis::Client) + allow(obj).to receive(:async_redis) { async_redis_double } + + expect(async_redis_double).to receive(:publish).with('test_channel_name', 'foobar') + + obj.publish('foobar') + end + end +end diff --git a/spec/services/async_redis_service_spec.rb b/spec/services/async_redis_service_spec.rb new file mode 100644 index 00000000..aa4eda9b --- /dev/null +++ b/spec/services/async_redis_service_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec.describe AsyncRedisService do + describe '.local' do + it 'returns a redis client' do + endpoint_double = double('Endpoint') + allow(described_class).to receive(:default_redis_url).and_return('redis://something:23123') + allow(IO::Endpoint).to receive(:tcp).with('something', 23_123) { endpoint_double } + fakeclient = double('Client') + allow(Async::Redis::Client).to receive(:new).with(endpoint_double) { fakeclient } + + expect(described_class.default).to eq fakeclient + end + end +end diff --git a/spec/sockets/application_socket_spec.rb b/spec/sockets/application_socket_spec.rb new file mode 100644 index 00000000..8744004e --- /dev/null +++ b/spec/sockets/application_socket_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.describe ApplicationSocket do + it 'uses the async websocket' do + allow(Async::WebSocket::Adapters::Rails).to receive(:open).with('request') + described_class.new.open('request') + expect(Async::WebSocket::Adapters::Rails).to have_received(:open).with('request') + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5da0db34..ba110b8f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -16,6 +16,9 @@ # it. # # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration + +require 'rails_helper' + RSpec.configure do |config| config.include FactoryBot::Syntax::Methods diff --git a/storage/.keep b/storage/.keep index e69de29b..4f07f1ca 100644 --- a/storage/.keep +++ b/storage/.keep @@ -0,0 +1 @@ +.keep \ No newline at end of file diff --git a/tmp/.keep b/tmp/.keep index e69de29b..4f07f1ca 100644 --- a/tmp/.keep +++ b/tmp/.keep @@ -0,0 +1 @@ +.keep \ No newline at end of file