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.
@@ -0,0 +1,1223 @@
1
+ # Package spwn as the `spawnpoint` Gem — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Restructure the spwn script into the publishable `spawnpoint` Ruby gem (executable `spwn`), fixing the exit-code, marker-merge, and `Syncronizer` naming bugs on the way.
6
+
7
+ **Architecture:** Standard gem layout: `exe/spwn` is a two-line executable; all logic moves from `bin/spwn` and `bin/__sync__.rb` into `lib/spawnpoint/*.rb` under the `Spawnpoint` namespace. Tests use minitest (stdlib).
8
+
9
+ **Tech Stack:** Ruby >= 3.0, rubygems, minitest, rake. No runtime dependencies.
10
+
11
+ **Spec:** `docs/2026-08-14-spawnpoint-gem-design.md`
12
+
13
+ ## Global Constraints
14
+
15
+ - Gem name is `spawnpoint`; the installed executable is `spwn`.
16
+ - Module namespace is `Spawnpoint` everywhere (`Spawnpoint::CLI`, `Spawnpoint::Synchronizer`, `Spawnpoint::VERSION`).
17
+ - `Spawnpoint::VERSION` is `"0.2.0"`.
18
+ - No runtime dependencies — Ruby stdlib only (`fileutils`, `pathname`, `set`).
19
+ - All user-facing messages keep the existing child-friendly voice ("Oops: ...").
20
+ - Handler return contract: a handler returns an Integer exit code, or `true`/`false` from `system`; the dispatcher normalizes (`true`→0, `false`/`nil`→1) and `Spawnpoint::CLI.run` returns it.
21
+ - Do not commit the untracked pipeline scaffolding (`plan.json`, `pipeline-config.json`, etc.).
22
+
23
+ ---
24
+
25
+ ### Task 1: Gem skeleton and version
26
+
27
+ **Files:**
28
+ - Create: `spawnpoint.gemspec`
29
+ - Create: `Gemfile`
30
+ - Create: `Rakefile`
31
+ - Create: `LICENSE`
32
+ - Modify: `.gitignore`
33
+ - Create: `lib/spawnpoint/version.rb`
34
+ - Create: `test/test_helper.rb`
35
+ - Create: `test/test_version.rb`
36
+
37
+ **Interfaces:**
38
+ - Produces: `Spawnpoint::VERSION` (String, `"0.2.0"`), required via `require "spawnpoint/version"`. Tasks 2–4 and the gemspec depend on it.
39
+
40
+ - [ ] **Step 1: Write the failing test**
41
+
42
+ `test/test_helper.rb`:
43
+
44
+ ```ruby
45
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
46
+ require "minitest/autorun"
47
+ ```
48
+
49
+ `test/test_version.rb`:
50
+
51
+ ```ruby
52
+ require "test_helper"
53
+ require "spawnpoint/version"
54
+
55
+ class TestVersion < Minitest::Test
56
+ def test_version_format
57
+ assert_match(/\A\d+\.\d+\.\d+\z/, Spawnpoint::VERSION)
58
+ end
59
+
60
+ def test_version_is_0_2_0
61
+ assert_equal "0.2.0", Spawnpoint::VERSION
62
+ end
63
+ end
64
+ ```
65
+
66
+ - [ ] **Step 2: Run test to verify it fails**
67
+
68
+ Run: `ruby -Itest test/test_version.rb`
69
+ Expected: FAIL with `cannot load such file -- spawnpoint/version` (LoadError)
70
+
71
+ - [ ] **Step 3: Write minimal implementation**
72
+
73
+ `lib/spawnpoint/version.rb`:
74
+
75
+ ```ruby
76
+ # frozen_string_literal: true
77
+
78
+ module Spawnpoint
79
+ VERSION = "0.2.0"
80
+ end
81
+ ```
82
+
83
+ - [ ] **Step 4: Run test to verify it passes**
84
+
85
+ Run: `ruby -Itest test/test_version.rb`
86
+ Expected: PASS — 2 runs, 2 assertions, 0 failures
87
+
88
+ - [ ] **Step 5: Add gem packaging files**
89
+
90
+ `spawnpoint.gemspec`:
91
+
92
+ ```ruby
93
+ # frozen_string_literal: true
94
+
95
+ require_relative "lib/spawnpoint/version"
96
+
97
+ Gem::Specification.new do |spec|
98
+ spec.name = "spawnpoint"
99
+ spec.version = Spawnpoint::VERSION
100
+ spec.authors = ["bebekim"]
101
+ spec.summary = "A child-friendly Git mask for learning game programming"
102
+ spec.description = "spwn is a thin wrapper around Git that renames Git " \
103
+ "commands into friendlier, game-like language for kids " \
104
+ "learning game programming."
105
+ spec.homepage = "https://github.com/bebekim/spawnpoint"
106
+ spec.license = "MIT"
107
+ spec.required_ruby_version = ">= 3.0"
108
+
109
+ spec.files = Dir.chdir(__dir__) { `git ls-files -z`.split("\x0") }
110
+ spec.bindir = "exe"
111
+ spec.executables = ["spwn"]
112
+ spec.require_paths = ["lib"]
113
+
114
+ spec.metadata = {
115
+ "source_code_uri" => "https://github.com/bebekim/spawnpoint",
116
+ "rubygems_mfa_required" => "true"
117
+ }
118
+ end
119
+ ```
120
+
121
+ `Gemfile`:
122
+
123
+ ```ruby
124
+ # frozen_string_literal: true
125
+
126
+ source "https://rubygems.org"
127
+
128
+ gemspec
129
+
130
+ gem "minitest", "~> 5.0"
131
+ gem "rake", "~> 13.0"
132
+ ```
133
+
134
+ `Rakefile`:
135
+
136
+ ```ruby
137
+ # frozen_string_literal: true
138
+
139
+ require "rake/testtask"
140
+
141
+ Rake::TestTask.new(:test) do |t|
142
+ t.libs << "test" << "lib"
143
+ t.test_files = FileList["test/**/test_*.rb"]
144
+ end
145
+
146
+ task default: :test
147
+ ```
148
+
149
+ `LICENSE` (MIT, year 2026, copyright holder "bebekim"):
150
+
151
+ ```text
152
+ MIT License
153
+
154
+ Copyright (c) 2026 bebekim
155
+
156
+ Permission is hereby granted, free of charge, to any person obtaining a copy
157
+ of this software and associated documentation files (the "Software"), to deal
158
+ in the Software without restriction, including without limitation the rights
159
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
160
+ copies of the Software, and to permit persons to whom the Software is
161
+ furnished to do so, subject to the following conditions:
162
+
163
+ The above copyright notice and this permission notice shall be included in all
164
+ copies or substantial portions of the Software.
165
+
166
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
167
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
168
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
169
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
170
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
171
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
172
+ SOFTWARE.
173
+ ```
174
+
175
+ Append to `.gitignore` (keep the existing `.claude` / `.agent-learning` lines):
176
+
177
+ ```text
178
+ *.gem
179
+ /pkg/
180
+ ```
181
+
182
+ - [ ] **Step 6: Verify rake and gem build**
183
+
184
+ Run: `bundle install && rake`
185
+ Expected: PASS — version tests run green via rake
186
+
187
+ Run: `gem build spawnpoint.gemspec`
188
+ Expected: `Successfully built RubyGem` producing `spawnpoint-0.2.0.gem`. Note: `spec.executables` lists `spwn` which does not exist yet — that warning is fine at this point; Task 2 adds `exe/spwn`. Delete the built file afterwards: `rm spawnpoint-0.2.0.gem`.
189
+
190
+ - [ ] **Step 7: Commit**
191
+
192
+ ```bash
193
+ git add spawnpoint.gemspec Gemfile Rakefile LICENSE .gitignore lib/spawnpoint/version.rb test/test_helper.rb test/test_version.rb
194
+ git commit -m "add gem skeleton with Spawnpoint::VERSION"
195
+ ```
196
+
197
+ ---
198
+
199
+ ### Task 2: Move the CLI into `lib/spawnpoint/cli.rb`
200
+
201
+ **Files:**
202
+ - Create: `lib/spawnpoint/cli.rb`
203
+ - Create: `lib/spawnpoint.rb`
204
+ - Create: `exe/spwn`
205
+ - Create: `test/test_cli.rb`
206
+ - Reference (do not modify yet): `bin/spwn` — the code moves from here
207
+
208
+ **Interfaces:**
209
+ - Consumes: `Spawnpoint::VERSION` from Task 1.
210
+ - Produces: `Spawnpoint::CLI.run(argv)` → Integer exit code. This is the only public entry point; `exe/spwn` calls it. The `sync`/`rollback` handlers lazily `require_relative "synchronizer"` and call `Spawnpoint::Synchronizer.new.run(args)` / `.rollback(args)` — Task 3 provides that class.
211
+
212
+ - [ ] **Step 1: Write the failing tests**
213
+
214
+ `test/test_cli.rb`:
215
+
216
+ ```ruby
217
+ require "test_helper"
218
+ require "spawnpoint/cli"
219
+
220
+ class TestCli < Minitest::Test
221
+ def run_cli(argv)
222
+ capture_io { @status = Spawnpoint::CLI.run(argv) }
223
+ @status
224
+ end
225
+
226
+ def test_no_arguments_prints_help_and_exits_zero
227
+ assert_equal 0, run_cli([])
228
+ end
229
+
230
+ def test_help_flag_exits_zero
231
+ assert_equal 0, run_cli(["--help"])
232
+ end
233
+
234
+ def test_version_flag_exits_zero
235
+ out, = capture_io { Spawnpoint::CLI.run(["--version"]) }
236
+ assert_includes out, "0.2.0"
237
+ end
238
+
239
+ def test_unknown_command_exits_one
240
+ assert_equal 1, run_cli(["teleport"])
241
+ end
242
+
243
+ def test_save_without_message_exits_one
244
+ assert_equal 1, run_cli(["save"])
245
+ end
246
+
247
+ def test_commit_without_message_exits_one
248
+ assert_equal 1, run_cli(["commit"])
249
+ end
250
+
251
+ def test_hop_without_arguments_exits_one
252
+ assert_equal 1, run_cli(["hop"])
253
+ end
254
+ end
255
+ ```
256
+
257
+ - [ ] **Step 2: Run tests to verify they fail**
258
+
259
+ Run: `ruby -Itest test/test_cli.rb`
260
+ Expected: FAIL with `cannot load such file -- spawnpoint/cli` (LoadError)
261
+
262
+ - [ ] **Step 3: Implement `lib/spawnpoint/cli.rb`**
263
+
264
+ This is the full content of `bin/spwn` reorganized into the `Spawnpoint::CLI` module, with the exit-code fix. Changes from `bin/spwn`: top-level methods become module functions (`extend self`); `VERSION` becomes `Spawnpoint::VERSION`; the sync/rollback handlers require `synchronizer` and use the renamed `Spawnpoint::Synchronizer`; `run` normalizes handler return values instead of always returning 0; the trailing `exit(run(ARGV))` script lines are gone (that moves to `exe/spwn`).
265
+
266
+ `lib/spawnpoint/cli.rb`:
267
+
268
+ ```ruby
269
+ # frozen_string_literal: true
270
+
271
+ require_relative "version"
272
+
273
+ module Spawnpoint
274
+ # CLI implements the spwn command-line interface.
275
+ #
276
+ # It is a thin wrapper around Git. It renames Git commands into friendlier,
277
+ # game-like language and delegates the actual work to Git.
278
+ #
279
+ # The mapping from "spwn commands" to Git invocations lives in the
280
+ # COMMANDS table below so it is easy to extend without changing the
281
+ # dispatch logic.
282
+ module CLI
283
+ extend self
284
+
285
+ # ---------------------------------------------------------------------
286
+ # Helper methods
287
+ # ---------------------------------------------------------------------
288
+
289
+ def git(*args)
290
+ system("git", *args)
291
+ end
292
+
293
+ def say(message)
294
+ puts message
295
+ end
296
+
297
+ def error(message)
298
+ say("Oops: #{message}")
299
+ end
300
+
301
+ def ask_yes_no(question)
302
+ loop do
303
+ print "#{question} (y/n): "
304
+ answer = STDIN.gets.to_s.strip.downcase
305
+ return true if answer == "y"
306
+ return false if answer == "n"
307
+ say("Please type y or n.")
308
+ end
309
+ end
310
+
311
+ def extract_message_from_args(args)
312
+ message_parts = []
313
+ remaining = []
314
+ i = 0
315
+ while i < args.length
316
+ if args[i] == "-m" && i + 1 < args.length
317
+ message_parts << args[i + 1]
318
+ i += 2
319
+ elsif args[i] == "--message" && i + 1 < args.length
320
+ message_parts << args[i + 1]
321
+ i += 2
322
+ else
323
+ remaining << args[i]
324
+ i += 1
325
+ end
326
+ end
327
+ [message_parts.join(" "), remaining]
328
+ end
329
+
330
+ def files_from_args(args, allow_all: false)
331
+ if allow_all && args.include?("-A")
332
+ ["-A"]
333
+ elsif args.empty? || args.include?(".")
334
+ ["."]
335
+ else
336
+ args
337
+ end
338
+ end
339
+
340
+ def command_args_include_help?(args)
341
+ args.include?("--help") || args.include?("-h")
342
+ end
343
+
344
+ def git_command_exists?(name)
345
+ # A quick probe using git's own error reporting.
346
+ system("git", name, "--help", out: "/dev/null", err: "/dev/null")
347
+ end
348
+
349
+ def file_on_head?(path)
350
+ # Returns true when the given path is tracked and present on the current HEAD.
351
+ # We use git ls-files so the check respects the index/HEAD rather than the
352
+ # working tree alone.
353
+ system("git", "ls-files", "--error-unmatch", path, out: "/dev/null", err: "/dev/null")
354
+ end
355
+
356
+ # ---------------------------------------------------------------------
357
+ # Command mapping table
358
+ #
359
+ # Each entry is a hash with:
360
+ # :name - the spwn subcommand students type
361
+ # :help - a short child-friendly description
362
+ # :usage - a short usage hint
363
+ # :handler - a callable that receives the remaining arguments
364
+ #
365
+ # Keeping this table in one place is deliberate: it is the single place
366
+ # to look when you want to add or change a command.
367
+ # ---------------------------------------------------------------------
368
+
369
+ COMMANDS = {
370
+ init: {
371
+ name: "init",
372
+ help: "Start a new project folder that Git can track.",
373
+ usage: "spwn init",
374
+ handler: ->(args) { git("init", *args) }
375
+ },
376
+
377
+ save: {
378
+ name: "save",
379
+ help: "Take a snapshot of your work. First pick which files to include, then give your snapshot a note.",
380
+ usage: "spwn save <files...> -m 'your note' or spwn save -m 'your note'",
381
+ handler: ->(args) {
382
+ message, _rest = extract_message_from_args(args)
383
+ if message.nil? || message.empty?
384
+ error("Tell spwn what you changed with -m, like: spwn save -m 'added a score'")
385
+ next nil
386
+ end
387
+
388
+ if ask_yes_no("Save all the changed files in this folder?")
389
+ git("add", ".")
390
+ git("commit", "-m", message)
391
+ else
392
+ say("Save cancelled. You can pick files one by one with spwn add.")
393
+ 1
394
+ end
395
+ }
396
+ },
397
+
398
+ add: {
399
+ name: "add",
400
+ help: "Tell Git which files to include in the next snapshot.",
401
+ usage: "spwn add <file>... or spwn add . or spwn add -A",
402
+ handler: ->(args) {
403
+ files = files_from_args(args, allow_all: true)
404
+ git("add", *files)
405
+ }
406
+ },
407
+
408
+ commit: {
409
+ name: "commit",
410
+ help: "Save the files you have already picked. You must include a note with -m.",
411
+ usage: "spwn commit -m 'your note'",
412
+ handler: ->(args) {
413
+ message, _rest = extract_message_from_args(args)
414
+ if message.nil? || message.empty?
415
+ error("Every save needs a note. Try: spwn commit -m 'made the hero jump'")
416
+ next nil
417
+ end
418
+ git("commit", "-m", message)
419
+ }
420
+ },
421
+
422
+ look: {
423
+ name: "look",
424
+ help: "See what is going on in your project right now.",
425
+ usage: "spwn look",
426
+ handler: ->(args) { git("status", *args) }
427
+ },
428
+
429
+ compare: {
430
+ name: "compare",
431
+ help: "See what changed since the last snapshot.",
432
+ usage: "spwn compare or spwn compare <file>",
433
+ handler: ->(args) { git("diff", *args) }
434
+ },
435
+
436
+ history: {
437
+ name: "history",
438
+ help: "Replay the story of your project, one snapshot at a time.",
439
+ usage: "spwn history or spwn history -n 5",
440
+ handler: ->(args) { git("log", *args) }
441
+ },
442
+
443
+ hop: {
444
+ name: "hop",
445
+ help: "Jump to another universe (branch) or bring back a file from another snapshot.",
446
+ usage: "spwn hop <branch> or spwn hop -- <branch> <file>",
447
+ handler: ->(args) {
448
+ if args.empty?
449
+ error("Where should we hop? Try a branch name, or use spwn hop -- <branch> <file> to restore a file.")
450
+ next nil
451
+ end
452
+
453
+ if args[0] == "--"
454
+ rest = args[1..]
455
+ if rest.nil? || rest.empty?
456
+ error("What should we restore? Try: spwn hop -- main my_level.rb")
457
+ next nil
458
+ end
459
+
460
+ if rest.length == 1
461
+ # Only one thing after --: could be a branch or a file.
462
+ # If it is a file that exists on the current HEAD, restore it from here.
463
+ # Otherwise ask for the source branch explicitly.
464
+ candidate = rest[0]
465
+ if file_on_head?(candidate)
466
+ next git("restore", "--", candidate)
467
+ else
468
+ error("I cannot tell which universe to pull #{candidate} from. Try: spwn hop -- <branch> #{candidate}")
469
+ next nil
470
+ end
471
+ else
472
+ # branch followed by one or more files
473
+ source_branch = rest[0]
474
+ files = rest[1..]
475
+ if files.nil? || files.empty?
476
+ error("Which file should we bring back from #{source_branch}? Try: spwn hop -- #{source_branch} my_level.rb")
477
+ next nil
478
+ end
479
+ next git("restore", "--source", source_branch, "--", *files)
480
+ end
481
+ end
482
+
483
+ git("switch", *args)
484
+ }
485
+ },
486
+
487
+ upload: {
488
+ name: "upload",
489
+ help: "Send your snapshots to the shared project space.",
490
+ usage: "spwn upload or spwn upload <branch>",
491
+ handler: ->(args) { git("push", *args) }
492
+ },
493
+
494
+ download: {
495
+ name: "download",
496
+ help: "Fetch new snapshots from the shared project space and combine them with yours.",
497
+ usage: "spwn download or spwn download <remote> <branch>",
498
+ handler: ->(args) { git("pull", *args) }
499
+ },
500
+
501
+ sync: {
502
+ name: "sync",
503
+ 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.",
504
+ usage: "spwn sync <lesson-folder> --into <game-folder> or spwn sync <lesson-folder> --into <game-folder> --force",
505
+ handler: ->(args) { require_relative "synchronizer"; Spawnpoint::Synchronizer.new.run(args) }
506
+ },
507
+
508
+ rollback: {
509
+ name: "rollback",
510
+ help: "Undo the most recent lesson sync.",
511
+ usage: "spwn rollback --into <game-folder>",
512
+ handler: ->(args) { require_relative "synchronizer"; Spawnpoint::Synchronizer.new.rollback(args) }
513
+ }
514
+ }.freeze
515
+
516
+ SUBCOMMANDS = COMMANDS.values.freeze
517
+
518
+ # ---------------------------------------------------------------------
519
+ # Help text
520
+ # ---------------------------------------------------------------------
521
+
522
+ def print_help
523
+ say("spwn v#{VERSION}")
524
+ say("A friendly face for Git, made for learning game programming.")
525
+ say("")
526
+ say("Usage:")
527
+ say(" spwn <command> [options]")
528
+ say("")
529
+ say("Commands:")
530
+ SUBCOMMANDS.sort_by { |c| c[:name] }.each do |cmd|
531
+ say(" #{cmd[:name].to_s.ljust(12)} #{cmd[:help]}")
532
+ end
533
+ say("")
534
+ say("Tips:")
535
+ say(" - Run spwn <command> --help to see Git's own help for that command.")
536
+ say(" - You can still use git directly when you are ready.")
537
+ say(" - spwn save is a shortcut for adding changed files and committing them together.")
538
+ end
539
+
540
+ def print_command_help(command_name)
541
+ cmd = COMMANDS[command_name]
542
+ if cmd.nil?
543
+ error("I do not know that command. Run spwn --help to see the list.")
544
+ return
545
+ end
546
+
547
+ say("spwn #{cmd[:name]}")
548
+ say("")
549
+ say(cmd[:help])
550
+ say("")
551
+ say("Usage:")
552
+ say(" #{cmd[:usage]}")
553
+ end
554
+
555
+ # ---------------------------------------------------------------------
556
+ # Dispatch
557
+ # ---------------------------------------------------------------------
558
+
559
+ # Runs the CLI and returns an Integer exit code.
560
+ #
561
+ # Handlers return an Integer exit code, or true/false from `system`.
562
+ # Anything truthy-but-not-an-Integer and `true` map to 0; false and nil
563
+ # map to 1, so misuse and Git failures exit non-zero.
564
+ def run(argv)
565
+ command_name = argv[0]
566
+
567
+ if command_name.nil? || command_name == "--help" || command_name == "-h"
568
+ print_help
569
+ return 0
570
+ end
571
+
572
+ if command_name == "--version" || command_name == "-v"
573
+ say("spwn v#{VERSION}")
574
+ return 0
575
+ end
576
+
577
+ command_key = command_name.to_sym
578
+ cmd = COMMANDS[command_key]
579
+
580
+ unless cmd
581
+ error("I do not know that command: #{command_name}")
582
+ say("Run spwn --help to see the commands I do know.")
583
+ return 1
584
+ end
585
+
586
+ command_args = argv[1..] || []
587
+
588
+ if command_args_include_help?(command_args)
589
+ print_command_help(command_key)
590
+ if git_command_exists?(command_key.to_s)
591
+ git(command_key.to_s, *command_args)
592
+ else
593
+ say("")
594
+ say("Git does not have a #{command_key} command, so there is no extra help to show beyond this.")
595
+ end
596
+ return 0
597
+ end
598
+
599
+ normalize_exit(cmd[:handler].call(command_args))
600
+ end
601
+
602
+ def normalize_exit(result)
603
+ case result
604
+ when Integer then result
605
+ when true then 0
606
+ else 1
607
+ end
608
+ end
609
+ end
610
+ end
611
+ ```
612
+
613
+ Note for the implementer: inside `COMMANDS` lambdas, `return` is replaced by `next` (a lambda `return` exits the defining scope; `next` returns from the lambda). Handlers that fail now `next nil`, which normalizes to exit code 1.
614
+
615
+ `lib/spawnpoint.rb`:
616
+
617
+ ```ruby
618
+ # frozen_string_literal: true
619
+
620
+ require_relative "spawnpoint/version"
621
+ require_relative "spawnpoint/cli"
622
+ ```
623
+
624
+ `exe/spwn`:
625
+
626
+ ```ruby
627
+ #!/usr/bin/env ruby
628
+ # frozen_string_literal: true
629
+
630
+ require "spawnpoint/cli"
631
+
632
+ exit Spawnpoint::CLI.run(ARGV)
633
+ ```
634
+
635
+ Make it executable:
636
+
637
+ ```bash
638
+ chmod +x exe/spwn
639
+ ```
640
+
641
+ - [ ] **Step 4: Run tests to verify they pass**
642
+
643
+ Run: `rake`
644
+ Expected: PASS — version + CLI tests green (9 runs, 0 failures)
645
+
646
+ - [ ] **Step 5: Smoke-test the executable from the repo**
647
+
648
+ Run: `ruby -Ilib exe/spwn --version`
649
+ Expected: prints `spwn v0.2.0`
650
+
651
+ Run: `ruby -Ilib exe/spwn teleport; echo "exit=$?"`
652
+ Expected: prints the unknown-command message and `exit=1`
653
+
654
+ Run: `ruby -Ilib exe/spwn --help | head -3`
655
+ Expected: help text starting with `spwn v0.2.0`
656
+
657
+ - [ ] **Step 6: Commit**
658
+
659
+ ```bash
660
+ git add lib/spawnpoint.rb lib/spawnpoint/cli.rb exe/spwn test/test_cli.rb
661
+ git commit -m "move CLI into Spawnpoint::CLI with exit-code propagation"
662
+ ```
663
+
664
+ ---
665
+
666
+ ### Task 3: Synchronizer with marker-merge fix
667
+
668
+ **Files:**
669
+ - Create: `lib/spawnpoint/synchronizer.rb`
670
+ - Create: `test/test_synchronizer.rb`
671
+ - Reference (do not modify): `bin/__sync__.rb` — the code moves from here
672
+
673
+ **Interfaces:**
674
+ - Consumes: nothing from other tasks (invoked lazily by `Spawnpoint::CLI`'s sync/rollback handlers from Task 2).
675
+ - Produces: `Spawnpoint::Synchronizer.new.run(argv)` → Integer (0 success, 1 usage error); `Spawnpoint::Synchronizer.new.rollback(argv)` → Integer. Side effects: copies files, writes `.spwn_synced_paths` marker and `.spwn_sync_backup/` in the target folder.
676
+
677
+ **Fixes relative to `bin/__sync__.rb`:**
678
+ 1. Class renamed `Spwn::Syncronizer` → `Spawnpoint::Synchronizer`.
679
+ 2. Marker is written as the union `known | new_known`, so paths accepted in earlier syncs are never forgotten.
680
+ 3. Dead branch removed: when `dest.exist?`, the file is necessarily in `already_existed` (the target was scanned first), so the final `else` that copied silently is unreachable.
681
+ 4. No-op guards `next if child == source` / `next if child == target` removed (`child` is always a file after the `child.file?` check; `source`/`target` are directories).
682
+
683
+ - [ ] **Step 1: Write the failing tests**
684
+
685
+ `test/test_synchronizer.rb`:
686
+
687
+ ```ruby
688
+ require "test_helper"
689
+ require "tmpdir"
690
+ require "fileutils"
691
+ require "spawnpoint/synchronizer"
692
+
693
+ class TestSynchronizer < Minitest::Test
694
+ def setup
695
+ @dir = Dir.mktmpdir
696
+ @lesson = File.join(@dir, "lesson")
697
+ @game = File.join(@dir, "game")
698
+ FileUtils.mkdir_p(@lesson)
699
+ FileUtils.mkdir_p(@game)
700
+ end
701
+
702
+ def teardown
703
+ FileUtils.remove_entry(@dir)
704
+ end
705
+
706
+ def write(folder, name, content)
707
+ File.write(File.join(folder, name), content)
708
+ end
709
+
710
+ def sync(*extra)
711
+ Spawnpoint::Synchronizer.new.run([@lesson, "--into", @game, *extra])
712
+ end
713
+
714
+ def marker_paths
715
+ marker = File.join(@game, ".spwn_synced_paths")
716
+ File.exist?(marker) ? File.readlines(marker).map(&:strip) : []
717
+ end
718
+
719
+ def test_run_without_arguments_exits_one
720
+ capture_io { @status = Spawnpoint::Synchronizer.new.run([]) }
721
+ assert_equal 1, @status
722
+ end
723
+
724
+ def test_run_without_into_exits_one
725
+ capture_io { @status = Spawnpoint::Synchronizer.new.run([@lesson]) }
726
+ assert_equal 1, @status
727
+ end
728
+
729
+ def test_run_with_missing_source_exits_one
730
+ capture_io { @status = Spawnpoint::Synchronizer.new.run(["nope", "--into", @game]) }
731
+ assert_equal 1, @status
732
+ end
733
+
734
+ def test_copies_new_files
735
+ write(@lesson, "main.rb", "puts :hi")
736
+ capture_io { @status = sync }
737
+ assert_equal 0, @status
738
+ assert_equal "puts :hi", File.read(File.join(@game, "main.rb"))
739
+ assert_includes marker_paths, "main.rb"
740
+ end
741
+
742
+ def test_force_replaces_existing_file
743
+ write(@game, "main.rb", "old")
744
+ write(@lesson, "main.rb", "new")
745
+ capture_io { @status = sync("--force") }
746
+ assert_equal 0, @status
747
+ assert_equal "new", File.read(File.join(@game, "main.rb"))
748
+ end
749
+
750
+ def test_rollback_restores_replaced_and_removes_created_files
751
+ write(@game, "main.rb", "old")
752
+ write(@lesson, "main.rb", "new")
753
+ write(@lesson, "enemy.rb", "enemy code")
754
+ capture_io { sync("--force") }
755
+ assert_equal "new", File.read(File.join(@game, "main.rb"))
756
+
757
+ capture_io { @status = Spawnpoint::Synchronizer.new.rollback(["--into", @game]) }
758
+ assert_equal 0, @status
759
+ assert_equal "old", File.read(File.join(@game, "main.rb"))
760
+ refute File.exist?(File.join(@game, "enemy.rb"))
761
+ end
762
+
763
+ def test_rollback_without_backup_exits_one
764
+ capture_io { @status = Spawnpoint::Synchronizer.new.rollback(["--into", @game]) }
765
+ assert_equal 1, @status
766
+ end
767
+
768
+ def test_marker_keeps_paths_accepted_in_earlier_lessons
769
+ # Lesson 1 introduces main.rb.
770
+ write(@lesson, "main.rb", "v1")
771
+ capture_io { sync }
772
+ assert_includes marker_paths, "main.rb"
773
+
774
+ # Lesson 2 introduces only enemy.rb; main.rb must not be forgotten.
775
+ FileUtils.rm(File.join(@lesson, "main.rb"))
776
+ write(@lesson, "enemy.rb", "enemy code")
777
+ capture_io { sync }
778
+
779
+ assert_includes marker_paths, "main.rb"
780
+ assert_includes marker_paths, "enemy.rb"
781
+ end
782
+ end
783
+ ```
784
+
785
+ - [ ] **Step 2: Run tests to verify they fail**
786
+
787
+ Run: `ruby -Itest test/test_synchronizer.rb`
788
+ Expected: FAIL with `cannot load such file -- spawnpoint/synchronizer` (LoadError)
789
+
790
+ - [ ] **Step 3: Implement `lib/spawnpoint/synchronizer.rb`**
791
+
792
+ `lib/spawnpoint/synchronizer.rb`:
793
+
794
+ ```ruby
795
+ # frozen_string_literal: true
796
+
797
+ # Synchronizer implements `spwn sync`.
798
+ #
799
+ # It copies a lesson source folder into a DragonRuby game folder (usually
800
+ # mygame/). It is intentionally a copy tool, not a Git command: the course
801
+ # materials are distributed as folders, and the student's game folder is the
802
+ # place where DragonRuby loads them.
803
+ #
804
+ # Design notes:
805
+ #
806
+ # - Source is a directory, not a single file. Lessons are more than one file
807
+ # once assets enter the picture.
808
+ # - Existing files are never overwritten without the student saying so. This
809
+ # matters most for assets: a later lesson may ship a better sprite, and the
810
+ # student should choose whether to replace the older one.
811
+ # - When the source contains an assets/ folder, the sync prints a reminder that
812
+ # assets are part of this lesson and may change between lessons.
813
+
814
+ require "fileutils"
815
+ require "pathname"
816
+ require "set"
817
+
818
+ module Spawnpoint
819
+ class Synchronizer
820
+ BACKUP_DIR = ".spwn_sync_backup"
821
+
822
+ def run(argv)
823
+ lesson_folder, into_folder, force = parse_args(argv)
824
+
825
+ unless lesson_folder
826
+ puts "Usage:"
827
+ puts " spwn sync <lesson-folder> --into <game-folder>"
828
+ puts " spwn sync <lesson-folder> --into <game-folder> --force"
829
+ puts ""
830
+ puts "Example:"
831
+ puts " spwn sync 04-collectibles/starter --into ~/DragonRuby/mygame"
832
+ puts ""
833
+ puts "The first time a lesson copies a file that already exists in your"
834
+ puts "game folder, spwn asks before replacing it. After you accept a"
835
+ puts "file, later lessons replace that same file automatically."
836
+ return 1
837
+ end
838
+
839
+ unless into_folder
840
+ puts "Oops: tell spwn where to copy the lesson with --into."
841
+ puts "Example: spwn sync 04-collectibles/starter --into ~/DragonRuby/mygame"
842
+ return 1
843
+ end
844
+
845
+ source = Pathname.new(lesson_folder).expand_path
846
+ target = Pathname.new(into_folder).expand_path
847
+
848
+ unless source.directory?
849
+ puts "Oops: #{source} is not a folder."
850
+ return 1
851
+ end
852
+
853
+ unless target.directory?
854
+ puts "Oops: #{target} is not a folder yet."
855
+ return 1
856
+ end
857
+
858
+ has_assets = source.join("assets").directory?
859
+
860
+ if has_assets
861
+ puts "This lesson includes an assets/ folder."
862
+ puts "Assets may look different from the previous lesson. "
863
+ puts "If you already have sprites from an older lesson, you will be asked before each one is replaced."
864
+ puts ""
865
+ end
866
+
867
+ marker = target.join(".spwn_synced_paths")
868
+ backup = target.join(BACKUP_DIR)
869
+ FileUtils.rm_rf(backup)
870
+ backup.join("files").mkpath
871
+ backup.join("created_paths").write("")
872
+ if marker.file?
873
+ FileUtils.cp(marker, backup.join("marker"))
874
+ else
875
+ backup.join("no_marker").write("")
876
+ end
877
+
878
+ known = if marker.file?
879
+ marker.readlines.map(&:strip).reject(&:empty?).to_set
880
+ else
881
+ Set.new
882
+ end
883
+
884
+ already_existed = Set.new
885
+ target.find.each do |child|
886
+ next unless child.file?
887
+ already_existed << child.relative_path_from(target).to_s
888
+ end
889
+
890
+ copied = 0
891
+ upgraded = 0
892
+ skipped = 0
893
+ new_known = Set.new
894
+
895
+ source.find.to_a.each do |child|
896
+ next unless child.file?
897
+
898
+ rel = child.relative_path_from(source)
899
+ rel_s = rel.to_s
900
+ dest = target.join(rel)
901
+
902
+ if dest.exist?
903
+ if force || known.include?(rel_s)
904
+ backup_existing(backup, dest, rel_s)
905
+ copy_file(child, dest)
906
+ upgraded += 1
907
+ new_known << rel_s
908
+ elsif already_existed.include?(rel_s)
909
+ if agree?("Copy #{rel} from this lesson?")
910
+ backup_existing(backup, dest, rel_s)
911
+ copy_file(child, dest)
912
+ upgraded += 1
913
+ new_known << rel_s
914
+ else
915
+ skipped += 1
916
+ end
917
+ end
918
+ else
919
+ copy_file(child, dest)
920
+ File.open(backup.join("created_paths"), "a") { |fh| fh.puts(rel_s) }
921
+ copied += 1
922
+ new_known << rel_s
923
+ end
924
+ end
925
+
926
+ merged = known | new_known
927
+ if merged.any?
928
+ marker.open("w") do |fh|
929
+ merged.to_a.sort.each do |name|
930
+ fh.puts(name)
931
+ end
932
+ end
933
+ end
934
+
935
+ puts ""
936
+ puts "Done."
937
+ puts "New: #{copied}"
938
+ puts "Updated: #{upgraded}"
939
+ puts "Skipped: #{skipped}"
940
+
941
+ 0
942
+ end
943
+
944
+ def rollback(argv)
945
+ target = parse_rollback_args(argv)
946
+ unless target
947
+ puts "Usage: spwn rollback --into <game-folder>"
948
+ return 1
949
+ end
950
+
951
+ target = Pathname.new(target).expand_path
952
+ backup = target.join(BACKUP_DIR)
953
+ unless backup.directory?
954
+ puts "Oops: there is no lesson sync to roll back in #{target}."
955
+ return 1
956
+ end
957
+
958
+ backup.join("created_paths").readlines.each do |line|
959
+ target.join(line.strip).delete if !line.strip.empty? && target.join(line.strip).file?
960
+ end
961
+ backup.join("files").find do |saved|
962
+ next if saved.directory? || saved == backup.join("files")
963
+ rel = saved.relative_path_from(backup.join("files"))
964
+ dest = target.join(rel)
965
+ FileUtils.mkdir_p(dest.parent)
966
+ FileUtils.cp(saved, dest)
967
+ end
968
+
969
+ marker = target.join(".spwn_synced_paths")
970
+ if backup.join("marker").file?
971
+ FileUtils.cp(backup.join("marker"), marker)
972
+ else
973
+ marker.delete if marker.file?
974
+ end
975
+
976
+ FileUtils.rm_rf(backup)
977
+ puts "Rolled back the most recent lesson sync."
978
+ 0
979
+ end
980
+
981
+ private
982
+
983
+ def parse_args(argv)
984
+ lesson_folder = nil
985
+ into_folder = nil
986
+ force = false
987
+
988
+ i = 0
989
+ while i < argv.length
990
+ arg = argv[i]
991
+ case arg
992
+ when "--into"
993
+ i += 1
994
+ into_folder = argv[i]
995
+ when "--force", "-f"
996
+ force = true
997
+ when /\A-/
998
+ puts "Oops: I do not understand #{arg}."
999
+ return [nil, nil, false]
1000
+ else
1001
+ lesson_folder ||= arg
1002
+ end
1003
+ i += 1
1004
+ end
1005
+
1006
+ [lesson_folder, into_folder, force]
1007
+ end
1008
+
1009
+ def parse_rollback_args(argv)
1010
+ i = argv.index("--into")
1011
+ i && argv[i + 1]
1012
+ end
1013
+
1014
+ def backup_existing(backup, dest, rel)
1015
+ saved = backup.join("files", rel)
1016
+ FileUtils.mkdir_p(saved.parent)
1017
+ FileUtils.cp(dest, saved)
1018
+ end
1019
+
1020
+ def copy_file(source, dest)
1021
+ FileUtils.mkdir_p(dest.parent)
1022
+ FileUtils.cp(source, dest)
1023
+ end
1024
+
1025
+ def agree?(question)
1026
+ loop do
1027
+ print "#{question} (y/n): "
1028
+ answer = STDIN.gets
1029
+ return false if answer.nil?
1030
+
1031
+ answer = answer.strip.downcase
1032
+ return true if answer == "y"
1033
+ return false if answer == "n"
1034
+ puts "Please type y or n."
1035
+ end
1036
+ end
1037
+ end
1038
+ end
1039
+ ```
1040
+
1041
+ - [ ] **Step 4: Run tests to verify they pass**
1042
+
1043
+ Run: `rake`
1044
+ Expected: PASS — all tests green, including `test_marker_keeps_paths_accepted_in_earlier_lessons` (the regression test for the marker-merge bug)
1045
+
1046
+ - [ ] **Step 5: Smoke-test sync through the CLI**
1047
+
1048
+ ```bash
1049
+ mkdir -p /tmp/spwn-lesson /tmp/spwn-game
1050
+ echo "puts :hi" > /tmp/spwn-lesson/main.rb
1051
+ ruby -Ilib exe/spwn sync /tmp/spwn-lesson --into /tmp/spwn-game
1052
+ cat /tmp/spwn-game/main.rb
1053
+ ruby -Ilib exe/spwn rollback --into /tmp/spwn-game
1054
+ ls /tmp/spwn-game
1055
+ ruby -Ilib exe/spwn sync; echo "exit=$?"
1056
+ rm -rf /tmp/spwn-lesson /tmp/spwn-game
1057
+ ```
1058
+
1059
+ Expected: file copied, then removed by rollback; bare `spwn sync` prints usage and `exit=1`.
1060
+
1061
+ - [ ] **Step 6: Commit**
1062
+
1063
+ ```bash
1064
+ git add lib/spawnpoint/synchronizer.rb test/test_synchronizer.rb
1065
+ git commit -m "add Spawnpoint::Synchronizer with marker-merge fix"
1066
+ ```
1067
+
1068
+ ---
1069
+
1070
+ ### Task 4: Delete `bin/`, rewrite README, final gem verification
1071
+
1072
+ **Files:**
1073
+ - Delete: `bin/spwn`, `bin/__sync__.rb`
1074
+ - Modify: `README.md`
1075
+
1076
+ **Interfaces:**
1077
+ - Consumes: everything from Tasks 1–3.
1078
+ - Produces: the final repo state; a locally installable `spawnpoint-0.2.0.gem`.
1079
+
1080
+ - [ ] **Step 1: Delete the old script files**
1081
+
1082
+ ```bash
1083
+ git rm bin/spwn bin/__sync__.rb
1084
+ ```
1085
+
1086
+ - [ ] **Step 2: Rewrite the README install sections**
1087
+
1088
+ Make these edits to `README.md`:
1089
+
1090
+ 1. Line 32, replace:
1091
+
1092
+ ```markdown
1093
+ Once Ruby and Git are installed, `spwn` itself is just one script.
1094
+ ```
1095
+
1096
+ with:
1097
+
1098
+ ```markdown
1099
+ Once Ruby and Git are installed, `spwn` itself is one `gem install` away.
1100
+ ```
1101
+
1102
+ 2. Line 40, replace:
1103
+
1104
+ ```markdown
1105
+ - Easy to extend. The mapping between `spwn` commands and Git invocations lives inside
1106
+ `bin/spwn` so students and instructors can add or change commands without juggling extra files.
1107
+ ```
1108
+
1109
+ with:
1110
+
1111
+ ```markdown
1112
+ - Easy to extend. The mapping between `spwn` commands and Git invocations lives inside
1113
+ `lib/spawnpoint/cli.rb` so students and instructors can add or change commands in one place.
1114
+ ```
1115
+
1116
+ 3. Replace the whole "Quick start" section (lines 43–56) with:
1117
+
1118
+ ```markdown
1119
+ ## Quick start
1120
+
1121
+ Install the gem and run `spwn`:
1122
+
1123
+ ```bash
1124
+ gem install spawnpoint
1125
+ spwn --help
1126
+ ```
1127
+
1128
+ To run the latest code from this repository instead:
1129
+
1130
+ ```bash
1131
+ ruby -Ilib exe/spwn --help
1132
+ ```
1133
+ ```
1134
+
1135
+ 4. Replace the whole "Installing for a student" section (lines 58–101, including the macOS, Windows, and Compressed archive subsections) with:
1136
+
1137
+ ```markdown
1138
+ ## Installing for a student
1139
+
1140
+ `spwn` is published as the `spawnpoint` gem, so installation is the same on macOS
1141
+ and Windows once Ruby is installed:
1142
+
1143
+ ```bash
1144
+ gem install spawnpoint
1145
+ ```
1146
+
1147
+ This puts the `spwn` command on the PATH. Updating later is `gem update spawnpoint`.
1148
+
1149
+ If a machine cannot reach rubygems.org, build the gem from this repository and
1150
+ install the file directly:
1151
+
1152
+ ```bash
1153
+ gem build spawnpoint.gemspec
1154
+ gem install --local ./spawnpoint-0.2.0.gem
1155
+ ```
1156
+ ```
1157
+
1158
+ 5. Line 139, replace:
1159
+
1160
+ ```markdown
1161
+ Run `ruby bin/spwn --help` for the current list.
1162
+ ```
1163
+
1164
+ with:
1165
+
1166
+ ```markdown
1167
+ Run `spwn --help` for the current list.
1168
+ ```
1169
+
1170
+ 6. Line 163, replace:
1171
+
1172
+ ```markdown
1173
+ Open `bin/spwn` and look for the command mapping table near the top of the script.
1174
+ ```
1175
+
1176
+ with:
1177
+
1178
+ ```markdown
1179
+ Open `lib/spawnpoint/cli.rb` and look for the command mapping table near the top of the file.
1180
+ ```
1181
+
1182
+ - [ ] **Step 3: Run the full test suite**
1183
+
1184
+ Run: `rake`
1185
+ Expected: PASS — all tests green
1186
+
1187
+ - [ ] **Step 4: Build and install the gem locally**
1188
+
1189
+ ```bash
1190
+ gem build spawnpoint.gemspec
1191
+ GEM_HOME=/tmp/spwn-gem-test gem install --local ./spawnpoint-0.2.0.gem
1192
+ GEM_HOME=/tmp/spwn-gem-test GEM_PATH=/tmp/spwn-gem-test /tmp/spwn-gem-test/bin/spwn --version
1193
+ GEM_HOME=/tmp/spwn-gem-test GEM_PATH=/tmp/spwn-gem-test /tmp/spwn-gem-test/bin/spwn --help | head -3
1194
+ ```
1195
+
1196
+ Expected: build succeeds; installed `spwn` prints `spwn v0.2.0` and the help text. Then clean up:
1197
+
1198
+ ```bash
1199
+ rm -rf /tmp/spwn-gem-test spawnpoint-0.2.0.gem
1200
+ ```
1201
+
1202
+ - [ ] **Step 5: Verify the gem contents**
1203
+
1204
+ ```bash
1205
+ gem build spawnpoint.gemspec
1206
+ gem spec spawnpoint-0.2.0.gem files
1207
+ ```
1208
+
1209
+ Expected: the file list includes `exe/spwn`, `lib/spawnpoint.rb`, `lib/spawnpoint/version.rb`, `lib/spawnpoint/cli.rb`, `lib/spawnpoint/synchronizer.rb`, `README.md`, `LICENSE` — and does NOT include `bin/spwn`, `bin/__sync__.rb`, or the untracked pipeline files. Clean up: `rm spawnpoint-0.2.0.gem`.
1210
+
1211
+ - [ ] **Step 6: Commit**
1212
+
1213
+ ```bash
1214
+ git add -A bin README.md
1215
+ git commit -m "replace bin/ scripts with the spawnpoint gem layout"
1216
+ ```
1217
+
1218
+ ---
1219
+
1220
+ ## Follow-up (not part of this plan — maintainer steps)
1221
+
1222
+ 1. Tag `v0.2.0`, push to GitHub, create the GitHub release.
1223
+ 2. `gem build spawnpoint.gemspec && gem push spawnpoint-0.2.0.gem` — needs the maintainer's rubygems.org credentials (MFA is required per the gemspec metadata).