spawnpoint 0.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.
data/exe/spwn ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "spawnpoint/cli"
5
+
6
+ exit Spawnpoint::CLI.run(ARGV)
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "version"
4
+
5
+ module Spawnpoint
6
+ # CLI implements the spwn command-line interface.
7
+ #
8
+ # It is a thin wrapper around Git. It renames Git commands into friendlier,
9
+ # game-like language and delegates the actual work to Git.
10
+ #
11
+ # The mapping from "spwn commands" to Git invocations lives in the
12
+ # COMMANDS table below so it is easy to extend without changing the
13
+ # dispatch logic.
14
+ module CLI
15
+ extend self
16
+
17
+ # ---------------------------------------------------------------------
18
+ # Helper methods
19
+ # ---------------------------------------------------------------------
20
+
21
+ def git(*args)
22
+ system("git", *args)
23
+ end
24
+
25
+ def say(message)
26
+ puts message
27
+ end
28
+
29
+ def error(message)
30
+ say("Oops: #{message}")
31
+ end
32
+
33
+ def ask_yes_no(question)
34
+ loop do
35
+ print "#{question} (y/n): "
36
+ answer = STDIN.gets.to_s.strip.downcase
37
+ return true if answer == "y"
38
+ return false if answer == "n"
39
+ say("Please type y or n.")
40
+ end
41
+ end
42
+
43
+ def extract_message_from_args(args)
44
+ message_parts = []
45
+ remaining = []
46
+ i = 0
47
+ while i < args.length
48
+ if args[i] == "-m" && i + 1 < args.length
49
+ message_parts << args[i + 1]
50
+ i += 2
51
+ elsif args[i] == "--message" && i + 1 < args.length
52
+ message_parts << args[i + 1]
53
+ i += 2
54
+ else
55
+ remaining << args[i]
56
+ i += 1
57
+ end
58
+ end
59
+ [message_parts.join(" "), remaining]
60
+ end
61
+
62
+ def files_from_args(args, allow_all: false)
63
+ if allow_all && args.include?("-A")
64
+ ["-A"]
65
+ elsif args.empty? || args.include?(".")
66
+ ["."]
67
+ else
68
+ args
69
+ end
70
+ end
71
+
72
+ def command_args_include_help?(args)
73
+ args.include?("--help") || args.include?("-h")
74
+ end
75
+
76
+ def git_command_exists?(name)
77
+ # A quick probe using git's own error reporting.
78
+ system("git", name, "--help", out: File::NULL, err: File::NULL)
79
+ end
80
+
81
+ def file_on_head?(path)
82
+ # Returns true when the given path is tracked and present on the current HEAD.
83
+ # We use git ls-files so the check respects the index/HEAD rather than the
84
+ # working tree alone.
85
+ system("git", "ls-files", "--error-unmatch", path, out: File::NULL, err: File::NULL)
86
+ end
87
+
88
+ # ---------------------------------------------------------------------
89
+ # Command mapping table
90
+ #
91
+ # Each entry is a hash with:
92
+ # :name - the spwn subcommand students type
93
+ # :help - a short child-friendly description
94
+ # :usage - a short usage hint
95
+ # :handler - a callable that receives the remaining arguments
96
+ #
97
+ # Keeping this table in one place is deliberate: it is the single place
98
+ # to look when you want to add or change a command.
99
+ # ---------------------------------------------------------------------
100
+
101
+ COMMANDS = {
102
+ init: {
103
+ name: "init",
104
+ help: "Start a new project folder that Git can track.",
105
+ usage: "spwn init",
106
+ handler: ->(args) { git("init", *args) }
107
+ },
108
+
109
+ save: {
110
+ name: "save",
111
+ help: "Take a snapshot of your work. First pick which files to include, then give your snapshot a note.",
112
+ usage: "spwn save <files...> -m 'your note' or spwn save -m 'your note'",
113
+ handler: ->(args) {
114
+ message, _rest = extract_message_from_args(args)
115
+ if message.nil? || message.empty?
116
+ error("Tell spwn what you changed with -m, like: spwn save -m 'added a score'")
117
+ next nil
118
+ end
119
+
120
+ if ask_yes_no("Save all the changed files in this folder?")
121
+ git("add", ".")
122
+ git("commit", "-m", message)
123
+ else
124
+ say("Save cancelled. You can pick files one by one with spwn add.")
125
+ 1
126
+ end
127
+ }
128
+ },
129
+
130
+ add: {
131
+ name: "add",
132
+ help: "Tell Git which files to include in the next snapshot.",
133
+ usage: "spwn add <file>... or spwn add . or spwn add -A",
134
+ handler: ->(args) {
135
+ files = files_from_args(args, allow_all: true)
136
+ git("add", *files)
137
+ }
138
+ },
139
+
140
+ commit: {
141
+ name: "commit",
142
+ help: "Save the files you have already picked. You must include a note with -m.",
143
+ usage: "spwn commit -m 'your note'",
144
+ handler: ->(args) {
145
+ message, _rest = extract_message_from_args(args)
146
+ if message.nil? || message.empty?
147
+ error("Every save needs a note. Try: spwn commit -m 'made the hero jump'")
148
+ next nil
149
+ end
150
+ git("commit", "-m", message)
151
+ }
152
+ },
153
+
154
+ look: {
155
+ name: "look",
156
+ help: "See what is going on in your project right now.",
157
+ usage: "spwn look",
158
+ handler: ->(args) { git("status", *args) }
159
+ },
160
+
161
+ compare: {
162
+ name: "compare",
163
+ help: "See what changed since the last snapshot.",
164
+ usage: "spwn compare or spwn compare <file>",
165
+ handler: ->(args) { git("diff", *args) }
166
+ },
167
+
168
+ history: {
169
+ name: "history",
170
+ help: "Replay the story of your project, one snapshot at a time.",
171
+ usage: "spwn history or spwn history -n 5",
172
+ handler: ->(args) { git("log", *args) }
173
+ },
174
+
175
+ hop: {
176
+ name: "hop",
177
+ help: "Jump to another universe (branch) or bring back a file from another snapshot.",
178
+ usage: "spwn hop <branch> or spwn hop -- <branch> <file>",
179
+ handler: ->(args) {
180
+ if args.empty?
181
+ error("Where should we hop? Try a branch name, or use spwn hop -- <branch> <file> to restore a file.")
182
+ next nil
183
+ end
184
+
185
+ if args[0] == "--"
186
+ rest = args[1..]
187
+ if rest.nil? || rest.empty?
188
+ error("What should we restore? Try: spwn hop -- main my_level.rb")
189
+ next nil
190
+ end
191
+
192
+ if rest.length == 1
193
+ # Only one thing after --: could be a branch or a file.
194
+ # If it is a file that exists on the current HEAD, restore it from here.
195
+ # Otherwise ask for the source branch explicitly.
196
+ candidate = rest[0]
197
+ if file_on_head?(candidate)
198
+ next git("restore", "--", candidate)
199
+ else
200
+ error("I cannot tell which universe to pull #{candidate} from. Try: spwn hop -- <branch> #{candidate}")
201
+ next nil
202
+ end
203
+ else
204
+ # branch followed by one or more files
205
+ source_branch = rest[0]
206
+ files = rest[1..]
207
+ if files.nil? || files.empty?
208
+ error("Which file should we bring back from #{source_branch}? Try: spwn hop -- #{source_branch} my_level.rb")
209
+ next nil
210
+ end
211
+ next git("restore", "--source", source_branch, "--", *files)
212
+ end
213
+ end
214
+
215
+ git("switch", *args)
216
+ }
217
+ },
218
+
219
+ upload: {
220
+ name: "upload",
221
+ help: "Send your snapshots to the shared project space.",
222
+ usage: "spwn upload or spwn upload <branch>",
223
+ handler: ->(args) { git("push", *args) }
224
+ },
225
+
226
+ download: {
227
+ name: "download",
228
+ help: "Fetch new snapshots from the shared project space and combine them with yours.",
229
+ usage: "spwn download or spwn download <remote> <branch>",
230
+ handler: ->(args) { git("pull", *args) }
231
+ },
232
+
233
+ sync: {
234
+ name: "sync",
235
+ help: "Copy a lesson folder into your game folder. The first time a lesson touches a file that already exists in your game folder, spwn asks before replacing it. After you accept a file, later lessons upgrade that same file automatically, so asset updates get easier as you go.",
236
+ usage: "spwn sync <lesson-folder> --into <game-folder> or spwn sync <lesson-folder> --into <game-folder> --force",
237
+ handler: ->(args) { require_relative "synchronizer"; Spawnpoint::Synchronizer.new.run(args) }
238
+ },
239
+
240
+ rollback: {
241
+ name: "rollback",
242
+ help: "Undo the most recent lesson sync.",
243
+ usage: "spwn rollback --into <game-folder>",
244
+ handler: ->(args) { require_relative "synchronizer"; Spawnpoint::Synchronizer.new.rollback(args) }
245
+ }
246
+ }.freeze
247
+
248
+ SUBCOMMANDS = COMMANDS.values.freeze
249
+
250
+ # ---------------------------------------------------------------------
251
+ # Help text
252
+ # ---------------------------------------------------------------------
253
+
254
+ def print_help
255
+ say("spwn v#{VERSION}")
256
+ say("A friendly face for Git, made for learning game programming.")
257
+ say("")
258
+ say("Usage:")
259
+ say(" spwn <command> [options]")
260
+ say("")
261
+ say("Commands:")
262
+ SUBCOMMANDS.sort_by { |c| c[:name] }.each do |cmd|
263
+ say(" #{cmd[:name].to_s.ljust(12)} #{cmd[:help]}")
264
+ end
265
+ say("")
266
+ say("Tips:")
267
+ say(" - Run spwn <command> --help to see Git's own help for that command.")
268
+ say(" - You can still use git directly when you are ready.")
269
+ say(" - spwn save is a shortcut for adding changed files and committing them together.")
270
+ end
271
+
272
+ def print_command_help(command_name)
273
+ cmd = COMMANDS[command_name]
274
+ if cmd.nil?
275
+ error("I do not know that command. Run spwn --help to see the list.")
276
+ return
277
+ end
278
+
279
+ say("spwn #{cmd[:name]}")
280
+ say("")
281
+ say(cmd[:help])
282
+ say("")
283
+ say("Usage:")
284
+ say(" #{cmd[:usage]}")
285
+ end
286
+
287
+ # ---------------------------------------------------------------------
288
+ # Dispatch
289
+ # ---------------------------------------------------------------------
290
+
291
+ # Runs the CLI and returns an Integer exit code.
292
+ #
293
+ # Handlers return an Integer exit code, or true/false from `system`.
294
+ # Integers pass through, `true` maps to 0, and everything else (false, nil)
295
+ # maps to 1, so misuse and Git failures exit non-zero.
296
+ def run(argv)
297
+ command_name = argv[0]
298
+
299
+ if command_name.nil? || command_name == "--help" || command_name == "-h"
300
+ print_help
301
+ return 0
302
+ end
303
+
304
+ if command_name == "--version" || command_name == "-v"
305
+ say("spwn v#{VERSION}")
306
+ return 0
307
+ end
308
+
309
+ command_key = command_name.to_sym
310
+ cmd = COMMANDS[command_key]
311
+
312
+ unless cmd
313
+ error("I do not know that command: #{command_name}")
314
+ say("Run spwn --help to see the commands I do know.")
315
+ return 1
316
+ end
317
+
318
+ command_args = argv[1..] || []
319
+
320
+ if command_args_include_help?(command_args)
321
+ print_command_help(command_key)
322
+ if git_command_exists?(command_key.to_s)
323
+ git(command_key.to_s, *command_args)
324
+ else
325
+ say("")
326
+ say("Git does not have a #{command_key} command, so there is no extra help to show beyond this.")
327
+ end
328
+ return 0
329
+ end
330
+
331
+ normalize_exit(cmd[:handler].call(command_args))
332
+ end
333
+
334
+ def normalize_exit(result)
335
+ case result
336
+ when Integer then result
337
+ when true then 0
338
+ else 1
339
+ end
340
+ end
341
+ end
342
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Synchronizer implements `spwn sync`.
4
+ #
5
+ # It copies a lesson source folder into a DragonRuby game folder (usually
6
+ # mygame/). It is intentionally a copy tool, not a Git command: the course
7
+ # materials are distributed as folders, and the student's game folder is the
8
+ # place where DragonRuby loads them.
9
+ #
10
+ # Design notes:
11
+ #
12
+ # - Source is a directory, not a single file. Lessons are more than one file
13
+ # once assets enter the picture.
14
+ # - Existing files are never overwritten without the student saying so. This
15
+ # matters most for assets: a later lesson may ship a better sprite, and the
16
+ # student should choose whether to replace the older one.
17
+ # - When the source contains an assets/ folder, the sync prints a reminder that
18
+ # assets are part of this lesson and may change between lessons.
19
+
20
+ require "fileutils"
21
+ require "pathname"
22
+ require "set"
23
+
24
+ module Spawnpoint
25
+ class Synchronizer
26
+ BACKUP_DIR = ".spwn_sync_backup"
27
+
28
+ def run(argv)
29
+ lesson_folder, into_folder, force = parse_args(argv)
30
+
31
+ unless lesson_folder
32
+ puts "Usage:"
33
+ puts " spwn sync <lesson-folder> --into <game-folder>"
34
+ puts " spwn sync <lesson-folder> --into <game-folder> --force"
35
+ puts ""
36
+ puts "Example:"
37
+ puts " spwn sync 04-collectibles/starter --into ~/DragonRuby/mygame"
38
+ puts ""
39
+ puts "The first time a lesson copies a file that already exists in your"
40
+ puts "game folder, spwn asks before replacing it. After you accept a"
41
+ puts "file, later lessons replace that same file automatically."
42
+ return 1
43
+ end
44
+
45
+ unless into_folder
46
+ puts "Oops: tell spwn where to copy the lesson with --into."
47
+ puts "Example: spwn sync 04-collectibles/starter --into ~/DragonRuby/mygame"
48
+ return 1
49
+ end
50
+
51
+ source = Pathname.new(lesson_folder).expand_path
52
+ target = Pathname.new(into_folder).expand_path
53
+
54
+ unless source.directory?
55
+ puts "Oops: #{source} is not a folder."
56
+ return 1
57
+ end
58
+
59
+ unless target.directory?
60
+ puts "Oops: #{target} is not a folder yet."
61
+ return 1
62
+ end
63
+
64
+ has_assets = source.join("assets").directory?
65
+
66
+ if has_assets
67
+ puts "This lesson includes an assets/ folder."
68
+ puts "Assets may look different from the previous lesson."
69
+ puts "If you already have sprites from an older lesson, you will be asked before each one is replaced."
70
+ puts ""
71
+ end
72
+
73
+ marker = target.join(".spwn_synced_paths")
74
+ backup = target.join(BACKUP_DIR)
75
+ FileUtils.rm_rf(backup)
76
+ backup.join("files").mkpath
77
+ backup.join("created_paths").write("")
78
+ if marker.file?
79
+ FileUtils.cp(marker, backup.join("marker"))
80
+ else
81
+ backup.join("no_marker").write("")
82
+ end
83
+
84
+ known = if marker.file?
85
+ marker.readlines.map(&:strip).reject(&:empty?).to_set
86
+ else
87
+ Set.new
88
+ end
89
+
90
+ already_existed = Set.new
91
+ target.find.each do |child|
92
+ next unless child.file?
93
+ already_existed << child.relative_path_from(target).to_s
94
+ end
95
+
96
+ copied = 0
97
+ upgraded = 0
98
+ skipped = 0
99
+ new_known = Set.new
100
+
101
+ source.find.to_a.each do |child|
102
+ next unless child.file?
103
+
104
+ rel = child.relative_path_from(source)
105
+ rel_s = rel.to_s
106
+ dest = target.join(rel)
107
+
108
+ if dest.exist?
109
+ if force || known.include?(rel_s)
110
+ backup_existing(backup, dest, rel_s)
111
+ copy_file(child, dest)
112
+ upgraded += 1
113
+ new_known << rel_s
114
+ elsif already_existed.include?(rel_s)
115
+ if agree?("Copy #{rel} from this lesson?")
116
+ backup_existing(backup, dest, rel_s)
117
+ copy_file(child, dest)
118
+ upgraded += 1
119
+ new_known << rel_s
120
+ else
121
+ skipped += 1
122
+ end
123
+ end
124
+ else
125
+ copy_file(child, dest)
126
+ File.open(backup.join("created_paths"), "a") { |fh| fh.puts(rel_s) }
127
+ copied += 1
128
+ new_known << rel_s
129
+ end
130
+ end
131
+
132
+ merged = known | new_known
133
+ if merged.any?
134
+ marker.open("w") do |fh|
135
+ merged.to_a.sort.each do |name|
136
+ fh.puts(name)
137
+ end
138
+ end
139
+ end
140
+
141
+ puts ""
142
+ puts "Done."
143
+ puts "New: #{copied}"
144
+ puts "Updated: #{upgraded}"
145
+ puts "Skipped: #{skipped}"
146
+
147
+ 0
148
+ end
149
+
150
+ def rollback(argv)
151
+ target = parse_rollback_args(argv)
152
+ unless target
153
+ puts "Usage: spwn rollback --into <game-folder>"
154
+ return 1
155
+ end
156
+
157
+ target = Pathname.new(target).expand_path
158
+ backup = target.join(BACKUP_DIR)
159
+ unless backup.directory?
160
+ puts "Oops: there is no lesson sync to roll back in #{target}."
161
+ return 1
162
+ end
163
+
164
+ backup.join("created_paths").readlines.each do |line|
165
+ target.join(line.strip).delete if !line.strip.empty? && target.join(line.strip).file?
166
+ end
167
+ backup.join("files").find do |saved|
168
+ next if saved.directory? || saved == backup.join("files")
169
+ rel = saved.relative_path_from(backup.join("files"))
170
+ dest = target.join(rel)
171
+ FileUtils.mkdir_p(dest.parent)
172
+ FileUtils.cp(saved, dest)
173
+ end
174
+
175
+ marker = target.join(".spwn_synced_paths")
176
+ if backup.join("marker").file?
177
+ FileUtils.cp(backup.join("marker"), marker)
178
+ else
179
+ marker.delete if marker.file?
180
+ end
181
+
182
+ FileUtils.rm_rf(backup)
183
+ puts "Rolled back the most recent lesson sync."
184
+ 0
185
+ end
186
+
187
+ private
188
+
189
+ def parse_args(argv)
190
+ lesson_folder = nil
191
+ into_folder = nil
192
+ force = false
193
+
194
+ i = 0
195
+ while i < argv.length
196
+ arg = argv[i]
197
+ case arg
198
+ when "--into"
199
+ i += 1
200
+ into_folder = argv[i]
201
+ when "--force", "-f"
202
+ force = true
203
+ when /\A-/
204
+ puts "Oops: I do not understand #{arg}."
205
+ return [nil, nil, false]
206
+ else
207
+ lesson_folder ||= arg
208
+ end
209
+ i += 1
210
+ end
211
+
212
+ [lesson_folder, into_folder, force]
213
+ end
214
+
215
+ def parse_rollback_args(argv)
216
+ i = argv.index("--into")
217
+ i && argv[i + 1]
218
+ end
219
+
220
+ def backup_existing(backup, dest, rel)
221
+ saved = backup.join("files", rel)
222
+ FileUtils.mkdir_p(saved.parent)
223
+ FileUtils.cp(dest, saved)
224
+ end
225
+
226
+ def copy_file(source, dest)
227
+ FileUtils.mkdir_p(dest.parent)
228
+ FileUtils.cp(source, dest)
229
+ end
230
+
231
+ def agree?(question)
232
+ loop do
233
+ print "#{question} (y/n): "
234
+ answer = STDIN.gets
235
+ return false if answer.nil?
236
+
237
+ answer = answer.strip.downcase
238
+ return true if answer == "y"
239
+ return false if answer == "n"
240
+ puts "Please type y or n."
241
+ end
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spawnpoint
4
+ VERSION = "0.2.0"
5
+ end
data/lib/spawnpoint.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "spawnpoint/version"
4
+ require_relative "spawnpoint/cli"
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/spawnpoint/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "spawnpoint"
7
+ spec.version = Spawnpoint::VERSION
8
+ spec.authors = ["bebekim"]
9
+ spec.summary = "A child-friendly Git mask for learning game programming"
10
+ spec.description = "spwn is a thin wrapper around Git that renames Git " \
11
+ "commands into friendlier, game-like language for kids " \
12
+ "learning game programming."
13
+ spec.homepage = "https://github.com/bebekim/spawnpoint"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = ">= 3.0"
16
+
17
+ spec.files = Dir.chdir(__dir__) { `git ls-files -z`.split("\x0") }
18
+ spec.bindir = "exe"
19
+ spec.executables = ["spwn"]
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.metadata = {
23
+ "source_code_uri" => "https://github.com/bebekim/spawnpoint",
24
+ "rubygems_mfa_required" => "true"
25
+ }
26
+ end
data/test/test_cli.rb ADDED
@@ -0,0 +1,38 @@
1
+ require "test_helper"
2
+ require "spawnpoint/cli"
3
+
4
+ class TestCli < Minitest::Test
5
+ def run_cli(argv)
6
+ capture_io { @status = Spawnpoint::CLI.run(argv) }
7
+ @status
8
+ end
9
+
10
+ def test_no_arguments_prints_help_and_exits_zero
11
+ assert_equal 0, run_cli([])
12
+ end
13
+
14
+ def test_help_flag_exits_zero
15
+ assert_equal 0, run_cli(["--help"])
16
+ end
17
+
18
+ def test_version_flag_exits_zero
19
+ out, = capture_io { Spawnpoint::CLI.run(["--version"]) }
20
+ assert_includes out, "0.2.0"
21
+ end
22
+
23
+ def test_unknown_command_exits_one
24
+ assert_equal 1, run_cli(["teleport"])
25
+ end
26
+
27
+ def test_save_without_message_exits_one
28
+ assert_equal 1, run_cli(["save"])
29
+ end
30
+
31
+ def test_commit_without_message_exits_one
32
+ assert_equal 1, run_cli(["commit"])
33
+ end
34
+
35
+ def test_hop_without_arguments_exits_one
36
+ assert_equal 1, run_cli(["hop"])
37
+ end
38
+ end
@@ -0,0 +1,2 @@
1
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
2
+ require "minitest/autorun"