aidp 0.21.1 → 0.22.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,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Aidp
7
+ module Setup
8
+ module Devcontainer
9
+ # Generates or updates devcontainer.json based on wizard configuration
10
+ class Generator
11
+ class GenerationError < StandardError; end
12
+
13
+ def initialize(project_dir, aidp_config = {})
14
+ @project_dir = project_dir
15
+ @aidp_config = aidp_config
16
+ end
17
+
18
+ # Generate complete devcontainer configuration
19
+ # @param wizard_config [Hash] Configuration from wizard
20
+ # @param existing [Hash, nil] Existing devcontainer config to merge with
21
+ # @return [Hash] Complete devcontainer configuration
22
+ def generate(wizard_config, existing = nil)
23
+ Aidp.log_debug("devcontainer_generator", "generating configuration",
24
+ has_existing: !existing.nil?,
25
+ wizard_keys: wizard_config.keys)
26
+
27
+ base_config = build_base_config(wizard_config)
28
+ feature_config = build_features_config(wizard_config)
29
+ port_config = build_ports_config(wizard_config)
30
+ env_config = build_env_config(wizard_config)
31
+ command_config = build_commands_config(wizard_config)
32
+ customization_config = build_customizations_config(wizard_config)
33
+
34
+ config = base_config
35
+ .merge(feature_config)
36
+ .merge(port_config)
37
+ .merge(env_config)
38
+ .merge(command_config)
39
+ .merge(customization_config)
40
+
41
+ if existing
42
+ merge_with_existing(config, existing)
43
+ else
44
+ config
45
+ end
46
+ end
47
+
48
+ # Merge new configuration with existing, preserving user customizations
49
+ # @param new_config [Hash] New configuration from wizard
50
+ # @param existing [Hash] Existing devcontainer configuration
51
+ # @return [Hash] Merged configuration
52
+ def merge_with_existing(new_config, existing)
53
+ Aidp.log_debug("devcontainer_generator", "merging configurations",
54
+ new_keys: new_config.keys,
55
+ existing_keys: existing.keys)
56
+
57
+ merged = existing.dup
58
+
59
+ # Merge features (combine arrays/hashes)
60
+ merged["features"] = merge_features(
61
+ new_config["features"],
62
+ existing["features"]
63
+ )
64
+
65
+ # Merge ports (combine arrays, deduplicate)
66
+ merged["forwardPorts"] = merge_ports(
67
+ new_config["forwardPorts"],
68
+ existing["forwardPorts"]
69
+ )
70
+
71
+ # Merge port attributes
72
+ merged["portsAttributes"] = (existing["portsAttributes"] || {})
73
+ .merge(new_config["portsAttributes"] || {})
74
+
75
+ # Merge environment variables
76
+ merged["containerEnv"] = (existing["containerEnv"] || {})
77
+ .merge(new_config["containerEnv"] || {})
78
+
79
+ # Merge customizations
80
+ merged["customizations"] = merge_customizations(
81
+ new_config["customizations"],
82
+ existing["customizations"]
83
+ )
84
+
85
+ # Update AIDP metadata
86
+ merged["_aidp"] = new_config["_aidp"]
87
+
88
+ # Preserve user-managed fields
89
+ preserve_user_fields(merged, existing)
90
+
91
+ merged
92
+ end
93
+
94
+ # Build list of devcontainer features from wizard selections
95
+ # @param wizard_config [Hash] Configuration from wizard
96
+ # @return [Hash] Features configuration
97
+ def build_features_list(wizard_config)
98
+ features = {}
99
+
100
+ # GitHub CLI (for all provider selections)
101
+ if wizard_config[:providers]&.any?
102
+ features["ghcr.io/devcontainers/features/github-cli:1"] = {}
103
+ end
104
+
105
+ # Ruby (for RSpec, StandardRB)
106
+ if needs_ruby?(wizard_config)
107
+ features["ghcr.io/devcontainers/features/ruby:1"] = {
108
+ "version" => wizard_config[:ruby_version] || "3.2"
109
+ }
110
+ end
111
+
112
+ # Node.js (for Jest, Playwright, ESLint)
113
+ if needs_node?(wizard_config)
114
+ features["ghcr.io/devcontainers/features/node:1"] = {
115
+ "version" => wizard_config[:node_version] || "lts"
116
+ }
117
+ end
118
+
119
+ # Playwright (for browser automation)
120
+ if wizard_config[:interactive_tools]&.include?("playwright")
121
+ features["ghcr.io/devcontainers-contrib/features/playwright:2"] = {}
122
+ end
123
+
124
+ # Docker-in-Docker (if requested)
125
+ if wizard_config[:features]&.include?("docker")
126
+ features["ghcr.io/devcontainers/features/docker-in-docker:2"] = {}
127
+ end
128
+
129
+ # Additional custom features
130
+ wizard_config[:additional_features]&.each do |feature|
131
+ features[feature] = {}
132
+ end
133
+
134
+ features
135
+ end
136
+
137
+ # Build post-create/start commands
138
+ # @param wizard_config [Hash] Configuration from wizard
139
+ # @return [String, nil] Combined post-create command
140
+ def build_post_commands(wizard_config)
141
+ commands = []
142
+
143
+ # Ruby dependencies
144
+ if needs_ruby?(wizard_config)
145
+ commands << "bundle install"
146
+ end
147
+
148
+ # Node dependencies
149
+ if needs_node?(wizard_config)
150
+ commands << "npm install"
151
+ end
152
+
153
+ # Custom post-create commands
154
+ if wizard_config[:post_create_commands]&.any?
155
+ commands.concat(wizard_config[:post_create_commands])
156
+ end
157
+
158
+ commands.empty? ? nil : commands.join(" && ")
159
+ end
160
+
161
+ private
162
+
163
+ def build_base_config(wizard_config)
164
+ {
165
+ "name" => wizard_config[:project_name] || "AIDP Development",
166
+ "image" => select_base_image(wizard_config),
167
+ "_aidp" => {
168
+ "managed" => true,
169
+ "version" => Aidp::VERSION,
170
+ "generated_at" => Time.now.utc.iso8601
171
+ }
172
+ }
173
+ end
174
+
175
+ def build_features_config(wizard_config)
176
+ features = build_features_list(wizard_config)
177
+ features.empty? ? {} : {"features" => features}
178
+ end
179
+
180
+ def build_ports_config(wizard_config)
181
+ ports = detect_required_ports(wizard_config)
182
+ return {} if ports.empty?
183
+
184
+ forward_ports = ports.map { |p| p[:number] }
185
+ port_attrs = ports.each_with_object({}) do |port, attrs|
186
+ attrs[port[:number].to_s] = {
187
+ "label" => port[:label],
188
+ "onAutoForward" => port[:auto_open] ? "notify" : "silent"
189
+ }
190
+ end
191
+
192
+ {
193
+ "forwardPorts" => forward_ports,
194
+ "portsAttributes" => port_attrs
195
+ }
196
+ end
197
+
198
+ def build_env_config(wizard_config)
199
+ env = {}
200
+
201
+ # AIDP environment variables
202
+ env["AIDP_LOG_LEVEL"] = wizard_config[:log_level] || "info"
203
+ env["AIDP_ENV"] = "development"
204
+
205
+ # Provider-specific env vars (non-sensitive only)
206
+ if wizard_config[:env_vars]
207
+ env.merge!(wizard_config[:env_vars].reject { |k, _| sensitive_key?(k) })
208
+ end
209
+
210
+ env.empty? ? {} : {"containerEnv" => env}
211
+ end
212
+
213
+ def build_commands_config(wizard_config)
214
+ post_create = build_post_commands(wizard_config)
215
+ post_create ? {"postCreateCommand" => post_create} : {}
216
+ end
217
+
218
+ def build_customizations_config(wizard_config)
219
+ extensions = detect_recommended_extensions(wizard_config)
220
+ return {} if extensions.empty?
221
+
222
+ {
223
+ "customizations" => {
224
+ "vscode" => {
225
+ "extensions" => extensions
226
+ }
227
+ }
228
+ }
229
+ end
230
+
231
+ def select_base_image(wizard_config)
232
+ # Prefer explicit image if provided
233
+ return wizard_config[:base_image] if wizard_config[:base_image]
234
+
235
+ # Otherwise, select based on primary language
236
+ if needs_ruby?(wizard_config)
237
+ "mcr.microsoft.com/devcontainers/ruby:3.2"
238
+ elsif needs_node?(wizard_config)
239
+ "mcr.microsoft.com/devcontainers/javascript-node:lts"
240
+ else
241
+ "mcr.microsoft.com/devcontainers/base:ubuntu"
242
+ end
243
+ end
244
+
245
+ def detect_required_ports(wizard_config)
246
+ ports = []
247
+
248
+ # Web application preview
249
+ if wizard_config[:app_type]&.match?(/web|rails|sinatra/)
250
+ ports << {
251
+ number: wizard_config[:app_port] || 3000,
252
+ label: "Application",
253
+ auto_open: true
254
+ }
255
+ end
256
+
257
+ # Remote terminal (if watch mode enabled)
258
+ if wizard_config[:watch_mode]
259
+ ports << {
260
+ number: 7681,
261
+ label: "Remote Terminal",
262
+ auto_open: false
263
+ }
264
+ end
265
+
266
+ # Playwright debug port
267
+ if wizard_config[:interactive_tools]&.include?("playwright")
268
+ ports << {
269
+ number: 9222,
270
+ label: "Playwright Debug",
271
+ auto_open: false
272
+ }
273
+ end
274
+
275
+ # Custom ports
276
+ wizard_config[:custom_ports]&.each do |port|
277
+ if port.is_a?(Hash)
278
+ # Handle both symbol and string keys from YAML/config
279
+ port_num = port[:number] || port["number"]
280
+ port_label = port[:label] || port["label"] || "Custom"
281
+
282
+ ports << {
283
+ number: port_num.to_i,
284
+ label: port_label,
285
+ auto_open: false
286
+ }
287
+ else
288
+ ports << {
289
+ number: port.to_i,
290
+ label: "Custom",
291
+ auto_open: false
292
+ }
293
+ end
294
+ end
295
+
296
+ ports
297
+ end
298
+
299
+ def detect_recommended_extensions(wizard_config)
300
+ extensions = []
301
+
302
+ # Ruby extensions
303
+ if needs_ruby?(wizard_config)
304
+ extensions << "shopify.ruby-lsp"
305
+ extensions << "kaiwood.endwise" if wizard_config[:editor_helpers]
306
+ end
307
+
308
+ # Node/JavaScript extensions
309
+ if needs_node?(wizard_config)
310
+ extensions << "dbaeumer.vscode-eslint" if wizard_config[:linters]&.include?("eslint")
311
+ end
312
+
313
+ # GitHub Copilot (if requested)
314
+ extensions << "GitHub.copilot" if wizard_config[:enable_copilot]
315
+
316
+ extensions
317
+ end
318
+
319
+ def merge_features(new_features, existing_features)
320
+ return new_features if existing_features.nil?
321
+ return existing_features if new_features.nil?
322
+
323
+ # Both can be Hash or Array format
324
+ new_hash = normalize_features(new_features)
325
+ existing_hash = normalize_features(existing_features)
326
+
327
+ existing_hash.merge(new_hash)
328
+ end
329
+
330
+ def normalize_features(features)
331
+ case features
332
+ when Hash
333
+ features
334
+ when Array
335
+ features.each_with_object({}) { |f, h| h[f] = {} }
336
+ else
337
+ {}
338
+ end
339
+ end
340
+
341
+ def merge_ports(new_ports, existing_ports)
342
+ new_array = Array(new_ports).compact
343
+ existing_array = Array(existing_ports).compact
344
+ (existing_array + new_array).uniq.sort
345
+ end
346
+
347
+ def merge_customizations(new_custom, existing_custom)
348
+ return new_custom if existing_custom.nil?
349
+ return existing_custom if new_custom.nil?
350
+
351
+ merged = existing_custom.dup
352
+
353
+ if new_custom.dig("vscode", "extensions")
354
+ existing_exts = existing_custom.dig("vscode", "extensions") || []
355
+ new_exts = new_custom.dig("vscode", "extensions") || []
356
+ merged["vscode"] ||= {}
357
+ merged["vscode"]["extensions"] = (existing_exts + new_exts).uniq
358
+ end
359
+
360
+ if new_custom.dig("vscode", "settings")
361
+ merged["vscode"] ||= {}
362
+ merged["vscode"]["settings"] = (merged.dig("vscode", "settings") || {})
363
+ .merge(new_custom.dig("vscode", "settings") || {})
364
+ end
365
+
366
+ merged
367
+ end
368
+
369
+ def preserve_user_fields(merged, existing)
370
+ # Preserve these fields if they exist in original
371
+ user_fields = %w[
372
+ remoteUser
373
+ workspaceFolder
374
+ workspaceMount
375
+ mounts
376
+ runArgs
377
+ shutdownAction
378
+ overrideCommand
379
+ userEnvProbe
380
+ ]
381
+
382
+ user_fields.each do |field|
383
+ merged[field] = existing[field] if existing.key?(field)
384
+ end
385
+ end
386
+
387
+ def needs_ruby?(wizard_config)
388
+ wizard_config[:test_framework]&.match?(/rspec|minitest/) ||
389
+ wizard_config[:linters]&.include?("standardrb") ||
390
+ wizard_config[:language] == "ruby"
391
+ end
392
+
393
+ def needs_node?(wizard_config)
394
+ wizard_config[:test_framework]&.match?(/jest|playwright|mocha/) ||
395
+ wizard_config[:linters]&.include?("eslint") ||
396
+ wizard_config[:language]&.match?(/javascript|typescript|node/)
397
+ end
398
+
399
+ def sensitive_key?(key)
400
+ key_str = key.to_s.downcase
401
+ key_str.include?("token") ||
402
+ key_str.include?("secret") ||
403
+ key_str.include?("key") ||
404
+ key_str.include?("password")
405
+ end
406
+ end
407
+ end
408
+ end
409
+ end
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+
6
+ module Aidp
7
+ module Setup
8
+ module Devcontainer
9
+ # Parses existing devcontainer.json files and extracts configuration
10
+ # for pre-filling wizard defaults.
11
+ class Parser
12
+ class DevcontainerNotFoundError < StandardError; end
13
+ class InvalidDevcontainerError < StandardError; end
14
+
15
+ STANDARD_LOCATIONS = [
16
+ ".devcontainer/devcontainer.json",
17
+ ".devcontainer.json",
18
+ "devcontainer.json"
19
+ ].freeze
20
+
21
+ attr_reader :project_dir, :devcontainer_path, :config
22
+
23
+ def initialize(project_dir = Dir.pwd)
24
+ @project_dir = project_dir
25
+ @devcontainer_path = nil
26
+ @config = nil
27
+ end
28
+
29
+ # Detect devcontainer.json in standard locations
30
+ # @return [String, nil] Path to devcontainer.json or nil if not found
31
+ def detect
32
+ STANDARD_LOCATIONS.each do |location|
33
+ path = File.join(project_dir, location)
34
+ if File.exist?(path)
35
+ @devcontainer_path = path
36
+ Aidp.log_debug("devcontainer_parser", "detected devcontainer", path: path)
37
+ return path
38
+ end
39
+ end
40
+
41
+ Aidp.log_debug("devcontainer_parser", "no devcontainer found")
42
+ nil
43
+ end
44
+
45
+ # Check if devcontainer exists
46
+ # @return [Boolean]
47
+ def devcontainer_exists?
48
+ !detect.nil?
49
+ end
50
+
51
+ # Parse devcontainer.json and extract configuration
52
+ # @return [Hash] Parsed configuration
53
+ # @raise [DevcontainerNotFoundError] If devcontainer doesn't exist
54
+ # @raise [InvalidDevcontainerError] If JSON is malformed
55
+ def parse
56
+ detect unless @devcontainer_path
57
+
58
+ unless @devcontainer_path
59
+ raise DevcontainerNotFoundError, "No devcontainer.json found in #{project_dir}"
60
+ end
61
+
62
+ begin
63
+ content = File.read(@devcontainer_path)
64
+ @config = JSON.parse(content)
65
+ Aidp.log_debug("devcontainer_parser", "parsed devcontainer",
66
+ features_count: extract_features.size,
67
+ ports_count: extract_ports.size)
68
+ @config
69
+ rescue JSON::ParserError => e
70
+ Aidp.log_error("devcontainer_parser", "invalid JSON", error: e.message, path: @devcontainer_path)
71
+ raise InvalidDevcontainerError, "Invalid JSON in #{@devcontainer_path}: #{e.message}"
72
+ rescue => e
73
+ Aidp.log_error("devcontainer_parser", "failed to read devcontainer", error: e.message)
74
+ raise InvalidDevcontainerError, "Failed to read #{@devcontainer_path}: #{e.message}"
75
+ end
76
+ end
77
+
78
+ # Extract port forwarding configuration
79
+ # @return [Array<Hash>] Array of port configurations
80
+ def extract_ports
81
+ ensure_parsed
82
+
83
+ ports = []
84
+
85
+ # Extract from forwardPorts array
86
+ forward_ports = @config["forwardPorts"] || []
87
+ forward_ports = [forward_ports] unless forward_ports.is_a?(Array)
88
+
89
+ # Get port attributes for labels
90
+ port_attrs = @config["portsAttributes"] || {}
91
+
92
+ forward_ports.each do |port|
93
+ port_num = port.to_i
94
+ next if port_num <= 0
95
+
96
+ attrs = port_attrs[port.to_s] || port_attrs[port_num.to_s] || {}
97
+
98
+ ports << {
99
+ number: port_num,
100
+ label: attrs["label"],
101
+ protocol: attrs["protocol"] || "http",
102
+ on_auto_forward: attrs["onAutoForward"] || "notify"
103
+ }
104
+ end
105
+
106
+ Aidp.log_debug("devcontainer_parser", "extracted ports", count: ports.size)
107
+ ports
108
+ end
109
+
110
+ # Extract devcontainer features
111
+ # @return [Array<String>] Array of feature identifiers
112
+ def extract_features
113
+ ensure_parsed
114
+
115
+ features = @config["features"] || {}
116
+
117
+ # Handle both object and array format
118
+ feature_list = case features
119
+ when Hash
120
+ features.keys
121
+ when Array
122
+ features
123
+ else
124
+ []
125
+ end
126
+
127
+ Aidp.log_debug("devcontainer_parser", "extracted features", count: feature_list.size)
128
+ feature_list
129
+ end
130
+
131
+ # Extract container environment variables
132
+ # @return [Hash] Environment variables (excluding secrets)
133
+ def extract_env
134
+ ensure_parsed
135
+
136
+ env = @config["containerEnv"] || @config["remoteEnv"] || {}
137
+ env = {} unless env.is_a?(Hash)
138
+
139
+ # Filter out sensitive values
140
+ filtered_env = env.reject { |key, value|
141
+ sensitive_key?(key) || sensitive_value?(value)
142
+ }
143
+
144
+ Aidp.log_debug("devcontainer_parser", "extracted env vars",
145
+ total: env.size,
146
+ filtered: filtered_env.size)
147
+ filtered_env
148
+ end
149
+
150
+ # Extract post-create and post-start commands
151
+ # @return [Hash] Commands configuration
152
+ def extract_post_commands
153
+ ensure_parsed
154
+
155
+ {
156
+ post_create: @config["postCreateCommand"],
157
+ post_start: @config["postStartCommand"],
158
+ post_attach: @config["postAttachCommand"]
159
+ }.compact
160
+ end
161
+
162
+ # Extract VS Code customizations
163
+ # @return [Hash] VS Code extensions and settings
164
+ def extract_customizations
165
+ ensure_parsed
166
+
167
+ customizations = @config["customizations"] || {}
168
+ vscode = customizations["vscode"] || {}
169
+
170
+ {
171
+ extensions: Array(vscode["extensions"]),
172
+ settings: vscode["settings"] || {}
173
+ }
174
+ end
175
+
176
+ # Extract remote user setting
177
+ # @return [String, nil] Remote user name
178
+ def extract_remote_user
179
+ ensure_parsed
180
+ @config["remoteUser"]
181
+ end
182
+
183
+ # Extract working directory
184
+ # @return [String, nil] Working directory path
185
+ def extract_workspace_folder
186
+ ensure_parsed
187
+ @config["workspaceFolder"]
188
+ end
189
+
190
+ # Extract the base image or dockerfile reference
191
+ # @return [Hash] Image configuration
192
+ def extract_image_config
193
+ ensure_parsed
194
+
195
+ {
196
+ image: @config["image"],
197
+ dockerfile: @config["dockerFile"] || @config["dockerfile"],
198
+ context: @config["context"],
199
+ build: @config["build"]
200
+ }.compact
201
+ end
202
+
203
+ # Get complete parsed configuration as hash
204
+ # @return [Hash] All extracted configuration
205
+ def to_h
206
+ ensure_parsed
207
+
208
+ {
209
+ path: @devcontainer_path,
210
+ ports: extract_ports,
211
+ features: extract_features,
212
+ env: extract_env,
213
+ post_commands: extract_post_commands,
214
+ customizations: extract_customizations,
215
+ remote_user: extract_remote_user,
216
+ workspace_folder: extract_workspace_folder,
217
+ image_config: extract_image_config,
218
+ raw: @config
219
+ }
220
+ end
221
+
222
+ private
223
+
224
+ def ensure_parsed
225
+ parse unless @config
226
+ end
227
+
228
+ def sensitive_key?(key)
229
+ key = key.to_s.downcase
230
+ key.include?("token") ||
231
+ key.include?("secret") ||
232
+ key.include?("key") ||
233
+ key.include?("password") ||
234
+ key.include?("api") && key.include?("key")
235
+ end
236
+
237
+ def sensitive_value?(value)
238
+ return false unless value.is_a?(String)
239
+ # Don't filter out common non-secret values
240
+ return false if value.empty? || value.length < 8
241
+ # Check for patterns that look like secrets (base64, hex, etc.)
242
+ value.match?(/^[A-Za-z0-9+\/=]{20,}$/) || # base64-ish
243
+ value.match?(/^[a-f0-9]{32,}$/) || # hex-ish
244
+ value.match?(/^sk-[A-Za-z0-9]{32,}$/) # API key pattern
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end