refactor(env): per-tool override keys for supporter binaries (T-495)
Store each tool's override under its own key (app.tools.<name>) instead of one map, so the settings panel can bind a plain text field per tool. loadSupporterBinaries reads the per-tool keys plus a first-run marker and keeps an explicit path; redetectSupporterBinaries backs the Re-detect action. The resolver itself is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+2
-2
@@ -119,8 +119,8 @@ Future<void> main() async {
|
|||||||
// overrides first, else the primed PATH + the well-known dirs; auto-detect
|
// overrides first, else the primed PATH + the well-known dirs; auto-detect
|
||||||
// and pin them on first run (T-495, D-104).
|
// and pin them on first run (T-495, D-104).
|
||||||
activeSupporterBinaries = await loadSupporterBinaries(
|
activeSupporterBinaries = await loadSupporterBinaries(
|
||||||
readOverrides: () => bootSettings.get<Map>(supporterToolsKey),
|
read: (k) => bootSettings.get<Object>(k),
|
||||||
writeOverrides: (m) => bootSettings.set(supporterToolsKey, m),
|
write: (k, v) => bootSettings.set<Object?>(k, v),
|
||||||
);
|
);
|
||||||
startupWorkRoot = resolveStartupWorkspace(
|
startupWorkRoot = resolveStartupWorkspace(
|
||||||
cwdRoot: startupWorkRoot,
|
cwdRoot: startupWorkRoot,
|
||||||
|
|||||||
Vendored
+49
-20
@@ -97,34 +97,63 @@ bool _fileExists(String p) => File(p).existsSync();
|
|||||||
/// headless contexts where boot wiring hasn't run.
|
/// headless contexts where boot wiring hasn't run.
|
||||||
SupporterBinaries? activeSupporterBinaries;
|
SupporterBinaries? activeSupporterBinaries;
|
||||||
|
|
||||||
/// The user-scope SettingsStore key (app layer, `~/.clide`, per-machine) holding
|
/// User-scope SettingsStore key (app layer, `~/.clide`, per-machine) holding the
|
||||||
/// the tool→absolute-path override map.
|
/// explicit override path for tool [name]. One key per tool so the settings
|
||||||
const supporterToolsKey = 'app.tools';
|
/// 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
|
/// The external supporter binaries clide auto-detects (pql/git are bundled per
|
||||||
/// D-58/D-59 and excluded).
|
/// D-58/D-59 and excluded).
|
||||||
const knownSupporterTools = ['claude', 'd2'];
|
const knownSupporterTools = ['claude', 'd2'];
|
||||||
|
|
||||||
/// Build a [SupporterBinaries] from persisted overrides, auto-detecting on first
|
/// Build a [SupporterBinaries] from the per-tool override keys, auto-detecting
|
||||||
/// run (D-104). [readOverrides] returns the stored map (or null if unset);
|
/// the as-yet-unconfigured tools ONCE on first run and pinning what it finds
|
||||||
/// [writeOverrides] persists a freshly-detected one. Injected (not the
|
/// (D-104). [read] returns a stored value (or null); [write] persists one.
|
||||||
/// SettingsStore directly) so this stays Flutter-free and `dart test`-able;
|
/// Injected (not the SettingsStore directly) so this stays Flutter-free and
|
||||||
/// [detect] is the prober (defaults to a real [SupporterBinaries]).
|
/// `dart test`-able; [detect] is the prober.
|
||||||
Future<SupporterBinaries> loadSupporterBinaries({
|
Future<SupporterBinaries> loadSupporterBinaries({
|
||||||
required Map<dynamic, dynamic>? Function() readOverrides,
|
required Object? Function(String key) read,
|
||||||
required Future<void> Function(Map<String, String>) writeOverrides,
|
required Future<void> Function(String key, Object? value) write,
|
||||||
List<String> tools = knownSupporterTools,
|
List<String> tools = knownSupporterTools,
|
||||||
SupporterBinaries Function()? detect,
|
SupporterBinaries Function()? detect,
|
||||||
}) async {
|
}) async {
|
||||||
final raw = readOverrides();
|
final overrides = _readOverrides(read, tools);
|
||||||
if (raw != null) return SupporterBinaries(overrides: _asStringMap(raw));
|
if (read(supporterDetectedKey) == null) {
|
||||||
// First run: probe once, persist (pinned), and use the result.
|
// First run: probe the tools without an explicit path, pin what's found.
|
||||||
final detected = (detect?.call() ?? SupporterBinaries()).detect(tools);
|
final fresh = (detect?.call() ?? SupporterBinaries()).detect(tools.where((t) => !overrides.containsKey(t)));
|
||||||
await writeOverrides(detected);
|
for (final e in fresh.entries) {
|
||||||
return SupporterBinaries(overrides: detected);
|
await write(supporterToolKey(e.key), e.value);
|
||||||
|
overrides[e.key] = e.value;
|
||||||
|
}
|
||||||
|
await write(supporterDetectedKey, true);
|
||||||
|
}
|
||||||
|
return SupporterBinaries(overrides: overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, String> _asStringMap(Map<dynamic, dynamic> raw) => {
|
/// Re-probe every tool and overwrite its override key (clearing one no longer
|
||||||
for (final e in raw.entries)
|
/// found), returning a fresh resolver. Backs the "Re-detect" settings action.
|
||||||
if (e.key is String && e.value is String) e.key as String: e.value as String,
|
Future<SupporterBinaries> redetectSupporterBinaries({
|
||||||
};
|
required Future<void> Function(String key, Object? value) write,
|
||||||
|
List<String> 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<String, String> _readOverrides(Object? Function(String) read, List<String> tools) {
|
||||||
|
final overrides = <String, String>{};
|
||||||
|
for (final t in tools) {
|
||||||
|
final v = read(supporterToolKey(t));
|
||||||
|
if (v is String && v.isNotEmpty) overrides[t] = v;
|
||||||
|
}
|
||||||
|
return overrides;
|
||||||
|
}
|
||||||
|
|||||||
+30
-19
@@ -61,37 +61,48 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('loadSupporterBinaries', () {
|
group('loadSupporterBinaries', () {
|
||||||
test('first run detects the tools and persists the result', () async {
|
test('first run detects the tools and pins each under its own key', () async {
|
||||||
Map<String, String>? written;
|
final store = <String, Object?>{};
|
||||||
final probe = SupporterBinaries(exists: {'/usr/bin/claude'}.contains, searchPath: () => '/usr/bin', home: '/h');
|
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);
|
await loadSupporterBinaries(read: (k) => store[k], write: (k, v) async => store[k] = v, tools: ['claude', 'd2'], detect: () => probe);
|
||||||
expect(written, {'claude': '/usr/bin/claude'});
|
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 {
|
test('a subsequent run reads the keys without re-detecting', () async {
|
||||||
Map<String, String>? written;
|
final store = <String, Object?>{'app.tools.detected': true, 'app.tools.d2': '/pin/d2'};
|
||||||
await loadSupporterBinaries(
|
var detected = false;
|
||||||
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(
|
final r = await loadSupporterBinaries(
|
||||||
readOverrides: () => {'d2': '/pin/d2'},
|
read: (k) => store[k],
|
||||||
writeOverrides: (m) async => wrote = true,
|
write: (k, v) async => store[k] = v,
|
||||||
detect: () {
|
detect: () {
|
||||||
detected = true;
|
detected = true;
|
||||||
return SupporterBinaries();
|
return SupporterBinaries();
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
expect(wrote, isFalse);
|
|
||||||
expect(detected, isFalse);
|
expect(detected, isFalse);
|
||||||
// The stored override loaded — /pin/d2 isn't a real file, so it reads stale.
|
// The stored override loaded — /pin/d2 isn't a real file, so it reads stale.
|
||||||
expect(r.isStalePin('d2'), isTrue);
|
expect(r.isStalePin('d2'), isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('first run keeps an explicit path and does not overwrite it', () async {
|
||||||
|
final store = <String, Object?>{'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 = <String, Object?>{'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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user