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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +29 -15
  3. data/.gitignore +4 -0
  4. data/CHANGELOG.md +95 -0
  5. data/Gemfile +1 -0
  6. data/Gemfile.lock +9 -6
  7. data/README.md +6 -0
  8. data/lib/neocities_red/cli.rb +156 -31
  9. data/lib/neocities_red/cli_display.rb +200 -5
  10. data/lib/neocities_red/client.rb +84 -77
  11. data/lib/neocities_red/errors.rb +20 -0
  12. data/lib/neocities_red/services/common/exclusions.rb +69 -0
  13. data/lib/neocities_red/services/common/pizza.rb +34 -21
  14. data/lib/neocities_red/services/common/worker_pool.rb +54 -0
  15. data/lib/neocities_red/services/file/folder_uploader.rb +39 -25
  16. data/lib/neocities_red/services/file/list.rb +60 -30
  17. data/lib/neocities_red/services/file/remover.rb +27 -12
  18. data/lib/neocities_red/services/file/uploader.rb +33 -13
  19. data/lib/neocities_red/services/site/differencer.rb +42 -12
  20. data/lib/neocities_red/services/site/exporter.rb +120 -7
  21. data/lib/neocities_red/services/site/informer.rb +29 -4
  22. data/lib/neocities_red/services/site/pusher.rb +74 -38
  23. data/lib/neocities_red/version.rb +2 -1
  24. data/lib/neocities_red.rb +32 -13
  25. data/neocities-red.gemspec +11 -6
  26. data/spec/neocities_red/cli_display_spec.rb +0 -6
  27. data/spec/neocities_red/cli_spec.rb +85 -0
  28. data/spec/neocities_red/client_spec.rb +7 -50
  29. data/spec/neocities_red/services/common/exclusions_spec.rb +47 -0
  30. data/spec/neocities_red/services/common/worker_pool_spec.rb +48 -0
  31. data/spec/neocities_red/services/file/folder_uploader_spec.rb +8 -6
  32. data/spec/neocities_red/services/file/list_spec.rb +13 -10
  33. data/spec/neocities_red/services/file/remover_spec.rb +15 -21
  34. data/spec/neocities_red/services/file/uploader_spec.rb +25 -20
  35. data/spec/neocities_red/services/site/differencer_spec.rb +32 -6
  36. data/spec/neocities_red/services/site/exporter_spec.rb +105 -38
  37. data/spec/neocities_red/services/site/informer_spec.rb +3 -3
  38. data/spec/neocities_red/services/site/pusher_spec.rb +1 -1
  39. data/spec/spec_helper.rb +1 -1
  40. 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
- attr_accessor :client, :sitename, :data, :app_config_path
32
+ # @return [NeocitiesRed::Client] the authenticated API client
33
+ attr_reader :client
11
34
 
12
- def initialize(client, sitename, data, app_config_path)
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
- @client.pull(@sitename, last_pull_time, last_pull_loc, quiet: quiet)
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
- attr_accessor :client
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::Services::ClientError, response if response[:result] == "error"
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
- # warning - the big quantity of working threads could be considered like-a DDOS.
11
- # Your ip-address could get banned on neocities for a few days.
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 = build_push_exclusions(@exclude)
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
- def build_push_exclusions(excluded_entries)
55
- excluded_files = []
56
-
57
- excluded_entries.each do |entry|
58
- filepath = Pathname.new(entry).cleanpath.to_s
59
-
60
- if ::File.file?(filepath)
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
- task_queue = Queue.new
102
- paths.each { |path| task_queue.push(path) }
103
-
104
- threads = []
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
- threads.each(&:join)
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
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module NeocitiesRed
4
- VERSION = "1.1.2"
4
+ # The current version of the NeocitiesRed gem.
5
+ VERSION = "1.2.0"
5
6
  end
data/lib/neocities_red.rb CHANGED
@@ -1,17 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require File.join(File.dirname(__FILE__), "neocities_red", "version")
4
- require File.join(File.dirname(__FILE__), "neocities_red", "client")
5
- require File.join(File.dirname(__FILE__), "neocities_red", "cli_display")
6
- require File.join(File.dirname(__FILE__), "neocities_red", "cli")
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", "services", "file", "uploader")
9
- require File.join(File.dirname(__FILE__), "neocities_red", "services", "file", "folder_uploader")
10
- require File.join(File.dirname(__FILE__), "neocities_red", "services", "file", "remover")
11
- require File.join(File.dirname(__FILE__), "neocities_red", "services", "file", "list")
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", "site", "differencer")
14
- require File.join(File.dirname(__FILE__), "neocities_red", "services", "site", "informer")
15
- require File.join(File.dirname(__FILE__), "neocities_red", "services", "site", "exporter")
16
- require File.join(File.dirname(__FILE__), "neocities_red", "services", "site", "pusher")
17
- require File.join(File.dirname(__FILE__), "neocities_red", "services", "common", "pizza")
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
@@ -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", "~> 0.8", "= 0.8.0"
47
- spec.add_dependency "rake", "~> 13", ">= 13.3.0"
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", "~> 0.23", "= 0.23.1"
50
- spec.add_dependency "tty-table", "~> 0.12", "= 0.12.0"
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" => true } } }
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 "#pull" do
250
+ describe "#download" do
251
251
  let(:client) { described_class.new(api_key: api_key) }
252
- let(:temp_dir) { Dir.mktmpdir }
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 receive_messages(list: mock_list_response, info: mock_info_response)
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
- context "when API returns error for list" do
299
- let(:mock_list_response) { { result: "error", message: "List failed" } }
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