1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
|
# frozen_string_literal: true
require 'fileutils'
require 'open3'
require 'shellwords'
module HyperstackVM
class ProvisioningScripts
def initialize(config:)
@config = config
end
def guest_bootstrap_script
script = []
script << 'set -euo pipefail'
# Wait for any running unattended-upgrades or apt locks to release
# before attempting package operations (transient lock on fresh VMs)
script << 'echo "Waiting for apt locks to clear..."'
script << 'for i in $(seq 1 30); do'
script << ' if ! fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; then break; fi'
script << ' echo " apt lock held, waiting ($i/30)..."; sleep 10'
script << 'done'
script << 'sudo systemctl stop unattended-upgrades.service 2>/dev/null || true'
script << 'sudo systemctl disable unattended-upgrades.service 2>/dev/null || true'
if @config.install_wireguard?
script << 'which wg >/dev/null 2>&1 || (sudo apt-get update && sudo apt-get install -y wireguard)'
end
if @config.configure_ufw?
script << "sudo ufw allow #{@config.ssh_port}/tcp comment 'Allow SSH' >/dev/null 2>&1 || true"
script << 'sudo ufw --force enable >/dev/null 2>&1 || true'
script << "sudo ufw allow #{@config.wireguard_udp_port}/udp comment 'WireGuard #{@config.local_interface_name}' >/dev/null 2>&1 || true"
# Port 11434 is shared by Ollama and vLLM; open for both regardless of which is installed.
script << "sudo ufw allow from #{Shellwords.escape(@config.wireguard_subnet)} to any port #{@config.ollama_port} proto tcp comment 'Inference API (Ollama/vLLM) via #{@config.local_interface_name}' >/dev/null 2>&1 || true"
# ComfyUI REST API on port 8188; only open when ComfyUI is enabled.
if @config.comfyui_install_enabled?
script << "sudo ufw allow from #{Shellwords.escape(@config.wireguard_subnet)} to any port #{@config.comfyui_port} proto tcp comment 'ComfyUI API via #{@config.local_interface_name}' >/dev/null 2>&1 || true"
end
end
if @config.configure_ollama_host?
# Only write a minimal OLLAMA_HOST override if no override exists yet;
# ollama_setup_script writes the full override (OLLAMA_MODELS, GPU_OVERHEAD, etc.)
script << "if systemctl list-unit-files | grep -q '^ollama.service'; then"
script << ' if [ ! -f /etc/systemd/system/ollama.service.d/override.conf ]; then'
script << ' sudo mkdir -p /etc/systemd/system/ollama.service.d'
script << " cat <<'OVERRIDE' | sudo tee /etc/systemd/system/ollama.service.d/override.conf >/dev/null"
script << '[Service]'
script << "Environment=\"OLLAMA_HOST=0.0.0.0:#{@config.ollama_port}\""
script << 'OVERRIDE'
script << ' sudo systemctl daemon-reload'
script << ' sudo systemctl restart ollama || true'
script << ' fi'
script << 'fi'
end
script << 'echo bootstrap-ok'
script.join("\n")
end
def desired_ollama_models
normalized_model_list(@config.ollama_pull_models)
end
def model_list_signature(models)
normalized_model_list(models).sort
end
def ollama_install_script
models_dir = @config.ollama_models_dir
listen_host = @config.ollama_listen_host
script = []
script << 'set -euo pipefail'
script << 'sudo pkill -f unattended-upgrade >/dev/null 2>&1 || true'
script << 'if ! command -v ollama >/dev/null 2>&1; then curl -fsSL https://ollama.ai/install.sh | sh; fi'
if models_dir.start_with?('/ephemeral')
script << "mountpoint -q /ephemeral || { echo 'Expected /ephemeral mount is missing'; exit 1; }"
end
script << "sudo mkdir -p #{Shellwords.escape(models_dir)}"
script << "sudo chown -R ollama:ollama #{Shellwords.escape(File.dirname(models_dir))}"
script << 'sudo mkdir -p /etc/systemd/system/ollama.service.d'
script << "cat <<'OVERRIDE' | sudo tee /etc/systemd/system/ollama.service.d/override.conf >/dev/null"
script << '[Service]'
script << "Environment=\"OLLAMA_MODELS=#{models_dir}\""
script << "Environment=\"OLLAMA_GPU_OVERHEAD=#{@config.ollama_gpu_overhead_mb}\""
script << "Environment=\"OLLAMA_NUM_PARALLEL=#{@config.ollama_num_parallel}\""
script << "Environment=\"OLLAMA_CONTEXT_LENGTH=#{@config.ollama_context_length}\""
script << "Environment=\"OLLAMA_HOST=#{listen_host}\""
script << 'OVERRIDE'
script << 'sudo systemctl daemon-reload'
script << 'sudo systemctl enable --now ollama'
script << 'sudo systemctl restart ollama'
script << 'sleep 3'
script << 'systemctl is-active --quiet ollama'
script << 'echo ollama-install-ok'
script.join("\n")
end
def ollama_pull_script(models: desired_ollama_models)
models_dir = @config.ollama_models_dir
script = []
script << 'set -euo pipefail'
# Pull each model with retry (transient network failures) and verify
# it is actually present afterwards
models.each do |model|
escaped = Shellwords.escape(model)
script << "echo \"Pulling model #{model}...\""
script << 'for attempt in 1 2 3; do'
script << " if ollama pull #{escaped}; then break; fi"
script << " if [ \"$attempt\" -eq 3 ]; then echo \"FATAL: failed to pull #{model} after 3 attempts\"; exit 1; fi"
script << ' echo " pull attempt $attempt failed, retrying in 15s..."; sleep 15'
script << 'done'
script << "ollama show #{escaped} --modelfile >/dev/null 2>&1 || { echo \"FATAL: model #{model} not found after pull\"; exit 1; }"
end
# Final verification: ensure all expected models are listed
script << 'echo "Verifying all models are present..."'
models.each do |model|
escaped = Shellwords.escape(model)
script << "ollama show #{escaped} --modelfile >/dev/null 2>&1 || { echo \"FATAL: model #{model} missing in final check\"; exit 1; }"
end
script << "echo ollama-models-dir=#{models_dir}"
script << 'echo ollama-ok'
script.join("\n")
end
def vllm_stop_script(container_name)
script = []
script << 'set -euo pipefail'
script << "docker stop #{Shellwords.escape(container_name)} 2>/dev/null || true"
script << "docker rm #{Shellwords.escape(container_name)} 2>/dev/null || true"
script << 'echo vllm-stopped'
script.join("\n")
end
def vllm_install_script(preset_config: nil, pull_image: true)
cfg = preset_config || {}
model = cfg['model'] || @config.vllm_model
cache_dir = @config.vllm_hug_cache_dir
compile_cache = @config.vllm_compile_cache_dir
container = cfg['container_name'] || @config.vllm_container_name
max_len = Integer(cfg['max_model_len'] || @config.vllm_max_model_len)
gpu_util = Float(cfg['gpu_memory_utilization'] || @config.vllm_gpu_memory_utilization)
tp_size = Integer(cfg['tensor_parallel_size'] || @config.vllm_tensor_parallel_size)
parser = cfg['tool_call_parser']
# parser is nil only when preset explicitly omits the key and config has no default;
# empty string means "disable tool calling" (e.g. gpt-oss reasoning models).
parser = @config.vllm_tool_call_parser if parser.nil?
# Fall back to the top-level [vllm] config values when no preset is in use.
# This allows setting trust_remote_code / extra_vllm_args in the default [vllm] block
# without requiring a --model preset flag at create time.
trust_remote = cfg.key?('trust_remote_code') ? cfg['trust_remote_code'] : @config.vllm_trust_remote_code
# Prefix caching: preset value takes priority; nil means fall back to top-level [vllm] setting.
prefix_cache = if cfg.key?('enable_prefix_caching') && !cfg['enable_prefix_caching'].nil?
cfg['enable_prefix_caching'] == true
else
@config.vllm_prefix_caching_enabled?
end
extra_env = cfg.key?('extra_docker_env') ? Array(cfg['extra_docker_env']) : @config.vllm_extra_docker_env
port = @config.ollama_port
docker_args = [
'docker run -d',
'--gpus all', '--ipc=host', '--network host',
"--name #{Shellwords.escape(container)}",
'--restart always',
"-v #{Shellwords.escape(cache_dir)}:/root/.cache/huggingface",
# Mount torch.compile cache so CUDA kernel compilation is skipped on warm restarts.
# Without this, every container restart recompiles (~30-60 s extra).
"-v #{Shellwords.escape(compile_cache)}:/root/.cache/vllm"
]
# Extra Docker env vars (e.g. VLLM_ALLOW_LONG_MAX_MODEL_LEN=1) injected before the image name.
extra_env.each { |kv| docker_args << "-e #{Shellwords.escape(kv)}" }
docker_args += [
'vllm/vllm-openai:latest',
"--model #{Shellwords.escape(model)}",
"--tensor-parallel-size #{tp_size}",
"--gpu-memory-utilization #{gpu_util}",
"--max-model-len #{max_len}",
'--host 0.0.0.0',
"--port #{port}"
]
# Prefix caching is beneficial for most models but forces Mamba "all" cache mode on
# NemotronH, which pre-allocates states for all sequences and can OOM on startup.
docker_args << '--enable-prefix-caching' if prefix_cache
# Tool calling is optional: empty/nil parser disables it.
unless parser.nil? || parser.empty?
docker_args << '--enable-auto-tool-choice'
docker_args << "--tool-call-parser #{Shellwords.escape(parser)}"
end
docker_args << '--trust-remote-code' if trust_remote
extra_args = cfg.key?('extra_vllm_args') ? Array(cfg['extra_vllm_args']) : @config.vllm_extra_args
extra_args.each { |arg| docker_args << arg }
docker_run = docker_args.join(' ')
script = []
script << 'set -euo pipefail'
script << "sudo mkdir -p #{Shellwords.escape(cache_dir)} #{Shellwords.escape(compile_cache)}"
script << "sudo chmod -R 0777 #{Shellwords.escape(cache_dir)} #{Shellwords.escape(compile_cache)}"
script << "docker stop #{Shellwords.escape(container)} 2>/dev/null || true"
script << "docker rm #{Shellwords.escape(container)} 2>/dev/null || true"
script << 'docker pull vllm/vllm-openai:latest' if pull_image
script << docker_run
script << 'echo "Waiting for vLLM to become ready (up to 10 min for first model download)..."'
script << 'for i in $(seq 1 240); do'
script << " if curl -sf http://localhost:#{port}/v1/models >/dev/null 2>&1; then echo vllm-ready; break; fi"
script << " state=$(docker inspect --format='{{.State.Status}}' #{Shellwords.escape(container)} 2>/dev/null || echo unknown)"
script << ' echo " vLLM not ready yet ($i/240, container=$state)..."'
script << ' sleep 5'
script << 'done'
script << "curl -sf http://localhost:#{port}/v1/models >/dev/null || { echo 'FATAL: vLLM did not become ready within 20 minutes'; exit 1; }"
script << 'echo vllm-install-ok'
script.join("\n")
end
def comfyui_install_script
models_dir = @config.comfyui_models_dir
output_dir = @config.comfyui_output_dir
port = @config.comfyui_port
model_names = @config.comfyui_models
# Use ubuntu home dir to avoid /opt permission issues when running as the SSH user.
install_dir = '/home/ubuntu/ComfyUI'
venv_dir = '/home/ubuntu/comfyui-venv'
service = 'comfyui'
script = []
script << 'set -euo pipefail'
# Wait for apt locks released by unattended-upgrades before touching packages.
script << 'for i in $(seq 1 30); do'
script << ' if ! fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock /var/cache/apt/archives/lock >/dev/null 2>&1; then break; fi'
script << ' echo " apt lock held, waiting ($i/30)..."; sleep 10'
script << 'done'
script << 'sudo pkill -f unattended-upgrade >/dev/null 2>&1 || true'
# Install system deps: git, python venv, wget.
script << 'sudo apt-get update -qq'
script << 'sudo apt-get install -y -qq git python3-venv python3-pip wget'
# Ephemeral NVMe dirs for models and output.
script << "sudo mkdir -p #{Shellwords.escape(models_dir)} #{Shellwords.escape(output_dir)}"
script << "sudo chmod -R 0777 #{Shellwords.escape(File.dirname(models_dir))}"
# Clone or update ComfyUI from the official repo (no sudo needed in ubuntu home).
script << "if [ ! -d #{Shellwords.escape(install_dir)} ]; then"
script << " git clone --depth 1 https://github.com/comfyanonymous/ComfyUI #{Shellwords.escape(install_dir)}"
script << 'else'
script << " git -C #{Shellwords.escape(install_dir)} pull --ff-only"
script << 'fi'
# Create Python venv and install PyTorch + ComfyUI deps.
# CUDA 12.8 is installed on the VM; cu128 wheel index covers it.
script << "[ -d #{Shellwords.escape(venv_dir)} ] || python3 -m venv #{Shellwords.escape(venv_dir)}"
script << "#{venv_dir}/bin/pip install --quiet --upgrade pip"
script << "#{venv_dir}/bin/pip install --quiet torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu128"
script << "#{venv_dir}/bin/pip install --quiet -r #{Shellwords.escape("#{install_dir}/requirements.txt")}"
# Symlink ephemeral model/output dirs into the ComfyUI directory tree.
script << "rm -rf #{Shellwords.escape("#{install_dir}/models")} && ln -sfn #{Shellwords.escape(models_dir)} #{Shellwords.escape("#{install_dir}/models")}"
script << "rm -rf #{Shellwords.escape("#{install_dir}/output")} && ln -sfn #{Shellwords.escape(output_dir)} #{Shellwords.escape("#{install_dir}/output")}"
# Systemd service so ComfyUI starts on reboot.
script << "cat <<'UNIT' | sudo tee /etc/systemd/system/#{Shellwords.escape(service)}.service >/dev/null"
script << '[Unit]'
script << 'Description=ComfyUI photo enhancement server'
script << 'After=network.target'
script << '[Service]'
script << "ExecStart=#{venv_dir}/bin/python #{install_dir}/main.py --listen 0.0.0.0 --port #{port} --output-directory #{output_dir}"
script << 'Restart=on-failure'
script << 'RestartSec=5'
script << "WorkingDirectory=#{install_dir}"
script << 'Environment=HOME=/root'
script << '[Install]'
script << 'WantedBy=multi-user.target'
script << 'UNIT'
script << 'sudo systemctl daemon-reload'
script << "sudo systemctl enable --now #{Shellwords.escape(service)}"
script << "sudo systemctl restart #{Shellwords.escape(service)}"
# Wait for ComfyUI API to respond (model loading and CUDA init can take ~60s).
script << 'echo "Waiting for ComfyUI to become ready (up to 5 min)..."'
script << 'for i in $(seq 1 60); do'
script << " if curl -sf http://localhost:#{port}/system_stats >/dev/null 2>&1; then echo comfyui-ready; break; fi"
script << ' echo " ComfyUI not ready yet ($i/60)..."; sleep 5'
script << 'done'
script << "curl -sf http://localhost:#{port}/system_stats >/dev/null || { echo 'FATAL: ComfyUI did not become ready within 5 minutes'; exit 1; }"
# Install ComfyUI-SUPIR custom node (provides SUPIR_Upscale and related nodes).
supir_node_dir = "#{install_dir}/custom_nodes/ComfyUI-SUPIR"
script << "if [ ! -d #{Shellwords.escape(supir_node_dir)} ]; then"
script << " git clone --depth 1 https://github.com/kijai/ComfyUI-SUPIR #{Shellwords.escape(supir_node_dir)}"
script << " #{venv_dir}/bin/pip install --quiet -r #{Shellwords.escape("#{supir_node_dir}/requirements.txt")}"
script << 'fi'
# Download model weights into the ComfyUI subdirectories.
# Real-ESRGAN → upscale_models/; SUPIR → checkpoints/; SDXL base → checkpoints/.
model_names.each do |model_name|
case model_name
when /RealESRGAN/i
dest_dir = "#{models_dir}/upscale_models"
url = if model_name =~ /anime/i
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/RealESRGAN_x4plus_anime_6B.pth'
else
'https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth'
end
dest_file = "#{dest_dir}/#{model_name}.pth"
script << "mkdir -p #{Shellwords.escape(dest_dir)}"
script << "[ -f #{Shellwords.escape(dest_file)} ] || wget -q --show-progress -O #{Shellwords.escape(dest_file)} #{Shellwords.escape(url)}"
when /SUPIR/i
# SUPIR-v0Q (~5 GB): AI photo restoration backbone (denoising + detail recovery).
# SDXL base (~7 GB): provides CLIP encoders that SUPIR uses for text conditioning.
# Both must live in checkpoints/ so SUPIR_Upscale can find them by filename.
dest_dir = "#{models_dir}/checkpoints"
hf_file = model_name.end_with?('F') ? 'SUPIR-v0F.ckpt' : 'SUPIR-v0Q.ckpt'
supir_url = "https://huggingface.co/camenduru/SUPIR/resolve/main/#{hf_file}"
sdxl_url = 'https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/resolve/main/sd_xl_base_1.0.safetensors'
script << "mkdir -p #{Shellwords.escape(dest_dir)}"
script << "[ -f #{Shellwords.escape("#{dest_dir}/#{hf_file}")} ] || wget -q --show-progress -O #{Shellwords.escape("#{dest_dir}/#{hf_file}")} #{Shellwords.escape(supir_url)}"
script << "[ -f #{Shellwords.escape("#{dest_dir}/sd_xl_base_1.0.safetensors")} ] || wget -q --show-progress -O #{Shellwords.escape("#{dest_dir}/sd_xl_base_1.0.safetensors")} #{Shellwords.escape(sdxl_url)}"
end
end
# Restart ComfyUI so it picks up the new custom nodes and model files.
script << "sudo systemctl restart #{Shellwords.escape(service)}"
script << 'echo "Waiting for ComfyUI restart..."'
script << 'for i in $(seq 1 60); do'
script << " if curl -sf http://localhost:#{port}/system_stats >/dev/null 2>&1; then echo comfyui-ready; break; fi"
script << ' echo " ComfyUI not ready yet ($i/60)..."; sleep 5'
script << 'done'
script << 'echo comfyui-install-ok'
script.join("\n")
end
def litellm_decommission_script
script = []
script << 'set -euo pipefail'
script << 'sudo systemctl stop litellm 2>/dev/null || true'
script << 'sudo systemctl disable litellm 2>/dev/null || true'
script << 'sudo rm -f /etc/systemd/system/litellm.service'
script << 'sudo systemctl daemon-reload'
script << 'sudo rm -f /ephemeral/litellm-config.yaml'
script << 'sudo rm -rf /ephemeral/litellm-env'
script << 'sudo rm -f /ephemeral/litellm.log'
script << "sudo ufw --force delete allow from #{Shellwords.escape(@config.wireguard_subnet)} to any port 4000 proto tcp >/dev/null 2>&1 || true"
script << 'echo litellm-decommission-ok'
script.join("\n")
end
private
def normalized_model_list(models)
Array(models).each_with_object([]) do |model, ordered|
normalized = model.to_s.strip
next if normalized.empty? || ordered.include?(normalized)
ordered << normalized
end
end
end
class RemoteProvisioner
def initialize(config:, scripts:, out:, ssh_command_runner:, ssh_stream_runner:)
@config = config
@scripts = scripts
@out = out
@ssh_command_runner = ssh_command_runner
@ssh_stream_runner = ssh_stream_runner
end
def bootstrap_guest(host)
info 'Bootstrapping Ubuntu guest over SSH...'
retries = 3
retries.times do |attempt|
stdout, stderr, status = @ssh_command_runner.call(host, @scripts.guest_bootstrap_script)
return if status.success?
msg = stderr.strip.empty? ? stdout : stderr
raise Error, "Guest bootstrap failed after #{retries} attempts: #{msg}" if attempt == retries - 1
warn "Bootstrap attempt #{attempt + 1}/#{retries} failed (#{msg.lines.last&.strip}), retrying in 15s..."
sleep 15
end
end
def install_ollama_service(host)
info "Installing and configuring Ollama on #{host}..."
output, status = @ssh_stream_runner.call(host, @scripts.ollama_install_script)
raise Error, "Ollama install failed: #{output.strip}" unless status.success?
end
def pull_ollama_models(host)
info "Pulling Ollama models on #{host}..."
output, status = @ssh_stream_runner.call(host, @scripts.ollama_pull_script)
raise Error, "Ollama model pull failed: #{output.strip}" unless status.success?
verify_remote_models(host)
end
def stop_vllm_container(host, container_name)
info "Stopping old vLLM container #{container_name}..."
output, status = @ssh_stream_runner.call(host, @scripts.vllm_stop_script(container_name))
raise Error, "Failed to stop container #{container_name}: #{output.strip}" unless status.success?
end
def install_vllm(host, preset_config: nil, pull_image: true)
info "Setting up vLLM Docker container on #{host}..."
output, status = @ssh_stream_runner.call(host, @scripts.vllm_install_script(preset_config: preset_config,
pull_image: pull_image))
raise Error, "vLLM install failed: #{output.strip}" unless status.success?
end
def decommission_litellm(host)
info "Removing deprecated LiteLLM service from #{host} if present..."
output, status = @ssh_stream_runner.call(host, @scripts.litellm_decommission_script)
raise Error, "LiteLLM decommission failed: #{output.strip}" unless status.success?
end
def setup_vllm_stack(host, preset_config: nil)
install_vllm(host, preset_config: preset_config)
end
def install_comfyui(host)
info "Setting up ComfyUI Docker container on #{host}..."
output, status = @ssh_stream_runner.call(host, @scripts.comfyui_install_script)
raise Error, "ComfyUI install failed: #{output.strip}" unless status.success?
end
private
def verify_remote_models(host)
stdout, _stderr, status = @ssh_command_runner.call(host, 'ollama list')
return unless status.success?
remote_models = stdout.lines.drop(1).map { |line| line.split.first }.compact
missing = @scripts.desired_ollama_models.reject do |model|
remote_models.any? do |remote|
remote.start_with?(model)
end
end
return if missing.empty?
raise Error, "Models missing after setup: #{missing.join(', ')}. Remote has: #{remote_models.join(', ')}"
end
def info(message)
@out.puts(message)
end
def warn(message)
@out.puts("WARNING: #{message}")
end
end
end
|