diff --git a/lib/main.dart b/lib/main.dart index c4f72141..01c7d3b4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -119,8 +119,8 @@ Future main() async { // overrides first, else the primed PATH + the well-known dirs; auto-detect // and pin them on first run (T-495, D-104). activeSupporterBinaries = await loadSupporterBinaries( - readOverrides: () => bootSettings.get(supporterToolsKey), - writeOverrides: (m) => bootSettings.set(supporterToolsKey, m), + read: (k) => bootSettings.get(k), + write: (k, v) => bootSettings.set(k, v), ); startupWorkRoot = resolveStartupWorkspace( cwdRoot: startupWorkRoot, diff --git a/lib/src/env/supporter_binaries.dart b/lib/src/env/supporter_binaries.dart index a1755df9..8a2fef4b 100644 --- a/lib/src/env/supporter_binaries.dart +++ b/lib/src/env/supporter_binaries.dart @@ -97,34 +97,63 @@ bool _fileExists(String p) => File(p).existsSync(); /// headless contexts where boot wiring hasn't run. SupporterBinaries? activeSupporterBinaries; -/// The user-scope SettingsStore key (app layer, `~/.clide`, per-machine) holding -/// the tool→absolute-path override map. -const supporterToolsKey = 'app.tools'; +/// User-scope SettingsStore key (app layer, `~/.clide`, per-machine) holding the +/// explicit override path for tool [name]. One key per tool so the settings +/// panel binds a plain text field to each (T-414 / D-104). +String supporterToolKey(String name) => 'app.tools.$name'; + +/// Marker key recording that first-run auto-detection has run, so a later launch +/// neither re-probes nor clobbers a path the user cleared on purpose. +const supporterDetectedKey = 'app.tools.detected'; /// The external supporter binaries clide auto-detects (pql/git are bundled per /// D-58/D-59 and excluded). const knownSupporterTools = ['claude', 'd2']; -/// Build a [SupporterBinaries] from persisted overrides, auto-detecting on first -/// run (D-104). [readOverrides] returns the stored map (or null if unset); -/// [writeOverrides] persists a freshly-detected one. Injected (not the -/// SettingsStore directly) so this stays Flutter-free and `dart test`-able; -/// [detect] is the prober (defaults to a real [SupporterBinaries]). +/// Build a [SupporterBinaries] from the per-tool override keys, auto-detecting +/// the as-yet-unconfigured tools ONCE on first run and pinning what it finds +/// (D-104). [read] returns a stored value (or null); [write] persists one. +/// Injected (not the SettingsStore directly) so this stays Flutter-free and +/// `dart test`-able; [detect] is the prober. Future loadSupporterBinaries({ - required Map? Function() readOverrides, - required Future Function(Map) writeOverrides, + required Object? Function(String key) read, + required Future Function(String key, Object? value) write, List tools = knownSupporterTools, SupporterBinaries Function()? detect, }) async { - final raw = readOverrides(); - if (raw != null) return SupporterBinaries(overrides: _asStringMap(raw)); - // First run: probe once, persist (pinned), and use the result. - final detected = (detect?.call() ?? SupporterBinaries()).detect(tools); - await writeOverrides(detected); - return SupporterBinaries(overrides: detected); + final overrides = _readOverrides(read, tools); + if (read(supporterDetectedKey) == null) { + // First run: probe the tools without an explicit path, pin what's found. + final fresh = (detect?.call() ?? SupporterBinaries()).detect(tools.where((t) => !overrides.containsKey(t))); + for (final e in fresh.entries) { + await write(supporterToolKey(e.key), e.value); + overrides[e.key] = e.value; + } + await write(supporterDetectedKey, true); + } + return SupporterBinaries(overrides: overrides); } -Map _asStringMap(Map raw) => { - for (final e in raw.entries) - if (e.key is String && e.value is String) e.key as String: e.value as String, -}; +/// Re-probe every tool and overwrite its override key (clearing one no longer +/// found), returning a fresh resolver. Backs the "Re-detect" settings action. +Future redetectSupporterBinaries({ + required Future Function(String key, Object? value) write, + List tools = knownSupporterTools, + SupporterBinaries Function()? detect, +}) async { + final fresh = (detect?.call() ?? SupporterBinaries()).detect(tools); + for (final t in tools) { + await write(supporterToolKey(t), fresh[t]); + } + await write(supporterDetectedKey, true); + return SupporterBinaries(overrides: fresh); +} + +Map _readOverrides(Object? Function(String) read, List tools) { + final overrides = {}; + for (final t in tools) { + final v = read(supporterToolKey(t)); + if (v is String && v.isNotEmpty) overrides[t] = v; + } + return overrides; +} diff --git a/test/src/env/supporter_binaries_test.dart b/test/src/env/supporter_binaries_test.dart index b1b7275f..23829465 100644 --- a/test/src/env/supporter_binaries_test.dart +++ b/test/src/env/supporter_binaries_test.dart @@ -61,37 +61,48 @@ void main() { }); group('loadSupporterBinaries', () { - test('first run detects the tools and persists the result', () async { - Map? written; + test('first run detects the tools and pins each under its own key', () async { + final store = {}; final probe = SupporterBinaries(exists: {'/usr/bin/claude'}.contains, searchPath: () => '/usr/bin', home: '/h'); - await loadSupporterBinaries(readOverrides: () => null, writeOverrides: (m) async => written = m, tools: ['claude', 'd2'], detect: () => probe); - expect(written, {'claude': '/usr/bin/claude'}); + await loadSupporterBinaries(read: (k) => store[k], write: (k, v) async => store[k] = v, tools: ['claude', 'd2'], detect: () => probe); + expect(store['app.tools.claude'], '/usr/bin/claude'); + expect(store.containsKey('app.tools.d2'), isFalse); // not found → not written + expect(store['app.tools.detected'], isTrue); // first-run marker }); - test('first run with nothing found still persists (marks first-run done)', () async { - Map? written; - await loadSupporterBinaries( - readOverrides: () => null, - writeOverrides: (m) async => written = m, - detect: () => SupporterBinaries(exists: (_) => false, searchPath: () => '/usr/bin'), - ); - expect(written, isEmpty); - }); - - test('a subsequent run uses stored overrides without detecting or writing', () async { - var wrote = false, detected = false; + test('a subsequent run reads the keys without re-detecting', () async { + final store = {'app.tools.detected': true, 'app.tools.d2': '/pin/d2'}; + var detected = false; final r = await loadSupporterBinaries( - readOverrides: () => {'d2': '/pin/d2'}, - writeOverrides: (m) async => wrote = true, + read: (k) => store[k], + write: (k, v) async => store[k] = v, detect: () { detected = true; return SupporterBinaries(); }, ); - expect(wrote, isFalse); expect(detected, isFalse); // The stored override loaded — /pin/d2 isn't a real file, so it reads stale. expect(r.isStalePin('d2'), isTrue); }); + + test('first run keeps an explicit path and does not overwrite it', () async { + final store = {'app.tools.d2': '/my/d2'}; // set, no marker yet + final probe = SupporterBinaries(exists: {'/usr/bin/d2'}.contains, searchPath: () => '/usr/bin'); + await loadSupporterBinaries(read: (k) => store[k], write: (k, v) async => store[k] = v, tools: ['d2'], detect: () => probe); + expect(store['app.tools.d2'], '/my/d2'); + expect(store['app.tools.detected'], isTrue); + }); + }); + + group('redetectSupporterBinaries', () { + test('overwrites every key from a fresh probe, clearing a vanished tool', () async { + final store = {'app.tools.claude': '/old/claude', 'app.tools.d2': '/old/d2'}; + final probe = SupporterBinaries(exists: {'/usr/bin/claude'}.contains, searchPath: () => '/usr/bin'); + await redetectSupporterBinaries(write: (k, v) async => store[k] = v, tools: ['claude', 'd2'], detect: () => probe); + expect(store['app.tools.claude'], '/usr/bin/claude'); + expect(store['app.tools.d2'], isNull); // no longer found → cleared + expect(store['app.tools.detected'], isTrue); + }); }); }