smart_box 0.1.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.
@@ -0,0 +1,412 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../smart_box"
4
+ require "optparse"
5
+
6
+ module SmartBox
7
+ module CLI
8
+ def self.run(argv = ARGV)
9
+ parse_options(argv)
10
+ command = argv.first
11
+
12
+ case command
13
+ when "--version", "-v"
14
+ puts "smart_box #{SmartBox::VERSION}"
15
+ when "--help", "-h", nil
16
+ print_help
17
+ when "create"
18
+ cmd_create(argv)
19
+ when "list"
20
+ cmd_list(argv)
21
+ when "status"
22
+ cmd_status(argv)
23
+ when "run"
24
+ cmd_run(argv)
25
+ when "checkpoint"
26
+ cmd_checkpoint(argv)
27
+ when "checkpoints"
28
+ cmd_checkpoints(argv)
29
+ when "rollback"
30
+ cmd_rollback(argv)
31
+ when "diff"
32
+ cmd_diff(argv)
33
+ when "export-patch"
34
+ cmd_export_patch(argv)
35
+ when "apply"
36
+ cmd_apply(argv)
37
+ when "discard"
38
+ cmd_discard(argv)
39
+ else
40
+ puts "Unknown command: #{command}"
41
+ print_help
42
+ exit 1
43
+ end
44
+ rescue SmartBox::Error => e
45
+ $stderr.puts "Error: #{e.message}"
46
+ exit 2
47
+ end
48
+
49
+ # --- Command implementations ---
50
+
51
+ def self.cmd_create(argv)
52
+ opts = parse_create(argv)
53
+ source = opts[:source] || "."
54
+ id = opts[:id]
55
+ mode = opts[:mode] || "copy"
56
+ name = opts[:name]
57
+
58
+ unless id
59
+ $stderr.puts "Error: --id is required"
60
+ exit 1
61
+ end
62
+
63
+ unless %w[copy git-worktree].include?(mode)
64
+ $stderr.puts "Error: --mode must be 'copy' or 'git-worktree'"
65
+ exit 1
66
+ end
67
+
68
+ box = SmartBox::Box.create(source: source, id: id, mode: mode, name: name)
69
+
70
+ puts "Box created:"
71
+ puts " id: #{box.id}"
72
+ puts " mode: #{box.mode}"
73
+ puts " workspace: #{box.workspace_path}"
74
+ puts " checkpoint: cp-001 initial"
75
+ end
76
+
77
+ def self.cmd_list(argv)
78
+ opts = parse_source(argv)
79
+ source = opts[:source] || "."
80
+
81
+ boxes = SmartBox::Box.list(source: source)
82
+
83
+ if boxes.empty?
84
+ puts "No boxes found."
85
+ return
86
+ end
87
+
88
+ puts "ID MODE STATUS UPDATED_AT"
89
+ boxes.each do |b|
90
+ updated = b["updated_at"]
91
+ updated = updated[0..15] if updated.is_a?(String) && updated.length > 16
92
+ printf "%-10s %-12s %-8s %s\n",
93
+ b["id"], b["mode"], b["status"], updated
94
+ end
95
+ end
96
+
97
+ def self.cmd_status(argv)
98
+ opts = parse_box_opts(argv, require_id: true)
99
+ box = SmartBox::Box.load(source: opts[:source] || ".", id: opts[:id])
100
+ s = box.status_summary
101
+
102
+ puts "Box: #{s['id']}"
103
+ puts "Mode: #{s['mode']}"
104
+ puts "Status: #{s['status']}"
105
+ puts "Workspace: #{s['workspace']}"
106
+ puts
107
+
108
+ if s["changed_files"]&.any?
109
+ puts "Changed files:"
110
+ s["changed_files"].each { |f| puts " #{f}" }
111
+ else
112
+ puts "No changed files."
113
+ end
114
+ puts
115
+
116
+ if s["checkpoints"]&.any?
117
+ puts "Checkpoints:"
118
+ s["checkpoints"].each do |cp|
119
+ cp.each { |id, name| puts " #{id} #{name}" }
120
+ end
121
+ end
122
+ end
123
+
124
+ def self.cmd_run(argv)
125
+ argv.shift # remove "run" subcommand name
126
+ opts = parse_run(argv)
127
+ id = opts[:id]
128
+
129
+ unless id
130
+ $stderr.puts "Error: --id is required"
131
+ exit 1
132
+ end
133
+
134
+ command = argv.join(" ")
135
+ if command.empty?
136
+ $stderr.puts "Error: command is required (use -- before command args)"
137
+ exit 1
138
+ end
139
+
140
+ box = SmartBox::Box.load(source: opts[:source] || ".", id: id)
141
+ result = box.run(command, allow_dangerous: opts[:allow_dangerous])
142
+
143
+ $stdout.write(result.stdout)
144
+ $stderr.write(result.stderr) unless result.stderr.empty?
145
+ exit result.exit_code
146
+ end
147
+
148
+ def self.cmd_checkpoint(argv)
149
+ opts = parse_checkpoint(argv)
150
+ id = opts[:id]
151
+ name = opts[:name]
152
+
153
+ unless id
154
+ $stderr.puts "Error: --id is required"
155
+ exit 1
156
+ end
157
+
158
+ unless name
159
+ $stderr.puts "Error: --name is required"
160
+ exit 1
161
+ end
162
+
163
+ box = SmartBox::Box.load(source: opts[:source] || ".", id: id)
164
+ cp = box.checkpoint(name)
165
+
166
+ puts "Checkpoint created:"
167
+ puts " id: #{cp[:id]}"
168
+ puts " name: #{cp[:name]}"
169
+ puts " commit: #{cp[:commit][0..7]}"
170
+ end
171
+
172
+ def self.cmd_checkpoints(argv)
173
+ opts = parse_box_opts(argv, require_id: true)
174
+ box = SmartBox::Box.load(source: opts[:source] || ".", id: opts[:id])
175
+ cps = box.checkpoints
176
+
177
+ if cps.empty?
178
+ puts "No checkpoints."
179
+ return
180
+ end
181
+
182
+ cps.each do |cp|
183
+ puts "#{cp['id']} #{cp['name']}"
184
+ end
185
+ end
186
+
187
+ def self.cmd_rollback(argv)
188
+ opts = parse_rollback(argv)
189
+ id = opts[:id]
190
+ checkpoint = opts[:checkpoint]
191
+
192
+ unless id
193
+ $stderr.puts "Error: --id is required"
194
+ exit 1
195
+ end
196
+
197
+ unless checkpoint
198
+ $stderr.puts "Error: --checkpoint is required"
199
+ exit 1
200
+ end
201
+
202
+ box = SmartBox::Box.load(source: opts[:source] || ".", id: id)
203
+ box.rollback(checkpoint)
204
+
205
+ puts "Rolled back:"
206
+ puts " box: #{id}"
207
+ puts " checkpoint: #{checkpoint}"
208
+ end
209
+
210
+ def self.cmd_diff(argv)
211
+ opts = parse_diff(argv)
212
+ box = SmartBox::Box.load(source: opts[:source] || ".", id: opts[:id])
213
+
214
+ unless box.metadata.id
215
+ $stderr.puts "Error: --id is required"
216
+ exit 1
217
+ end
218
+
219
+ puts box.diff(from: opts[:from], to: opts[:to])
220
+ end
221
+
222
+ def self.cmd_export_patch(argv)
223
+ opts = parse_export_patch(argv)
224
+
225
+ unless opts[:id]
226
+ $stderr.puts "Error: --id is required"
227
+ exit 1
228
+ end
229
+
230
+ unless opts[:output]
231
+ $stderr.puts "Error: --output is required"
232
+ exit 1
233
+ end
234
+
235
+ box = SmartBox::Box.load(source: opts[:source] || ".", id: opts[:id])
236
+ result = box.export_patch(output: opts[:output], from: opts[:from], to: opts[:to])
237
+
238
+ puts "Patch exported:"
239
+ puts " #{result[:output]}"
240
+ puts " #{result[:size]} bytes"
241
+ end
242
+
243
+ def self.cmd_apply(argv)
244
+ opts = parse_apply(argv)
245
+
246
+ unless opts[:id]
247
+ $stderr.puts "Error: --id is required"
248
+ exit 1
249
+ end
250
+
251
+ box = SmartBox::Box.load(source: opts[:source] || ".", id: opts[:id])
252
+
253
+ if opts[:dry_run]
254
+ result = box.apply(dry_run: true)
255
+ puts "Dry run:"
256
+ puts " patch size: #{result[:patch_size]} bytes"
257
+ puts " Would apply #{result[:patch_size] > 0 ? 'changes' : 'no changes'} to #{box.source_path}"
258
+ return
259
+ end
260
+
261
+ # Check if source is clean before applying
262
+ unless opts[:force] || box.source_clean?
263
+ $stderr.puts "Error: Source project has uncommitted changes."
264
+ $stderr.puts "Use --force to apply anyway, or --dry-run to preview."
265
+ exit 1
266
+ end
267
+
268
+ result = box.apply(force: opts[:force])
269
+ puts "Patch applied:"
270
+ puts result
271
+ end
272
+
273
+ def self.cmd_discard(argv)
274
+ opts = parse_box_opts(argv, require_id: true)
275
+ box = SmartBox::Box.load(source: opts[:source] || ".", id: opts[:id])
276
+ result = box.discard
277
+
278
+ puts "Box discarded:"
279
+ puts " id: #{result[:id]}"
280
+ puts " status: #{result[:status]}"
281
+ end
282
+
283
+ # --- Option parsers ---
284
+
285
+ def self.parse_create(argv)
286
+ opts = {}
287
+ OptionParser.new do |p|
288
+ p.on("--source PATH") { |v| opts[:source] = v }
289
+ p.on("--id ID") { |v| opts[:id] = v }
290
+ p.on("--mode MODE") { |v| opts[:mode] = v }
291
+ p.on("--name NAME") { |v| opts[:name] = v }
292
+ end.parse!(argv)
293
+ opts
294
+ end
295
+
296
+ def self.parse_source(argv)
297
+ opts = {}
298
+ OptionParser.new do |p|
299
+ p.on("--source PATH") { |v| opts[:source] = v }
300
+ end.parse!(argv)
301
+ opts
302
+ end
303
+
304
+ def self.parse_box_opts(argv, require_id: false)
305
+ opts = {}
306
+ OptionParser.new do |p|
307
+ p.on("--source PATH") { |v| opts[:source] = v }
308
+ p.on("--id ID") { |v| opts[:id] = v }
309
+ end.parse!(argv)
310
+
311
+ if require_id && !opts[:id]
312
+ $stderr.puts "Error: --id is required"
313
+ exit 1
314
+ end
315
+ opts
316
+ end
317
+
318
+ def self.parse_run(argv)
319
+ opts = {}
320
+ OptionParser.new do |p|
321
+ p.on("--source PATH") { |v| opts[:source] = v }
322
+ p.on("--id ID") { |v| opts[:id] = v }
323
+ p.on("--allow-dangerous") { |v| opts[:allow_dangerous] = true }
324
+ end.parse!(argv)
325
+ opts
326
+ end
327
+
328
+ def self.parse_checkpoint(argv)
329
+ opts = {}
330
+ OptionParser.new do |p|
331
+ p.on("--source PATH") { |v| opts[:source] = v }
332
+ p.on("--id ID") { |v| opts[:id] = v }
333
+ p.on("--name NAME") { |v| opts[:name] = v }
334
+ end.parse!(argv)
335
+ opts
336
+ end
337
+
338
+ def self.parse_rollback(argv)
339
+ opts = {}
340
+ OptionParser.new do |p|
341
+ p.on("--source PATH") { |v| opts[:source] = v }
342
+ p.on("--id ID") { |v| opts[:id] = v }
343
+ p.on("--checkpoint CP") { |v| opts[:checkpoint] = v }
344
+ end.parse!(argv)
345
+ opts
346
+ end
347
+
348
+ def self.parse_diff(argv)
349
+ opts = {}
350
+ OptionParser.new do |p|
351
+ p.on("--source PATH") { |v| opts[:source] = v }
352
+ p.on("--id ID") { |v| opts[:id] = v }
353
+ p.on("--from CP") { |v| opts[:from] = v }
354
+ p.on("--to CP") { |v| opts[:to] = v }
355
+ end.parse!(argv)
356
+ opts
357
+ end
358
+
359
+ def self.parse_export_patch(argv)
360
+ opts = {}
361
+ OptionParser.new do |p|
362
+ p.on("--source PATH") { |v| opts[:source] = v }
363
+ p.on("--id ID") { |v| opts[:id] = v }
364
+ p.on("--output FILE") { |v| opts[:output] = v }
365
+ p.on("--from CP") { |v| opts[:from] = v }
366
+ p.on("--to CP") { |v| opts[:to] = v }
367
+ end.parse!(argv)
368
+ opts
369
+ end
370
+
371
+ def self.parse_apply(argv)
372
+ opts = {}
373
+ OptionParser.new do |p|
374
+ p.on("--source PATH") { |v| opts[:source] = v }
375
+ p.on("--id ID") { |v| opts[:id] = v }
376
+ p.on("--dry-run") { |v| opts[:dry_run] = true }
377
+ p.on("--force") { |v| opts[:force] = true }
378
+ end.parse!(argv)
379
+ opts
380
+ end
381
+
382
+ def self.parse_options(argv)
383
+ # Global options stripped before command dispatch — handled in `run`
384
+ end
385
+
386
+ def self.print_help
387
+ puts <<~HELP
388
+ smart_box #{SmartBox::VERSION}
389
+
390
+ Usage:
391
+ smart_box <command> [options]
392
+
393
+ Commands:
394
+ create Create a new box
395
+ list List boxes
396
+ status Show box status
397
+ run Run a command in a box
398
+ checkpoint Create a checkpoint
399
+ checkpoints List checkpoints
400
+ rollback Rollback to a checkpoint
401
+ diff Show diff
402
+ export-patch Export patch file
403
+ apply Apply patch to source project
404
+ discard Discard a box
405
+
406
+ Options:
407
+ --version, -v Show version
408
+ --help, -h Show this help
409
+ HELP
410
+ end
411
+ end
412
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartBox
4
+ class CommandResult
5
+ attr_reader :command, :cwd, :stdout, :stderr, :exit_code, :started_at, :ended_at
6
+
7
+ def initialize(command:, cwd:, stdout:, stderr:, exit_code:, started_at:, ended_at:)
8
+ @command = command
9
+ @cwd = cwd
10
+ @stdout = stdout
11
+ @stderr = stderr
12
+ @exit_code = exit_code
13
+ @started_at = started_at
14
+ @ended_at = ended_at
15
+ end
16
+
17
+ def success?
18
+ @exit_code == 0
19
+ end
20
+
21
+ def elapsed_ms
22
+ ((@ended_at - @started_at) * 1000).to_i
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SmartBox
4
+ class Error < StandardError; end
5
+
6
+ class BoxAlreadyExistsError < Error; end
7
+ class BoxNotFoundError < Error; end
8
+ class InvalidModeError < Error; end
9
+ class DirtySourceError < Error; end
10
+ class CheckpointNotFoundError < Error; end
11
+ class DangerousCommandError < Error; end
12
+ class PatchApplyError < Error; end
13
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "time"
5
+
6
+ module SmartBox
7
+ class Metadata
8
+ attr_reader :path, :data
9
+
10
+ def initialize(path)
11
+ @path = path
12
+ @data = {}
13
+ end
14
+
15
+ def load!
16
+ if File.exist?(@path)
17
+ @data = YAML.safe_load_file(@path, permitted_classes: [Time]) || {}
18
+ end
19
+ self
20
+ end
21
+
22
+ def save!
23
+ dir = File.dirname(@path)
24
+ FileUtils.mkdir_p(dir)
25
+ File.write(@path, YAML.dump(@data))
26
+ self
27
+ end
28
+
29
+ # --- Convenience accessors ---
30
+
31
+ def id
32
+ @data["id"]
33
+ end
34
+
35
+ def id=(val)
36
+ @data["id"] = val
37
+ end
38
+
39
+ def name
40
+ @data["name"]
41
+ end
42
+
43
+ def name=(val)
44
+ @data["name"] = val
45
+ end
46
+
47
+ def mode
48
+ @data["mode"]
49
+ end
50
+
51
+ def mode=(val)
52
+ @data["mode"] = val
53
+ end
54
+
55
+ def source_path
56
+ @data["source_path"]
57
+ end
58
+
59
+ def source_path=(val)
60
+ @data["source_path"] = val
61
+ end
62
+
63
+ def workspace_path
64
+ @data["workspace_path"]
65
+ end
66
+
67
+ def workspace_path=(val)
68
+ @data["workspace_path"] = val
69
+ end
70
+
71
+ def status
72
+ @data["status"] || "active"
73
+ end
74
+
75
+ def status=(val)
76
+ @data["status"] = val
77
+ end
78
+
79
+ def created_at
80
+ @data["created_at"]
81
+ end
82
+
83
+ def created_at=(val)
84
+ @data["created_at"] = val
85
+ end
86
+
87
+ def updated_at
88
+ @data["updated_at"] || Time.now.utc.iso8601
89
+ end
90
+
91
+ def updated_at=(val)
92
+ @data["updated_at"] = val
93
+ end
94
+
95
+ def base
96
+ @data["base"] ||= {}
97
+ end
98
+
99
+ def base=(val)
100
+ @data["base"] = val
101
+ end
102
+
103
+ def checkpoints
104
+ @data["checkpoints"] ||= []
105
+ end
106
+
107
+ def stats
108
+ @data["stats"] ||= {}
109
+ end
110
+
111
+ def stats=(val)
112
+ @data["stats"] = val
113
+ end
114
+
115
+ def add_checkpoint(id:, name:, git_commit:, created_at: Time.now.utc.iso8601)
116
+ checkpoints << {
117
+ "id" => id,
118
+ "name" => name,
119
+ "git_commit" => git_commit,
120
+ "created_at" => created_at
121
+ }
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "time"
5
+
6
+ module SmartBox
7
+ module Modes
8
+ # copy mode: copies the source project into an isolated workspace directory,
9
+ # then initializes a git repo inside the workspace for checkpoint support.
10
+ class CopyMode
11
+ # Directories and files to exclude when copying the source project
12
+ COPY_EXCLUDES = %w[
13
+ .git
14
+ .smart_box
15
+ node_modules
16
+ vendor/bundle
17
+ .bundle
18
+ tmp
19
+ log
20
+ ].freeze
21
+
22
+ EXCLUDE_DIRS = %w[
23
+ .git
24
+ .smart_box
25
+ node_modules
26
+ tmp
27
+ log
28
+ ].freeze
29
+
30
+ attr_reader :source_path, :workspace_path
31
+
32
+ def initialize(source_path:, workspace_path:)
33
+ @source_path = File.expand_path(source_path)
34
+ @workspace_path = File.expand_path(workspace_path)
35
+ end
36
+
37
+ # --- entry point ---
38
+
39
+ def setup
40
+ validate_source!
41
+ create_workspace!
42
+ copy_source!
43
+ init_git!
44
+ end
45
+
46
+ def teardown
47
+ FileUtils.rm_rf(@workspace_path) if Dir.exist?(@workspace_path)
48
+ end
49
+
50
+ private
51
+
52
+ def validate_source!
53
+ unless Dir.exist?(@source_path)
54
+ raise SmartBox::Error, "Source directory not found: #{@source_path}"
55
+ end
56
+
57
+ # For copy mode we allow non-git sources, no extra check needed.
58
+ end
59
+
60
+ def create_workspace!
61
+ FileUtils.mkdir_p(@workspace_path)
62
+ end
63
+
64
+ def copy_source!
65
+ # Use FileUtils.cp_r with a filter to skip excluded directories.
66
+ # Walk through each entry in the source directory
67
+ Dir.each_child(@source_path) do |entry|
68
+ # Skip excluded dirs/files at the top level
69
+ next if EXCLUDE_DIRS.include?(entry)
70
+
71
+ # Skip vendor/bundle — it's a nested pattern; handle at top-level
72
+ # since we only walk one level deep via each_child
73
+ next if entry == "vendor"
74
+
75
+ src = File.join(@source_path, entry)
76
+ dest = File.join(@workspace_path, entry)
77
+
78
+ if File.directory?(src)
79
+ # vendor directory: copy but skip vendor/bundle
80
+ if entry == "vendor"
81
+ FileUtils.mkdir_p(dest)
82
+ Dir.each_child(src) do |sub|
83
+ next if sub == "bundle"
84
+ FileUtils.cp_r(File.join(src, sub), File.join(dest, sub))
85
+ end
86
+ else
87
+ FileUtils.cp_r(src, dest)
88
+ end
89
+ else
90
+ FileUtils.cp_r(src, dest)
91
+ end
92
+ end
93
+ end
94
+
95
+ def init_git!
96
+ Dir.chdir(@workspace_path) do
97
+ system("git", "init", out: File::NULL, err: File::NULL)
98
+ system("git", "config", "user.email", "smart_box@localhost", out: File::NULL, err: File::NULL)
99
+ system("git", "config", "user.name", "smart_box", out: File::NULL, err: File::NULL)
100
+ system("git", "add", "-A", out: File::NULL, err: File::NULL)
101
+ system("git", "commit", "-m", "smart_box initial checkpoint", out: File::NULL, err: File::NULL)
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end