neocities-red 1.1.2 → 1.2.1

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +30 -16
  3. data/.gitignore +9 -0
  4. data/.rubocop.yml +1 -0
  5. data/CHANGELOG.md +95 -0
  6. data/Gemfile +2 -1
  7. data/Gemfile.lock +21 -18
  8. data/README.md +187 -20
  9. data/lib/neocities_red/cli.rb +156 -31
  10. data/lib/neocities_red/cli_display.rb +200 -5
  11. data/lib/neocities_red/client.rb +84 -77
  12. data/lib/neocities_red/errors.rb +20 -0
  13. data/lib/neocities_red/services/common/exclusions.rb +69 -0
  14. data/lib/neocities_red/services/common/pizza.rb +34 -21
  15. data/lib/neocities_red/services/common/worker_pool.rb +54 -0
  16. data/lib/neocities_red/services/file/folder_uploader.rb +39 -25
  17. data/lib/neocities_red/services/file/list.rb +60 -30
  18. data/lib/neocities_red/services/file/remover.rb +27 -12
  19. data/lib/neocities_red/services/file/uploader.rb +33 -13
  20. data/lib/neocities_red/services/site/differencer.rb +42 -12
  21. data/lib/neocities_red/services/site/exporter.rb +120 -7
  22. data/lib/neocities_red/services/site/informer.rb +29 -4
  23. data/lib/neocities_red/services/site/pusher.rb +74 -38
  24. data/lib/neocities_red/version.rb +2 -1
  25. data/lib/neocities_red.rb +32 -13
  26. data/neocities-red.gemspec +11 -6
  27. data/spec/neocities_red/cli_display_spec.rb +0 -6
  28. data/spec/neocities_red/cli_spec.rb +86 -0
  29. data/spec/neocities_red/client_spec.rb +7 -50
  30. data/spec/neocities_red/services/common/exclusions_spec.rb +47 -0
  31. data/spec/neocities_red/services/common/worker_pool_spec.rb +48 -0
  32. data/spec/neocities_red/services/file/folder_uploader_spec.rb +8 -6
  33. data/spec/neocities_red/services/file/list_spec.rb +13 -10
  34. data/spec/neocities_red/services/file/remover_spec.rb +15 -21
  35. data/spec/neocities_red/services/file/uploader_spec.rb +25 -20
  36. data/spec/neocities_red/services/site/differencer_spec.rb +33 -8
  37. data/spec/neocities_red/services/site/exporter_spec.rb +105 -38
  38. data/spec/neocities_red/services/site/informer_spec.rb +3 -3
  39. data/spec/neocities_red/services/site/pusher_spec.rb +1 -1
  40. data/spec/spec_helper.rb +1 -1
  41. metadata +11 -25
@@ -9,6 +9,21 @@ require "thor"
9
9
  require_relative "cli_display"
10
10
 
11
11
  module NeocitiesRed
12
+ # Thor-based command-line interface for the NeocitiesRed gem.
13
+ #
14
+ # Provides subcommands for managing a Neocities site: push, upload,
15
+ # delete, diff, list, info, pull, purge, logout, and pizza.
16
+ #
17
+ # Authentication is handled lazily — the first command that requires
18
+ # an API connection will prompt for credentials (or read from config/env).
19
+ #
20
+ # @example Running from shell
21
+ # $ neocities-red push .
22
+ # $ neocities-red list -a
23
+ # $ neocities-red diff --ignore-dotfiles
24
+ #
25
+ # @see NeocitiesRed::Client Underlying API client
26
+ # @see NeocitiesRed::CliDisplay Terminal output helper
12
27
  class CLI < Thor
13
28
  package_name "neocities-red"
14
29
  default_task :help
@@ -23,11 +38,17 @@ module NeocitiesRed
23
38
  method_option :help, aliases: "-h", type: :boolean
24
39
  method_option :ignore_dotfiles, type: :boolean, default: false
25
40
  method_option :exclude, aliases: "-e", type: :string, repeatable: true, default: []
41
+
42
+ # Compares local files with the remote Neocities site and displays
43
+ # added, modified, and removed files.
44
+ #
45
+ # @param path [String] local directory path to compare (defaults to current directory)
46
+ # @return [void]
26
47
  def diff(path = ".")
27
48
  return display_help_for("diff") if help_requested?(options[:help], path)
28
49
 
29
50
  client = ensure_client!
30
- exclude = build_diff_exclusions(path, Array(options[:exclude]))
51
+ exclude = Services::Common::Exclusions.build(Array(options[:exclude]), base_path: path)
31
52
 
32
53
  added, modified, removed = Services::Site::Differencer.new(
33
54
  client,
@@ -42,16 +63,27 @@ module NeocitiesRed
42
63
 
43
64
  desc "delete PATH [PATH ...]", "Delete files on your Neocities site"
44
65
  method_option :help, aliases: "-h", type: :boolean
66
+
67
+ # Deletes one or more files from the remote Neocities site.
68
+ #
69
+ # @param paths [Array<String>] remote file paths to delete
70
+ # @return [void]
45
71
  def delete(*paths)
46
72
  return display_help_for("delete") if paths.empty? || help_requested?(options[:help], paths)
47
73
 
48
74
  client = ensure_client!
49
- paths.each { |path| Services::File::Remover.new(client, path).remove }
75
+ paths.each { |path| Services::File::Remover.new(client, path, display: display).remove }
50
76
  end
51
77
 
52
78
  desc "logout", "Remove the site api key from the config"
53
79
  method_option :help, aliases: "-h", type: :boolean
54
80
  method_option :yes, aliases: "-y", type: :boolean, default: false
81
+
82
+ # Removes the stored API key from the local config file.
83
+ #
84
+ # Requires the +--yes+ / +-y+ flag to confirm the action.
85
+ #
86
+ # @return [void]
55
87
  def logout
56
88
  return display_help_for("logout") if help_requested?(options[:help]) || !options[:yes]
57
89
 
@@ -61,6 +93,13 @@ module NeocitiesRed
61
93
 
62
94
  desc "info [SITENAME]", "Get site info"
63
95
  method_option :help, aliases: "-h", type: :boolean
96
+
97
+ # Displays information and statistics for a Neocities site.
98
+ #
99
+ # @param sitename [String, nil] site name to query; defaults to the
100
+ # currently authenticated site when omitted
101
+ # @return [void]
102
+ # @raise [NeocitiesRed::APIError] if the API request fails
64
103
  def info(sitename = nil)
65
104
  return display_help_for("info") if help_requested?(options[:help], sitename)
66
105
 
@@ -75,6 +114,13 @@ module NeocitiesRed
75
114
  method_option :help, aliases: "-h", type: :boolean
76
115
  method_option :detail, aliases: "-d", type: :boolean, default: false
77
116
  method_option :all, aliases: "-a", type: :boolean, default: false
117
+
118
+ # Lists files on the remote Neocities site.
119
+ #
120
+ # @param path [String, nil] remote directory path to list (nil for root,
121
+ # or when +--all+ is used)
122
+ # @return [void]
123
+ # @raise [NeocitiesRed::APIError] if the API request fails
78
124
  def list(path = nil)
79
125
  if help_requested?(options[:help], path) || (path.nil? && options[:all].nil? && options[:detail].nil?)
80
126
  display_help_for("list")
@@ -83,7 +129,9 @@ module NeocitiesRed
83
129
 
84
130
  client = ensure_client!
85
131
  path = nil if options[:all]
86
- display.say Services::File::List.new(client, path, options[:detail]).show
132
+ display.say Services::File::List.new(client, path, options[:detail], display: display).show
133
+ rescue NeocitiesRed::APIError => e
134
+ display.display_response(e)
87
135
  end
88
136
 
89
137
  desc "push PATH", "Recursively upload a local directory to your Neocities site"
@@ -94,6 +142,18 @@ module NeocitiesRed
94
142
  method_option :dry_run, type: :boolean, default: false
95
143
  method_option :prune, type: :boolean, default: false
96
144
  method_option :optimized, type: :boolean, default: false
145
+
146
+ # Recursively uploads a local directory to the Neocities site.
147
+ #
148
+ # Supports +--no-gitignore+ to ignore .gitignore rules,
149
+ # +--ignore-dotfiles+ to skip dot-prefixed files,
150
+ # +--exclude+ to skip specific paths, +--dry-run+ to preview changes,
151
+ # +--prune+ to delete remote files not present locally, and
152
+ # +--optimized+ to skip files whose SHA1 hash matches the server.
153
+ #
154
+ # @param root [String, nil] local directory path to upload
155
+ # @return [void]
156
+ # @raise [ArgumentError] if the path does not exist or is not a directory
97
157
  def push(root = nil)
98
158
  return display_help_for("push") if help_requested?(options[:help], root)
99
159
 
@@ -118,6 +178,15 @@ module NeocitiesRed
118
178
 
119
179
  desc "upload LOCAL_PATH [REMOTE_PATH]", "Upload a file/folder to your Neocities site"
120
180
  method_option :help, aliases: "-h", type: :boolean
181
+
182
+ # Uploads a single file or an entire folder to the Neocities site.
183
+ #
184
+ # When +LOCAL_PATH+ is a file, uploads it directly.
185
+ # When it is a directory, uploads all files within it in parallel.
186
+ #
187
+ # @param local_path [String, nil] local file or directory path
188
+ # @param remote_path [String, nil] remote destination; defaults to the basename of +local_path+
189
+ # @return [void]
121
190
  def upload(local_path = nil, remote_path = nil)
122
191
  return display_help_for("upload") if help_requested?(options[:help], [local_path, remote_path])
123
192
  return display_help_for("upload") if local_path.nil?
@@ -125,9 +194,9 @@ module NeocitiesRed
125
194
  client = ensure_client!
126
195
  dest = remote_path || File.basename(local_path)
127
196
  if File.file?(local_path)
128
- Services::File::Uploader.new(client, local_path, dest).upload
197
+ Services::File::Uploader.new(client, local_path, dest, display: display).upload
129
198
  elsif File.directory?(local_path)
130
- folder_uploader = Services::File::FolderUploader.new(client, local_path, dest)
199
+ folder_uploader = Services::File::FolderUploader.new(client, local_path, dest, display: display)
131
200
  files_list = folder_uploader.files
132
201
  folder_uploader.upload(files_list)
133
202
  end
@@ -136,6 +205,15 @@ module NeocitiesRed
136
205
  desc "pull", "Get the most recent version of files from your site"
137
206
  method_option :help, aliases: "-h", type: :boolean
138
207
  method_option :quiet, aliases: "-q", type: :boolean, default: false
208
+
209
+ # Downloads the latest version of site files from the remote Neocities site.
210
+ #
211
+ # Skips files that haven't changed since the last pull (based on stored
212
+ # timestamp and working directory). Use +--quiet+ to suppress per-file
213
+ # output and show a spinner instead.
214
+ #
215
+ # @return [void]
216
+ # @raise [StandardError] on network or API errors
139
217
  def pull
140
218
  return display_help_for("pull") if help_requested?(options[:help])
141
219
 
@@ -145,16 +223,27 @@ module NeocitiesRed
145
223
  last_pull_time = data.dig("LAST_PULL", "time")
146
224
  last_pull_loc = data.dig("LAST_PULL", "loc")
147
225
 
148
- Services::Site::Exporter.new(client, @sitename, data, app_config_path)
226
+ Services::Site::Exporter.new(client, @sitename, data, app_config_path, display: display)
149
227
  .export(quiet: options[:quiet], last_pull_time: last_pull_time, last_pull_loc: last_pull_loc)
228
+ rescue StandardError => e
229
+ display.display_response(e)
150
230
  end
151
231
 
152
232
  desc "purge", "Delete everything from your site (development only)"
153
233
  method_option :yes, aliases: "-y", type: :boolean, default: false
234
+ method_option :dry_run, type: :boolean, default: false
235
+
236
+ # Deletes all files from the Neocities site.
237
+ #
238
+ # Requires the +--yes+ / +-y+ flag to confirm the destructive action.
239
+ # Use +--dry-run+ to preview what would be deleted without changes.
240
+ #
241
+ # @return [void]
154
242
  def purge
155
243
  return display_help_for("purge") unless options[:yes]
156
244
 
157
245
  client = ensure_client!
246
+ display.display_dry_run_notice if options[:dry_run]
158
247
  resp = client.list
159
248
  deleted_dirs = []
160
249
  resp[:files].sort_by { |f| f[:is_directory] ? 0 : 1 }.each do |file|
@@ -173,10 +262,19 @@ module NeocitiesRed
173
262
  end
174
263
 
175
264
  desc "pizza", "Order a free pizza"
265
+
266
+ # Easter egg — displays a humorous pizza-related excuse.
267
+ #
268
+ # @return [void]
176
269
  def pizza
177
270
  display_help_for(__method__)
178
271
  end
179
272
 
273
+ # Returns the platform-specific application config directory path.
274
+ #
275
+ # @param name [String] application name (e.g. "neocities")
276
+ # @return [String, nil] full path to the config directory, or nil if
277
+ # the platform cannot be determined
180
278
  def self.app_config_path(name)
181
279
  platform = case RUBY_PLATFORM
182
280
  when /cygwin|mswin|mingw|bccwin|wince|emx|win32/
@@ -217,6 +315,12 @@ module NeocitiesRed
217
315
  end
218
316
 
219
317
  desc "help [COMMAND]", "Show help for a command"
318
+
319
+ # Displays help for a specific command or the general help screen.
320
+ #
321
+ # @param command [String, nil] command name to show help for;
322
+ # nil displays the main help screen
323
+ # @return [void]
220
324
  def help(command = nil)
221
325
  return display.display_help_and_exit if command.nil?
222
326
 
@@ -227,6 +331,10 @@ module NeocitiesRed
227
331
  end
228
332
 
229
333
  desc "version", "Display neocities-red version"
334
+
335
+ # Prints the current gem version to stdout.
336
+ #
337
+ # @return [void]
230
338
  def version
231
339
  display.say NeocitiesRed::VERSION
232
340
  end
@@ -234,18 +342,30 @@ module NeocitiesRed
234
342
  no_commands do
235
343
  alias_method :display_help_for, :help
236
344
 
345
+ # Returns the initialized {CliDisplay} instance.
346
+ #
347
+ # @return [NeocitiesRed::CliDisplay]
237
348
  def display
238
349
  @display ||= NeocitiesRed::CliDisplay.new
239
350
  end
240
351
 
352
+ # Returns the initialized TTY::Prompt instance for interactive input.
353
+ #
354
+ # @return [TTY::Prompt]
241
355
  def prompt
242
356
  @prompt ||= TTY::Prompt.new
243
357
  end
244
358
 
359
+ # Returns the full path to the application config file.
360
+ #
361
+ # @return [String] path to +config.json+ inside the platform config directory
245
362
  def app_config_path
246
363
  @app_config_path ||= File.join(self.class.app_config_path("neocities"), "config.json")
247
364
  end
248
365
 
366
+ # Reads and parses the JSON config file from disk.
367
+ #
368
+ # @return [Hash, nil] parsed config hash, or nil if the file does not exist
249
369
  def read_config
250
370
  file = File.read(app_config_path)
251
371
  JSON.parse(file)
@@ -253,12 +373,17 @@ module NeocitiesRed
253
373
  nil
254
374
  end
255
375
 
376
+ # Lazily initializes and returns an authenticated {Client} instance.
377
+ #
378
+ # Reads the API key from (in order): CLI option, environment variable,
379
+ # or stored config. If no key is found, triggers interactive login.
380
+ #
381
+ # @return [NeocitiesRed::Client]
256
382
  def ensure_client!
257
383
  return @client if @client
258
384
 
259
385
  config = read_config
260
386
  @sitename = config && config["SITENAME"]
261
- @last_pull = config && config["LAST_PULL"]
262
387
 
263
388
  @api_key = options[:api_key] || ENV.fetch("NEOCITIES_API_KEY", nil)
264
389
  @api_key ||= config && config["API_KEY"]&.strip
@@ -272,6 +397,13 @@ module NeocitiesRed
272
397
  @client
273
398
  end
274
399
 
400
+ # Prompts the user for credentials, obtains an API key, and stores it.
401
+ #
402
+ # Saves the API key and sitename to the local config file with
403
+ # restricted permissions (0600).
404
+ #
405
+ # @return [void]
406
+ # @raise [Thor::Error] if the API key cannot be obtained
275
407
  def authenticate_and_persist_key!
276
408
  display.display_login_prompt
277
409
 
@@ -293,36 +425,24 @@ module NeocitiesRed
293
425
  }
294
426
 
295
427
  FileUtils.mkdir_p(Pathname(app_config_path).dirname)
296
- File.write(app_config_path, conf.to_json)
428
+ persist_config(conf)
297
429
  display.display_api_key_saved(@sitename, app_config_path)
298
430
  @client = NeocitiesRed::Client.new(api_key: @api_key)
299
431
  end
300
432
 
301
- def build_diff_exclusions(base_path, excluded_entries)
302
- base = Pathname.new(base_path).expand_path
303
- excludes = []
304
-
305
- excluded_entries.each do |entry|
306
- target = Pathname.new(entry).expand_path
307
- next unless target.exist?
308
-
309
- filepath = target.relative_path_from(base).to_s
310
-
311
- if File.file?(target)
312
- excludes << filepath
313
- elsif File.directory?(target)
314
- excludes.concat(
315
- Dir.glob(File.join(target, "**", "*"), File::FNM_DOTMATCH).map do |path|
316
- Pathname.new(path).expand_path.relative_path_from(base).to_s
317
- end
318
- )
319
- excludes << filepath
320
- end
321
- end
322
-
323
- excludes
433
+ # Writes the config hash to disk as JSON and restricts file permissions.
434
+ #
435
+ # @param conf [Hash] configuration data to persist
436
+ # @return [void]
437
+ def persist_config(conf)
438
+ File.write(app_config_path, conf.to_json)
439
+ FileUtils.chmod(0o600, app_config_path)
324
440
  end
325
441
 
442
+ # Checks if the given value contains a help flag.
443
+ #
444
+ # @param value [String, Array<String>, nil] value to inspect
445
+ # @return [Boolean] true if the value is or contains "-h", "--help", or "help"
326
446
  def help_requested_for?(value)
327
447
  case value
328
448
  when Array
@@ -332,6 +452,11 @@ module NeocitiesRed
332
452
  end
333
453
  end
334
454
 
455
+ # Determines if help was requested via the +--help+ option or the value.
456
+ #
457
+ # @param help_option [Boolean, nil] the Thor +--help+ option value
458
+ # @param value [String, Array<String>, nil] the positional argument to check
459
+ # @return [Boolean]
335
460
  def help_requested?(help_option, value = nil)
336
461
  help_option || (value && help_requested_for?(value))
337
462
  end