monq 0.1.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,1384 @@
1
+ # Monq 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:** Build a Pry workbench that loads user-authored Mongoid models from `~/.monq.d` and queries MongoDB the way Arql queries SQL.
6
+
7
+ **Architecture:** `Cli` parses options, `App` merges YAML and owns environments, `Environment` owns one Mongoid client plus one namespace. Display (`t`/`v`/`vd`) and Pry commands sit on top and never open connections themselves.
8
+
9
+ **Tech Stack:** Ruby >= 3.2, Mongoid ~> 9, mongo driver, Pry, net-ssh-gateway, terminal-table, rainbow, caxlsx, roo, RSpec.
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-10-24-monq-design.md`
12
+
13
+ ---
14
+
15
+ ## File map
16
+
17
+ | File | Responsibility |
18
+ |------|----------------|
19
+ | `lib/monq/errors.rb` | `Monq::Error` exit messages |
20
+ | `lib/monq/config.rb` | YAML load, env selection, CLI merge, client option build |
21
+ | `lib/monq/cli.rb` | OptionParser → OpenStruct |
22
+ | `lib/monq/app.rb` | Singleton, connect, load init.rb, `run!` |
23
+ | `lib/monq/environment.rb` | Client, namespace, models, reconnect, sandbox session |
24
+ | `lib/monq/ssh_proxy.rb` | Local port forward |
25
+ | `lib/monq/presenter.rb` | BSON → printable |
26
+ | `lib/monq/table.rb` `vd.rb` `multi_io.rb` | Tables, visidata, log fan-out |
27
+ | `lib/monq/repl.rb` | Pry prompt |
28
+ | `lib/monq/commands/*.rb` | Pry commands |
29
+ | `lib/monq/ext/*.rb` | Kernel, const_missing, document/criteria/view/array |
30
+ | `exe/monq` | Executable |
31
+ | `spec/` | Unit tests; integration gated by env |
32
+
33
+ Working directory for every command: `/Users/liuxiang/cascode/github.com/monq`.
34
+
35
+ ---
36
+
37
+ ### Task 1: Gem skeleton and error class
38
+
39
+ **Files:**
40
+ - Modify: `monq.gemspec`
41
+ - Modify: `lib/monq.rb`
42
+ - Create: `lib/monq/errors.rb`
43
+ - Create: `exe/monq`
44
+ - Modify: `spec/monq_spec.rb`
45
+
46
+ - [ ] **Step 1: Write the failing test**
47
+
48
+ Replace `spec/monq_spec.rb` with:
49
+
50
+ ```ruby
51
+ # frozen_string_literal: true
52
+
53
+ RSpec.describe Monq do
54
+ it "has a version" do
55
+ expect(Monq::VERSION).to match(/\A\d+\.\d+\.\d+\z/)
56
+ end
57
+
58
+ it "defines Error" do
59
+ expect(Monq::Error.new("nope").message).to eq("nope")
60
+ end
61
+ end
62
+ ```
63
+
64
+ - [ ] **Step 2: Run test to verify it fails**
65
+
66
+ Run: `bundle exec rspec spec/monq_spec.rb`
67
+ Expected: FAIL, `uninitialized constant Monq::Error`
68
+
69
+ - [ ] **Step 3: Implement**
70
+
71
+ `lib/monq/errors.rb`:
72
+
73
+ ```ruby
74
+ # frozen_string_literal: true
75
+
76
+ module Monq
77
+ class Error < StandardError; end
78
+ end
79
+ ```
80
+
81
+ `lib/monq.rb`:
82
+
83
+ ```ruby
84
+ # frozen_string_literal: true
85
+
86
+ require_relative "monq/version"
87
+ require_relative "monq/errors"
88
+
89
+ module Monq
90
+ end
91
+ ```
92
+
93
+ `exe/monq`:
94
+
95
+ ```ruby
96
+ #!/usr/bin/env ruby
97
+ # frozen_string_literal: true
98
+
99
+ require "monq"
100
+
101
+ Monq::Cli.start
102
+ ```
103
+
104
+ `chmod +x exe/monq`.
105
+
106
+ In `monq.gemspec`, replace the TODO summary/description:
107
+
108
+ ```ruby
109
+ spec.summary = "Mongoid + Pry MongoDB query workbench"
110
+ spec.description = "Load user-authored Mongoid models and query MongoDB from a Pry REPL."
111
+ spec.metadata["allowed_push_host"] = "https://rubygems.org"
112
+ ```
113
+
114
+ Add runtime deps:
115
+
116
+ ```ruby
117
+ spec.add_dependency "mongoid", "~> 9.0"
118
+ spec.add_dependency "pry", "~> 0.15"
119
+ spec.add_dependency "pry-byebug", "~> 3.10"
120
+ spec.add_dependency "net-ssh-gateway", "~> 2.0"
121
+ spec.add_dependency "terminal-table", "~> 4.0"
122
+ spec.add_dependency "rainbow", "~> 3.1"
123
+ spec.add_dependency "caxlsx", "~> 4.1"
124
+ spec.add_dependency "roo", "~> 2.10"
125
+ ```
126
+
127
+ Run `bundle install`.
128
+
129
+ - [ ] **Step 4: Run test to verify it passes**
130
+
131
+ Run: `bundle exec rspec spec/monq_spec.rb`
132
+ Expected: 2 examples, 0 failures
133
+
134
+ - [ ] **Step 5: Commit**
135
+
136
+ ```bash
137
+ git add monq.gemspec Gemfile.lock lib/monq.rb lib/monq/errors.rb exe/monq spec/monq_spec.rb
138
+ git commit -m "feat: add monq executable skeleton and Error"
139
+ ```
140
+
141
+ ---
142
+
143
+ ### Task 2: Config — select environments
144
+
145
+ **Files:**
146
+ - Create: `lib/monq/config.rb`
147
+ - Create: `spec/config_spec.rb`
148
+
149
+ - [ ] **Step 1: Write the failing test**
150
+
151
+ ```ruby
152
+ # frozen_string_literal: true
153
+
154
+ require "ostruct"
155
+
156
+ RSpec.describe Monq::Config do
157
+ let(:yaml) do
158
+ {
159
+ "default" => { "auth_source" => "admin" },
160
+ "blog.dev" => { "uri" => "mongodb://h/db", "database" => "blog_dev", "namespace" => "Blog" },
161
+ "ops.prod" => { "host" => "ops.example.com", "database" => "ops" }
162
+ }
163
+ end
164
+
165
+ it "rejects reserved environment names" do
166
+ expect { described_class.select_environments(yaml, ["default"], OpenStruct.new) }
167
+ .to raise_error(Monq::Error, /reserved/i)
168
+ end
169
+
170
+ it "fails when an -e name is missing" do
171
+ expect { described_class.select_environments(yaml, ["nope"], OpenStruct.new) }
172
+ .to raise_error(Monq::Error, /nope/)
173
+ end
174
+
175
+ it "does not treat the YAML default anchor as an environment when -e is absent" do
176
+ expect { described_class.select_environments(yaml, nil, OpenStruct.new) }
177
+ .to raise_error(Monq::Error, /-e/)
178
+ end
179
+
180
+ it "slices only requested environments" do
181
+ envs = described_class.select_environments(yaml, ["blog.dev"], OpenStruct.new)
182
+ expect(envs.keys).to eq(["blog.dev"])
183
+ end
184
+
185
+ it "builds a synthetic default from CLI when -e is absent but uri is present" do
186
+ opts = OpenStruct.new(uri: "mongodb://h/x", database: "x")
187
+ envs = described_class.select_environments({}, nil, opts)
188
+ expect(envs.keys).to eq(["default"])
189
+ expect(envs["default"][:uri]).to eq("mongodb://h/x")
190
+ end
191
+
192
+ it "rejects two environments that share a namespace" do
193
+ yaml["ops.prod"]["namespace"] = "Blog"
194
+ expect { described_class.select_environments(yaml, ["blog.dev", "ops.prod"], OpenStruct.new) }
195
+ .to raise_error(Monq::Error, /namespace/i)
196
+ end
197
+ end
198
+ ```
199
+
200
+ - [ ] **Step 2: Run test to verify it fails**
201
+
202
+ Run: `bundle exec rspec spec/config_spec.rb`
203
+ Expected: FAIL, `uninitialized constant Monq::Config`
204
+
205
+ - [ ] **Step 3: Implement**
206
+
207
+ `lib/monq/config.rb`:
208
+
209
+ ```ruby
210
+ # frozen_string_literal: true
211
+
212
+ require "yaml"
213
+ require "ostruct"
214
+
215
+ module Monq
216
+ module Config
217
+ RESERVED = %w[default arql].freeze
218
+
219
+ module_function
220
+
221
+ def load_file(path)
222
+ raw = YAML.safe_load(File.read(path), aliases: true)
223
+ (raw || {}).transform_keys(&:to_s)
224
+ rescue ArgumentError
225
+ raw = YAML.safe_load(File.read(path))
226
+ (raw || {}).transform_keys(&:to_s)
227
+ end
228
+
229
+ def select_environments(yaml, names, options)
230
+ yaml = stringify(yaml)
231
+ if names.nil? || names.empty?
232
+ return { "default" => cli_env(options) } if sufficient_cli?(options)
233
+
234
+ raise Error, "No -e given and CLI has no connection. Pass -e or --uri, or create ~/.monq.d/init.yml"
235
+ end
236
+
237
+ names.each do |name|
238
+ raise Error, "#{name} is a reserved environment name" if RESERVED.include?(name)
239
+ raise Error, "Environment #{name} not in config. Known: #{known(yaml).join(', ')}" unless yaml.key?(name)
240
+ end
241
+
242
+ selected = names.to_h { |n| [n, deep_sym(yaml[n])] }
243
+ apply_cli!(selected, options)
244
+ assign_namespaces!(selected)
245
+ assert_unique_namespaces!(selected)
246
+ selected
247
+ end
248
+
249
+ def known(yaml)
250
+ yaml.keys - RESERVED
251
+ end
252
+
253
+ def sufficient_cli?(options)
254
+ options.uri.to_s != "" || (options.host.to_s != "" && options.database.to_s != "")
255
+ end
256
+
257
+ def cli_env(options)
258
+ {
259
+ uri: options.uri,
260
+ host: options.host,
261
+ port: options.port,
262
+ database: options.database,
263
+ username: options.username,
264
+ password: options.password,
265
+ auth_source: options.auth_source
266
+ }.compact
267
+ end
268
+
269
+ def apply_cli!(selected, options)
270
+ overrides = connection_overrides(options)
271
+ if selected.size > 1 && overrides.any?
272
+ raise Error, "Connection CLI flags are not allowed with multiple -e environments"
273
+ end
274
+ return if selected.size != 1
275
+
276
+ env = selected.values.first
277
+ overrides.each { |k, v| env[k] = v }
278
+ merge_ssh!(env, options)
279
+ end
280
+
281
+ def connection_overrides(options)
282
+ {
283
+ uri: options.uri,
284
+ host: options.host,
285
+ port: options.port,
286
+ database: options.database,
287
+ username: options.username,
288
+ password: options.password,
289
+ auth_source: options.auth_source
290
+ }.compact.reject { |_, v| v.respond_to?(:empty?) && v.empty? }
291
+ end
292
+
293
+ def merge_ssh!(env, options)
294
+ return if options.ssh.nil? || options.ssh.empty?
295
+
296
+ env[:ssh] = (env[:ssh] || {}).merge(options.ssh.transform_keys(&:to_sym))
297
+ end
298
+
299
+ def assign_namespaces!(selected)
300
+ selected.each do |name, env|
301
+ env[:namespace] ||= camelize(name)
302
+ end
303
+ end
304
+
305
+ def assert_unique_namespaces!(selected)
306
+ groups = selected.group_by { |_, env| env[:namespace] }
307
+ dup = groups.find { |_, pairs| pairs.size > 1 }
308
+ return unless dup
309
+
310
+ raise Error, "Environments share namespace #{dup.first}: #{dup.last.map(&:first).join(', ')}"
311
+ end
312
+
313
+ def camelize(name)
314
+ name.split(/[^A-Za-z0-9]+/).reject(&:empty?).map { |p| p[0].upcase + p[1..] }.join
315
+ end
316
+
317
+ def stringify(hash)
318
+ hash.to_h.transform_keys(&:to_s).transform_values { |v| v.is_a?(Hash) ? stringify(v) : v }
319
+ end
320
+
321
+ def deep_sym(obj)
322
+ case obj
323
+ when Hash then obj.to_h { |k, v| [k.to_sym, deep_sym(v)] }
324
+ else obj
325
+ end
326
+ end
327
+
328
+ def prefix_for(env_name)
329
+ env_name.split(/[._-]/, 2).first
330
+ end
331
+
332
+ def client_name_for(env_name)
333
+ env_name.gsub(/[^A-Za-z0-9]/, "_").to_sym
334
+ end
335
+ end
336
+ end
337
+ ```
338
+
339
+ `require_relative "monq/config"` in `lib/monq.rb`.
340
+
341
+ - [ ] **Step 4: Run test to verify it passes**
342
+
343
+ Run: `bundle exec rspec spec/config_spec.rb`
344
+ Expected: 6 examples, 0 failures
345
+
346
+ - [ ] **Step 5: Commit**
347
+
348
+ ```bash
349
+ git add lib/monq.rb lib/monq/config.rb spec/config_spec.rb
350
+ git commit -m "feat: select monq environments from YAML and CLI"
351
+ ```
352
+
353
+ ---
354
+
355
+ ### Task 3: Config — client options, URI mask, SSH rewrite
356
+
357
+ **Files:**
358
+ - Modify: `lib/monq/config.rb`
359
+ - Modify: `spec/config_spec.rb`
360
+
361
+ - [ ] **Step 1: Write the failing test**
362
+
363
+ Append to `spec/config_spec.rb`:
364
+
365
+ ```ruby
366
+ RSpec.describe Monq::Config do
367
+ describe ".client_options" do
368
+ it "prefers uri and lets database override the URI path" do
369
+ opts = described_class.client_options(uri: "mongodb://u:p@db.example.com:27017/other?authSource=admin", database: "blog_dev")
370
+ expect(opts[:database]).to eq("blog_dev")
371
+ expect(opts[:hosts]).to eq(["db.example.com:27017"])
372
+ end
373
+
374
+ it "requires database when the URI has no path" do
375
+ expect { described_class.client_options(uri: "mongodb://db.example.com:27017/?authSource=admin") }
376
+ .to raise_error(Monq::Error, /database/)
377
+ end
378
+
379
+ it "builds hosts from split fields when uri is absent" do
380
+ opts = described_class.client_options(host: "mongo.example.com", database: "x")
381
+ expect(opts[:hosts]).to eq(["mongo.example.com:27017"])
382
+ expect(opts[:database]).to eq("x")
383
+ end
384
+
385
+ it "rewrites ssh to one direct seed and drops replica_set" do
386
+ opts = described_class.client_options(
387
+ uri: "mongodb://u:p@db.example.com:27017,db2.example.com:27017/blog_dev?replicaSet=rs0",
388
+ ssh: { local_host: "127.0.0.1", local_port: 27018 }
389
+ )
390
+ expect(opts[:hosts]).to eq(["127.0.0.1:27018"])
391
+ expect(opts[:options][:direct_connection]).to eq(true)
392
+ expect(opts[:options]).not_to have_key(:replica_set)
393
+ end
394
+
395
+ it "rejects mongodb+srv together with ssh" do
396
+ expect {
397
+ described_class.client_options(uri: "mongodb+srv://u:p@cluster.example/?authSource=admin", database: "x", ssh: { local_port: 1 })
398
+ }.to raise_error(Monq::Error, /srv/i)
399
+ end
400
+ end
401
+
402
+ describe ".mask_uri" do
403
+ it "masks the password" do
404
+ expect(described_class.mask_uri("mongodb://admin:secret@db.example.com:27017/db"))
405
+ .to eq("mongodb://admin:***@db.example.com:27017/db")
406
+ end
407
+ end
408
+
409
+ it "maps env name to prefix and client name" do
410
+ expect(described_class.prefix_for("blog.dev")).to eq("blog")
411
+ expect(described_class.client_name_for("blog.dev")).to eq(:blog_dev)
412
+ expect(described_class.camelize("blog.dev")).to eq("BlogDev")
413
+ end
414
+ end
415
+ ```
416
+
417
+ - [ ] **Step 2: Run test to verify it fails**
418
+
419
+ Run: `bundle exec rspec spec/config_spec.rb`
420
+ Expected: FAIL, `client_options` missing
421
+
422
+ - [ ] **Step 3: Implement**
423
+
424
+ Add to `Monq::Config` (still `module_function`):
425
+
426
+ ```ruby
427
+ def client_options(env)
428
+ env = deep_sym(env)
429
+ ssh = env[:ssh]
430
+ if ssh && env[:uri].to_s.start_with?("mongodb+srv:")
431
+ raise Error, "mongodb+srv with ssh is not supported; use a direct host"
432
+ end
433
+
434
+ built = env[:uri].to_s != "" ? from_uri(env) : from_fields(env)
435
+ return built unless ssh && ssh[:local_port]
436
+
437
+ {
438
+ hosts: ["127.0.0.1:#{ssh[:local_port]}"],
439
+ database: built[:database],
440
+ options: built[:options].merge(direct_connection: true).except(:replica_set, :connect)
441
+ }
442
+ end
443
+
444
+ def from_uri(env)
445
+ require "mongo"
446
+ parsed = Mongo::URI.get(env[:uri])
447
+ database = env[:database].to_s
448
+ database = parsed.database if database.empty?
449
+ raise Error, "database is required (URI path is empty)" if database.nil? || database.empty?
450
+
451
+ options = parsed.uri_options.dup
452
+ options.delete(:replica_set) if env[:ssh] && env[:ssh][:local_port]
453
+ {
454
+ hosts: parsed.servers,
455
+ database: database,
456
+ options: options
457
+ }
458
+ end
459
+
460
+ def from_fields(env)
461
+ raise Error, "host and database are required" if env[:host].to_s.empty? || env[:database].to_s.empty?
462
+
463
+ port = env[:port] || 27017
464
+ options = {}
465
+ options[:user] = env[:username] if env[:username]
466
+ options[:password] = env[:password] if env[:password]
467
+ options[:auth_source] = env[:auth_source] if env[:auth_source]
468
+ options[:replica_set] = env[:replica_set] if env[:replica_set] && !(env[:ssh] && env[:ssh][:local_port])
469
+ options[:ssl] = env[:ssl] unless env[:ssl].nil?
470
+ { hosts: ["#{env[:host]}:#{port}"], database: env[:database], options: options }
471
+ end
472
+
473
+ def mask_uri(uri)
474
+ uri.to_s.sub(%r{://([^:/@]+):[^@]+@}, '://\1:***@')
475
+ end
476
+
477
+ def ssh_forward_target(env)
478
+ env = deep_sym(env)
479
+ if env[:uri].to_s != ""
480
+ require "mongo"
481
+ host, port = Mongo::URI.get(env[:uri]).servers.first.split(":")
482
+ { host: host, port: (port || 27017).to_i }
483
+ else
484
+ { host: env[:host], port: (env[:port] || 27017).to_i }
485
+ end
486
+ end
487
+ ```
488
+
489
+ If `Mongo::URI.get` does not exist in the installed driver, use `Mongo::URI.new(env[:uri])` and read `#servers` / `#database` / `#options` — adjust the three method names to whatever `bundle exec ruby -rmongo -e 'p Mongo::URI.instance_methods(false)'` prints, keeping the spec expectations above.
490
+
491
+ - [ ] **Step 4: Run test to verify it passes**
492
+
493
+ Run: `bundle exec rspec spec/config_spec.rb`
494
+ Expected: all examples pass
495
+
496
+ - [ ] **Step 5: Commit**
497
+
498
+ ```bash
499
+ git add lib/monq/config.rb spec/config_spec.rb
500
+ git commit -m "feat: build Mongo client options and mask URIs"
501
+ ```
502
+
503
+ ---
504
+
505
+ ### Task 4: Environment connects a named client without models
506
+
507
+ **Files:**
508
+ - Create: `lib/monq/environment.rb`
509
+ - Create: `spec/environment_spec.rb`
510
+
511
+ Use a fake client so unit tests do not dial the network.
512
+
513
+ - [ ] **Step 1: Write the failing test**
514
+
515
+ ```ruby
516
+ # frozen_string_literal: true
517
+
518
+ RSpec.describe Monq::Environment do
519
+ let(:hello_doc) { { "ok" => 1, "setName" => "rs0" } }
520
+ let(:fake_client) do
521
+ db = double("db", command: double(documents: [hello_doc]), collection_names: ["users"])
522
+ instance_double("Mongo::Client", database: db, close: nil)
523
+ end
524
+
525
+ before do
526
+ allow(Mongo::Client).to receive(:new).and_return(fake_client)
527
+ allow(Mongoid::Clients).to receive(:set)
528
+ end
529
+
530
+ it "registers the named client and maps the first env to :default" do
531
+ env = described_class.new("blog.dev", { uri: "mongodb://h/blog_dev", database: "blog_dev", namespace: "Blog" }, first: true, model_dir: "/tmp")
532
+ env.connect!
533
+ expect(Mongoid::Clients).to have_received(:set).with(:blog_dev, fake_client)
534
+ expect(Mongoid::Clients).to have_received(:set).with(:default, fake_client)
535
+ expect(env.namespace).to eq("Blog")
536
+ expect(env.ping?).to eq(true)
537
+ end
538
+
539
+ it "does not map later environments onto :default" do
540
+ env = described_class.new("ops.prod", { host: "h", database: "ops", namespace: "Ops" }, first: false, model_dir: "/tmp")
541
+ env.connect!
542
+ expect(Mongoid::Clients).to have_received(:set).with(:ops_prod, fake_client)
543
+ expect(Mongoid::Clients).not_to have_received(:set).with(:default, fake_client)
544
+ end
545
+ end
546
+ ```
547
+
548
+ - [ ] **Step 2: Run test to verify it fails**
549
+
550
+ Run: `bundle exec rspec spec/environment_spec.rb`
551
+ Expected: FAIL, `uninitialized constant Monq::Environment`
552
+
553
+ - [ ] **Step 3: Implement**
554
+
555
+ `lib/monq/environment.rb`:
556
+
557
+ ```ruby
558
+ # frozen_string_literal: true
559
+
560
+ require "mongoid"
561
+
562
+ module Monq
563
+ class Environment
564
+ attr_reader :name, :config, :client_name, :namespace, :client, :ssh_proxy
565
+
566
+ def initialize(name, config, first:, model_dir:)
567
+ @name = name
568
+ @config = config
569
+ @first = first
570
+ @model_dir = model_dir
571
+ @client_name = Config.client_name_for(name)
572
+ @namespace = config[:namespace]
573
+ @models = []
574
+ @sandbox_session = nil
575
+ end
576
+
577
+ def connect!
578
+ opts = Config.client_options(config)
579
+ @client = Mongo::Client.new(opts[:hosts], **opts[:options].merge(database: opts[:database]))
580
+ Mongoid::Clients.set(client_name, @client)
581
+ Mongoid::Clients.set(:default, @client) if @first
582
+ hello!
583
+ end
584
+
585
+ def hello!
586
+ @client.database.command(hello: 1).documents.first
587
+ end
588
+
589
+ def ping?
590
+ hello!
591
+ true
592
+ rescue StandardError
593
+ false
594
+ end
595
+
596
+ def reconnect!
597
+ raise Error, "sandbox is active; run sandbox-quit first" if sandbox?
598
+
599
+ @client&.close
600
+ connect!
601
+ end
602
+
603
+ def namespace_module
604
+ @namespace_module ||= Object.const_set(namespace, Module.new)
605
+ end
606
+
607
+ def sandbox?
608
+ !@sandbox_session.nil?
609
+ end
610
+ end
611
+ end
612
+ ```
613
+
614
+ `require_relative "monq/environment"` in `lib/monq.rb`.
615
+
616
+ If `Mongoid::Clients.set` is not public in the installed version, check `Mongoid::Clients.methods` and use the method that stores a client under a symbol (often `set` or `clients[name] =`). Keep the spec's `have_received(:set)` by wrapping whatever the gem actually exposes in `Environment#register_client`.
617
+
618
+ - [ ] **Step 4: Run test to verify it passes**
619
+
620
+ Run: `bundle exec rspec spec/environment_spec.rb`
621
+ Expected: 2 examples, 0 failures
622
+
623
+ - [ ] **Step 5: Commit**
624
+
625
+ ```bash
626
+ git add lib/monq.rb lib/monq/environment.rb spec/environment_spec.rb
627
+ git commit -m "feat: connect a named Mongoid client per environment"
628
+ ```
629
+
630
+ ---
631
+
632
+ ### Task 5: Load models into the namespace and bind clients
633
+
634
+ **Files:**
635
+ - Modify: `lib/monq/environment.rb`
636
+ - Modify: `spec/environment_spec.rb`
637
+
638
+ - [ ] **Step 1: Write the failing test**
639
+
640
+ ```ruby
641
+ RSpec.describe Monq::Environment do
642
+ it "loads prefix file into the namespace and rebinds :default client" do
643
+ dir = Dir.mktmpdir
644
+ File.write(File.join(dir, "blog.rb"), <<~RUBY)
645
+ class User
646
+ include Mongoid::Document
647
+ store_in collection: "users"
648
+ embeds_one :meta, class_name: "UserMeta"
649
+ end
650
+ class UserMeta
651
+ include Mongoid::Document
652
+ embedded_in :user
653
+ field :status, type: String
654
+ end
655
+ RUBY
656
+
657
+ env = described_class.new("blog.dev", { host: "h", database: "d", namespace: "Blog" }, first: true, model_dir: dir)
658
+ allow(env).to receive(:connect!)
659
+ env.load_models!
660
+ expect(Blog::User.storage_options[:client]).to eq(:blog_dev)
661
+ expect(Blog::User.storage_options[:collection]).to eq("users")
662
+ expect(Blog::UserMeta).to be_embedded
663
+ expect(Blog.const_defined?(:U)).to eq(true)
664
+ ensure
665
+ Object.send(:remove_const, :Blog) if Object.const_defined?(:Blog)
666
+ FileUtils.remove_entry(dir) if dir
667
+ end
668
+
669
+ it "warns and continues when the prefix file is missing" do
670
+ env = described_class.new("blog.dev", { host: "h", database: "d", namespace: "BlogX" }, first: true, model_dir: Dir.mktmpdir)
671
+ expect { env.load_models! }.to output(/blog\.rb/).to_stderr
672
+ expect(env.models).to eq([])
673
+ ensure
674
+ Object.send(:remove_const, :BlogX) if Object.const_defined?(:BlogX)
675
+ end
676
+ end
677
+ ```
678
+
679
+ - [ ] **Step 2: Run test to verify it fails**
680
+
681
+ Run: `bundle exec rspec spec/environment_spec.rb`
682
+ Expected: FAIL, `load_models!` missing
683
+
684
+ - [ ] **Step 3: Implement**
685
+
686
+ Add to `Environment`:
687
+
688
+ ```ruby
689
+ def load_models!
690
+ install_namespace_api!
691
+ path = File.join(@model_dir, "#{Config.prefix_for(name)}.rb")
692
+ unless File.file?(path)
693
+ warn "model file not found: #{path}"
694
+ return
695
+ end
696
+ namespace_module.module_eval(File.read(path), path)
697
+ bind_clients!
698
+ install_abbreviations!
699
+ end
700
+
701
+ def models
702
+ namespace_module.constants.filter_map do |const_name|
703
+ klass = namespace_module.const_get(const_name)
704
+ klass if klass.is_a?(Class) && klass < Mongoid::Document && !klass.embedded?
705
+ end
706
+ end
707
+
708
+ def redefine!
709
+ raise Error, "sandbox is active; run sandbox-quit first" if sandbox?
710
+
711
+ tracked_classes.each do |klass|
712
+ Mongoid.models.delete(klass) if Mongoid.models.respond_to?(:delete)
713
+ end
714
+ tracked_names.each { |n| namespace_module.send(:remove_const, n) if namespace_module.const_defined?(n, false) }
715
+ @tracked = []
716
+ load_models!
717
+ end
718
+
719
+ def tracked_names
720
+ @tracked ||= []
721
+ end
722
+
723
+ def tracked_classes
724
+ tracked_names.filter_map do |n|
725
+ namespace_module.const_get(n) if namespace_module.const_defined?(n, false)
726
+ end.select { |k| k.is_a?(Class) }
727
+ end
728
+
729
+ def bind_clients!
730
+ namespace_module.constants.each do |const_name|
731
+ klass = namespace_module.const_get(const_name)
732
+ next unless klass.is_a?(Class) && klass < Mongoid::Document
733
+ next if klass.embedded?
734
+
735
+ current = klass.storage_options[:client]
736
+ next if current && current.to_sym != :default
737
+
738
+ klass.store_in klass.storage_options.merge(client: client_name)
739
+ tracked_names << const_name
740
+ end
741
+ end
742
+
743
+ def install_abbreviations!
744
+ models.each do |klass|
745
+ abbr = klass.name.split("::").last.gsub(/[^A-Z]/, "")
746
+ abbr = "M" if abbr.empty?
747
+ candidate = abbr
748
+ n = 2
749
+ while namespace_module.const_defined?(candidate, false)
750
+ candidate = "#{abbr}#{n}"
751
+ n += 1
752
+ end
753
+ namespace_module.const_set(candidate, klass)
754
+ tracked_names << candidate.to_sym
755
+ end
756
+ end
757
+
758
+ def install_namespace_api!
759
+ env = self
760
+ mod = namespace_module
761
+ mod.define_singleton_method(:client) { env.client }
762
+ mod.define_singleton_method(:models) { env.models }
763
+ mod.define_singleton_method(:collections) { env.client.database.collection_names }
764
+ mod.define_singleton_method(:config) { env.config }
765
+ mod.define_singleton_method(:q) { |doc| env.client.database.command(doc) }
766
+ end
767
+ ```
768
+
769
+ `redefine!` must also record embedded classes created in the file so they are removed from `Mongoid.models`. After `module_eval`, walk `models` and each `embedded_relations` target class and push those class names into `tracked_names`.
770
+
771
+ - [ ] **Step 4: Run test to verify it passes**
772
+
773
+ Run: `bundle exec rspec spec/environment_spec.rb`
774
+ Expected: pass, including `Blog::User.storage_options[:collection] == "users"`
775
+
776
+ - [ ] **Step 5: Commit**
777
+
778
+ ```bash
779
+ git add lib/monq/environment.rb spec/environment_spec.rb
780
+ git commit -m "feat: load namespace models and bind Mongoid clients"
781
+ ```
782
+
783
+ ---
784
+
785
+ ### Task 6: CLI and App runner
786
+
787
+ **Files:**
788
+ - Create: `lib/monq/cli.rb`
789
+ - Create: `lib/monq/app.rb`
790
+ - Create: `spec/app_spec.rb`
791
+
792
+ - [ ] **Step 1: Write the failing test**
793
+
794
+ ```ruby
795
+ # frozen_string_literal: true
796
+
797
+ RSpec.describe Monq::Cli do
798
+ it "parses multiple environments" do
799
+ opts = described_class.parse!(%w[-e blog.dev,ops.prod])
800
+ expect(opts.environments).to eq(%w[blog.dev ops.prod])
801
+ end
802
+
803
+ it "rejects connection flags with multiple environments" do
804
+ expect { described_class.parse!(%w[-e a,b --uri mongodb://h/db]) }
805
+ .to raise_error(Monq::Error, /multiple/i)
806
+ end
807
+ end
808
+
809
+ RSpec.describe Monq::App do
810
+ it "evals -E and does not start pry" do
811
+ yaml = Tempfile.new(["monq", ".yml"])
812
+ yaml.write("blog.dev:\n host: h\n database: d\n namespace: BlogApp\n")
813
+ yaml.close
814
+ app = described_class.new(OpenStruct.new(config_file: yaml.path, environments: ["blog.dev"], code: "MONQ_RAN = 1"))
815
+ allow_any_instance_of(Monq::Environment).to receive(:connect!)
816
+ expect(Pry).not_to receive(:start) if defined?(Pry)
817
+ app.run!
818
+ expect(MONQ_RAN).to eq(1)
819
+ ensure
820
+ yaml&.unlink
821
+ end
822
+ end
823
+ ```
824
+
825
+ - [ ] **Step 2: Run test to verify it fails**
826
+
827
+ Run: `bundle exec rspec spec/app_spec.rb`
828
+ Expected: FAIL, `Monq::Cli` missing
829
+
830
+ - [ ] **Step 3: Implement**
831
+
832
+ `lib/monq/cli.rb` uses `OptionParser` for the flags in spec section 4.1. `parse!` returns an `OpenStruct`. `start` calls `App.new(parse!(ARGV)).run!`. Multiple `-e` plus any connection flag raises `Monq::Error`.
833
+
834
+ `lib/monq/app.rb`:
835
+
836
+ ```ruby
837
+ module Monq
838
+ class App
839
+ attr_reader :environments, :options
840
+
841
+ class << self
842
+ attr_accessor :instance
843
+ end
844
+
845
+ def initialize(options)
846
+ @options = options
847
+ self.class.instance = self
848
+ @environments = build_environments
849
+ end
850
+
851
+ def run!
852
+ @environments.each(&:connect!)
853
+ load_initializer!
854
+ @environments.each(&:load_models!)
855
+ if @options.code && !@options.code.empty?
856
+ eval(@options.code, TOPLEVEL_BINDING, "-e")
857
+ elsif @options.args&.first
858
+ load(@options.args.first)
859
+ elsif !$stdin.isatty
860
+ eval($stdin.read, TOPLEVEL_BINDING, "stdin")
861
+ else
862
+ require "monq/repl"
863
+ Repl.new.start
864
+ end
865
+ end
866
+ end
867
+ end
868
+ ```
869
+
870
+ `build_environments` resolves the config file (explicit `-c` missing → raise; no file and no CLI connection → raise), calls `Config.select_environments`, and maps each to `Environment.new(..., first: index.zero?, model_dir: File.expand_path("~/.monq.d"))`.
871
+
872
+ `load_initializer!`: if `-i` set and missing, raise; if default path missing, return; else `load` once.
873
+
874
+ `require_relative` both files from `lib/monq.rb`. `exe/monq` already calls `Monq::Cli.start`.
875
+
876
+ - [ ] **Step 4: Run test to verify it passes**
877
+
878
+ Run: `bundle exec rspec spec/app_spec.rb spec/config_spec.rb`
879
+ Expected: pass
880
+
881
+ - [ ] **Step 5: Commit**
882
+
883
+ ```bash
884
+ git add lib/monq.rb lib/monq/cli.rb lib/monq/app.rb spec/app_spec.rb
885
+ git commit -m "feat: add CLI and App runner"
886
+ ```
887
+
888
+ ---
889
+
890
+ ### Task 7: Presenter and Table
891
+
892
+ **Files:**
893
+ - Create: `lib/monq/presenter.rb`
894
+ - Create: `lib/monq/table.rb`
895
+ - Create: `spec/presenter_spec.rb`
896
+
897
+ - [ ] **Step 1: Write the failing test**
898
+
899
+ ```ruby
900
+ # frozen_string_literal: true
901
+
902
+ require "bson"
903
+
904
+ RSpec.describe Monq::Presenter do
905
+ it "stringifies ObjectId" do
906
+ id = BSON::ObjectId.new
907
+ expect(described_class.scalar(id)).to eq(id.to_s)
908
+ end
909
+
910
+ it "truncates long hashes" do
911
+ text = described_class.scalar({ "a" => "x" * 200 })
912
+ expect(text.length).to be <= 121
913
+ expect(text).to end_with("…")
914
+ end
915
+
916
+ it "builds rows from the first document's keys" do
917
+ rows = described_class.rows([{ "a" => 1, "b" => 2 }, { "a" => 3, "c" => 9 }])
918
+ expect(rows.first).to eq(%w[a b])
919
+ expect(rows.last).to eq([3, nil])
920
+ end
921
+ end
922
+ ```
923
+
924
+ - [ ] **Step 2: Run test to verify it fails**
925
+
926
+ Run: `bundle exec rspec spec/presenter_spec.rb`
927
+ Expected: FAIL
928
+
929
+ - [ ] **Step 3: Implement**
930
+
931
+ `Presenter.scalar` follows spec 6.2 (ObjectId, Time, Binary, Decimal128, Hash/Array truncated at 120). `Presenter.rows(docs)` uses `docs.first.keys`, missing keys become `nil`.
932
+
933
+ `Table` wraps `Terminal::Table` and supports `:terminal`, `:md`, `:org` the same way Arql's `lib/arql/table.rb` does (copy the style branches, do not require arql).
934
+
935
+ - [ ] **Step 4: Run test to verify it passes**
936
+
937
+ Run: `bundle exec rspec spec/presenter_spec.rb`
938
+ Expected: pass
939
+
940
+ - [ ] **Step 5: Commit**
941
+
942
+ ```bash
943
+ git add lib/monq/presenter.rb lib/monq/table.rb spec/presenter_spec.rb lib/monq.rb
944
+ git commit -m "feat: present BSON values as tables"
945
+ ```
946
+
947
+ ---
948
+
949
+ ### Task 8: t/v/vd on documents, criteria, views, arrays
950
+
951
+ **Files:**
952
+ - Create: `lib/monq/ext/document.rb`
953
+ - Create: `lib/monq/ext/criteria.rb`
954
+ - Create: `lib/monq/ext/view.rb`
955
+ - Create: `lib/monq/ext/array.rb`
956
+ - Create: `spec/display_spec.rb`
957
+
958
+ - [ ] **Step 1: Write the failing test**
959
+
960
+ ```ruby
961
+ # frozen_string_literal: true
962
+
963
+ RSpec.describe "display" do
964
+ it "caps an unlimited criteria at 500 and reports it" do
965
+ criteria = double("criteria", options: {})
966
+ allow(criteria).to receive(:limit).with(500).and_return(criteria)
967
+ docs = [{ "_id" => "a", "name" => "n" }]
968
+ allow(criteria).to receive(:to_a).and_return(docs)
969
+ expect { Monq::Ext.display_criteria(criteria) }.to output(/capped at 500/).to_stdout
970
+ end
971
+
972
+ it "does not cap when the caller set limit" do
973
+ criteria = double("criteria", options: { limit: 2 })
974
+ expect(criteria).not_to receive(:limit)
975
+ allow(criteria).to receive(:to_a).and_return([{ "_id" => "a" }])
976
+ expect { Monq::Ext.display_criteria(criteria) }.not_to output(/capped/).to_stdout
977
+ end
978
+ end
979
+ ```
980
+
981
+ - [ ] **Step 2: Run test to verify it fails**
982
+
983
+ Run: `bundle exec rspec spec/display_spec.rb`
984
+ Expected: FAIL
985
+
986
+ - [ ] **Step 3: Implement**
987
+
988
+ `Monq::Ext.display_criteria(criteria)`:
989
+
990
+ - if `criteria.options[:limit].nil?`, `criteria = criteria.limit(500)` and set `capped = true`
991
+ - print `Table` of `Presenter.rows(criteria.to_a)`
992
+ - print `showing N rows (capped at 500)` only when capped
993
+
994
+ `v` returns `[keys, nil, *rows]` and does not print the cap line. `t` and `vd` print it.
995
+
996
+ Include the module into `Mongoid::Document`, `Mongoid::Criteria`, and `Mongo::Collection::View` (`view.rb` calls `to_a` then array `t`). Array `t` uses `Presenter.rows` when elements are Hash-like.
997
+
998
+ Do **not** define `Fund.t` as a class method.
999
+
1000
+ - [ ] **Step 4: Run test to verify it passes**
1001
+
1002
+ Run: `bundle exec rspec spec/display_spec.rb spec/presenter_spec.rb`
1003
+ Expected: pass
1004
+
1005
+ - [ ] **Step 5: Commit**
1006
+
1007
+ ```bash
1008
+ git add lib/monq/ext spec/display_spec.rb lib/monq.rb
1009
+ git commit -m "feat: add t/v display with a 500-row cap"
1010
+ ```
1011
+
1012
+ ---
1013
+
1014
+ ### Task 9: const_missing and Kernel helpers
1015
+
1016
+ **Files:**
1017
+ - Create: `lib/monq/ext/object.rb`
1018
+ - Create: `lib/monq/ext/kernel.rb`
1019
+ - Create: `spec/const_missing_spec.rb`
1020
+
1021
+ - [ ] **Step 1: Write the failing test**
1022
+
1023
+ ```ruby
1024
+ # frozen_string_literal: true
1025
+
1026
+ RSpec.describe "const_missing" do
1027
+ it "resolves the first environment that defines the constant" do
1028
+ a = Module.new
1029
+ b = Module.new
1030
+ stub_const("Alpha", Class.new)
1031
+ a.const_set(:Alpha, Alpha)
1032
+ app = instance_double(Monq::App, environments: [
1033
+ instance_double(Monq::Environment, namespace_module: a),
1034
+ instance_double(Monq::Environment, namespace_module: b)
1035
+ ])
1036
+ allow(Monq::App).to receive(:instance).and_return(app)
1037
+ expect(Object.const_missing(:Alpha)).to eq(Alpha)
1038
+ end
1039
+ end
1040
+ ```
1041
+
1042
+ Adjust if `Object.const_missing` cannot be called directly because the constant exists — remove it first or use a name that is not defined, e.g. `FundProbe`.
1043
+
1044
+ - [ ] **Step 2: Run test to verify it fails**
1045
+
1046
+ Run: `bundle exec rspec spec/const_missing_spec.rb`
1047
+ Expected: FAIL or `NameError` from `super`
1048
+
1049
+ - [ ] **Step 3: Implement**
1050
+
1051
+ `object.rb` prepends/overrides `Object.const_missing` exactly as Arql does, walking `Monq::App.instance.environments`. `kernel.rb` defines `models`, `model_names`, `within_namespace`, `within_env`, `env_config`, and `q` (single environment only; otherwise stderr + nil).
1052
+
1053
+ - [ ] **Step 4: Run test to verify it passes**
1054
+
1055
+ Run: `bundle exec rspec spec/const_missing_spec.rb`
1056
+ Expected: pass
1057
+
1058
+ - [ ] **Step 5: Commit**
1059
+
1060
+ ```bash
1061
+ git add lib/monq/ext/object.rb lib/monq/ext/kernel.rb spec/const_missing_spec.rb lib/monq.rb
1062
+ git commit -m "feat: alias top-level model constants across environments"
1063
+ ```
1064
+
1065
+ ---
1066
+
1067
+ ### Task 10: Pry commands info, m, show-sql, reconnect, redefine
1068
+
1069
+ **Files:**
1070
+ - Create: `lib/monq/repl.rb`
1071
+ - Create: `lib/monq/multi_io.rb`
1072
+ - Create: `lib/monq/commands/info.rb`
1073
+ - Create: `lib/monq/commands/models.rb`
1074
+ - Create: `lib/monq/commands/show_sql.rb`
1075
+ - Create: `lib/monq/commands/reconnect.rb`
1076
+ - Create: `lib/monq/commands/redefine.rb`
1077
+ - Create: `spec/commands_spec.rb`
1078
+
1079
+ - [ ] **Step 1: Write the failing test**
1080
+
1081
+ ```ruby
1082
+ # frozen_string_literal: true
1083
+
1084
+ RSpec.describe Monq::Commands::Info do
1085
+ it "masks passwords" do
1086
+ env = instance_double(
1087
+ Monq::Environment,
1088
+ name: "blog.dev",
1089
+ config: { uri: "mongodb://admin:secret@db.example.com:27017/blog_dev", database: "blog_dev" },
1090
+ client_name: :blog_dev,
1091
+ ping?: true,
1092
+ ssh_proxy: nil
1093
+ )
1094
+ text = described_class.render([env], /blog/)
1095
+ expect(text).to include("admin:***@")
1096
+ expect(text).not_to include("secret")
1097
+ expect(text).to include("blog_dev")
1098
+ end
1099
+ end
1100
+
1101
+ RSpec.describe Monq::Commands::Redefine do
1102
+ it "refuses while sandbox is active" do
1103
+ env = instance_double(Monq::Environment, sandbox?: true)
1104
+ expect { described_class.run([env]) }.to raise_error(Monq::Error, /sandbox/)
1105
+ expect(env).not_to receive(:redefine!)
1106
+ end
1107
+ end
1108
+ ```
1109
+
1110
+ - [ ] **Step 2: Run test to verify it fails**
1111
+
1112
+ Run: `bundle exec rspec spec/commands_spec.rb`
1113
+ Expected: FAIL
1114
+
1115
+ - [ ] **Step 3: Implement**
1116
+
1117
+ Command modules expose pure methods (`render`, `run`) that Pry command blocks call. `Info.render` uses `Config.mask_uri`. `Models.render` lists `env.models` with collection, class name, abbreviation. `ShowSql` subscribes `Mongo::Monitoring::COMMAND` and writes JSON to `App.log_io` (`MultiIO`). `Reconnect.run` calls `ping?` then `reconnect!` unless `sandbox?`. `Redefine.run` raises `Monq::Error` when any env `sandbox?`, else `redefine!`.
1118
+
1119
+ `Repl#start` sets the prompt `MONQ@<names>` and calls `Pry.start` unless `options.babel_compatable`.
1120
+
1121
+ - [ ] **Step 4: Run test to verify it passes**
1122
+
1123
+ Run: `bundle exec rspec spec/commands_spec.rb`
1124
+ Expected: pass
1125
+
1126
+ - [ ] **Step 5: Commit**
1127
+
1128
+ ```bash
1129
+ git add lib/monq/repl.rb lib/monq/multi_io.rb lib/monq/commands spec/commands_spec.rb lib/monq.rb
1130
+ git commit -m "feat: add info, models, show-sql, reconnect, and redefine commands"
1131
+ ```
1132
+
1133
+ ---
1134
+
1135
+ ### Task 11: SSH proxy
1136
+
1137
+ **Files:**
1138
+ - Create: `lib/monq/ssh_proxy.rb`
1139
+ - Modify: `lib/monq/environment.rb`
1140
+ - Create: `spec/ssh_proxy_spec.rb`
1141
+
1142
+ - [ ] **Step 1: Write the failing test**
1143
+
1144
+ ```ruby
1145
+ # frozen_string_literal: true
1146
+
1147
+ RSpec.describe Monq::SSHProxy do
1148
+ it "returns the local host and port" do
1149
+ gateway = instance_double(Net::SSH::Gateway, open: 27018, shutdown!: nil, active?: true)
1150
+ allow(Net::SSH::Gateway).to receive(:new).and_return(gateway)
1151
+ proxy = described_class.new(host: "jump", user: "deploy", port: 22, forward_host: "db.example.com", forward_port: 27017, local_port: 27018)
1152
+ expect(proxy.database_host_port).to eq(host: "127.0.0.1", port: 27018)
1153
+ end
1154
+
1155
+ it "reconnects only when inactive" do
1156
+ gateway = instance_double(Net::SSH::Gateway, open: 27018, shutdown!: nil, active?: true)
1157
+ allow(Net::SSH::Gateway).to receive(:new).and_return(gateway)
1158
+ proxy = described_class.new(host: "jump", user: "deploy", forward_host: "db.example.com", forward_port: 27017)
1159
+ expect(proxy).not_to receive(:reconnect!)
1160
+ proxy.reconnect
1161
+ end
1162
+ end
1163
+ ```
1164
+
1165
+ - [ ] **Step 2: Run test to verify it fails**
1166
+
1167
+ Run: `bundle exec rspec spec/ssh_proxy_spec.rb`
1168
+ Expected: FAIL
1169
+
1170
+ - [ ] **Step 3: Implement**
1171
+
1172
+ `SSHProxy#initialize` calls `Net::SSH::Gateway.new` and `open`. Password is optional. `reconnect` calls `reconnect!` only when `!active?`. `reconnect!` shuts down and opens the **same** local port.
1173
+
1174
+ `Environment#connect!` if `config[:ssh]`: build proxy from `Config.ssh_forward_target`, put `{ local_host:, local_port: }` into the hash passed to `Config.client_options` so Task 3's rewrite runs. `reconnect!` calls `ssh_proxy.reconnect!` before rebuilding the client.
1175
+
1176
+ - [ ] **Step 4: Run test to verify it passes**
1177
+
1178
+ Run: `bundle exec rspec spec/ssh_proxy_spec.rb spec/config_spec.rb`
1179
+ Expected: pass
1180
+
1181
+ - [ ] **Step 5: Commit**
1182
+
1183
+ ```bash
1184
+ git add lib/monq/ssh_proxy.rb lib/monq/environment.rb spec/ssh_proxy_spec.rb
1185
+ git commit -m "feat: tunnel Mongo connections over SSH"
1186
+ ```
1187
+
1188
+ ---
1189
+
1190
+ ### Task 12: Sandbox
1191
+
1192
+ **Files:**
1193
+ - Create: `lib/monq/commands/sandbox.rb`
1194
+ - Modify: `lib/monq/environment.rb`
1195
+ - Create: `spec/sandbox_spec.rb`
1196
+
1197
+ - [ ] **Step 1: Write the failing test**
1198
+
1199
+ ```ruby
1200
+ # frozen_string_literal: true
1201
+
1202
+ RSpec.describe Monq::Commands::Sandbox do
1203
+ def env(hello)
1204
+ client = double("client")
1205
+ db = double("db")
1206
+ allow(client).to receive(:database).and_return(db)
1207
+ allow(db).to receive(:command).with(hello: 1).and_return(double(documents: [hello]))
1208
+ session = double("session", start_transaction: nil, abort_transaction: nil, end_session: nil)
1209
+ allow(client).to receive(:start_session).and_return(session)
1210
+ instance_double(Monq::Environment, client: client, name: "blog.dev", sandbox?: false).tap do |e|
1211
+ allow(e).to receive(:bind_session)
1212
+ allow(e).to receive(:clear_session)
1213
+ end
1214
+ end
1215
+
1216
+ it "does not start a session when any environment is standalone" do
1217
+ good = env("ok" => 1, "setName" => "rs0")
1218
+ bad = env("ok" => 1)
1219
+ expect(good.client).not_to receive(:start_session)
1220
+ expect { described_class.enter([good, bad]) }.to raise_error(Monq::Error, /replica set|mongos/i)
1221
+ end
1222
+ end
1223
+ ```
1224
+
1225
+ - [ ] **Step 2: Run test to verify it fails**
1226
+
1227
+ Run: `bundle exec rspec spec/sandbox_spec.rb`
1228
+ Expected: FAIL
1229
+
1230
+ - [ ] **Step 3: Implement**
1231
+
1232
+ `Sandbox.enter(envs)`:
1233
+
1234
+ 1. Collect `hello` for every env. If any lacks `setName` and `msg != "isdbgrid"`, raise `Monq::Error` and return **before** `start_session`.
1235
+ 2. For each env, `start_session` + `start_transaction` + `bind_session`. On failure, `abort_transaction` + `end_session` the earlier ones and raise.
1236
+
1237
+ `Environment#bind_session(session)` stores the session on **that** client (client instance variable or a wrapper). Do not call `Mongoid::Threaded.set_session`.
1238
+
1239
+ `Sandbox.quit` aborts every session. `enter` when already active prints and returns.
1240
+
1241
+ - [ ] **Step 4: Run test to verify it passes**
1242
+
1243
+ Run: `bundle exec rspec spec/sandbox_spec.rb spec/commands_spec.rb`
1244
+ Expected: pass
1245
+
1246
+ - [ ] **Step 5: Commit**
1247
+
1248
+ ```bash
1249
+ git add lib/monq/commands/sandbox.rb lib/monq/environment.rb spec/sandbox_spec.rb
1250
+ git commit -m "feat: add all-or-nothing Mongo sandbox transactions"
1251
+ ```
1252
+
1253
+ ---
1254
+
1255
+ ### Task 13: CSV and Excel export
1256
+
1257
+ **Files:**
1258
+ - Modify: `lib/monq/ext/array.rb`
1259
+ - Modify: `lib/monq/ext/document.rb`
1260
+ - Modify: `lib/monq/ext/criteria.rb`
1261
+ - Create: `spec/export_spec.rb`
1262
+
1263
+ - [ ] **Step 1: Write the failing test**
1264
+
1265
+ ```ruby
1266
+ # frozen_string_literal: true
1267
+
1268
+ require "csv"
1269
+
1270
+ RSpec.describe Array do
1271
+ it "writes csv without the 500-row display cap" do
1272
+ path = File.join(Dir.tmpdir, "monq-export.csv")
1273
+ [{ "a" => 1 }, { "a" => 2 }].write_csv(path)
1274
+ rows = CSV.read(path)
1275
+ expect(rows.size).to eq(3)
1276
+ ensure
1277
+ File.delete(path) if path && File.exist?(path)
1278
+ end
1279
+ end
1280
+ ```
1281
+
1282
+ - [ ] **Step 2: Run test to verify it fails**
1283
+
1284
+ Run: `bundle exec rspec spec/export_spec.rb`
1285
+ Expected: FAIL, `write_csv` missing
1286
+
1287
+ - [ ] **Step 3: Implement**
1288
+
1289
+ `Array#write_csv` writes UTF-8 CSV with a header from the first hash's keys. `Array#write_excel` uses `Axlsx::Package` one sheet. Document and Criteria delegate to `to_a` **without** the 500 cap. `vd` shells out to `vd` only when `which vd` succeeds; otherwise print the install hint.
1290
+
1291
+ - [ ] **Step 4: Run test to verify it passes**
1292
+
1293
+ Run: `bundle exec rspec spec/export_spec.rb spec/display_spec.rb`
1294
+ Expected: pass
1295
+
1296
+ - [ ] **Step 5: Commit**
1297
+
1298
+ ```bash
1299
+ git add lib/monq/ext spec/export_spec.rb
1300
+ git commit -m "feat: export query results to CSV and Excel"
1301
+ ```
1302
+
1303
+ ---
1304
+
1305
+ ### Task 14: Optional blog_dev integration spec and README
1306
+
1307
+ **Files:**
1308
+ - Create: `spec/integration/blog_dev_spec.rb`
1309
+ - Modify: `README.md`
1310
+
1311
+ - [ ] **Step 1: Write the spec (skips unless env is set)**
1312
+
1313
+ ```ruby
1314
+ # frozen_string_literal: true
1315
+
1316
+ RSpec.describe "blog_dev", :integration do
1317
+ before do
1318
+ skip "set MONQ_TEST_URI and MONQ_TEST_DB" unless ENV["MONQ_TEST_URI"] && ENV["MONQ_TEST_DB"]
1319
+ end
1320
+
1321
+ it "counts users" do
1322
+ klass = Class.new do
1323
+ include Mongoid::Document
1324
+ store_in collection: "users", client: :default, database: ENV.fetch("MONQ_TEST_DB")
1325
+ end
1326
+ Mongo::Client.new(ENV.fetch("MONQ_TEST_URI"), database: ENV.fetch("MONQ_TEST_DB")) do |client|
1327
+ Mongoid::Clients.set(:default, client)
1328
+ expect(klass.count).to be >= 1
1329
+ end
1330
+ end
1331
+ end
1332
+ ```
1333
+
1334
+ This spec does not fail CI when the variables are unset.
1335
+
1336
+ - [ ] **Step 2: Run it skipped**
1337
+
1338
+ Run: `bundle exec rspec spec/integration/blog_dev_spec.rb`
1339
+ Expected: 1 skipped (or 1 passed if the variables are already in the shell)
1340
+
1341
+ - [ ] **Step 3: Replace README usage**
1342
+
1343
+ Document:
1344
+
1345
+ - `gem install` / `bundle exec exe/monq`
1346
+ - `~/.monq.d/init.yml` example using `uri` + `database: blog_dev` + `namespace: Blog` (placeholder password, not the real one)
1347
+ - `~/.monq.d/blog.rb` example: `class User` with `store_in collection: 'users'`, string date fields, `embeds_one` `_meta`, and a `posts` method using a natural key
1348
+ - Commands: `info`, `m`, `show-sql`, `reconnect`, `sandbox-enter`, `redefine`
1349
+ - SSH limitation: one seed, `direct_connection`, no `replicaSet`, no SRV+SSH
1350
+ - Sandbox needs a replica set; driver writes via the model are covered, raw `client[:c].insert_one` is not
1351
+
1352
+ - [ ] **Step 4: Run the full suite**
1353
+
1354
+ Run: `bundle exec rspec`
1355
+ Expected: 0 failures. Integration skipped unless env vars are set.
1356
+
1357
+ - [ ] **Step 5: Commit**
1358
+
1359
+ ```bash
1360
+ git add spec/integration/blog_dev_spec.rb README.md
1361
+ git commit -m "docs: describe monq usage and add optional blog_dev spec"
1362
+ ```
1363
+
1364
+ ---
1365
+
1366
+ ## Spec coverage check
1367
+
1368
+ | Spec section | Task |
1369
+ |--------------|------|
1370
+ | CLI flags, multi-env ban | 6 |
1371
+ | YAML select, reserved `default`, no `-e` | 2 |
1372
+ | URI vs fields, database override, mask | 3 |
1373
+ | SSH rewrite, drop replicaSet, SRV reject | 3, 11 |
1374
+ | Named client + `:default` is the same instance | 4, 11 |
1375
+ | Prefix load, store_in merge, abbreviations | 5 |
1376
+ | redefine unregisters embedded + refuses in sandbox | 5, 10 |
1377
+ | const_missing, Kernel helpers | 9 |
1378
+ | t/v 500 cap, first-doc keys, no class `Model.t` | 7, 8 |
1379
+ | info/m/show-sql/reconnect | 10 |
1380
+ | sandbox all-or-nothing, per-client session | 12 |
1381
+ | CSV/Excel uncapped | 13 |
1382
+ | blog_dev optional integration, secrets not in git | 14 |
1383
+
1384
+ Out of scope, matching the spec: auto models, DDL, Ransack, charts, mongodump, `model_names`, class-level `Model.t`.