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.
- checksums.yaml +4 -4
- data/lib/aidp/cli/devcontainer_commands.rb +501 -0
- data/lib/aidp/cli/issue_importer.rb +15 -3
- data/lib/aidp/cli.rb +91 -0
- data/lib/aidp/execute/prompt_manager.rb +16 -2
- data/lib/aidp/execute/runner.rb +10 -5
- data/lib/aidp/execute/work_loop_runner.rb +3 -3
- data/lib/aidp/harness/state/persistence.rb +12 -1
- data/lib/aidp/harness/state_manager.rb +13 -1
- data/lib/aidp/jobs/background_runner.rb +3 -1
- data/lib/aidp/logger.rb +41 -5
- data/lib/aidp/safe_directory.rb +87 -0
- data/lib/aidp/setup/devcontainer/backup_manager.rb +175 -0
- data/lib/aidp/setup/devcontainer/generator.rb +409 -0
- data/lib/aidp/setup/devcontainer/parser.rb +249 -0
- data/lib/aidp/setup/devcontainer/port_manager.rb +286 -0
- data/lib/aidp/setup/wizard.rb +145 -0
- data/lib/aidp/storage/csv_storage.rb +39 -2
- data/lib/aidp/storage/file_manager.rb +28 -3
- data/lib/aidp/storage/json_storage.rb +41 -2
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/workflows/guided_agent.rb +8 -42
- metadata +7 -1
|
@@ -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
|