feat(env): first-run auto-detect + persist for supporter binaries (T-495)

loadSupporterBinaries reads the persisted tool→path override map; on first
run (key absent) it probes the known tools once and persists the result —
pinned thereafter, not re-detected each launch (D-104). Injected read/write
so it stays Flutter-free and dart-test covered (3 new cases). Boot wiring
next.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-28 23:05:49 +02:00
co-authored by Claude Opus 4.8
parent dab0b3194e
commit 0adccc5cd1
2 changed files with 67 additions and 0 deletions
+32
View File
@@ -91,3 +91,35 @@ class SupporterBinaries {
}
bool _fileExists(String p) => File(p).existsSync();
/// The user-scope SettingsStore key (app layer, `~/.clide`, per-machine) holding
/// the tool→absolute-path override map.
const supporterToolsKey = 'app.tools';
/// 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]).
Future<SupporterBinaries> loadSupporterBinaries({
required Map<dynamic, dynamic>? Function() readOverrides,
required Future<void> Function(Map<String, String>) writeOverrides,
List<String> 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);
}
Map<String, String> _asStringMap(Map<dynamic, dynamic> raw) => {
for (final e in raw.entries)
if (e.key is String && e.value is String) e.key as String: e.value as String,
};
+35
View File
@@ -59,4 +59,39 @@ void main() {
expect(r.detect(['d2'])['d2'], '/usr/bin/d2');
});
});
group('loadSupporterBinaries', () {
test('first run detects the tools and persists the result', () async {
Map<String, String>? written;
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'});
});
test('first run with nothing found still persists (marks first-run done)', () async {
Map<String, String>? 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;
final r = await loadSupporterBinaries(
readOverrides: () => {'d2': '/pin/d2'},
writeOverrides: (m) async => wrote = true,
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);
});
});
}