aidp 0.21.1 → 0.23.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,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aidp
4
+ module Setup
5
+ module Devcontainer
6
+ # Manages port configuration for devcontainers and generates documentation
7
+ class PortManager
8
+ # Standard port assignments for common services
9
+ STANDARD_PORTS = {
10
+ web_app: {number: 3000, label: "Application", protocol: "http"},
11
+ remote_terminal: {number: 7681, label: "Remote Terminal (ttyd)", protocol: "http"},
12
+ playwright_debug: {number: 9222, label: "Playwright Debug", protocol: "http"},
13
+ mcp_server: {number: 8080, label: "MCP Server", protocol: "http"},
14
+ postgres: {number: 5432, label: "PostgreSQL", protocol: "tcp"},
15
+ redis: {number: 6379, label: "Redis", protocol: "tcp"},
16
+ mysql: {number: 3306, label: "MySQL", protocol: "tcp"}
17
+ }.freeze
18
+
19
+ def initialize(wizard_config)
20
+ @wizard_config = wizard_config
21
+ @detected_ports = []
22
+ end
23
+
24
+ # Detect all required ports based on wizard configuration
25
+ # @return [Array<Hash>] Array of port configurations
26
+ def detect_required_ports
27
+ return @detected_ports if @detected_ports.any?
28
+
29
+ @detected_ports = []
30
+
31
+ detect_application_ports
32
+ detect_tool_ports
33
+ detect_service_ports
34
+ add_custom_ports
35
+
36
+ Aidp.log_debug("port_manager", "detected ports",
37
+ count: @detected_ports.size,
38
+ ports: @detected_ports.map { |p| p[:number] })
39
+
40
+ @detected_ports
41
+ end
42
+
43
+ # Generate forwardPorts array for devcontainer.json
44
+ # @return [Array<Integer>] Port numbers
45
+ def generate_forward_ports
46
+ detect_required_ports.map { |p| p[:number] }
47
+ end
48
+
49
+ # Generate portsAttributes hash for devcontainer.json
50
+ # @return [Hash] Port attributes with labels and settings
51
+ def generate_port_attributes
52
+ detect_required_ports.each_with_object({}) do |port, attrs|
53
+ attrs[port[:number].to_s] = {
54
+ "label" => port[:label],
55
+ "protocol" => port[:protocol] || "http",
56
+ "onAutoForward" => port[:auto_open] ? "notify" : "silent"
57
+ }
58
+ end
59
+ end
60
+
61
+ # Generate PORTS.md documentation
62
+ # @param output_path [String] Path to write PORTS.md
63
+ # @return [String] Generated markdown content
64
+ def generate_ports_documentation(output_path = nil)
65
+ detect_required_ports
66
+
67
+ content = build_ports_markdown
68
+ File.write(output_path, content) if output_path
69
+
70
+ content
71
+ end
72
+
73
+ private
74
+
75
+ def detect_application_ports
76
+ # Web application port
77
+ if web_application?
78
+ @detected_ports << {
79
+ number: @wizard_config[:app_port] || 3000,
80
+ label: @wizard_config[:app_label] || "Application",
81
+ protocol: "http",
82
+ auto_open: true,
83
+ description: "Main application web server"
84
+ }
85
+ end
86
+
87
+ # API server (if separate from main app)
88
+ if @wizard_config[:api_port]
89
+ @detected_ports << {
90
+ number: @wizard_config[:api_port],
91
+ label: "API Server",
92
+ protocol: "http",
93
+ auto_open: false,
94
+ description: "REST/GraphQL API endpoint"
95
+ }
96
+ end
97
+ end
98
+
99
+ def detect_tool_ports
100
+ # Watch mode remote terminal
101
+ if @wizard_config[:watch_mode] || @wizard_config[:enable_watch]
102
+ @detected_ports << STANDARD_PORTS[:remote_terminal].merge(
103
+ auto_open: false,
104
+ description: "Terminal access via ttyd for watch mode operations"
105
+ )
106
+ end
107
+
108
+ # Playwright debug port
109
+ if playwright_enabled?
110
+ @detected_ports << STANDARD_PORTS[:playwright_debug].merge(
111
+ auto_open: false,
112
+ description: "Chrome DevTools Protocol for Playwright debugging"
113
+ )
114
+ end
115
+
116
+ # MCP server
117
+ if mcp_enabled?
118
+ port_config = STANDARD_PORTS[:mcp_server].dup
119
+ port_config[:number] = @wizard_config[:mcp_port] if @wizard_config[:mcp_port]
120
+ @detected_ports << port_config.merge(
121
+ auto_open: false,
122
+ description: "Model Context Protocol server endpoint"
123
+ )
124
+ end
125
+ end
126
+
127
+ def detect_service_ports
128
+ return unless @wizard_config[:services]
129
+
130
+ @wizard_config[:services].each do |service|
131
+ service_key = service.to_sym
132
+ next unless STANDARD_PORTS.key?(service_key)
133
+
134
+ @detected_ports << STANDARD_PORTS[service_key].merge(
135
+ auto_open: false,
136
+ description: service_description(service_key)
137
+ )
138
+ end
139
+ end
140
+
141
+ def add_custom_ports
142
+ return unless @wizard_config[:custom_ports]
143
+
144
+ @wizard_config[:custom_ports].each do |port|
145
+ port_config = if port.is_a?(Hash)
146
+ {
147
+ number: port[:number],
148
+ label: port[:label] || "Custom Port",
149
+ protocol: port[:protocol] || "http",
150
+ auto_open: port[:auto_open] || false,
151
+ description: port[:description] || "User-defined port"
152
+ }
153
+ else
154
+ {
155
+ number: port.to_i,
156
+ label: "Custom Port",
157
+ protocol: "http",
158
+ auto_open: false,
159
+ description: "User-defined port"
160
+ }
161
+ end
162
+
163
+ @detected_ports << port_config if port_config[:number]&.positive?
164
+ end
165
+ end
166
+
167
+ def build_ports_markdown
168
+ <<~MARKDOWN
169
+ # Port Configuration
170
+
171
+ This document lists all ports configured for this development environment.
172
+
173
+ ## Overview
174
+
175
+ Total ports configured: **#{@detected_ports.size}**
176
+
177
+ ## Port Details
178
+
179
+ #{build_port_table}
180
+
181
+ ## Security Considerations
182
+
183
+ - All ports are forwarded from the devcontainer to your local machine
184
+ - Ports marked as "Auto-open" will trigger a notification when the container starts
185
+ - Ensure sensitive services (databases, etc.) are not exposed publicly
186
+ - Use firewall rules to restrict access if deploying to a remote environment
187
+
188
+ ## Adding Custom Ports
189
+
190
+ To add custom ports, update your `aidp.yml`:
191
+
192
+ ```yaml
193
+ devcontainer:
194
+ custom_ports:
195
+ - number: 8000
196
+ label: "Custom Service"
197
+ protocol: "http"
198
+ auto_open: false
199
+ ```
200
+
201
+ Then re-run `aidp config --interactive` to update your devcontainer.
202
+
203
+ ## Firewall Configuration
204
+
205
+ #{build_firewall_section}
206
+
207
+ ---
208
+
209
+ *Generated by AIDP #{Aidp::VERSION} on #{Time.now.utc.strftime("%Y-%m-%d")}*
210
+ MARKDOWN
211
+ end
212
+
213
+ def build_port_table
214
+ return "*No ports configured*" if @detected_ports.empty?
215
+
216
+ table = "| Port | Label | Protocol | Auto-open | Description |\n"
217
+ table += "|------|-------|----------|-----------|-------------|\n"
218
+
219
+ @detected_ports.sort_by { |p| p[:number] }.each do |port|
220
+ auto_open = port[:auto_open] ? "Yes" : "No"
221
+ table += "| #{port[:number]} | #{port[:label]} | #{port[:protocol]} | #{auto_open} | #{port[:description] || "-"} |\n"
222
+ end
223
+
224
+ table
225
+ end
226
+
227
+ def build_firewall_section
228
+ if @detected_ports.empty?
229
+ return "No ports require firewall configuration."
230
+ end
231
+
232
+ ports_list = @detected_ports.map { |p| p[:number] }.sort.join(", ")
233
+
234
+ <<~FIREWALL
235
+ If running in a cloud environment or VM, ensure these ports are allowed through your firewall:
236
+
237
+ ```bash
238
+ # Example: UFW (Ubuntu)
239
+ #{@detected_ports.sort_by { |p| p[:number] }.map { |p| "sudo ufw allow #{p[:number]}/tcp # #{p[:label]}" }.join("\n")}
240
+
241
+ # Example: firewalld (RHEL/CentOS)
242
+ #{@detected_ports.sort_by { |p| p[:number] }.map { |p| "sudo firewall-cmd --permanent --add-port=#{p[:number]}/tcp # #{p[:label]}" }.join("\n")}
243
+ sudo firewall-cmd --reload
244
+ ```
245
+
246
+ **Ports to allow:** #{ports_list}
247
+ FIREWALL
248
+ end
249
+
250
+ def web_application?
251
+ @wizard_config[:app_type]&.match?(/web|rails|sinatra|express|django|flask/) ||
252
+ @wizard_config[:has_web_interface] == true
253
+ end
254
+
255
+ def playwright_enabled?
256
+ @wizard_config[:interactive_tools]&.include?("playwright") ||
257
+ @wizard_config[:test_framework]&.include?("playwright")
258
+ end
259
+
260
+ def mcp_enabled?
261
+ @wizard_config[:interactive_tools]&.include?("mcp") ||
262
+ @wizard_config[:enable_mcp] == true
263
+ end
264
+
265
+ def service_description(service_key)
266
+ case service_key
267
+ when :postgres
268
+ "PostgreSQL database server"
269
+ when :redis
270
+ "Redis in-memory data store"
271
+ when :mysql
272
+ "MySQL database server"
273
+ when :remote_terminal
274
+ "Remote terminal access"
275
+ when :playwright_debug
276
+ "Browser automation debugging"
277
+ when :mcp_server
278
+ "Model Context Protocol server"
279
+ else
280
+ "Service port"
281
+ end
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
@@ -4,9 +4,14 @@ require "tty-prompt"
4
4
  require "yaml"
5
5
  require "time"
6
6
  require "fileutils"
7
+ require "json"
7
8
 
8
9
  require_relative "../util"
9
10
  require_relative "../config/paths"
11
+ require_relative "devcontainer/parser"
12
+ require_relative "devcontainer/generator"
13
+ require_relative "devcontainer/port_manager"
14
+ require_relative "devcontainer/backup_manager"
10
15
 
11
16
  module Aidp
12
17
  module Setup
@@ -15,6 +20,7 @@ module Aidp
15
20
  # while remaining idempotent and safe to re-run.
16
21
  class Wizard
17
22
  SCHEMA_VERSION = 1
23
+ DEVCONTAINER_COMPONENT = "setup_wizard.devcontainer"
18
24
 
19
25
  attr_reader :project_dir, :prompt, :dry_run
20
26
 
@@ -41,6 +47,7 @@ module Aidp
41
47
  configure_nfrs
42
48
  configure_logging
43
49
  configure_modes
50
+ configure_devcontainer
44
51
 
45
52
  yaml_content = generate_yaml
46
53
  display_preview(yaml_content)
@@ -779,6 +786,79 @@ module Aidp
779
786
  watch_enabled: watch,
780
787
  quick_mode_default: quick_mode
781
788
  })
789
+
790
+ # Configure watch mode settings if enabled
791
+ configure_watch_mode if watch
792
+ end
793
+
794
+ def configure_watch_mode
795
+ prompt.say("\n👀 Watch Mode Configuration")
796
+ prompt.say("-" * 40)
797
+
798
+ configure_watch_safety
799
+ configure_watch_labels
800
+ end
801
+
802
+ def configure_watch_safety
803
+ prompt.say("\n🔒 Watch mode safety settings")
804
+ existing = get([:watch, :safety]) || {}
805
+
806
+ allow_public_repos = prompt.yes?(
807
+ "Allow watch mode on public repositories?",
808
+ default: existing.fetch(:allow_public_repos, false)
809
+ )
810
+
811
+ prompt.say("\n📝 Author allowlist (GitHub usernames allowed to trigger watch mode)")
812
+ prompt.say(" Leave empty to allow all authors (not recommended for public repos)")
813
+ author_allowlist = ask_list(
814
+ "Author allowlist (comma-separated GitHub usernames)",
815
+ existing[:author_allowlist] || [],
816
+ allow_empty: true
817
+ )
818
+
819
+ require_container = prompt.yes?(
820
+ "Require watch mode to run in a container?",
821
+ default: existing.fetch(:require_container, true)
822
+ )
823
+
824
+ set([:watch, :safety], {
825
+ allow_public_repos: allow_public_repos,
826
+ author_allowlist: author_allowlist,
827
+ require_container: require_container
828
+ })
829
+ end
830
+
831
+ def configure_watch_labels
832
+ prompt.say("\n🏷️ Watch mode label configuration")
833
+ prompt.say(" Configure GitHub issue labels that trigger watch mode actions")
834
+ existing = get([:watch, :labels]) || {}
835
+
836
+ plan_trigger = ask_with_default(
837
+ "Label to trigger plan generation",
838
+ existing[:plan_trigger] || "aidp-plan"
839
+ )
840
+
841
+ needs_input = ask_with_default(
842
+ "Label for plans needing user input",
843
+ existing[:needs_input] || "aidp-needs-input"
844
+ )
845
+
846
+ ready_to_build = ask_with_default(
847
+ "Label for plans ready to build",
848
+ existing[:ready_to_build] || "aidp-ready"
849
+ )
850
+
851
+ build_trigger = ask_with_default(
852
+ "Label to trigger implementation",
853
+ existing[:build_trigger] || "aidp-build"
854
+ )
855
+
856
+ set([:watch, :labels], {
857
+ plan_trigger: plan_trigger,
858
+ needs_input: needs_input,
859
+ ready_to_build: ready_to_build,
860
+ build_trigger: build_trigger
861
+ })
782
862
  end
783
863
 
784
864
  # -------------------------------------------
@@ -809,6 +889,7 @@ module Aidp
809
889
  .sub(/^nfrs:/, "# Non-functional requirements to reference during planning\nnfrs:")
810
890
  .sub(/^logging:/, "# Logging configuration\nlogging:")
811
891
  .sub(/^modes:/, "# Defaults for background/watch/quick modes\nmodes:")
892
+ .sub(/^watch:/, "# Watch mode safety and label configuration\nwatch:")
812
893
  end
813
894
 
814
895
  def display_preview(yaml_content)
@@ -852,6 +933,9 @@ module Aidp
852
933
  def save_config(yaml_content)
853
934
  Aidp::ConfigPaths.ensure_config_dir(project_dir)
854
935
  File.write(config_path, yaml_content)
936
+
937
+ # Generate devcontainer if managed
938
+ generate_devcontainer_file
855
939
  end
856
940
 
857
941
  def display_warnings
@@ -1291,6 +1375,141 @@ module Aidp
1291
1375
  def project_file?(relative_path)
1292
1376
  File.exist?(File.join(project_dir, relative_path))
1293
1377
  end
1378
+
1379
+ def configure_devcontainer
1380
+ prompt.say("\n🐳 Devcontainer Configuration")
1381
+ Aidp.log_debug(DEVCONTAINER_COMPONENT, "configure.start")
1382
+
1383
+ # Detect existing devcontainer
1384
+ parser = Devcontainer::Parser.new(project_dir)
1385
+ existing_devcontainer = parser.devcontainer_exists? ? parser.parse : nil
1386
+
1387
+ if existing_devcontainer
1388
+ prompt.say("✓ Found existing devcontainer.json")
1389
+ Aidp.log_debug(DEVCONTAINER_COMPONENT, "configure.detected_existing",
1390
+ path: parser.detect)
1391
+ end
1392
+
1393
+ # Ask if user wants AIDP to manage devcontainer
1394
+ manage = prompt.yes?(
1395
+ "Would you like AIDP to manage your devcontainer configuration?",
1396
+ default: (@config.dig(:devcontainer, :manage) || existing_devcontainer) ? true : false
1397
+ )
1398
+
1399
+ unless manage
1400
+ Aidp.log_debug(DEVCONTAINER_COMPONENT, "configure.opt_out")
1401
+ return set([:devcontainer, :manage], false)
1402
+ end
1403
+
1404
+ # Build wizard config and detect ports
1405
+ wizard_config = build_wizard_config_for_devcontainer
1406
+ port_manager = Devcontainer::PortManager.new(wizard_config)
1407
+ detected_ports = port_manager.detect_required_ports
1408
+
1409
+ # Show detected ports
1410
+ if detected_ports.any?
1411
+ prompt.say("\nDetected ports:")
1412
+ detected_ports.each do |port|
1413
+ prompt.say(" • #{port[:number]} - #{port[:label]}")
1414
+ end
1415
+ Aidp.log_debug(DEVCONTAINER_COMPONENT, "configure.detected_ports",
1416
+ ports: detected_ports.map { |port| port[:number] })
1417
+ end
1418
+
1419
+ # Ask about custom ports
1420
+ custom_ports = []
1421
+ if prompt.yes?("Add custom ports?", default: false)
1422
+ loop do
1423
+ port_num = prompt.ask("Port number (or press Enter to finish):")
1424
+ break if port_num.nil? || port_num.to_s.strip.empty?
1425
+
1426
+ unless port_num.to_s.match?(/^\d+$/)
1427
+ prompt.error("Port must be a number")
1428
+ next
1429
+ end
1430
+
1431
+ port_label = prompt.ask("Port label:", default: "Custom")
1432
+ custom_ports << {number: port_num.to_i, label: port_label}
1433
+ end
1434
+ Aidp.log_debug(DEVCONTAINER_COMPONENT, "configure.custom_ports_selected",
1435
+ ports: custom_ports.map { |port| port[:number] })
1436
+ end
1437
+
1438
+ # Save configuration
1439
+ set([:devcontainer, :manage], true)
1440
+ set([:devcontainer, :custom_ports], custom_ports) if custom_ports.any?
1441
+ set([:devcontainer, :last_generated], Time.now.utc.iso8601)
1442
+ Aidp.log_debug(DEVCONTAINER_COMPONENT, "configure.enabled",
1443
+ custom_port_count: custom_ports.count,
1444
+ detected_port_count: detected_ports.count)
1445
+ end
1446
+
1447
+ def build_wizard_config_for_devcontainer
1448
+ {
1449
+ providers: @config[:providers]&.keys,
1450
+ test_framework: @config.dig(:work_loop, :test_commands)&.first&.dig(:framework),
1451
+ linters: @config.dig(:work_loop, :linting, :tools),
1452
+ watch_mode: @config.dig(:work_loop, :watch, :enabled),
1453
+ app_type: detect_app_type,
1454
+ services: detect_services,
1455
+ custom_ports: @config.dig(:devcontainer, :custom_ports)
1456
+ }.compact
1457
+ end
1458
+
1459
+ def detect_app_type
1460
+ return "rails_web" if project_file?("config/routes.rb")
1461
+ return "sinatra" if project_file?("config.ru")
1462
+ return "express" if project_file?("app.js") && project_file?("package.json")
1463
+ "cli"
1464
+ end
1465
+
1466
+ def detect_services
1467
+ services = []
1468
+ services << "postgres" if project_file?("config/database.yml")
1469
+ services << "redis" if project_file?("config/redis.yml")
1470
+ services
1471
+ end
1472
+
1473
+ def generate_devcontainer_file
1474
+ unless @config.dig(:devcontainer, :manage)
1475
+ Aidp.log_debug(DEVCONTAINER_COMPONENT, "generate.skip_unmanaged")
1476
+ return
1477
+ end
1478
+
1479
+ Aidp.log_debug(DEVCONTAINER_COMPONENT, "generate.start")
1480
+
1481
+ wizard_config = build_wizard_config_for_devcontainer
1482
+ Aidp.log_debug(DEVCONTAINER_COMPONENT, "generate.wizard_config",
1483
+ keys: wizard_config.keys)
1484
+
1485
+ parser = Devcontainer::Parser.new(project_dir)
1486
+ existing = parser.devcontainer_exists? ? parser.parse : nil
1487
+
1488
+ generator = Devcontainer::Generator.new(project_dir, @config)
1489
+ new_config = generator.generate(wizard_config, existing)
1490
+
1491
+ # Create backup if existing file
1492
+ if existing
1493
+ backup_manager = Devcontainer::BackupManager.new(project_dir)
1494
+ backup_manager.create_backup(
1495
+ parser.detect,
1496
+ {reason: "wizard_update", timestamp: Time.now.utc.iso8601}
1497
+ )
1498
+ prompt.say(" └─ Backup created")
1499
+ Aidp.log_debug(DEVCONTAINER_COMPONENT, "generate.backup_created",
1500
+ path: parser.detect)
1501
+ end
1502
+
1503
+ # Write devcontainer.json
1504
+ devcontainer_path = File.join(project_dir, ".devcontainer", "devcontainer.json")
1505
+ FileUtils.mkdir_p(File.dirname(devcontainer_path))
1506
+ File.write(devcontainer_path, JSON.pretty_generate(new_config))
1507
+
1508
+ prompt.ok("✅ Generated #{devcontainer_path}")
1509
+ Aidp.log_debug(DEVCONTAINER_COMPONENT, "generate.complete",
1510
+ devcontainer_path: devcontainer_path,
1511
+ forward_ports: new_config["forwardPorts"]&.length)
1512
+ end
1294
1513
  end
1295
1514
  end
1296
1515
  end
@@ -11,7 +11,7 @@ module Aidp
11
11
  include Aidp::RescueLogging
12
12
 
13
13
  def initialize(base_dir = ".aidp")
14
- @base_dir = base_dir
14
+ @base_dir = sanitize_base_dir(base_dir)
15
15
  ensure_directory_exists
16
16
  end
17
17
 
@@ -191,7 +191,44 @@ module Aidp
191
191
  end
192
192
 
193
193
  def ensure_directory_exists
194
- FileUtils.mkdir_p(@base_dir) unless Dir.exist?(@base_dir)
194
+ return if Dir.exist?(@base_dir)
195
+ begin
196
+ FileUtils.mkdir_p(@base_dir)
197
+ rescue SystemCallError => e
198
+ fallback = begin
199
+ home = Dir.respond_to?(:home) ? Dir.home : nil
200
+ if home && !home.empty? && File.writable?(home)
201
+ File.join(home, ".aidp")
202
+ else
203
+ File.join(Dir.tmpdir, "aidp_storage")
204
+ end
205
+ rescue
206
+ File.join(Dir.tmpdir, "aidp_storage")
207
+ end
208
+ Kernel.warn "[AIDP Storage] Cannot create base directory #{@base_dir}: #{e.class}: #{e.message}. Using fallback #{fallback}"
209
+ @base_dir = fallback
210
+ begin
211
+ FileUtils.mkdir_p(@base_dir) unless Dir.exist?(@base_dir)
212
+ rescue SystemCallError => e2
213
+ Kernel.warn "[AIDP Storage] Fallback directory creation also failed: #{e2.class}: #{e2.message}. Continuing without persistent CSV storage."
214
+ end
215
+ end
216
+ end
217
+
218
+ def sanitize_base_dir(dir)
219
+ return Dir.pwd if dir.nil? || dir.to_s.strip.empty?
220
+ str = dir.to_s
221
+ if str == File::SEPARATOR
222
+ fallback = begin
223
+ home = Dir.home
224
+ (home && !home.empty? && File.writable?(home)) ? File.join(home, ".aidp") : File.join(Dir.tmpdir, "aidp_storage")
225
+ rescue
226
+ File.join(Dir.tmpdir, "aidp_storage")
227
+ end
228
+ Kernel.warn "[AIDP Storage] Root base_dir detected - using fallback #{fallback} instead of '#{str}'"
229
+ return fallback
230
+ end
231
+ str
195
232
  end
196
233
  end
197
234
  end
@@ -8,9 +8,16 @@ module Aidp
8
8
  # Simple file manager that provides easy access to JSON and CSV storage
9
9
  class FileManager
10
10
  def initialize(base_dir = ".aidp")
11
- @base_dir = base_dir
12
- @json_storage = JsonStorage.new(base_dir)
13
- @csv_storage = CsvStorage.new(base_dir)
11
+ @base_dir = sanitize_base_dir(base_dir)
12
+ @json_storage = JsonStorage.new(@base_dir)
13
+ @csv_storage = CsvStorage.new(@base_dir)
14
+ # Normalize base_dir if storages had to fallback
15
+ json_dir = @json_storage.instance_variable_get(:@base_dir)
16
+ csv_dir = @csv_storage.instance_variable_get(:@base_dir)
17
+ if json_dir != @base_dir || csv_dir != @base_dir
18
+ @base_dir = json_dir # Prefer JSON storage directory
19
+ Kernel.warn "[AIDP Storage] Base directory normalized to #{@base_dir} after fallback."
20
+ end
14
21
  end
15
22
 
16
23
  # JSON operations for structured data
@@ -209,6 +216,24 @@ module Aidp
209
216
  rescue => error
210
217
  {success: false, error: error.message}
211
218
  end
219
+
220
+ private
221
+
222
+ def sanitize_base_dir(dir)
223
+ return Dir.pwd if dir.nil? || dir.to_s.strip.empty?
224
+ str = dir.to_s
225
+ if str == File::SEPARATOR
226
+ fallback = begin
227
+ home = Dir.home
228
+ (home && !home.empty? && File.writable?(home)) ? File.join(home, ".aidp") : File.join(Dir.tmpdir, "aidp_storage")
229
+ rescue
230
+ File.join(Dir.tmpdir, "aidp_storage")
231
+ end
232
+ Kernel.warn "[AIDP Storage] Root base_dir detected - using fallback #{fallback} instead of '#{str}'"
233
+ return fallback
234
+ end
235
+ str
236
+ end
212
237
  end
213
238
  end
214
239
  end