Skip to content

fix: CLI update resilience + setup wizard UX overhaul#642

Merged
Aureliolo merged 9 commits intomainfrom
fix/update-resilience-and-setup-wizard
Mar 20, 2026
Merged

fix: CLI update resilience + setup wizard UX overhaul#642
Aureliolo merged 9 commits intomainfrom
fix/update-resilience-and-setup-wizard

Conversation

@Aureliolo
Copy link
Copy Markdown
Owner

Summary

  • CLI update command: Add dirty state detection (missing compose, images, config, secrets) with interactive recovery prompt after partial uninstall. Add old image cleanup after successful upgrade -- identifies non-current images by Docker image ID (handles tagged + digest-pinned refs), validates IDs before docker rmi.
  • Setup wizard UX: Always show all 5 steps (admin never disappears on refresh). Store-driven step completion tracking synced from backend /setup/status. Completed steps show read-only summaries (admin locked, provider/company/agent editable). Free navigation to any completed step. Provider: "Change Provider" button + proper internal back navigation. Password: show/hide toggles + autocomplete fixes.
  • Provider discovery SSRF fix: Local providers (Ollama, LM Studio) use localhost/private IPs by design, which SSRF validation blocked. Added trust_url bypass for preset-originated and no-auth provider URLs. Validated preset_hint against preset registry to prevent arbitrary SSRF bypass. SSRF bypass logged at WARNING level for security monitoring.
  • Security hardening: ImageTag validation in config.State.validate() prevents glob injection via crafted config. Docker image ID format validation before docker rmi. User-supplied base_url overrides go through normal SSRF validation (only preset defaults are trusted).

Test plan

  • Go: go build ./... + go vet ./... + go test ./... -- all pass
  • Python: ruff check + ruff format + mypy + pytest (9713 tests) -- all pass
  • Vue: type-check + lint + test (723 tests) -- all pass
  • Pre-commit hooks: all pass
  • Pre-push hooks: mypy, pytest, golangci-lint, go vet, go test -- all pass

Review coverage

Pre-reviewed by 14 specialized agents: Go reviewer, Security reviewer, Frontend reviewer, Python reviewer, Docs consistency, Issue resolution verifier, Silent failure hunter, Conventions enforcer, Go conventions, Logging audit, Test quality analyzer, Async concurrency reviewer, Resilience audit, Go security reviewer.

18 findings identified and addressed (6 Critical, 7 Major, 5 Medium). Key fixes:

  • SSRF: Validate preset_hint against registry; only trust preset default URLs
  • Go: ImageTag validation, Docker error handling, image ID format validation
  • Vue: Reactive isComplete, store action for step mutation, stale cache fix

Closes #641

Aureliolo and others added 4 commits March 20, 2026 14:54
CLI update command:
- Add dirty state detection (missing compose, images, config, secrets)
  with interactive recovery prompt after partial uninstall
- Add old image cleanup after successful upgrade, identifying non-current
  images by Docker image ID (handles both tagged and digest-pinned refs)

Setup wizard:
- Always show all 5 steps (admin never disappears on refresh)
- Store-driven step completion tracking synced from backend status
- Re-fetch /setup/status after each step for accurate state
- Completed steps show read-only summaries (admin locked, others editable)
- Free navigation to any completed step via step indicators
- Provider step: "Change Provider" button, proper internal back navigation
- Password fields: show/hide toggles, fixed autocomplete attributes

Provider discovery SSRF fix:
- Extract _fetch_json_trusted() for preset-originated URLs that skip SSRF
  validation (localhost/private IPs are valid for Ollama, LM Studio, etc.)
- Thread trust_url through discover_models -> _fetch_json when preset_hint
  is provided in management service

Closes #641

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extends trust_url logic in discover_models_for_provider to also skip
SSRF validation when the provider uses auth_type=none (local providers
like Ollama/LM Studio configured outside the preset flow). The admin
explicitly configured the URL, and localhost/private IPs are valid
for local providers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Security (CRITICAL):
- Validate preset_hint against preset registry before trusting (SSRF)
- Only trust preset default URL, not user-supplied base_url override
- Add ImageTag validation to config.State.validate() (glob injection)
- Add httpx.HTTPStatusError handler to _fetch_json_trusted
- Upgrade SSRF bypass log level from INFO to WARNING

Go CLI (MAJOR):
- Refactor checkInstallationHealth into helpers (< 50 lines each)
- Refactor cleanupOldImages into findOldImages + collectCurrentImageIDs
  + promptAndRemoveImages (< 50 lines each)
- Docker detection failure now reported in health check issues
- Separate Docker errors from missing-image detection
- Abort cleanup if current image IDs can't be resolved
- Log Docker listing failures before early return
- Validate Docker image IDs before passing to docker rmi

Vue (CRITICAL/MAJOR):
- Make SetupAdmin isComplete reactive via computed()
- Add markStepComplete store action, remove direct mutation
- Add fetchStatus to handleAgentComplete
- Clear savedAgent on startEditing in SetupAgent
- Replace bare catch with error logging in SetupProvider probe

Docs (MEDIUM):
- Update operations.md SSRF description for trust_url bypass
- Update CLAUDE.md package structure for discovery.py
- Update user_guide.md setup wizard step count

Pre-reviewed by 14 agents, 18 findings addressed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 20, 2026

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 20, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: bf52db82-5cac-4899-8ed7-7b4c50f3c894

📥 Commits

Reviewing files that changed from the base of the PR and between f38e4d1 and 835b663.

📒 Files selected for processing (1)
  • cli/cmd/update.go

Walkthrough

This PR adds CLI installation-health checks and a recovery prompt, optional post-upgrade cleanup of non-current Docker images, and image-tag validation. It refactors the update flow (restart/rollback behavior) and adds new health/cleanup helpers. The setup wizard was reworked to always show five steps, track per-step completion, present completed-step summaries (editable), and add password show/hide controls. Provider discovery was refactored: probing moved to a new probing module, discover_models gained a trust_url flag to allow SSRF-safe bypasses for trusted preset/local URLs, and a PROVIDER_DISCOVERY_SSRF_BYPASSED event was added. Tests and docs updated.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: CLI update resilience + setup wizard UX overhaul' accurately describes the main changes: CLI update command improvements and setup wizard UX enhancements are clearly the primary focus.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, covering CLI update resilience, setup wizard UX improvements, provider discovery SSRF fixes, and security hardening with test coverage details.
Linked Issues check ✅ Passed The PR successfully addresses all key objectives from issue #641: CLI dirty state detection and image cleanup, setup wizard step persistence and completion tracking, SSRF bypass for trusted preset URLs, and security hardening with validation improvements.
Out of Scope Changes check ✅ Passed All changes are directly aligned with the stated objectives. Updates to documentation (CLAUDE.md, design docs, user guide) reflect actual implementation changes. No unrelated refactoring or scope creep detected.
Docstring Coverage ✅ Passed Docstring coverage is 93.06% which is sufficient. The required threshold is 40.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@Aureliolo Aureliolo temporarily deployed to cloudflare-preview March 20, 2026 14:33 — with GitHub Actions Inactive
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cli/internal/config/state.go`:
- Around line 148-164: IsValidImageTag currently validates characters but not
length; add a check enforcing Docker's 128-character tag limit by returning
false if len(tag) > 128 (place this near the existing len(tag) == 0 check at the
top of IsValidImageTag) so tags exceeding 128 chars fail validation before
character checks run.

In `@web/src/components/setup/SetupAdmin.vue`:
- Around line 127-135: Add an aria-label to the password visibility toggle
buttons in SetupAdmin.vue so screen readers receive a clear accessible name: for
the button that toggles showPassword (the button with `@click`="showPassword =
!showPassword" and :title="showPassword ? 'Hide password' : 'Show password'")
add a bound aria-label that mirrors the title (e.g., :aria-label="showPassword ?
'Hide password' : 'Show password'"); do the same for the confirm-password toggle
(the corresponding button around lines 152-159) so both use aria-label rather
than relying solely on title.

In `@web/src/components/setup/SetupAgent.vue`:
- Around line 125-128: The startEditing() function currently clears savedAgent
(savedAgent.value = null) which prevents form fields from being pre-populated;
change startEditing() to preserve savedAgent and instead set editing.value =
true while leaving savedAgent.value intact (or copy savedAgent into a separate
mutable form model such as agentForm or initialize reactive form fields from
savedAgent when editing starts), and update the form initialization logic to
read from savedAgent (or agentForm) so fields are populated when entering edit
mode (refer to startEditing(), savedAgent.value, editing.value and the
component's form model where SetupCompany implements similar behavior).

In `@web/src/components/setup/SetupCompany.vue`:
- Around line 88-127: The summary can show stale/fallback values because
createdResult is a component-local ref and lost on refresh; update
SetupCompany.vue so that on mounted (or when showSummary is true) if
setup.isStepComplete('company') you fetch the company details from the backend
(or read them from a Pinia store) and populate createdResult before
rendering/allowing the Next action; ensure the emit call in the Next button uses
the fetched createdResult.companyName (not the fallback) and consider persisting
createdResult into the Pinia store when initially created so navigation/refresh
retains the real values.
- Around line 42-44: startEditing() currently only sets editing.value = true so
form fields stay empty; update startEditing() to populate companyName,
companyDescription, and selectedTemplate from createdResult (guarding for
null/undefined) before flipping editing.value to true so the form is pre-filled.
In other words, inside startEditing() read createdResult (or
createdResult.value) and assign its name/description/template into the reactive
variables companyName, companyDescription, selectedTemplate (use default
fallbacks), then set editing.value = true.

In `@web/src/components/setup/SetupProvider.vue`:
- Around line 165-183: The handler handleChangeProvider sets
changingProvider.value = true when starting a provider change but never resets
it, so showSummary (which checks !changingProvider.value) never re-enables;
update the provider-completion flow to set changingProvider.value = false when
the new provider setup completes successfully (e.g., after store.deleteProvider
resolves and after any subsequent successful provider creation/configuration
path), ensuring you clear changingProvider in the success branch alongside
resetting createdProviderName, selectedPreset, testPassed, testResult, and error
so the summary can show again.

In `@web/src/views/SetupPage.vue`:
- Around line 69-74: The click handler currently permits navigating to any index
less than setup.currentStep which can let users jump to earlier but incomplete
steps; update handleStepClick to only allow navigation when the target step is
marked complete or is the current step itself by replacing the condition with a
check that calls setup.setStep(index, steps.value.length) only if
isStepDone(index) || index === setup.currentStep; keep references to
handleStepClick, isStepDone, setup.currentStep, setup.setStep and
steps.value.length so the change is easy to locate.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: 86b12df4-493f-4950-b3a2-f7e186c541b0

📥 Commits

Reviewing files that changed from the base of the PR and between 34bc322 and 9420687.

📒 Files selected for processing (15)
  • CLAUDE.md
  • cli/cmd/update.go
  • cli/internal/config/state.go
  • docs/design/operations.md
  • docs/user_guide.md
  • src/synthorg/observability/events/provider.py
  • src/synthorg/providers/discovery.py
  • src/synthorg/providers/management/service.py
  • tests/unit/providers/management/test_service.py
  • web/src/components/setup/SetupAdmin.vue
  • web/src/components/setup/SetupAgent.vue
  • web/src/components/setup/SetupCompany.vue
  • web/src/components/setup/SetupProvider.vue
  • web/src/stores/setup.ts
  • web/src/views/SetupPage.vue

Comment on lines +42 to +44
function startEditing() {
editing.value = true
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Form fields not pre-populated when entering edit mode.

When startEditing() is called, the form fields (companyName, companyDescription, selectedTemplate) retain their initial empty values rather than being populated from createdResult. The user would need to re-enter all information.

♻️ Proposed fix to pre-populate form fields
 function startEditing() {
+  if (createdResult.value) {
+    companyName.value = createdResult.value.companyName
+    // Note: description and template aren't in createdResult; 
+    // consider storing them or fetching from backend
+  }
   editing.value = true
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function startEditing() {
editing.value = true
}
function startEditing() {
if (createdResult.value) {
companyName.value = createdResult.value.companyName
// Note: description and template aren't in createdResult;
// consider storing them or fetching from backend
}
editing.value = true
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/setup/SetupCompany.vue` around lines 42 - 44,
startEditing() currently only sets editing.value = true so form fields stay
empty; update startEditing() to populate companyName, companyDescription, and
selectedTemplate from createdResult (guarding for null/undefined) before
flipping editing.value to true so the form is pre-filled. In other words, inside
startEditing() read createdResult (or createdResult.value) and assign its
name/description/template into the reactive variables companyName,
companyDescription, selectedTemplate (use default fallbacks), then set
editing.value = true.

Comment on lines +88 to +127
<!-- Completed summary -->
<template v-if="showSummary">
<div class="rounded-lg border border-green-500/20 bg-green-500/10 p-4">
<div class="mb-3 flex items-center gap-2">
<i class="pi pi-check-circle text-xl text-green-400" />
<span class="text-sm font-medium text-green-300">Company created</span>
</div>
<div class="space-y-1 text-sm text-slate-300">
<p>Name: <strong>{{ createdResult?.companyName ?? 'Your Company' }}</strong></p>
<p>Template: {{ createdResult?.templateApplied ?? 'Start Blank' }}</p>
<p>Departments: {{ createdResult?.departmentCount ?? 0 }}</p>
</div>
<Button
label="Edit"
icon="pi pi-pencil"
severity="secondary"
size="small"
outlined
class="mt-3"
@click="startEditing"
/>
</div>

<div
v-if="error"
role="alert"
class="rounded bg-red-500/10 p-3 text-sm text-red-400"
>
{{ error }}
</div>

<div class="flex items-center gap-3">
<div class="mt-8 flex items-center gap-3">
<Button
type="button"
label="Back"
icon="pi pi-arrow-left"
severity="secondary"
outlined
:disabled="creating"
@click="emit('previous')"
/>
<Button
type="submit"
label="Create Company"
icon="pi pi-building"
label="Next"
icon="pi pi-arrow-right"
icon-pos="right"
class="flex-1"
:loading="creating"
:disabled="!isValid"
@click="emit('next', createdResult?.companyName ?? '')"
/>
</div>
</form>
</template>
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Summary view may show stale/empty data after page refresh.

When the user refreshes the page, createdResult is lost (component-local ref) but setup.isStepComplete('company') may still return true (from backend status). This causes:

  1. The summary to display fallback values ("Your Company", 0 departments)
  2. The "Next" button at line 124 to emit an empty string for companyName

Consider fetching the company details from the backend on mount when the step is already complete, or store the created result in the Pinia store for persistence across navigation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/setup/SetupCompany.vue` around lines 88 - 127, The summary
can show stale/fallback values because createdResult is a component-local ref
and lost on refresh; update SetupCompany.vue so that on mounted (or when
showSummary is true) if setup.isStepComplete('company') you fetch the company
details from the backend (or read them from a Pinia store) and populate
createdResult before rendering/allowing the Next action; ensure the emit call in
the Next button uses the fetched createdResult.companyName (not the fallback)
and consider persisting createdResult into the Pinia store when initially
created so navigation/refresh retains the real values.

@gemini-code-assist
Copy link
Copy Markdown
Contributor

Warning

Gemini is experiencing higher than usual traffic and was unable to create the summary. Please try again in a few hours by commenting /gemini summary.

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 20, 2026

Codecov Report

❌ Patch coverage is 87.69231% with 16 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.44%. Comparing base (476ee5f) to head (835b663).
⚠️ Report is 4 commits behind head on main.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/synthorg/providers/probing.py 88.46% 6 Missing and 3 partials ⚠️
src/synthorg/providers/url_utils.py 69.23% 3 Missing and 1 partial ⚠️
src/synthorg/providers/discovery.py 90.90% 1 Missing and 1 partial ⚠️
src/synthorg/providers/management/service.py 92.85% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #642      +/-   ##
==========================================
- Coverage   92.45%   92.44%   -0.02%     
==========================================
  Files         557      559       +2     
  Lines       28168    28229      +61     
  Branches     2705     2715      +10     
==========================================
+ Hits        26043    26095      +52     
- Misses       1675     1680       +5     
- Partials      450      454       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

SSRF: remove auth_type=none trust bypass -- only validated preset
hints grant SSRF bypass now (security-reviewer, code-reviewer).

Python: extract probing.py from discovery.py (888->625 lines),
refactor _fetch_json/_discover_* to shared helpers (<50 lines each),
extract trust resolution and preset discovery helpers in service.py,
rename validated_preset to hint_is_known_preset.

Go: split update.go (942->702 lines) into update_cleanup.go and
update_health.go, fix rollback error logging, Windows \r\n line
splitting, recover->doRecover rename, safeStateDir error surfacing,
remove dead sha256: branch, accept uppercase hex, remove --force
from docker rmi, add 128-char tag limit, add IsValidImageTag tests,
add PRIVATE_KEY/CERT to secret redaction, add Docker health timeout.

Vue: reset changingProvider after success, fix stale company summary,
pre-populate edit forms (company + agent), guard step advance on
statusLoaded, remove console.warn, import RouterLink, tighten step
click nav, add aria-label to password toggles.

Tests: 7 new tests for trust_url logic, SSRF bypass verification,
and IsValidImageTag edge cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/src/components/setup/SetupCompany.vue (1)

73-79: 🧹 Nitpick | 🔵 Trivial

Consider fetching company details on mount for refresh resilience.

After a page refresh, createdResult is lost (component-local ref) but isStepComplete('company') may still be true from backend status. Currently, this causes the form to display (since showSummary requires createdResult !== null), which is acceptable fallback behavior.

For a more polished UX, consider fetching the company details from the backend on mount when the step is already complete, so the summary displays correctly after refresh.

Also applies to: 91-130

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/setup/SetupCompany.vue` around lines 73 - 79, When the
component mounts, if the backend reports isStepComplete('company') true but
createdResult is null, call the setup method that fetches the persisted company
(e.g., add a call like setup.fetchCompanyDetails or setup.getCompany /
setup.fetchCreatedResult) inside the existing onMounted block after
setup.fetchTemplates; handle errors the same way you do for setup.fetchTemplates
(surface via setup.error -> error.value). Also apply the same logic in the other
mount/initialization code path around the showSummary logic (the region
referenced by lines 91-130) so the summary is populated after a page refresh
when createdResult is lost.
♻️ Duplicate comments (2)
web/src/components/setup/SetupCompany.vue (1)

42-47: ⚠️ Potential issue | 🟡 Minor

Incomplete form pre-population when entering edit mode.

The startEditing() function only restores companyName from createdResult, but companyDescription and selectedTemplate are not restored. Users editing a company will see their name but must re-enter the description and re-select the template.

♻️ Proposed enhancement to store and restore all fields
 const createdResult = ref<{
   companyName: string
   templateApplied: string | null
   departmentCount: number
+  description: string | null
 } | null>(null)

 // In handleCreate, store description:
     createdResult.value = {
       companyName: result.company_name,
       templateApplied: result.template_applied,
       departmentCount: result.department_count,
+      description: companyDescription.value.trim() || null,
     }

 // In startEditing:
 function startEditing() {
   if (createdResult.value) {
     companyName.value = createdResult.value.companyName
+    companyDescription.value = createdResult.value.description ?? ''
+    selectedTemplate.value = createdResult.value.templateApplied
   }
   editing.value = true
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/setup/SetupCompany.vue` around lines 42 - 47, startEditing
currently only restores companyName from createdResult, causing
companyDescription and selectedTemplate to be lost when entering edit mode;
update startEditing to also read and set companyDescription.value and
selectedTemplate.value from createdResult.value (e.g.,
createdResult.value.companyDescription and the template identifier/property used
in the component) before setting editing.value = true so all form fields are
pre-populated when editing.
web/src/components/setup/SetupAgent.vue (1)

125-132: ⚠️ Potential issue | 🟡 Minor

selectedPersonality not pre-populated when entering edit mode.

The startEditing() function restores agentName, selectedRole, and selectedModel from savedAgent, but omits selectedPersonality. Since isValid requires selectedPersonality !== null (line 104), the user must re-select personality after clicking Edit, even though it was already configured.

Additionally, selectedProvider is not restored, which may cause the model dropdown to show all providers instead of filtering to the agent's original provider.

🐛 Proposed fix to pre-populate all fields
 function startEditing() {
   if (savedAgent.value) {
     agentName.value = savedAgent.value.name
     selectedRole.value = savedAgent.value.role
+    selectedProvider.value = savedAgent.value.model_provider
     selectedModel.value = `${savedAgent.value.model_provider}::${savedAgent.value.model_id}`
+    // Restore personality if available (may need backend to return it)
+    // selectedPersonality.value = savedAgent.value.personality_preset ?? null
   }
   editing.value = true
 }

Note: The backend response (SetupAgentResponse) needs to include personality_preset for full restoration. If it's not currently returned, consider adding it to the API response.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/src/components/setup/SetupAgent.vue` around lines 125 - 132, startEditing
currently restores agentName, selectedRole and selectedModel from savedAgent but
omits selectedPersonality and selectedProvider, which causes isValid to fail and
the model list to be unfiltered; update startEditing to also set
selectedPersonality.value = savedAgent.value.personality_preset (or personality
field returned by the API) and selectedProvider.value =
savedAgent.value.model_provider when savedAgent exists, and ensure the
API/SetupAgentResponse returns personality_preset if not present so the
personality can be fully restored; keep setting editing.value = true at the end.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cli/cmd/update_cleanup.go`:
- Around line 86-99: collectCurrentImageIDs currently treats an empty idOut as
success and returns a partial currentIDs map, causing findOldImages to
misclassify current images as old; update collectCurrentImageIDs to treat an
empty or whitespace idOut as an error for each svc: after running docker.RunCmd
(in collectCurrentImageIDs) check if strings.TrimSpace(idOut) == "" and if so
return an error (e.g., fmt.Errorf("no current image ID found for %s", svc)) so
callers never receive a partial currentIDs set and findOldImages won't mark
current images for removal.

In `@cli/cmd/update_health.go`:
- Around line 57-69: The Docker probe failure from docker.Detect should not be
treated as a definitive "dirty install" issue; remove adding docker.Detect
errors to the issues slice and only report missing images when
detectMissingImages actually returns missing artifacts. In practice, keep the
short healthCtx and call docker.Detect(healthCtx), but if docker.Detect returns
an error do not append fmt.Sprintf("Docker not available: %v", dockerErr) to
issues — instead treat docker as unavailable (allowing updateContainerImages to
handle it) and only append the formatted missing-image message when
detectMissingImages(healthCtx, info, state) returns a non-empty slice; ensure
the logic around state.ImageTag, detectMissingImages, and promptHealthRecover
remains intact so non-interactive runs are only aborted for confirmed missing
artifacts.

In `@cli/cmd/update.go`:
- Around line 54-59: The recovery path still short-circuits recreate/pull logic
when the stored CLI tag equals the current tag; modify the post-recovery flow so
that when checkInstallationHealth indicates a recover-and-continue scenario you
set a "force recovery" flag and pass it into refreshCompose and
updateContainerImages; change refreshCompose to treat a missing compose.yml as
requiring recreation when force is true (instead of "nothing to refresh") and
change updateContainerImages to skip the early-return on matching stored tag
when force is true (force a re-pull/recreate of images); update calling sites
and signatures for refreshCompose and updateContainerImages accordingly so
recovery triggers recreation even if the version did not change.
- Around line 61-63: Call migrateSettingsKey before running the installation
health check so the migration that populates SettingsKey runs prior to
detectInstallationIssues; specifically, move the migrateSettingsKey(cmd, &state)
invocation to execute before detectInstallationIssues(...) (the health-check
call that currently flags an empty SettingsKey) so older installs with no
SettingsKey are repaired before prompts/aborts.

In `@src/synthorg/providers/discovery.py`:
- Around line 328-339: The code currently always emits the
PROVIDER_MODELS_DISCOVERED event even when all raw entries were malformed;
change the final logger.info call in the discovery function to be conditional:
after computing raw_key, raw_entries, skipped and calling _log_skip_counts, only
call logger.info(PROVIDER_MODELS_DISCOVERED, preset=preset_name,
model_count=len(models)) when not (skipped > 0 and len(models) == 0) (i.e.,
suppress the success event if entries were skipped and no models were parsed) so
that an all_entries_malformed outcome does not also emit a success metric.

In `@src/synthorg/providers/management/service.py`:
- Around line 449-455: The current logic sets trust=True whenever preset_hint
resolves, which re-enables SSRF for arbitrary stored config.base_url; change
discover_models_for_provider to only set trust=True when the stored
config.base_url exactly matches the preset's known safe URLs (the preset's
default_base_url or any entry in candidate_urls) — retrieve the preset object
(the one used by _resolve_discovery_trust or similar lookup), compare
config.base_url against preset.default_base_url and preset.candidate_urls, and
only pass trust=True to discover_models when there is a match; otherwise keep
trust=False and let _validate_discovery_url run normally. Apply the same guarded
check for the other occurrence noted (around the 468-486 block).

In `@src/synthorg/providers/probing.py`:
- Around line 195-209: Change probe_preset_urls so it no longer accepts
arbitrary candidate_urls: have it take only preset_name and internally call
get_preset(preset_name) (from the presets module) to resolve the trusted
candidate URLs (and raise/return an error if the preset is missing), or
alternatively make the function controller-private by renaming it to
_probe_preset_urls and keep callers that already resolve presets; update all
callers of probe_preset_urls to pass only preset_name (or use the new private
name) so the SSRF-trust boundary is enforced in code rather than by docstring.

---

Outside diff comments:
In `@web/src/components/setup/SetupCompany.vue`:
- Around line 73-79: When the component mounts, if the backend reports
isStepComplete('company') true but createdResult is null, call the setup method
that fetches the persisted company (e.g., add a call like
setup.fetchCompanyDetails or setup.getCompany / setup.fetchCreatedResult) inside
the existing onMounted block after setup.fetchTemplates; handle errors the same
way you do for setup.fetchTemplates (surface via setup.error -> error.value).
Also apply the same logic in the other mount/initialization code path around the
showSummary logic (the region referenced by lines 91-130) so the summary is
populated after a page refresh when createdResult is lost.

---

Duplicate comments:
In `@web/src/components/setup/SetupAgent.vue`:
- Around line 125-132: startEditing currently restores agentName, selectedRole
and selectedModel from savedAgent but omits selectedPersonality and
selectedProvider, which causes isValid to fail and the model list to be
unfiltered; update startEditing to also set selectedPersonality.value =
savedAgent.value.personality_preset (or personality field returned by the API)
and selectedProvider.value = savedAgent.value.model_provider when savedAgent
exists, and ensure the API/SetupAgentResponse returns personality_preset if not
present so the personality can be fully restored; keep setting editing.value =
true at the end.

In `@web/src/components/setup/SetupCompany.vue`:
- Around line 42-47: startEditing currently only restores companyName from
createdResult, causing companyDescription and selectedTemplate to be lost when
entering edit mode; update startEditing to also read and set
companyDescription.value and selectedTemplate.value from createdResult.value
(e.g., createdResult.value.companyDescription and the template
identifier/property used in the component) before setting editing.value = true
so all form fields are pre-populated when editing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: 41a3cf68-6b63-47c6-97d8-85d0e89d5ff9

📥 Commits

Reviewing files that changed from the base of the PR and between 9420687 and db4ea69.

📒 Files selected for processing (17)
  • cli/cmd/update.go
  • cli/cmd/update_cleanup.go
  • cli/cmd/update_health.go
  • cli/internal/config/state.go
  • cli/internal/config/state_imagetag_test.go
  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/discovery.py
  • src/synthorg/providers/management/service.py
  • src/synthorg/providers/probing.py
  • tests/unit/api/controllers/test_providers.py
  • tests/unit/providers/management/test_service.py
  • tests/unit/providers/test_discovery.py
  • web/src/components/setup/SetupAdmin.vue
  • web/src/components/setup/SetupAgent.vue
  • web/src/components/setup/SetupCompany.vue
  • web/src/components/setup/SetupProvider.vue
  • web/src/views/SetupPage.vue

SSRF: preset_hint trust now also verifies base_url matches the
preset's candidate_urls or default_base_url -- prevents storing
an arbitrary URL and using a valid preset_hint to bypass SSRF.

Go: empty image ID guard in collectCurrentImageIDs, Docker
unavailability no longer triggers recovery, migration runs before
health check, recovery clears stored tag to force re-pull.

Python: suppress PROVIDER_MODELS_DISCOVERED after all_entries_malformed,
probe_preset_urls resolves own candidates from preset registry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

♻️ Duplicate comments (1)
cli/cmd/update.go (1)

60-74: ⚠️ Potential issue | 🟠 Major

Recovery still doesn't rebuild a missing compose.yml.

Line 73 only forces the later image-update path. refreshCompose() still treats a missing compose file as success, so a “recover” run can still exit with compose.yml missing whenever Docker is unavailable or the user declines the later image prompt. Please thread an explicit recovery/force flag into refreshCompose() instead of relying on state.ImageTag = "".

Also applies to: 78-103, 239-254, 425-429

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/cmd/update.go` around lines 60 - 74, checkInstallationHealth's recovered
boolean is currently used only to clear state.ImageTag, which doesn't force
refreshCompose to rebuild a missing compose.yml; instead add a recovery/force
parameter to refreshCompose (e.g., refreshCompose(ctx, force bool)) and pass the
recovered flag into it wherever refreshCompose is called (including the update
flow around updateContainerImages and the other call sites referenced), so
refreshCompose treats force=true as a hard rebuild of compose.yml rather than a
no-op; remove the implicit reliance on state.ImageTag="" for this behavior and
ensure callers that receive the recovered value from checkInstallationHealth
forward it to refreshCompose.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cli/cmd/update_health.go`:
- Around line 51-56: The code currently treats a missing state.JWTSecret as
recoverable but only offers the existing recovery path which regenerates
images/compose and calls migrateSettingsKey() (which only repairs
state.SettingsKey), leaving the installation unusable; fix by either (preferred)
synthesizing and persisting a new JWT secret before offering recovery (e.g.,
generate a secure secret, assign to state.JWTSecret and persist the state/save
function used elsewhere) so subsequent recovery works, or skip the in-place
"recover" option for the JWT secret and route this case to the interactive
synthorg init flow instead; also ensure similar handling is applied where
JWTSecret is checked in the later block (lines ~86-107) so you don't present an
inappropriate recover option.

In `@cli/cmd/update.go`:
- Around line 445-450: Change restartIfRunning to signal whether the new
revision is actually running: update its signature to return (bool, error) (true
= restarted and passed health checks, false = skipped or failed). Inside
restartIfRunning ensure you return false when the user skips restart or when the
post-restart health check fails; return true only on a successful
restart+healthy result. Update callers (the call near restartIfRunning(...) then
cleanupOldImages(...) and the other similar block around lines 652-674) to check
the bool and only call cleanupOldImages(cmd, info, updatedState) when the bool
is true; propagate errors unchanged.

In `@src/synthorg/providers/probing.py`:
- Around line 47-54: Extract the _redact_url function into a single shared
utility function in synthorg.providers.utils (keep the same signature and
behavior: parsing, hostname/port handling, and replacing query with
"<redacted>"), then replace the local definitions in probing.py and discovery.py
with an import from synthorg.providers.utils; remove the duplicated
implementations, update imports (use from synthorg.providers.utils import
_redact_url or a public name), and run tests/linters to ensure no references
break.

---

Duplicate comments:
In `@cli/cmd/update.go`:
- Around line 60-74: checkInstallationHealth's recovered boolean is currently
used only to clear state.ImageTag, which doesn't force refreshCompose to rebuild
a missing compose.yml; instead add a recovery/force parameter to refreshCompose
(e.g., refreshCompose(ctx, force bool)) and pass the recovered flag into it
wherever refreshCompose is called (including the update flow around
updateContainerImages and the other call sites referenced), so refreshCompose
treats force=true as a hard rebuild of compose.yml rather than a no-op; remove
the implicit reliance on state.ImageTag="" for this behavior and ensure callers
that receive the recovered value from checkInstallationHealth forward it to
refreshCompose.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: 14c2c7e5-13b7-40fb-b931-3bf8c6e099a7

📥 Commits

Reviewing files that changed from the base of the PR and between db4ea69 and 006d3af.

📒 Files selected for processing (8)
  • cli/cmd/update.go
  • cli/cmd/update_cleanup.go
  • cli/cmd/update_health.go
  • src/synthorg/api/controllers/providers.py
  • src/synthorg/providers/discovery.py
  • src/synthorg/providers/management/service.py
  • src/synthorg/providers/probing.py
  • tests/unit/providers/test_discovery.py

Comment on lines +51 to +56
if state.JWTSecret == "" {
issues = append(issues, "JWT secret is not configured")
}
if state.SettingsKey == "" {
issues = append(issues, "settings encryption key is not configured")
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't offer “recover” for a missing JWTSecret.

Lines 51-53 flag this as an installation issue, but the recovery path only regenerates compose/images and migrateSettingsKey() only repairs SettingsKey. If the user accepts recovery here, the command still leaves an unusable config. Either synthesize and persist the JWT secret before prompting, or route this case straight to synthorg init.

Also applies to: 86-107

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/cmd/update_health.go` around lines 51 - 56, The code currently treats a
missing state.JWTSecret as recoverable but only offers the existing recovery
path which regenerates images/compose and calls migrateSettingsKey() (which only
repairs state.SettingsKey), leaving the installation unusable; fix by either
(preferred) synthesizing and persisting a new JWT secret before offering
recovery (e.g., generate a secure secret, assign to state.JWTSecret and persist
the state/save function used elsewhere) so subsequent recovery works, or skip
the in-place "recover" option for the JWT secret and route this case to the
interactive synthorg init flow instead; also ensure similar handling is applied
where JWTSecret is checked in the later block (lines ~86-107) so you don't
present an inappropriate recover option.

Go: restartIfRunning returns (bool, error) -- cleanup only runs
when restart succeeded and health check passed. refreshCompose
takes force parameter for recovery mode (generates missing
compose.yml from template). Remove migrateSettingsKey (no
migration code).

Python: extract _redact_url into shared url_utils.py, remove
duplicate implementations from discovery.py and probing.py.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/synthorg/providers/probing.py (1)

164-182: ⚠️ Potential issue | 🟠 Major

Treat all-malformed model lists as a probe miss.

This has the same failure mode that was already fixed in discovery: _parse_ollama_models() / _parse_standard_models() return (), not None, for payloads like {"models": [42]} or {"data": [42]}. _build_probe_hit() therefore accepts a garbage response as a successful probe with model_count=0 and stops before trying the next candidate.

🛠️ Suggested fix
 def _build_probe_hit(
@@
     Returns:
         Probe result on success, ``None`` if the payload is not a
         recognizable model-listing response.
     """
     if preset_name == "ollama":
+        raw_entries = data.get("models", [])
         models = _parse_ollama_models(data)
     else:
+        raw_entries = data.get("data", [])
         models = _parse_standard_models(data)
 
     if models is None:
         _log_probe_miss(preset_name, "unrecognized_schema", url)
         return None
+    if isinstance(raw_entries, list) and raw_entries and not models:
+        _log_probe_miss(preset_name, "all_entries_malformed", url)
+        return None
 
     logger.info(
         PROVIDER_PROBE_HIT,
         preset=preset_name,
         url=_redact_url(url),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/synthorg/providers/probing.py` around lines 164 - 182, The code only
treats None as a probe miss but _parse_ollama_models/_parse_standard_models can
return an empty tuple or empty list for malformed payloads, so change the check
from "if models is None" to a falsy/emptiness check (e.g., "if not models") so
that empty/invalid model lists are treated as probe misses; keep using
_log_probe_miss(preset_name, "unrecognized_schema", url) and return None, and
ensure the subsequent ProbeResult(...) only runs when models is a non-empty
sequence (referencing preset_name, _parse_ollama_models, _parse_standard_models,
models, _log_probe_miss, url, idx, and ProbeResult).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cli/cmd/update.go`:
- Around line 250-263: When existing == nil and force recovery is requested, the
code assumes fresh contains generated content but loadAndGenerate currently
returns (nil,nil,nil) for a missing compose.yml; update the recovery branch in
the function containing the existing == nil check to explicitly generate the
compose content (call the same generator used by loadAndGenerate or invoke
loadAndGenerate in a mode that forces generation) to populate fresh before
calling atomicWriteFile(composePath, fresh, safeDir), handle and wrap any
generation errors, and then write and log the new compose.yml; ensure you
reference loadAndGenerate (or the underlying generator), composePath, fresh,
atomicWriteFile, and the force flag when implementing the fix so recovery
succeeds when compose.yml is absent.

In `@src/synthorg/providers/url_utils.py`:
- Around line 16-21: The redact_url() implementation accesses parsed.port
directly (can raise ValueError for malformed ports) and rebuilds IPv6 addresses
without brackets, causing exceptions during logging; fix it by guarding
parsed.port in a try/except (treat invalid/ValueError ports as absent), build
safe_netloc from parsed.hostname (or empty) and if hostname contains ':' wrap it
in brackets before appending a valid port, set query to "<redacted>" when
present, and return the rebuilt URL; update the logic inside redact_url() (refer
to parsed, parsed.port, parsed.hostname, safe_netloc and
urlunparse(parsed._replace(...))) so malformed ports are ignored and IPv6
literals are bracketed to avoid the unhandled exception during
_fetch_json()/discover_models() logging.

---

Duplicate comments:
In `@src/synthorg/providers/probing.py`:
- Around line 164-182: The code only treats None as a probe miss but
_parse_ollama_models/_parse_standard_models can return an empty tuple or empty
list for malformed payloads, so change the check from "if models is None" to a
falsy/emptiness check (e.g., "if not models") so that empty/invalid model lists
are treated as probe misses; keep using _log_probe_miss(preset_name,
"unrecognized_schema", url) and return None, and ensure the subsequent
ProbeResult(...) only runs when models is a non-empty sequence (referencing
preset_name, _parse_ollama_models, _parse_standard_models, models,
_log_probe_miss, url, idx, and ProbeResult).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0a9c2ec9-440f-43d3-9af2-f61efcc4015d

📥 Commits

Reviewing files that changed from the base of the PR and between 006d3af and c1d7300.

📒 Files selected for processing (4)
  • cli/cmd/update.go
  • src/synthorg/providers/discovery.py
  • src/synthorg/providers/probing.py
  • src/synthorg/providers/url_utils.py

Go: refreshCompose recovery explicitly generates compose from
template when compose.yml is missing (loadAndGenerate returns
nil for absent files).

Python: redact_url handles IPv6 literals (brackets) and
malformed ports (try/except ValueError). _build_probe_hit
uses falsy check instead of None check to catch empty model
tuples from valid-schema-but-all-malformed payloads.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
cli/cmd/update.go (1)

54-75: ⚠️ Potential issue | 🟠 Major

Don’t clear state.ImageTag before refreshCompose().

Line 68 repurposes state.ImageTag as a force-update flag, but Line 74 passes that same state into refreshCompose(), which regenerates compose.yml from compose.ParamsFromState(state). On installs without persisted digest pins, recovery can now render empty image refs or fail compose generation entirely, so the same-version dirty-state path still can’t self-heal. Keep the original tag for compose generation and pass an explicit forceImageRefresh flag into updateContainerImages() instead.

🐛 Suggested direction
-	if recovered {
-		// Clear stored tag so updateContainerImages does not short-circuit
-		// when the CLI version matches the stored tag but images are gone.
-		state.ImageTag = ""
-	}
+	forceImageRefresh := recovered
...
-		return updateContainerImages(cmd, state, true)
+		return updateContainerImages(cmd, state, true, forceImageRefresh)
...
-	return updateContainerImages(cmd, state, false)
+	return updateContainerImages(cmd, state, false, forceImageRefresh)

Then make updateContainerImages() skip the state.ImageTag == tag early return only when forceImageRefresh is true.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cli/cmd/update.go` around lines 54 - 75, Do not clear state.ImageTag before
calling refreshCompose(cmd, state, recovered); restore the original tag for
compose generation so compose.ParamsFromState(state) can render valid image
refs. Instead, add a boolean parameter (e.g., forceImageRefresh) to
updateContainerImages(...) and, when recovered is true, call
updateContainerImages(..., forceImageRefresh=true). Modify updateContainerImages
to skip its early-return that compares state.ImageTag == tag only when
forceImageRefresh is true; you can still clear or reset state.ImageTag after
refreshCompose if you must preserve the “force” semantics for later steps.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@cli/cmd/update.go`:
- Around line 54-75: Do not clear state.ImageTag before calling
refreshCompose(cmd, state, recovered); restore the original tag for compose
generation so compose.ParamsFromState(state) can render valid image refs.
Instead, add a boolean parameter (e.g., forceImageRefresh) to
updateContainerImages(...) and, when recovered is true, call
updateContainerImages(..., forceImageRefresh=true). Modify updateContainerImages
to skip its early-return that compares state.ImageTag == tag only when
forceImageRefresh is true; you can still clear or reset state.ImageTag after
refreshCompose if you must preserve the “force” semantics for later steps.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro

Run ID: 37b08046-ff67-403d-85d3-b6b3a86005c9

📥 Commits

Reviewing files that changed from the base of the PR and between c1d7300 and f38e4d1.

📒 Files selected for processing (3)
  • cli/cmd/update.go
  • src/synthorg/providers/probing.py
  • src/synthorg/providers/url_utils.py

…mageTag

Clearing state.ImageTag before refreshCompose broke compose
generation (empty image refs). Instead, pass a forceRefresh
flag to updateContainerImages to skip the version-match early
return during recovery, preserving valid state for compose.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Aureliolo Aureliolo merged commit 774e4e4 into main Mar 20, 2026
40 of 42 checks passed
@Aureliolo Aureliolo deleted the fix/update-resilience-and-setup-wizard branch March 20, 2026 18:38
@Aureliolo Aureliolo temporarily deployed to cloudflare-preview March 20, 2026 18:39 — with GitHub Actions Inactive
Aureliolo added a commit that referenced this pull request Mar 20, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.4.1](v0.4.0...v0.4.1)
(2026-03-20)


### Bug Fixes

* CLI update resilience + setup wizard UX overhaul
([#642](#642))
([774e4e4](774e4e4))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: CLI update resilience + setup wizard UX overhaul

2 participants