neocities-red 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +29 -15
- data/.gitignore +4 -0
- data/CHANGELOG.md +95 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +9 -6
- data/README.md +6 -0
- data/lib/neocities_red/cli.rb +156 -31
- data/lib/neocities_red/cli_display.rb +200 -5
- data/lib/neocities_red/client.rb +84 -77
- data/lib/neocities_red/errors.rb +20 -0
- data/lib/neocities_red/services/common/exclusions.rb +69 -0
- data/lib/neocities_red/services/common/pizza.rb +34 -21
- data/lib/neocities_red/services/common/worker_pool.rb +54 -0
- data/lib/neocities_red/services/file/folder_uploader.rb +39 -25
- data/lib/neocities_red/services/file/list.rb +60 -30
- data/lib/neocities_red/services/file/remover.rb +27 -12
- data/lib/neocities_red/services/file/uploader.rb +33 -13
- data/lib/neocities_red/services/site/differencer.rb +42 -12
- data/lib/neocities_red/services/site/exporter.rb +120 -7
- data/lib/neocities_red/services/site/informer.rb +29 -4
- data/lib/neocities_red/services/site/pusher.rb +74 -38
- data/lib/neocities_red/version.rb +2 -1
- data/lib/neocities_red.rb +32 -13
- data/neocities-red.gemspec +11 -6
- data/spec/neocities_red/cli_display_spec.rb +0 -6
- data/spec/neocities_red/cli_spec.rb +85 -0
- data/spec/neocities_red/client_spec.rb +7 -50
- data/spec/neocities_red/services/common/exclusions_spec.rb +47 -0
- data/spec/neocities_red/services/common/worker_pool_spec.rb +48 -0
- data/spec/neocities_red/services/file/folder_uploader_spec.rb +8 -6
- data/spec/neocities_red/services/file/list_spec.rb +13 -10
- data/spec/neocities_red/services/file/remover_spec.rb +15 -21
- data/spec/neocities_red/services/file/uploader_spec.rb +25 -20
- data/spec/neocities_red/services/site/differencer_spec.rb +32 -6
- data/spec/neocities_red/services/site/exporter_spec.rb +105 -38
- data/spec/neocities_red/services/site/informer_spec.rb +3 -3
- data/spec/neocities_red/services/site/pusher_spec.rb +1 -1
- data/spec/spec_helper.rb +1 -1
- metadata +11 -25
|
@@ -2,41 +2,154 @@
|
|
|
2
2
|
|
|
3
3
|
require "whirly"
|
|
4
4
|
require "pastel"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "time"
|
|
7
|
+
require "fileutils"
|
|
5
8
|
|
|
6
9
|
module NeocitiesRed
|
|
7
10
|
module Services
|
|
8
11
|
module Site
|
|
12
|
+
# Downloads all files from the remote Neocities site to the local filesystem.
|
|
13
|
+
#
|
|
14
|
+
# Supports incremental pulls — files that haven't been updated since the
|
|
15
|
+
# last pull (and exist locally) are skipped. Progress is displayed per-file,
|
|
16
|
+
# or via a Whirly spinner in quiet mode. Pull metadata (timestamp and
|
|
17
|
+
# working directory) is persisted in the config file for future incremental
|
|
18
|
+
# pulls.
|
|
19
|
+
#
|
|
20
|
+
# @example Full pull
|
|
21
|
+
# exporter = NeocitiesRed::Services::Site::Exporter.new(
|
|
22
|
+
# client, "my-site", config_data, config_path, display: display
|
|
23
|
+
# )
|
|
24
|
+
# exporter.export
|
|
25
|
+
#
|
|
26
|
+
# @example Quiet pull with incremental support
|
|
27
|
+
# exporter.export(quiet: true, last_pull_time: "2024-01-01", last_pull_loc: "/path/to/site")
|
|
28
|
+
#
|
|
29
|
+
# @see NeocitiesRed::Client#list Fetches remote file list
|
|
30
|
+
# @see NeocitiesRed::Client#download Downloads individual files
|
|
9
31
|
class Exporter
|
|
10
|
-
|
|
32
|
+
# @return [NeocitiesRed::Client] the authenticated API client
|
|
33
|
+
attr_reader :client
|
|
11
34
|
|
|
12
|
-
|
|
35
|
+
# @return [String] the site name being exported
|
|
36
|
+
attr_reader :sitename
|
|
37
|
+
|
|
38
|
+
# @return [Hash] the current config data (modified in-place with last pull info)
|
|
39
|
+
attr_reader :data
|
|
40
|
+
|
|
41
|
+
# @return [String] path to the config file for persisting pull metadata
|
|
42
|
+
attr_reader :app_config_path
|
|
43
|
+
|
|
44
|
+
# @param client [NeocitiesRed::Client] authenticated API client
|
|
45
|
+
# @param sitename [String] the Neocities site name
|
|
46
|
+
# @param data [Hash] current application config data
|
|
47
|
+
# @param app_config_path [String] path to the config file
|
|
48
|
+
# @param display [NeocitiesRed::CliDisplay] output helper
|
|
49
|
+
def initialize(client, sitename, data, app_config_path, display:)
|
|
13
50
|
@client = client
|
|
14
51
|
@sitename = sitename
|
|
15
52
|
@data = data
|
|
16
53
|
@app_config_path = app_config_path
|
|
54
|
+
@display = display
|
|
17
55
|
@pastel = Pastel.new(eachline: "\n")
|
|
18
56
|
end
|
|
19
57
|
|
|
58
|
+
# Downloads all site files to the current working directory.
|
|
59
|
+
#
|
|
60
|
+
# After the download, persists the current timestamp and working
|
|
61
|
+
# directory to the config file for future incremental pulls.
|
|
62
|
+
#
|
|
63
|
+
# @param quiet [Boolean] when true, shows a spinner instead of per-file output
|
|
64
|
+
# @param last_pull_time [String, nil] ISO timestamp of the last pull
|
|
65
|
+
# @param last_pull_loc [String, nil] working directory of the last pull
|
|
66
|
+
# @return [void]
|
|
20
67
|
def export(quiet: false, last_pull_time: nil, last_pull_loc: nil)
|
|
21
68
|
if quiet
|
|
22
69
|
Whirly.start spinner: ["😺", "😸", "😹", "😻", "😼", "😽", "🙀", "😿", "😾"],
|
|
23
70
|
status: "Retrieving files for #{@pastel.bold @sitename}"
|
|
24
71
|
end
|
|
25
72
|
|
|
26
|
-
|
|
73
|
+
fetch_files(last_pull_time, last_pull_loc, quiet)
|
|
27
74
|
|
|
28
|
-
# write last pull data to file (not necessarily the best way to do this, but better than cloning every time)
|
|
29
75
|
data["LAST_PULL"] = {
|
|
30
76
|
time: Time.now,
|
|
31
77
|
loc: Dir.pwd
|
|
32
78
|
}
|
|
33
79
|
|
|
34
|
-
File.write(app_config_path, data.to_json)
|
|
35
|
-
rescue StandardError => e
|
|
36
|
-
e
|
|
80
|
+
::File.write(app_config_path, data.to_json)
|
|
37
81
|
ensure
|
|
38
82
|
Whirly.stop if quiet
|
|
39
83
|
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Fetches each file from the remote site and writes it locally.
|
|
88
|
+
#
|
|
89
|
+
# Skips files that haven't changed since the last pull when
|
|
90
|
+
# incremental data is available.
|
|
91
|
+
#
|
|
92
|
+
# @param last_pull_time [String, nil] ISO timestamp of the last pull
|
|
93
|
+
# @param last_pull_loc [String, nil] working directory of the last pull
|
|
94
|
+
# @param quiet [Boolean] suppresses per-file output when true
|
|
95
|
+
# @return [void]
|
|
96
|
+
# @raise [NeocitiesRed::APIError] if the site info or file list API fails
|
|
97
|
+
def fetch_files(last_pull_time, last_pull_loc, quiet)
|
|
98
|
+
site_info = @client.info(@sitename)
|
|
99
|
+
|
|
100
|
+
raise NeocitiesRed::APIError, site_info[:message] if site_info[:result] == "error"
|
|
101
|
+
|
|
102
|
+
info_data = site_info[:info]
|
|
103
|
+
|
|
104
|
+
domain =
|
|
105
|
+
if info_data[:domain].to_s.empty?
|
|
106
|
+
"https://#{@sitename}.neocities.org/"
|
|
107
|
+
else
|
|
108
|
+
"https://#{info_data[:domain]}/"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
success_loaded = 0
|
|
112
|
+
start_time = Time.now
|
|
113
|
+
curr_dir = Dir.pwd
|
|
114
|
+
|
|
115
|
+
resp = @client.list
|
|
116
|
+
|
|
117
|
+
raise NeocitiesRed::APIError, resp[:message] if resp[:result] == "error"
|
|
118
|
+
|
|
119
|
+
uri_parser = URI::Parser.new
|
|
120
|
+
resp[:files].each do |file|
|
|
121
|
+
if file[:is_directory]
|
|
122
|
+
FileUtils.mkdir_p file[:path].to_s
|
|
123
|
+
else
|
|
124
|
+
@display.display_pull_progress(file[:path]) unless quiet
|
|
125
|
+
|
|
126
|
+
if last_pull_time &&
|
|
127
|
+
last_pull_loc &&
|
|
128
|
+
Time.parse(file[:updated_at]) <= Time.parse(last_pull_time) &&
|
|
129
|
+
last_pull_loc == curr_dir &&
|
|
130
|
+
::File.exist?(file[:path])
|
|
131
|
+
|
|
132
|
+
@display.display_pull_no_updates unless quiet
|
|
133
|
+
|
|
134
|
+
next
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
pathtotry = uri_parser.escape(domain + file[:path])
|
|
138
|
+
fileconts = @client.download(pathtotry)
|
|
139
|
+
|
|
140
|
+
if fileconts.status == 200
|
|
141
|
+
@display.display_pull_success unless quiet
|
|
142
|
+
success_loaded += 1
|
|
143
|
+
|
|
144
|
+
::File.write(file[:path].to_s, fileconts.body)
|
|
145
|
+
elsif !quiet
|
|
146
|
+
@display.display_pull_failure
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
@display.display_pull_stats(success_loaded, Time.now - start_time)
|
|
152
|
+
end
|
|
40
153
|
end
|
|
41
154
|
end
|
|
42
155
|
end
|
|
@@ -5,12 +5,27 @@ require "pastel"
|
|
|
5
5
|
|
|
6
6
|
module NeocitiesRed
|
|
7
7
|
module Services
|
|
8
|
-
class ClientError < StandardError; end
|
|
9
|
-
|
|
10
8
|
module Site
|
|
9
|
+
# Retrieves and formats site information for the Neocities site.
|
|
10
|
+
#
|
|
11
|
+
# Queries the Neocities API for site metadata (domain, bandwidth,
|
|
12
|
+
# created_at, last_updated, etc.) and formats it as rows suitable
|
|
13
|
+
# for display in a TTY::Table.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# informer = NeocitiesRed::Services::Site::Informer.new(client, ["my-site"], "my-site")
|
|
17
|
+
# rows = informer.pretty_print
|
|
18
|
+
# puts TTY::Table.new(rows)
|
|
19
|
+
#
|
|
20
|
+
# @see NeocitiesRed::Client#info Underlying API call
|
|
11
21
|
class Informer
|
|
12
|
-
|
|
22
|
+
# @return [NeocitiesRed::Client] the authenticated API client
|
|
23
|
+
attr_reader :client
|
|
13
24
|
|
|
25
|
+
# @param client [NeocitiesRed::Client] authenticated API client
|
|
26
|
+
# @param subargs [Array<String>] CLI subarguments; the first element
|
|
27
|
+
# may be a sitename to query
|
|
28
|
+
# @param sitename [String, nil] default sitename (used when subargs is empty)
|
|
14
29
|
def initialize(client, subargs = [], sitename = nil)
|
|
15
30
|
@client = client
|
|
16
31
|
@subargs = subargs
|
|
@@ -18,14 +33,24 @@ module NeocitiesRed
|
|
|
18
33
|
@pastel = Pastel.new(eachline: "\n")
|
|
19
34
|
end
|
|
20
35
|
|
|
36
|
+
# Fetches site information from the Neocities API.
|
|
37
|
+
#
|
|
38
|
+
# @return [Hash] parsed API response containing +:info+ with site metadata
|
|
39
|
+
# @raise [NeocitiesRed::APIError] if the API returns an error
|
|
21
40
|
def stats
|
|
22
41
|
response = @client.info(@subargs[0] || @sitename)
|
|
23
42
|
|
|
24
|
-
raise NeocitiesRed::
|
|
43
|
+
raise NeocitiesRed::APIError, response[:message] if response[:result] == "error"
|
|
25
44
|
|
|
26
45
|
response
|
|
27
46
|
end
|
|
28
47
|
|
|
48
|
+
# Formats the site stats as bold-labeled rows.
|
|
49
|
+
#
|
|
50
|
+
# Timestamp fields (+created_at+, +last_updated+) are converted
|
|
51
|
+
# to local time for readability.
|
|
52
|
+
#
|
|
53
|
+
# @return [Array<Array(String, Object)>] array of +[label, value]+ pairs
|
|
29
54
|
def pretty_print
|
|
30
55
|
out = []
|
|
31
56
|
|
|
@@ -5,12 +5,45 @@ require "digest"
|
|
|
5
5
|
|
|
6
6
|
module NeocitiesRed
|
|
7
7
|
module Services
|
|
8
|
+
# Site-level operations: push, diff, info, and export.
|
|
8
9
|
module Site
|
|
10
|
+
# Recursively uploads a local directory to the Neocities site.
|
|
11
|
+
#
|
|
12
|
+
# Orchestrates the full push workflow:
|
|
13
|
+
# 1. Validates the root path
|
|
14
|
+
# 2. Optionally prunes remote files not present locally
|
|
15
|
+
# 3. Collects all files via glob
|
|
16
|
+
# 4. Applies .gitignore rules, dotfile filtering, exclusions, and
|
|
17
|
+
# optimized (hash-based) filtering
|
|
18
|
+
# 5. Uploads remaining files in parallel via a worker pool
|
|
19
|
+
#
|
|
20
|
+
# @example Basic push
|
|
21
|
+
# pusher = NeocitiesRed::Services::Site::Pusher.new(
|
|
22
|
+
# client, display,
|
|
23
|
+
# root: ".", no_gitignore: false, ignore_dotfiles: false,
|
|
24
|
+
# exclude: [], dry_run: false, prune: false, optimized: false
|
|
25
|
+
# )
|
|
26
|
+
# pusher.push
|
|
27
|
+
#
|
|
28
|
+
# @see NeocitiesRed::Services::File::Uploader Individual file upload
|
|
29
|
+
# @see NeocitiesRed::Services::Common::Exclusions Exclusion builder
|
|
30
|
+
# @see NeocitiesRed::Services::Common::WorkerPool Thread pool
|
|
9
31
|
class Pusher
|
|
10
|
-
#
|
|
11
|
-
#
|
|
32
|
+
# @return [Integer] maximum concurrent upload threads.
|
|
33
|
+
#
|
|
34
|
+
# Warning: a high thread count may be flagged as DDOS-like traffic
|
|
35
|
+
# by Neocities, potentially resulting in a temporary IP ban.
|
|
12
36
|
MAX_THREADS = 5
|
|
13
37
|
|
|
38
|
+
# @param client [NeocitiesRed::Client] authenticated API client
|
|
39
|
+
# @param display [NeocitiesRed::CliDisplay] output helper
|
|
40
|
+
# @param root [String] local directory path to push
|
|
41
|
+
# @param no_gitignore [Boolean] when true, ignores .gitignore rules
|
|
42
|
+
# @param ignore_dotfiles [Boolean] when true, skips files starting with "."
|
|
43
|
+
# @param exclude [Array<String>] additional paths to exclude from upload
|
|
44
|
+
# @param dry_run [Boolean] when true, simulates the push without uploading
|
|
45
|
+
# @param prune [Boolean] when true, deletes remote files not present locally
|
|
46
|
+
# @param optimized [Boolean] when true, skips files whose SHA1 matches the server
|
|
14
47
|
def initialize(client, display, root:, no_gitignore:, ignore_dotfiles:, exclude:, dry_run:, prune:, optimized:)
|
|
15
48
|
@client = client
|
|
16
49
|
@display = display
|
|
@@ -23,6 +56,10 @@ module NeocitiesRed
|
|
|
23
56
|
@optimized = optimized
|
|
24
57
|
end
|
|
25
58
|
|
|
59
|
+
# Executes the full push workflow.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
# @raise [ArgumentError] if the root path does not exist or is not a directory
|
|
26
63
|
def push
|
|
27
64
|
root_path = Pathname(@root)
|
|
28
65
|
validate_root_path!(root_path)
|
|
@@ -30,7 +67,7 @@ module NeocitiesRed
|
|
|
30
67
|
@display.display_dry_run_notice if @dry_run
|
|
31
68
|
prune_remote_files if @prune
|
|
32
69
|
|
|
33
|
-
excluded_files =
|
|
70
|
+
excluded_files = Services::Common::Exclusions.build(@exclude)
|
|
34
71
|
|
|
35
72
|
Dir.chdir(root_path) do
|
|
36
73
|
paths = Dir.glob(::File.join("**", "*"), ::File::FNM_DOTMATCH)
|
|
@@ -46,27 +83,22 @@ module NeocitiesRed
|
|
|
46
83
|
|
|
47
84
|
private
|
|
48
85
|
|
|
86
|
+
# Validates that the root path exists and is a directory.
|
|
87
|
+
#
|
|
88
|
+
# @param root_path [Pathname] the local root directory
|
|
89
|
+
# @raise [ArgumentError] if validation fails
|
|
49
90
|
def validate_root_path!(root_path)
|
|
50
91
|
raise ArgumentError, "path #{root_path} does not exist" unless root_path.exist?
|
|
51
92
|
raise ArgumentError, "provided path is not a directory" unless root_path.directory?
|
|
52
93
|
end
|
|
53
94
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
excluded_files << filepath
|
|
62
|
-
elsif ::File.directory?(filepath)
|
|
63
|
-
excluded_files.concat(Dir.glob(::File.join(filepath, "**", "*"), ::File::FNM_DOTMATCH).push(filepath))
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
excluded_files
|
|
68
|
-
end
|
|
69
|
-
|
|
95
|
+
# Filters file paths based on .gitignore rules.
|
|
96
|
+
#
|
|
97
|
+
# Reads the local .gitignore and removes matching paths using
|
|
98
|
+
# +File.fnmatch+ for glob pattern support.
|
|
99
|
+
#
|
|
100
|
+
# @param paths [Array<String>] all file paths in the root
|
|
101
|
+
# @return [Array<String>> filtered paths not matched by .gitignore
|
|
70
102
|
def apply_gitignore(paths)
|
|
71
103
|
return paths unless ::File.exist?(".gitignore")
|
|
72
104
|
|
|
@@ -82,6 +114,14 @@ module NeocitiesRed
|
|
|
82
114
|
filtered
|
|
83
115
|
end
|
|
84
116
|
|
|
117
|
+
# Computes which files can be skipped because their SHA1 hash
|
|
118
|
+
# matches the server version.
|
|
119
|
+
#
|
|
120
|
+
# Fetches the remote file list, computes local SHA1 hashes, and
|
|
121
|
+
# returns paths that don't need re-uploading.
|
|
122
|
+
#
|
|
123
|
+
# @param paths [Array<String>] all local file paths
|
|
124
|
+
# @return [Array<String>] file paths to exclude (already up to date)
|
|
85
125
|
def optimized_exclusions(paths)
|
|
86
126
|
hex = paths.select { |path| ::File.file?(path) }
|
|
87
127
|
.map { |file| { filepath: file, sha1_hash: Digest::SHA1.file(file).hexdigest } }
|
|
@@ -97,31 +137,27 @@ module NeocitiesRed
|
|
|
97
137
|
.map { |entry| entry[:filepath] }
|
|
98
138
|
end
|
|
99
139
|
|
|
140
|
+
# Uploads all filtered paths in parallel using a worker pool.
|
|
141
|
+
#
|
|
142
|
+
# @param paths [Array<Pathname>] local files to upload
|
|
143
|
+
# @return [void]
|
|
100
144
|
def upload_files(paths)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
MAX_THREADS.times do
|
|
106
|
-
threads << Thread.new do
|
|
107
|
-
until task_queue.empty?
|
|
108
|
-
path = begin
|
|
109
|
-
task_queue.pop(true)
|
|
110
|
-
rescue StandardError
|
|
111
|
-
nil
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
next if path.nil? || path.directory?
|
|
115
|
-
|
|
116
|
-
Services::File::Uploader.new(@client, path, path).upload
|
|
117
|
-
end
|
|
118
|
-
end
|
|
145
|
+
worker_pool = Services::Common::WorkerPool.new(MAX_THREADS) do |path|
|
|
146
|
+
next if path.directory?
|
|
147
|
+
|
|
148
|
+
Services::File::Uploader.new(@client, path, path, display: @display).upload
|
|
119
149
|
end
|
|
120
150
|
|
|
121
|
-
|
|
151
|
+
worker_pool.process(paths)
|
|
122
152
|
@display.display_upload_complete
|
|
123
153
|
end
|
|
124
154
|
|
|
155
|
+
# Deletes remote files that no longer exist in the local directory.
|
|
156
|
+
#
|
|
157
|
+
# Skips directories that have already been pruned. Respects the
|
|
158
|
+
# +@dry_run+ flag.
|
|
159
|
+
#
|
|
160
|
+
# @return [void]
|
|
125
161
|
def prune_remote_files
|
|
126
162
|
pruned_dirs = []
|
|
127
163
|
resp = @client.list
|
data/lib/neocities_red.rb
CHANGED
|
@@ -1,17 +1,36 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
# Top-level namespace for the NeocitiesRed gem.
|
|
4
|
+
#
|
|
5
|
+
# Provides a CLI tool and Ruby API client for managing static sites
|
|
6
|
+
# hosted on {https://neocities.org Neocities.org}.
|
|
7
|
+
#
|
|
8
|
+
# @see NeocitiesRed::Client HTTP API client
|
|
9
|
+
# @see NeocitiesRed::CLI Command-line interface
|
|
10
|
+
module NeocitiesRed
|
|
11
|
+
# Service layer organized by domain.
|
|
12
|
+
#
|
|
13
|
+
# - {Services::File} — single-file and folder-level operations (upload, delete, list)
|
|
14
|
+
# - {Services::Site} — site-level operations (push, diff, info, export)
|
|
15
|
+
# - {Services::Common} — shared utilities (exclusions, worker pool)
|
|
16
|
+
module Services; end
|
|
7
17
|
|
|
8
|
-
require File.join(File.dirname(__FILE__), "neocities_red", "
|
|
9
|
-
require File.join(File.dirname(__FILE__), "neocities_red", "
|
|
10
|
-
require File.join(File.dirname(__FILE__), "neocities_red", "
|
|
11
|
-
require File.join(File.dirname(__FILE__), "neocities_red", "
|
|
18
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "version")
|
|
19
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "errors")
|
|
20
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "client")
|
|
21
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "cli_display")
|
|
22
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "cli")
|
|
12
23
|
|
|
13
|
-
require File.join(File.dirname(__FILE__), "neocities_red", "services", "
|
|
14
|
-
require File.join(File.dirname(__FILE__), "neocities_red", "services", "
|
|
15
|
-
require File.join(File.dirname(__FILE__), "neocities_red", "services", "
|
|
16
|
-
require File.join(File.dirname(__FILE__), "neocities_red", "services", "
|
|
17
|
-
|
|
24
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "services", "file", "uploader")
|
|
25
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "services", "file", "folder_uploader")
|
|
26
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "services", "file", "remover")
|
|
27
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "services", "file", "list")
|
|
28
|
+
|
|
29
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "services", "site", "differencer")
|
|
30
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "services", "site", "informer")
|
|
31
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "services", "site", "exporter")
|
|
32
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "services", "site", "pusher")
|
|
33
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "services", "common", "exclusions")
|
|
34
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "services", "common", "worker_pool")
|
|
35
|
+
require File.join(File.dirname(__FILE__), "neocities_red", "services", "common", "pizza")
|
|
36
|
+
end
|
data/neocities-red.gemspec
CHANGED
|
@@ -15,7 +15,7 @@ Gem::Specification.new do |spec|
|
|
|
15
15
|
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
16
16
|
files = begin
|
|
17
17
|
if system("git", "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL)
|
|
18
|
-
`git ls-files -z`.split("\x0")
|
|
18
|
+
`git ls-files -z`.split("\x0").reject { |f| f == ".yardopts" }
|
|
19
19
|
else
|
|
20
20
|
[]
|
|
21
21
|
end
|
|
@@ -26,7 +26,8 @@ Gem::Specification.new do |spec|
|
|
|
26
26
|
if files.empty?
|
|
27
27
|
files = Dir.glob("**/*", File::FNM_DOTMATCH).select do |path|
|
|
28
28
|
File.file?(path) &&
|
|
29
|
-
!path.start_with?("test/", "spec/", "features/", ".git/", ".github/", ".rubocop_cache/")
|
|
29
|
+
!path.start_with?("test/", "spec/", "features/", ".git/", ".github/", ".rubocop_cache/") &&
|
|
30
|
+
path != ".yardopts"
|
|
30
31
|
end
|
|
31
32
|
end
|
|
32
33
|
|
|
@@ -43,12 +44,16 @@ Gem::Specification.new do |spec|
|
|
|
43
44
|
spec.add_dependency "faraday-multipart"
|
|
44
45
|
spec.add_dependency "faraday-retry"
|
|
45
46
|
spec.add_dependency "fiddle"
|
|
46
|
-
spec.add_dependency "pastel", "
|
|
47
|
-
spec.add_dependency "rake", "~> 13"
|
|
47
|
+
spec.add_dependency "pastel", "= 0.8.0"
|
|
48
|
+
spec.add_dependency "rake", "~> 13"
|
|
48
49
|
spec.add_dependency "thor", "~> 1.5.0", ">= 1.5.0"
|
|
49
|
-
spec.add_dependency "tty-prompt", "
|
|
50
|
-
spec.add_dependency "tty-table", "
|
|
50
|
+
spec.add_dependency "tty-prompt", "= 0.23.1"
|
|
51
|
+
spec.add_dependency "tty-table", "= 0.12.0"
|
|
51
52
|
spec.add_dependency "whirly", "~> 0.3", ">= 0.3.0"
|
|
52
53
|
|
|
53
54
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
55
|
+
spec.metadata["homepage_uri"] = spec.homepage
|
|
56
|
+
spec.metadata["source_code_uri"] = "https://github.com/o-200/neocities-red"
|
|
57
|
+
spec.metadata["changelog_uri"] = "https://github.com/o-200/neocities-red/blob/main/CHANGELOG.md"
|
|
58
|
+
spec.metadata["documentation_uri"] = "https://github.com/o-200/neocities-red#readme"
|
|
54
59
|
end
|
|
@@ -81,12 +81,6 @@ RSpec.describe NeocitiesRed::CliDisplay do
|
|
|
81
81
|
end
|
|
82
82
|
|
|
83
83
|
describe "status helpers" do
|
|
84
|
-
it "prints unknown option" do
|
|
85
|
-
display.display_unknown_option("--wat")
|
|
86
|
-
|
|
87
|
-
expect(text_output).to include('Unknown option: "--wat"')
|
|
88
|
-
end
|
|
89
|
-
|
|
90
84
|
it "prints delete progress and success on the same line" do
|
|
91
85
|
display.display_delete_progress("foo.txt")
|
|
92
86
|
display.display_delete_success
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "spec_helper"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "json"
|
|
7
|
+
require "stringio"
|
|
4
8
|
|
|
5
9
|
RSpec.describe NeocitiesRed::CLI do
|
|
6
10
|
describe ".app_config_path" do
|
|
@@ -65,4 +69,85 @@ RSpec.describe NeocitiesRed::CLI do
|
|
|
65
69
|
expect(path).to eq(File.join("/home/alice", ".#{app_name}"))
|
|
66
70
|
end
|
|
67
71
|
end
|
|
72
|
+
|
|
73
|
+
describe "#persist_config" do
|
|
74
|
+
let(:tmp_dir) { Dir.mktmpdir }
|
|
75
|
+
let(:config_path) { File.join(tmp_dir, "config.json") }
|
|
76
|
+
let(:cli) { described_class.new }
|
|
77
|
+
|
|
78
|
+
before do
|
|
79
|
+
allow(cli).to receive(:app_config_path).and_return(config_path)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
after do
|
|
83
|
+
FileUtils.rm_rf(tmp_dir)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "writes the config with 0600 permissions" do
|
|
87
|
+
cli.send(:persist_config, API_KEY: "secret", SITENAME: "test-site")
|
|
88
|
+
|
|
89
|
+
expect(File.read(config_path)).to eq('{"API_KEY":"secret","SITENAME":"test-site"}')
|
|
90
|
+
expect(File.stat(config_path).mode & 0o777).to eq(0o600)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
describe "purge" do
|
|
95
|
+
around do |example|
|
|
96
|
+
# rubocop:disable RSpec/ExpectOutput
|
|
97
|
+
original_stdout = $stdout
|
|
98
|
+
$stdout = StringIO.new
|
|
99
|
+
example.run
|
|
100
|
+
ensure
|
|
101
|
+
$stdout = original_stdout
|
|
102
|
+
end
|
|
103
|
+
# rubocop:enable RSpec/ExpectOutput
|
|
104
|
+
|
|
105
|
+
let(:tmp_dir) { Dir.mktmpdir }
|
|
106
|
+
let(:config_path) { File.join(tmp_dir, "config.json") }
|
|
107
|
+
|
|
108
|
+
before do
|
|
109
|
+
allow(described_class).to receive(:app_config_path).and_return(tmp_dir)
|
|
110
|
+
File.write(config_path, JSON.generate(API_KEY: "test-key", SITENAME: "test-site"))
|
|
111
|
+
|
|
112
|
+
stub_request(:get, %r{https://neocities\.org/api/list})
|
|
113
|
+
.to_return(body: {
|
|
114
|
+
result: "success",
|
|
115
|
+
files: [
|
|
116
|
+
{ path: "index.html", is_directory: false },
|
|
117
|
+
{ path: "gone_dir", is_directory: true }
|
|
118
|
+
]
|
|
119
|
+
}.to_json)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
after do
|
|
123
|
+
WebMock.reset!
|
|
124
|
+
FileUtils.rm_rf(tmp_dir)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it "deletes listed files when confirmed" do
|
|
128
|
+
stub_request(:post, %r{https://neocities\.org/api/delete})
|
|
129
|
+
.to_return(body: { result: "success" }.to_json)
|
|
130
|
+
|
|
131
|
+
described_class.start(%w[purge -y])
|
|
132
|
+
|
|
133
|
+
expect(a_request(:post, %r{https://neocities\.org/api/delete}))
|
|
134
|
+
.to have_been_made.twice
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "does not delete anything with --dry-run" do
|
|
138
|
+
described_class.start(%w[purge -y --dry-run])
|
|
139
|
+
|
|
140
|
+
expect(a_request(:post, %r{https://neocities\.org/api/delete}))
|
|
141
|
+
.not_to have_been_made
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it "shows help when -y is missing" do
|
|
145
|
+
expect { described_class.start(%w[purge]) }.to raise_error(SystemExit)
|
|
146
|
+
|
|
147
|
+
expect(a_request(:post, %r{https://neocities\.org/api/delete}))
|
|
148
|
+
.not_to have_been_made
|
|
149
|
+
expect(a_request(:get, %r{https://neocities\.org/api/list}))
|
|
150
|
+
.not_to have_been_made
|
|
151
|
+
end
|
|
152
|
+
end
|
|
68
153
|
end
|
|
@@ -191,7 +191,7 @@ RSpec.describe NeocitiesRed::Client do
|
|
|
191
191
|
end
|
|
192
192
|
|
|
193
193
|
context "when file already exists on server with same hash" do
|
|
194
|
-
let(:mock_hash_response) { { result: "success", files: { "test_upload.txt"
|
|
194
|
+
let(:mock_hash_response) { { result: "success", files: { "test_upload.txt": true } } }
|
|
195
195
|
|
|
196
196
|
before do
|
|
197
197
|
allow(client).to receive(:upload_hash).and_return(mock_hash_response)
|
|
@@ -247,60 +247,17 @@ RSpec.describe NeocitiesRed::Client do
|
|
|
247
247
|
end
|
|
248
248
|
end
|
|
249
249
|
|
|
250
|
-
describe "#
|
|
250
|
+
describe "#download" do
|
|
251
251
|
let(:client) { described_class.new(api_key: api_key) }
|
|
252
|
-
let(:
|
|
253
|
-
let(:mock_list_response) { { result: "success", files: [] } }
|
|
254
|
-
let(:mock_info_response) { { result: "success", info: { domain: nil } } }
|
|
255
|
-
|
|
256
|
-
around do |example|
|
|
257
|
-
original_dir = Dir.pwd
|
|
258
|
-
Dir.chdir(temp_dir)
|
|
259
|
-
example.run
|
|
260
|
-
ensure
|
|
261
|
-
Dir.chdir(original_dir)
|
|
262
|
-
FileUtils.rm_rf(temp_dir)
|
|
263
|
-
end
|
|
252
|
+
let(:mock_conn_response) { instance_double(Faraday::Response, status: 200, body: "raw content") }
|
|
264
253
|
|
|
265
254
|
before do
|
|
266
|
-
allow(client).to
|
|
267
|
-
allow(client.instance_variable_get(:@conn)).to receive(:get).and_return(
|
|
268
|
-
instance_double(Faraday::Response, status: 200, body: "")
|
|
269
|
-
)
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
it "pulls files from the site" do
|
|
273
|
-
expect { client.pull(sitename, nil, nil, quiet: true) }.not_to raise_error
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
it "supports last_pull_time and last_pull_loc parameters" do
|
|
277
|
-
last_pull_time = Time.now - 86_400
|
|
278
|
-
last_pull_loc = Dir.pwd
|
|
279
|
-
expect { client.pull(sitename, last_pull_time, last_pull_loc, quiet: true) }.not_to raise_error
|
|
280
|
-
end
|
|
281
|
-
|
|
282
|
-
context "when site has custom domain" do
|
|
283
|
-
let(:mock_info_response) { { result: "success", info: { domain: "example.com" } } }
|
|
284
|
-
|
|
285
|
-
it "uses the custom domain for fetching files" do
|
|
286
|
-
expect { client.pull(sitename, nil, nil, quiet: true) }.not_to raise_error
|
|
287
|
-
end
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
context "when API returns error for info" do
|
|
291
|
-
let(:mock_info_response) { { result: "error", message: "Site not found" } }
|
|
292
|
-
|
|
293
|
-
it "raises an error" do
|
|
294
|
-
expect { client.pull(sitename, nil, nil, quiet: true) }.to raise_error(ArgumentError, /Site not found/)
|
|
295
|
-
end
|
|
255
|
+
allow(client.instance_variable_get(:@conn)).to receive(:get).and_return(mock_conn_response)
|
|
296
256
|
end
|
|
297
257
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
it "raises an error" do
|
|
302
|
-
expect { client.pull(sitename, nil, nil, quiet: true) }.to raise_error(ArgumentError, /List failed/)
|
|
303
|
-
end
|
|
258
|
+
it "returns the raw Faraday response for a url" do
|
|
259
|
+
response = client.download("https://example.com/index.html")
|
|
260
|
+
expect(response).to eq(mock_conn_response)
|
|
304
261
|
end
|
|
305
262
|
end
|
|
306
263
|
end
|