Skip to content

Commit da29e23

Browse files
committed
fix(config): prevent "app won't reopen" crash after deleting last provider
Installed v0.1.0 became unlaunchable if the user deleted providers down to an empty state: the app wrote activeModel='' to config.toml, readConfig rejected it on next boot (zod min(1)), loadConfigOnBoot threw, and the main process died before any BrowserWindow could be created — so clicking the dock icon did nothing. Three-part fix: - packages/shared/src/config.ts — drop the .min(1) invariant on activeProvider and activeModel. Empty strings are the LEGAL "no active provider" state; downstream consumers (toState, resolveActiveCredentials, Settings UI) already branch on hasKey/undefined-entry for it. Making the empty state unrepresentable on disk was the trap. - apps/desktop/src/main/onboarding-ipc.ts — in runDeleteProvider's nextActive === null branch, reset activeProvider to '' too (previously it preserved cfg.activeProvider, leaving a dangling reference to the just-deleted provider id). - apps/desktop/src/main/config.ts — validate with ConfigV3Schema.parse BEFORE writing. Write-time validation converts silent disk corruption into a surfaced error on the originating action, where it can be retried or reported. User impact: installed-app unstick requires one-time manual edit of ~/.config/open-codesign/config.toml (set activeModel to any non-empty string). After upgrading to the next build, the empty state survives round-tripping cleanly.
1 parent bc32b4b commit da29e23

3 files changed

Lines changed: 21 additions & 3 deletions

File tree

apps/desktop/src/main/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
44
import {
55
CodesignError,
66
type Config,
7+
ConfigV3Schema,
78
parseConfigFlexible,
89
toPersistedV3,
910
} from '@open-codesign/shared';
@@ -69,6 +70,11 @@ function safeParseConfig(
6970

7071
export async function writeConfig(config: Config): Promise<void> {
7172
const persisted = toPersistedV3(config);
73+
// Fail fast on shape drift at write-time instead of letting a broken
74+
// config land on disk and crash the NEXT boot. This is how the v0.1
75+
// "app won't reopen after deleting all providers" bug shipped —
76+
// activeModel='' was written here, then readConfig's parse rejected it.
77+
ConfigV3Schema.parse(persisted);
7278
const dir = configDir();
7379
await mkdir(dir, { recursive: true });
7480
const path = configPath();

apps/desktop/src/main/onboarding-ipc.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,9 +325,14 @@ async function runDeleteProvider(raw: unknown): Promise<ProviderRow[]> {
325325
const { nextActive, modelPrimary } = computeDeleteProviderResult(cfg, raw);
326326

327327
if (nextActive === null) {
328+
// All providers gone. Reset BOTH activeProvider and activeModel to ''
329+
// so the config doesn't carry a dangling reference to the just-deleted
330+
// provider id (which was the old bug: the app would boot next time
331+
// with activeProvider='openrouter' pointing at a missing entry and
332+
// activeModel='' failing zod's min(1)).
328333
const emptyNext: Config = hydrateConfig({
329334
version: 3,
330-
activeProvider: cfg.activeProvider,
335+
activeProvider: '',
331336
activeModel: '',
332337
secrets: {},
333338
providers: nextProviders,

packages/shared/src/config.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,15 @@ export const BUILTIN_PROVIDERS: Readonly<Record<SupportedOnboardingProvider, Pro
124124
*/
125125
export const ConfigV3Schema = z.object({
126126
version: z.literal(3),
127-
activeProvider: z.string().min(1),
128-
activeModel: z.string().min(1),
127+
// `activeProvider` / `activeModel` are ALLOWED to be empty: that's the
128+
// legal "no active provider" state the app lands in once the last
129+
// provider is deleted. Consumers (`toState`, `resolveActiveCredentials`,
130+
// Settings UI) already branch on hasKey/undefined-entry for this case.
131+
// The previous `.min(1)` invariant made the empty state unrepresentable
132+
// on disk — writing it succeeded but the next boot rejected the file,
133+
// hanging the main process before the window could open.
134+
activeProvider: z.string(),
135+
activeModel: z.string(),
129136
secrets: z.record(z.string(), SecretRef).default({}),
130137
providers: z.record(z.string(), ProviderEntrySchema).default({}),
131138
designSystem: StoredDesignSystem.optional(),

0 commit comments

Comments
 (0)