add in-app "Install clide command in PATH" affordance (T-212)
A command-palette / `clide` CLI verb (`clide.installCli`) copies the bundled C client into ~/.local/bin, VS Code "Install code command" style, so a user who runs the app without `make install` can still reach the CLI from a shell. On launch the app detects when `clide` is missing from PATH or resolves to a stale symlink into the Flutter GUI bundle (the dogfood footgun: a bare `clide` launched a second app instead of querying the socket) and notifies with a pointer to the command. `make build` now ships the C client inside the app bundle so the affordance can self-install from it; `make run` points it at the dev-tree client via CLIDE_CLI_BIN. Detection and the copy live in the Flutter-free CliInstaller (kernel/src/cli_install.dart) so they run under unit tests without a real install. Closes epic T-209 (ship the clide CLI on PATH). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
/// T-212: the cli-install extension wires the Flutter-free [CliInstaller] to
|
||||
/// a palette/CLI command and proactive launch-time notifications.
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/builtin/cli_install/cli_install.dart';
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/kernel/src/cli_install.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
late Directory tmp;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
tmp = Directory.systemTemp.createTempSync('clide_cli_ext_');
|
||||
});
|
||||
tearDown(() async {
|
||||
await f.dispose();
|
||||
tmp.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
File touchExec(String path, {String contents = '#!/bin/sh\n'}) {
|
||||
final file = File(path)
|
||||
..createSync(recursive: true)
|
||||
..writeAsStringSync(contents);
|
||||
if (!Platform.isWindows) Process.runSync('chmod', ['755', path]);
|
||||
return file;
|
||||
}
|
||||
|
||||
/// Register + activate the extension with an injected installer.
|
||||
Future<void> boot(CliInstaller installer) async {
|
||||
f.services.extensions.register(CliInstallExtension(installer: installer));
|
||||
await f.services.extensions.activate('builtin.cli-install');
|
||||
}
|
||||
|
||||
test('identifies itself', () {
|
||||
final ext = CliInstallExtension();
|
||||
expect(ext.id, 'builtin.cli-install');
|
||||
expect(ext.title, 'CLI Install');
|
||||
expect(ext.version, '0.1.0');
|
||||
});
|
||||
|
||||
test('contributes the install command', () async {
|
||||
await boot(CliInstaller(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': '${tmp.path}/bin'},
|
||||
bundledClientCandidates: const [],
|
||||
installDir: '${tmp.path}/bin',
|
||||
));
|
||||
expect(f.services.commands.get('clide.installCli'), isNotNull);
|
||||
});
|
||||
|
||||
test('warns on activation when clide is missing from PATH', () async {
|
||||
await boot(CliInstaller(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': '${tmp.path}/empty'},
|
||||
bundledClientCandidates: const [],
|
||||
installDir: '${tmp.path}/bin',
|
||||
));
|
||||
final notes = f.services.notify.active;
|
||||
expect(notes, isNotEmpty);
|
||||
expect(notes.first.level, NotificationLevel.warning);
|
||||
expect(notes.first.title, 'clide CLI not installed');
|
||||
});
|
||||
|
||||
test('does not warn when clide is already installed', () async {
|
||||
final binDir = Directory('${tmp.path}/bin')..createSync();
|
||||
touchExec('${binDir.path}/clide');
|
||||
await boot(CliInstaller(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': binDir.path},
|
||||
bundledClientCandidates: const [],
|
||||
installDir: binDir.path,
|
||||
));
|
||||
expect(f.services.notify.active, isEmpty);
|
||||
});
|
||||
|
||||
test('running the command installs the client and reports success', () async {
|
||||
final src = touchExec('${tmp.path}/bundle/clide-cli');
|
||||
final binDir = '${tmp.path}/bin';
|
||||
await boot(CliInstaller(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': binDir},
|
||||
bundledClientCandidates: [src.path],
|
||||
installDir: binDir,
|
||||
));
|
||||
final r = await f.services.commands.execute('clide.installCli');
|
||||
expect(r.ok, isTrue);
|
||||
expect(r.data['installed'], '$binDir/clide');
|
||||
expect(File('$binDir/clide').existsSync(), isTrue);
|
||||
expect(
|
||||
f.services.notify.active.any((n) => n.level == NotificationLevel.success && n.title == 'clide CLI installed'),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
test('running the command surfaces a tool error when nothing to install', () async {
|
||||
await boot(CliInstaller(
|
||||
resolvedExecutable: '${tmp.path}/gui/clide',
|
||||
env: {'PATH': '${tmp.path}/bin'},
|
||||
bundledClientCandidates: const [],
|
||||
installDir: '${tmp.path}/bin',
|
||||
));
|
||||
final r = await f.services.commands.execute('clide.installCli');
|
||||
expect(r.ok, isFalse);
|
||||
expect(r.error!.kind, IpcErrorKind.toolError);
|
||||
expect(
|
||||
f.services.notify.active.any((n) => n.level == NotificationLevel.error),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user