openclacky 1.2.2 → 1.2.4

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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/lib/clacky/agent.rb +11 -0
  4. data/lib/clacky/client.rb +8 -4
  5. data/lib/clacky/default_skills/browser-setup/SKILL.md +90 -0
  6. data/lib/clacky/default_skills/channel-manager/SKILL.md +1 -0
  7. data/lib/clacky/default_skills/channel-manager/weixin_setup.rb +28 -8
  8. data/lib/clacky/mcp/stdio_transport.rb +6 -1
  9. data/lib/clacky/providers.rb +5 -7
  10. data/lib/clacky/server/browser_manager.rb +16 -3
  11. data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +46 -1
  12. data/lib/clacky/server/channel/adapters/wecom/adapter.rb +38 -13
  13. data/lib/clacky/server/channel/channel_manager.rb +14 -4
  14. data/lib/clacky/server/channel/channel_ui_controller.rb +27 -11
  15. data/lib/clacky/server/http_server.rb +13 -3
  16. data/lib/clacky/tools/browser.rb +3 -3
  17. data/lib/clacky/tools/glob.rb +19 -4
  18. data/lib/clacky/tools/grep.rb +13 -1
  19. data/lib/clacky/tools/security.rb +1 -2
  20. data/lib/clacky/tools/terminal.rb +210 -14
  21. data/lib/clacky/ui2/ui_controller.rb +20 -2
  22. data/lib/clacky/utils/file_ignore_helper.rb +78 -5
  23. data/lib/clacky/utils/login_shell.rb +3 -1
  24. data/lib/clacky/utils/model_pricing.rb +28 -3
  25. data/lib/clacky/utils/scripts_manager.rb +1 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +1 -0
  28. data/lib/clacky/web/settings.js +5 -0
  29. data/lib/clacky/web/weixin-qr.html +5 -4
  30. data/scripts/build/lib/apt.sh +71 -1
  31. data/scripts/build/src/install.sh.cc +1 -1
  32. data/scripts/build/src/install_rails_deps.sh.cc +4 -4
  33. data/scripts/build/src/install_system_deps.sh.cc +1 -1
  34. data/scripts/install.ps1 +44 -17
  35. data/scripts/install.sh +72 -2
  36. data/scripts/install_rails_deps.sh +75 -5
  37. data/scripts/install_system_deps.sh +72 -2
  38. data/scripts/wsl_network_doctor.ps1 +196 -0
  39. metadata +3 -2
@@ -116,21 +116,94 @@ module Clacky
116
116
  CONFIG_FILE_PATTERNS.any? { |pattern| file.match?(pattern) }
117
117
  end
118
118
 
119
+ # Paths considered too broad to recursively walk by default. Searching from
120
+ # these would commonly traverse millions of files (system roots, $HOME with
121
+ # many workspaces, WSL Windows mounts). Tools should refuse such requests
122
+ # and ask for a narrower base_path.
123
+ def self.dangerous_root?(path)
124
+ return false if path.nil? || path.empty?
125
+
126
+ expanded = File.expand_path(path)
127
+ return true if expanded == "/"
128
+
129
+ system_roots = ["/root", "/home", "/Users", "/mnt", "/media", "/var", "/etc", "/usr", "/opt"]
130
+ return true if system_roots.include?(expanded)
131
+
132
+ ["/Users/", "/home/"].each do |prefix|
133
+ next unless expanded.start_with?(prefix)
134
+ tail = expanded[prefix.length..]
135
+ return true if tail && !tail.empty? && !tail.include?("/")
136
+ end
137
+
138
+ return true if expanded =~ %r{\A/mnt/[a-zA-Z]\z}
139
+
140
+ home = ENV["HOME"]
141
+ return true if home && !home.empty? && expanded == File.expand_path(home)
142
+
143
+ false
144
+ end
145
+
146
+ # Hard ceiling on directories visited in a single walk. Prevents indefinite
147
+ # traversal across huge trees (e.g. /root, $HOME, /mnt/c on WSL).
148
+ MAX_DIRS_VISITED = 20_000
149
+
150
+ # Wall-clock budget for a single walk, in seconds.
151
+ WALK_TIMEOUT_SECONDS = 15
152
+
153
+ # Raised internally to abort a walk when a budget is exhausted.
154
+ class WalkBudgetExceeded < StandardError
155
+ attr_reader :reason
156
+ def initialize(reason)
157
+ @reason = reason
158
+ super(reason.to_s)
159
+ end
160
+ end
161
+
119
162
  # Walk a directory tree, pruning ignored directories early.
120
163
  # Yields each non-ignored file path. Supports nested .gitignore files.
121
164
  # @param skipped [Hash, nil] If provided, increments :ignored for each gitignore-skipped entry.
122
- def self.walk_files(base_path, gitignore: nil, skipped: nil, &block)
123
- return enum_for(:walk_files, base_path, gitignore: gitignore, skipped: skipped) unless block_given?
165
+ # @param status [Hash, nil] If provided, populated with :truncated and :truncation_reason
166
+ # when the walk is aborted due to dir-count or wall-clock budget.
167
+ def self.walk_files(base_path, gitignore: nil, skipped: nil, status: nil,
168
+ max_dirs_visited: MAX_DIRS_VISITED,
169
+ timeout_seconds: WALK_TIMEOUT_SECONDS,
170
+ &block)
171
+ unless block_given?
172
+ return enum_for(:walk_files, base_path,
173
+ gitignore: gitignore, skipped: skipped, status: status,
174
+ max_dirs_visited: max_dirs_visited, timeout_seconds: timeout_seconds)
175
+ end
124
176
 
125
177
  root_gitignore = gitignore || begin
126
178
  gi_path = find_gitignore(base_path)
127
179
  gi_path ? Clacky::GitignoreParser.new(gi_path) : nil
128
180
  end
129
181
 
130
- _walk_recursive(base_path, base_path, root_gitignore, skipped, &block)
182
+ budget = {
183
+ dirs_visited: 0,
184
+ max_dirs: max_dirs_visited,
185
+ deadline: timeout_seconds ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_seconds) : nil
186
+ }
187
+
188
+ begin
189
+ _walk_recursive(base_path, base_path, root_gitignore, skipped, budget, &block)
190
+ rescue WalkBudgetExceeded => e
191
+ if status
192
+ status[:truncated] = true
193
+ status[:truncation_reason] = e.reason.to_s
194
+ end
195
+ end
131
196
  end
132
197
 
133
- def self._walk_recursive(dir, base_path, gitignore, skipped, &block)
198
+ def self._walk_recursive(dir, base_path, gitignore, skipped, budget, &block)
199
+ budget[:dirs_visited] += 1
200
+ if budget[:dirs_visited] > budget[:max_dirs]
201
+ raise WalkBudgetExceeded.new(:max_dirs_visited)
202
+ end
203
+ if budget[:deadline] && Process.clock_gettime(Process::CLOCK_MONOTONIC) > budget[:deadline]
204
+ raise WalkBudgetExceeded.new(:timeout)
205
+ end
206
+
134
207
  child_gitignore_path = File.join(dir, ".gitignore")
135
208
  if dir != base_path && File.exist?(child_gitignore_path)
136
209
  gitignore ||= Clacky::GitignoreParser.new(nil)
@@ -153,7 +226,7 @@ module Clacky
153
226
  if gitignore&.ignored?("#{relative}/") || should_ignore_file?(full, base_path, gitignore)
154
227
  next
155
228
  end
156
- _walk_recursive(full, base_path, gitignore, skipped, &block)
229
+ _walk_recursive(full, base_path, gitignore, skipped, budget, &block)
157
230
  else
158
231
  if !is_config_file?(full) && should_ignore_file?(full, base_path, gitignore)
159
232
  skipped[:ignored] += 1 if skipped
@@ -51,7 +51,9 @@ module Clacky
51
51
 
52
52
  # { rc_sources; } 1>&2 — send rc-time stdout to stderr so target's
53
53
  # stdout is pristine. `exec` replaces the shell with target.
54
- script = "{ #{rc_sources}; } 1>&2; exec #{command}"
54
+ # PS1 trick: Ubuntu .bashrc has `[ -z "$PS1" ] && return` guard that
55
+ # skips the entire file in non-interactive shells. Setting PS1 defeats it.
56
+ script = "PS1='$ '; { #{rc_sources}; } 1>&2; exec #{command}"
55
57
  [shell, "-c", script]
56
58
  end
57
59
 
@@ -366,6 +366,24 @@ module Clacky
366
366
  # surprising users with the explicit 25% surcharge).
367
367
  # - We bill reads at 20% (implicit rate) — the conservative side; users on
368
368
  # explicit caching will see real bills slightly *lower* than displayed.
369
+ "qwen3.7-max" => {
370
+ input: { default: 1.20, over_200k: 1.20 },
371
+ output: { default: 6.00, over_200k: 6.00 },
372
+ cache: { write: 1.20, read: 0.24 }
373
+ },
374
+
375
+ "qwen3.7-plus" => {
376
+ input: { default: 0.40, over_200k: 0.40 },
377
+ output: { default: 2.40, over_200k: 2.40 },
378
+ cache: { write: 0.40, read: 0.08 }
379
+ },
380
+
381
+ "qwen3.7-flash" => {
382
+ input: { default: 0.15, over_200k: 0.15 },
383
+ output: { default: 0.90, over_200k: 0.90 },
384
+ cache: { write: 0.15, read: 0.03 }
385
+ },
386
+
369
387
  "qwen3.6-plus" => {
370
388
  input: { default: 0.40, over_200k: 0.40 },
371
389
  output: { default: 2.40, over_200k: 2.40 },
@@ -571,9 +589,16 @@ module Clacky
571
589
  "minimax-m2.7"
572
590
 
573
591
  # Qwen (Alibaba DashScope) — strict anchored match per registered
574
- # model id in providers.rb. qwen3.6-* are the new flagship line;
575
- # qwen-plus-latest is the rolling alias for the latest Qwen-Plus
576
- # release; qwen-vl-* are the multimodal SKUs.
592
+ # model id in providers.rb. qwen3.7-* is the latest flagship line;
593
+ # qwen3.6-* are the previous generation; qwen-plus-latest is the
594
+ # rolling alias for the latest Qwen-Plus release; qwen-vl-* are
595
+ # the multimodal SKUs.
596
+ when /^qwen3\.7-max$/i
597
+ "qwen3.7-max"
598
+ when /^qwen3\.7-plus$/i
599
+ "qwen3.7-plus"
600
+ when /^qwen3\.7-flash$/i
601
+ "qwen3.7-flash"
577
602
  when /^qwen3\.6-plus$/i
578
603
  "qwen3.6-plus"
579
604
  when /^qwen3\.6-max$/i
@@ -21,6 +21,7 @@ module Clacky
21
21
  install_browser.sh
22
22
  install_system_deps.sh
23
23
  install_rails_deps.sh
24
+ wsl_network_doctor.ps1
24
25
  ].freeze
25
26
 
26
27
  # Copy bundled scripts to ~/.clacky/scripts/ if missing or outdated.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.2"
4
+ VERSION = "1.2.4"
5
5
  end
@@ -3649,6 +3649,7 @@ body {
3649
3649
  .model-card-grid-actions {
3650
3650
  display: flex;
3651
3651
  flex-direction: row;
3652
+ flex-wrap: wrap;
3652
3653
  gap: 0.5rem;
3653
3654
  padding-top: 0.625rem;
3654
3655
  border-top: 1px solid var(--color-border-primary);
@@ -169,6 +169,11 @@ const Settings = (() => {
169
169
  providerValue.classList.add("placeholder");
170
170
  }
171
171
 
172
+ // Reset save button
173
+ const saveBtn = document.getElementById("model-modal-save");
174
+ saveBtn.textContent = I18n.t("settings.models.btn.save");
175
+ saveBtn.disabled = false;
176
+
172
177
  // Clear test result
173
178
  document.getElementById("model-modal-test-result").textContent = "";
174
179
  document.getElementById("model-modal-test-result").className = "model-test-result";
@@ -140,10 +140,11 @@
140
140
  <script>
141
141
  const params = new URLSearchParams(location.search);
142
142
  const url = params.get("url");
143
- // since: Unix timestamp (seconds) passed by the setup skill when opening this page.
144
- // We only show success if token_updated_at > since, preventing false positives
145
- // when the user already had a token from a previous login.
146
- const since = parseInt(params.get("since") || "0", 10);
143
+ // since: the moment this page loaded, in Unix seconds.
144
+ // We only show success if token_updated_at >= since, so a pre-existing token
145
+ // never triggers the overlay.
146
+ // Intentionally NOT taken from the URL param — the page is the source of truth.
147
+ const since = Math.floor(Date.now() / 1000);
147
148
  const el = document.getElementById("qrcode");
148
149
 
149
150
  if (!url) {
@@ -4,6 +4,76 @@
4
4
  # Sets-Vars: (none)
5
5
  # Include via: @include lib/apt.sh
6
6
 
7
+ # Wait until apt/dpkg lock files are no longer held (e.g. by apt-daily on
8
+ # freshly-booted WSL/Ubuntu). Uses flock(1) — the same mechanism apt uses —
9
+ # rather than checking file existence (the lock files are always present;
10
+ # advisory locks live in the kernel, not the filesystem).
11
+ wait_apt_lock() {
12
+ [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ] || return 0
13
+
14
+ local locks=(
15
+ "/var/lib/dpkg/lock-frontend"
16
+ "/var/lib/dpkg/lock"
17
+ "/var/lib/apt/lists/lock"
18
+ )
19
+ local max_wait="${1:-120}"
20
+ local waited=0
21
+ local announced=false
22
+
23
+ while :; do
24
+ local busy=false
25
+ for f in "${locks[@]}"; do
26
+ [ -e "$f" ] || continue
27
+ if ! sudo flock -n "$f" -c true 2>/dev/null; then
28
+ busy=true
29
+ break
30
+ fi
31
+ done
32
+
33
+ [ "$busy" = false ] && break
34
+
35
+ if [ "$announced" = false ]; then
36
+ print_info "Waiting for system apt/dpkg to finish (up to ${max_wait}s)..."
37
+ announced=true
38
+ fi
39
+
40
+ if [ "$waited" -ge "$max_wait" ]; then
41
+ print_error "apt is still locked after ${max_wait}s."
42
+ print_info "On WSL try: 'wsl --shutdown' from PowerShell, then rerun the installer."
43
+ return 1
44
+ fi
45
+
46
+ sleep 3
47
+ waited=$((waited + 3))
48
+ done
49
+
50
+ [ "$announced" = true ] && print_success "apt lock released"
51
+ return 0
52
+ }
53
+
54
+ # Run an apt-get subcommand with lock-wait + transient-failure retry.
55
+ # Usage: apt_get_run update [-qq]
56
+ # apt_get_run install -y pkg1 pkg2
57
+ apt_get_run() {
58
+ local attempts=3
59
+ local i=1
60
+ while [ "$i" -le "$attempts" ]; do
61
+ wait_apt_lock 120 || return 1
62
+ if sudo apt-get "$@"; then
63
+ return 0
64
+ fi
65
+ local rc=$?
66
+ if [ "$i" -lt "$attempts" ]; then
67
+ print_warning "apt-get $1 failed (exit $rc), retrying ($i/$((attempts-1)))..."
68
+ sleep 5
69
+ else
70
+ print_error "apt-get $1 failed after $attempts attempts."
71
+ return "$rc"
72
+ fi
73
+ i=$((i + 1))
74
+ done
75
+ }
76
+
7
77
  # Configure apt mirror for CN region and run apt-get update.
8
78
  # Guards: only runs on ubuntu/debian ($DISTRO).
9
79
  # Relies on $USE_CN_MIRRORS set by detect_network_region (network.sh).
@@ -51,6 +121,6 @@ EOF
51
121
  print_info "Region: global — using default apt sources"
52
122
  fi
53
123
 
54
- sudo apt-get update -qq
124
+ apt_get_run update -qq || return 1
55
125
  print_success "apt updated"
56
126
  }
@@ -34,7 +34,7 @@ ensure_ruby() {
34
34
 
35
35
  if is_linux_apt; then
36
36
  print_info "Installing Ruby via apt..."
37
- sudo apt-get install -y ruby ruby-dev 2>/dev/null && check_ruby && return 0
37
+ apt_get_run install -y ruby ruby-dev 2>/dev/null && check_ruby && return 0
38
38
  print_warning "apt Ruby install failed or version too old"
39
39
  fi
40
40
 
@@ -31,8 +31,8 @@ install_ruby() {
31
31
  if is_macos; then
32
32
  brew install openssl@3 libyaml gmp
33
33
  elif is_linux_apt; then
34
- sudo apt-get install -y \
35
- rustc libssl-dev libyaml-dev zlib1g-dev libgmp-dev
34
+ apt_get_run install -y \
35
+ rustc libssl-dev libyaml-dev zlib1g-dev libgmp-dev || return 1
36
36
  fi
37
37
 
38
38
  ensure_mise || return 1
@@ -75,8 +75,8 @@ install_postgres() {
75
75
 
76
76
  elif is_linux_apt; then
77
77
  setup_apt_mirror
78
- sudo apt-get install -y postgresql libpq-dev \
79
- libssl-dev libreadline-dev zlib1g-dev libyaml-dev libffi-dev
78
+ apt_get_run install -y postgresql libpq-dev \
79
+ libssl-dev libreadline-dev zlib1g-dev libyaml-dev libffi-dev || return 1
80
80
  sudo systemctl enable --now postgresql || true
81
81
  fi
82
82
 
@@ -67,7 +67,7 @@ ensure_linux_deps() {
67
67
 
68
68
  detect_network_region
69
69
  setup_apt_mirror
70
- sudo apt-get install -y build-essential git curl python3
70
+ apt_get_run install -y build-essential git curl python3 || return 1
71
71
  print_success "Dependencies installed"
72
72
  }
73
73
 
data/scripts/install.ps1 CHANGED
@@ -11,8 +11,8 @@
11
11
  # -BrandName Display name shown in prompts (default: OpenClacky)
12
12
  # -CommandName CLI command name after install (default: openclacky)
13
13
  #
14
- # WSL2 is preferred. If virtualisation is unavailable (e.g. running inside a VM),
15
- # the script automatically falls back to WSL1.
14
+ # WSL1 is preferred (shares Windows network stack no mirrored networking needed).
15
+ # If WSL1 import fails, the script falls back to WSL2 with mirrored networking.
16
16
  # If WSL is not installed at all, the script enables it and asks you to reboot.
17
17
  # After rebooting, run the same command again to complete installation.
18
18
  #
@@ -501,32 +501,59 @@ if ($installPhase -eq "wsl-pending" -and $wslCode -eq 1) {
501
501
  # wslCode != 1 (0, -1, -444, 50, etc.): WSL is functional, continue.
502
502
  Remove-InstallReg -Name "InstallPhase"
503
503
 
504
- # Step 2: Install Ubuntu, preferring WSL2 when the real rootfs imports cleanly.
504
+ # Step 2: Install Ubuntu, preferring WSL1 (shares Windows network no mirrored needed).
505
+ # If WSL1 import fails, fall back to WSL2.
506
+ # If the distro already exists, keep whatever version was previously installed.
505
507
  if (Test-UbuntuInstalled) {
506
508
  Write-Info "Ubuntu (WSL) already installed — skipping import."
507
509
  $wslVersion = Get-InstallReg -Name "WslVersion" -Default 2
508
510
  } else {
509
511
  $tarPath = Get-UbuntuRootfs
510
- if (Test-VirtualisationSupported -TarPath $tarPath) {
511
- wsl.exe --set-default-version 2 >$null 2>$null
512
- Install-UbuntuRootfs -WslVersion 2 -TarPath $tarPath
513
- $wslVersion = 2
512
+
513
+ # Try WSL1 first
514
+ Write-Info "Attempting WSL1 import..."
515
+ $wsl1Ok = $false
516
+ try {
517
+ New-Item -ItemType Directory -Force -Path $UBUNTU_WSL_DIR | Out-Null
518
+ wsl.exe --import Ubuntu $UBUNTU_WSL_DIR $tarPath --version 1 >$null 2>$null
519
+ $wsl1Ok = ($LASTEXITCODE -eq 0)
520
+ } catch {
521
+ $wsl1Ok = $false
522
+ }
523
+
524
+ if ($wsl1Ok) {
525
+ Write-Success "Ubuntu (WSL1) imported successfully."
526
+ $wslVersion = 1
514
527
  } else {
515
- if ($wslFeaturesEnabled -ne "1") {
516
- # WSL components were never fully prepared — run Enable-WslFeatures and reboot.
517
- Write-Warn "WSL2 is not available and WSL components have not been fully set up."
518
- Enable-WslFeatures
519
- # Always exits (prompts reboot)
528
+ # Clean up failed WSL1 attempt
529
+ wsl.exe --unregister Ubuntu 2>$null | Out-Null
530
+ Remove-Item -Force -Recurse -ErrorAction SilentlyContinue $UBUNTU_WSL_DIR
531
+
532
+ Write-Info "WSL1 import failed, trying WSL2..."
533
+ if (Test-VirtualisationSupported -TarPath $tarPath) {
534
+ wsl.exe --set-default-version 2 >$null 2>$null
535
+ Install-UbuntuRootfs -WslVersion 2 -TarPath $tarPath
536
+ $wslVersion = 2
537
+ } else {
538
+ if ($wslFeaturesEnabled -ne "1") {
539
+ Write-Warn "Neither WSL1 nor WSL2 is available. Enabling WSL components..."
540
+ Enable-WslFeatures
541
+ # Always exits (prompts reboot)
542
+ }
543
+ Write-Fail "Failed to import Ubuntu into both WSL1 and WSL2."
544
+ Write-Fail "Please ensure Windows Subsystem for Linux is enabled and try again."
545
+ exit 1
520
546
  }
521
- Write-Info "[main] WSL2 unavailable, falling back to WSL1..."
522
- Install-UbuntuRootfs -WslVersion 1 -TarPath $tarPath
523
- $wslVersion = 1
524
547
  }
525
548
  }
526
549
 
527
- if ($wslVersion -eq 2) { Set-Wsl2MirroredNetworking }
528
-
529
550
  Write-Success "WSL is ready."
530
551
  Run-InstallInWsl
552
+
553
+ # For WSL2, configure mirrored networking AFTER install.sh succeeds (NAT is more
554
+ # reliable for outbound traffic during installation). The shutdown here is safe
555
+ # because installation is already complete.
556
+ if ($wslVersion -eq 2) { Set-Wsl2MirroredNetworking }
557
+
531
558
  Set-InstallReg -Name "WslVersion" -Value $wslVersion
532
559
  Show-PostInstall -WslVersion $wslVersion
data/scripts/install.sh CHANGED
@@ -271,6 +271,76 @@ detect_network_region() {
271
271
 
272
272
  # ---[ @include lib/apt.sh ]---
273
273
 
274
+ # Wait until apt/dpkg lock files are no longer held (e.g. by apt-daily on
275
+ # freshly-booted WSL/Ubuntu). Uses flock(1) — the same mechanism apt uses —
276
+ # rather than checking file existence (the lock files are always present;
277
+ # advisory locks live in the kernel, not the filesystem).
278
+ wait_apt_lock() {
279
+ [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ] || return 0
280
+
281
+ local locks=(
282
+ "/var/lib/dpkg/lock-frontend"
283
+ "/var/lib/dpkg/lock"
284
+ "/var/lib/apt/lists/lock"
285
+ )
286
+ local max_wait="${1:-120}"
287
+ local waited=0
288
+ local announced=false
289
+
290
+ while :; do
291
+ local busy=false
292
+ for f in "${locks[@]}"; do
293
+ [ -e "$f" ] || continue
294
+ if ! sudo flock -n "$f" -c true 2>/dev/null; then
295
+ busy=true
296
+ break
297
+ fi
298
+ done
299
+
300
+ [ "$busy" = false ] && break
301
+
302
+ if [ "$announced" = false ]; then
303
+ print_info "Waiting for system apt/dpkg to finish (up to ${max_wait}s)..."
304
+ announced=true
305
+ fi
306
+
307
+ if [ "$waited" -ge "$max_wait" ]; then
308
+ print_error "apt is still locked after ${max_wait}s."
309
+ print_info "On WSL try: 'wsl --shutdown' from PowerShell, then rerun the installer."
310
+ return 1
311
+ fi
312
+
313
+ sleep 3
314
+ waited=$((waited + 3))
315
+ done
316
+
317
+ [ "$announced" = true ] && print_success "apt lock released"
318
+ return 0
319
+ }
320
+
321
+ # Run an apt-get subcommand with lock-wait + transient-failure retry.
322
+ # Usage: apt_get_run update [-qq]
323
+ # apt_get_run install -y pkg1 pkg2
324
+ apt_get_run() {
325
+ local attempts=3
326
+ local i=1
327
+ while [ "$i" -le "$attempts" ]; do
328
+ wait_apt_lock 120 || return 1
329
+ if sudo apt-get "$@"; then
330
+ return 0
331
+ fi
332
+ local rc=$?
333
+ if [ "$i" -lt "$attempts" ]; then
334
+ print_warning "apt-get $1 failed (exit $rc), retrying ($i/$((attempts-1)))..."
335
+ sleep 5
336
+ else
337
+ print_error "apt-get $1 failed after $attempts attempts."
338
+ return "$rc"
339
+ fi
340
+ i=$((i + 1))
341
+ done
342
+ }
343
+
274
344
  # Configure apt mirror for CN region and run apt-get update.
275
345
  # Guards: only runs on ubuntu/debian ($DISTRO).
276
346
  # Relies on $USE_CN_MIRRORS set by detect_network_region (network.sh).
@@ -318,7 +388,7 @@ EOF
318
388
  print_info "Region: global — using default apt sources"
319
389
  fi
320
390
 
321
- sudo apt-get update -qq
391
+ apt_get_run update -qq || return 1
322
392
  print_success "apt updated"
323
393
  }
324
394
 
@@ -427,7 +497,7 @@ ensure_ruby() {
427
497
 
428
498
  if is_linux_apt; then
429
499
  print_info "Installing Ruby via apt..."
430
- sudo apt-get install -y ruby ruby-dev 2>/dev/null && check_ruby && return 0
500
+ apt_get_run install -y ruby ruby-dev 2>/dev/null && check_ruby && return 0
431
501
  print_warning "apt Ruby install failed or version too old"
432
502
  fi
433
503
 
@@ -275,6 +275,76 @@ detect_network_region() {
275
275
 
276
276
  # ---[ @include lib/apt.sh ]---
277
277
 
278
+ # Wait until apt/dpkg lock files are no longer held (e.g. by apt-daily on
279
+ # freshly-booted WSL/Ubuntu). Uses flock(1) — the same mechanism apt uses —
280
+ # rather than checking file existence (the lock files are always present;
281
+ # advisory locks live in the kernel, not the filesystem).
282
+ wait_apt_lock() {
283
+ [ "$DISTRO" = "ubuntu" ] || [ "$DISTRO" = "debian" ] || return 0
284
+
285
+ local locks=(
286
+ "/var/lib/dpkg/lock-frontend"
287
+ "/var/lib/dpkg/lock"
288
+ "/var/lib/apt/lists/lock"
289
+ )
290
+ local max_wait="${1:-120}"
291
+ local waited=0
292
+ local announced=false
293
+
294
+ while :; do
295
+ local busy=false
296
+ for f in "${locks[@]}"; do
297
+ [ -e "$f" ] || continue
298
+ if ! sudo flock -n "$f" -c true 2>/dev/null; then
299
+ busy=true
300
+ break
301
+ fi
302
+ done
303
+
304
+ [ "$busy" = false ] && break
305
+
306
+ if [ "$announced" = false ]; then
307
+ print_info "Waiting for system apt/dpkg to finish (up to ${max_wait}s)..."
308
+ announced=true
309
+ fi
310
+
311
+ if [ "$waited" -ge "$max_wait" ]; then
312
+ print_error "apt is still locked after ${max_wait}s."
313
+ print_info "On WSL try: 'wsl --shutdown' from PowerShell, then rerun the installer."
314
+ return 1
315
+ fi
316
+
317
+ sleep 3
318
+ waited=$((waited + 3))
319
+ done
320
+
321
+ [ "$announced" = true ] && print_success "apt lock released"
322
+ return 0
323
+ }
324
+
325
+ # Run an apt-get subcommand with lock-wait + transient-failure retry.
326
+ # Usage: apt_get_run update [-qq]
327
+ # apt_get_run install -y pkg1 pkg2
328
+ apt_get_run() {
329
+ local attempts=3
330
+ local i=1
331
+ while [ "$i" -le "$attempts" ]; do
332
+ wait_apt_lock 120 || return 1
333
+ if sudo apt-get "$@"; then
334
+ return 0
335
+ fi
336
+ local rc=$?
337
+ if [ "$i" -lt "$attempts" ]; then
338
+ print_warning "apt-get $1 failed (exit $rc), retrying ($i/$((attempts-1)))..."
339
+ sleep 5
340
+ else
341
+ print_error "apt-get $1 failed after $attempts attempts."
342
+ return "$rc"
343
+ fi
344
+ i=$((i + 1))
345
+ done
346
+ }
347
+
278
348
  # Configure apt mirror for CN region and run apt-get update.
279
349
  # Guards: only runs on ubuntu/debian ($DISTRO).
280
350
  # Relies on $USE_CN_MIRRORS set by detect_network_region (network.sh).
@@ -322,7 +392,7 @@ EOF
322
392
  print_info "Region: global — using default apt sources"
323
393
  fi
324
394
 
325
- sudo apt-get update -qq
395
+ apt_get_run update -qq || return 1
326
396
  print_success "apt updated"
327
397
  }
328
398
 
@@ -632,8 +702,8 @@ install_ruby() {
632
702
  if is_macos; then
633
703
  brew install openssl@3 libyaml gmp
634
704
  elif is_linux_apt; then
635
- sudo apt-get install -y \
636
- rustc libssl-dev libyaml-dev zlib1g-dev libgmp-dev
705
+ apt_get_run install -y \
706
+ rustc libssl-dev libyaml-dev zlib1g-dev libgmp-dev || return 1
637
707
  fi
638
708
 
639
709
  ensure_mise || return 1
@@ -676,8 +746,8 @@ install_postgres() {
676
746
 
677
747
  elif is_linux_apt; then
678
748
  setup_apt_mirror
679
- sudo apt-get install -y postgresql libpq-dev \
680
- libssl-dev libreadline-dev zlib1g-dev libyaml-dev libffi-dev
749
+ apt_get_run install -y postgresql libpq-dev \
750
+ libssl-dev libreadline-dev zlib1g-dev libyaml-dev libffi-dev || return 1
681
751
  sudo systemctl enable --now postgresql || true
682
752
  fi
683
753