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:
2026-06-06 12:05:41 +02:00
co-authored by Claude Opus 4.8
parent 02a19d10f5
commit 562c17c06d
10 changed files with 696 additions and 4 deletions
@@ -460,3 +460,5 @@ list, ship our own curated, on-brand list instead. Document the decision.
- Touch point today is the `widget.busy` branch in `claude_composer.dart`; consider
whether the rotating-word state belongs there or in the session/orchestrator layer.
- Add a widget/golden test for the animated states (bounded pumps — no real timers).', NULL, '2026-06-06 09:57:12', '2026-06-06 09:57:12', '2026-06-06 09:57:12', NULL, '586f397ed5d0f14a0e7d510237d54ea2', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-212', 'status', 'in_progress', 'done', NULL, '2026-06-06 10:02:03', '2026-06-06 10:02:03', '2026-06-06 10:02:03', NULL, 'c0174026e93366bda336a58bfdaec999', 1) ON CONFLICT(hash) DO NOTHING;
INSERT INTO ticket_history (ticket_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-209', 'status', 'in_progress', 'done', NULL, '2026-06-06 10:02:06', '2026-06-06 10:02:06', '2026-06-06 10:02:06', NULL, 'b66cdca1e3acedc775ec2e25e9509cd6', 1) ON CONFLICT(hash) DO NOTHING;
+8
View File
@@ -985,3 +985,11 @@ list, ship our own curated, on-brand list instead. Document the decision.
- Touch point today is the `widget.busy` branch in `claude_composer.dart`; consider
whether the rotating-word state belongs there or in the session/orchestrator layer.
- Add a widget/golden test for the animated states (bounded pumps no real timers).', 'backlog', 'medium', NULL, NULL, NULL, '2026-06-06 09:56:23', '2026-06-06 09:57:12', NULL, '17a3feaae4f5481f212578f0174fd09a', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-212', 'task', 'T-209', 'In-app ''Install clide command in PATH'' affordance', 'VS Code-style: the GUI offers to install the `clide` shell command on PATH, for users who run the .app without `make install`. A command + command-palette entry that copies/symlinks the bundled C client to a PATH dir and reports success/failure. Acceptance: invoking it makes `clide` resolve on PATH from a fresh shell.
Refinement (2026-06-03, from live dogfooding): scope should include PROACTIVE detection on launch, not just a palette command. When the clide IDE starts in a repo, check whether ''clide'' resolves on PATH AND points to the C client (not a stale symlink to the GUI bundle runner) -- we hit exactly this: ~/.local/bin/clide was a May-6 symlink to ~/.local/lib/clide/clide (the Flutter GUI), so a bare ''clide pane list'' launched a second app instead of querying. If missing or stale, prompt/offer to install (copy the bundled C client to a PATH dir, VS Code ''Install code command'' style) and report success. This is what lets a fresh agent actually reach the CLI (D-83 names the hosted session primary, but an external agent benefits too). Detecting ''stale GUI symlink'' specifically: the target should be an ELF/Mach-O executable, not a symlink into the bundle. Alternative path the user raised: instead of/alongside this, make the /ide MCP surface reachable (T-225) -- but CLI is primary per D-68.', 'done', 'medium', NULL, NULL, NULL, '2026-06-02 18:13:52', '2026-06-06 10:02:03', NULL, 'cc452389e195d3efa2f0244e74444768', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
INSERT INTO tickets (id, type, parent_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('T-209', 'epic', 'T-208', 'Ship the clide CLI on PATH', 'Gap 1 (BLOCKER) from self-analysis.md. The C client native/clide-cli/clide.c (T-126, done) is the real CLI, but there is no supported way for `clide` to land on an agent''s PATH:
- `make clide-cli` (Makefile:284) is a non-default target; `build` (Makefile:169) does not depend on it, so the binary is never produced by a normal build (native/macos-arm64/clide had never been built).
- `make install` (Makefile:195) copies the GUI bundle and `ln -sf $(INSTALL_PREFIX)/clide/clide $(INSTALL_DIR)/clide` (Makefile:200) symlinks the FLUTTER GUI runner not the C client on BOTH macOS and Linux (on macOS that path doesn''t even resolve inside clide.app).
Net: `which clide` -> not found, `clide ping` -> exit 127. The CLI-first contract (D-1) has no delivery mechanism. Related to the broader installer epic T-46, but that is the GUI/runtime installer; this epic is specifically C-client-on-PATH delivery.', 'done', 'high', NULL, NULL, 'D-1', '2026-06-02 18:13:52', '2026-06-06 10:02:06', NULL, '7a173eae965eedcfaf742707873651aa', 1) ON CONFLICT(id) DO UPDATE SET type=excluded.type, parent_id=excluded.parent_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at > tickets.updated_at OR (excluded.updated_at = tickets.updated_at AND excluded.hash > tickets.hash);
+9
View File
@@ -18,6 +18,15 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
### Added
- "Install 'clide' command in PATH": a command-palette / `clide` CLI verb
(`clide.installCli`) that 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 now
detects when `clide` is missing from PATH, or resolves to a stale symlink
into the Flutter GUI bundle (which would launch a second app instead of
querying the socket), and notifies with a pointer to the command. `make
build` ships the C client inside the app bundle so the affordance can
self-install from it. (T-212)
- `clide ui open diff <path>` reveals the diff in a split above the Claude
conversation, scrolls to that file and highlights its header — the
diff-panel arm of `ui open`. Workspace tabs other than Claude/editor now
+12 -3
View File
@@ -32,11 +32,14 @@ help: ## Show this help.
# -- app (Flutter) -------------------------------------------------------
.PHONY: run
run: gen-build-info ## Launch the Flutter desktop app.
run: gen-build-info clide-cli ## Launch the Flutter desktop app.
# CLIDE_CLI_BIN points the in-app "Install clide command in PATH"
# affordance (T-212) at the dev-tree C client; a packaged build finds it
# beside the GUI runner in the bundle instead.
ifeq ($(FLUTTER_OS),linux)
GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_PROJECT=$(CURDIR)
CLIDE_CLI_BIN=$(CURDIR)/$(CLIDE_CLI_BIN) GDK_BACKEND=x11 LD_LIBRARY_PATH=$(CURDIR)/native/linux-x64$${LD_LIBRARY_PATH:+:$$LD_LIBRARY_PATH} flutter run -d linux --dart-define=CLIDE_PROJECT=$(CURDIR)
else
flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_PROJECT=$(CURDIR)
CLIDE_CLI_BIN=$(CURDIR)/$(CLIDE_CLI_BIN) flutter run -d $(FLUTTER_OS) --dart-define=CLIDE_PROJECT=$(CURDIR)
endif
TESTMODE_CATEGORY ?= all
@@ -168,6 +171,8 @@ ui-smoke: ## Build + serve + run Playwright smoke + stop.
.PHONY: build
build: gen-build-info clide-cli ## flutter build for the current OS (incl. the C CLI client).
flutter build $(FLUTTER_OS)
@install -m 755 $(CLIDE_CLI_BIN) $(CLI_BUNDLE_DEST)
@echo "==> bundled C client at $(CLI_BUNDLE_DEST)"
.PHONY: build-linux
build-linux: gen-build-info ## flutter build linux (desktop bundle).
@@ -185,8 +190,12 @@ INSTALL_PREFIX ?= $(HOME)/.local/lib
ifeq ($(FLUTTER_OS),linux)
BUNDLE_DIR := build/linux/x64/release/bundle
# The C client ships beside the GUI runner so the in-app "Install clide
# command in PATH" affordance (T-212) can self-install from the bundle.
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/clide-cli
else ifeq ($(FLUTTER_OS),macos)
BUNDLE_DIR := build/macos/Build/Products/Release/clide.app
CLI_BUNDLE_DEST := $(BUNDLE_DIR)/Contents/MacOS/clide-cli
endif
ICON_SIZES := 16 32 48 128 192 256 512
+1
View File
@@ -0,0 +1 @@
export 'src/extension.dart';
@@ -0,0 +1,84 @@
import 'dart:io';
import 'package:clide/clide.dart';
import 'package:clide/extension/extension.dart';
import 'package:clide/kernel/src/cli_install.dart';
/// VS Code-style "Install 'clide' command in PATH" affordance (T-212).
///
/// Registers a palette/CLI command that copies the bundled C client into a
/// PATH dir, and on activation proactively warns when `clide` is missing from
/// PATH or — the dogfood footgun — resolves to a stale symlink into the
/// Flutter GUI bundle instead of the C client. Detection and the copy live in
/// the Flutter-free [CliInstaller]; this extension only wires it to the
/// command and notification surfaces.
class CliInstallExtension extends ClideExtension {
CliInstallExtension({CliInstaller? installer}) : _installer = installer;
CliInstaller? _installer;
ClideExtensionContext? _ctx;
CliInstaller get _resolved => _installer ??= CliInstaller(resolvedExecutable: Platform.resolvedExecutable);
@override
String get id => 'builtin.cli-install';
@override
String get title => 'CLI Install';
@override
String get version => '0.1.0';
@override
Future<void> activate(ClideExtensionContext ctx) async {
_ctx = ctx;
// Proactive launch-time detection (desktop only). Non-modal: we notify
// and point at the command rather than auto-installing — no surprise
// filesystem writes (interaction-zone discipline, D-78).
if (!(Platform.isLinux || Platform.isMacOS)) return;
switch (_resolved.inspect().state) {
case CliInstallState.missing:
ctx.notify.warn(
'The `clide` command is not on your PATH. Run "clide: Install '
'command in PATH" from the command palette to reach it from a shell.',
title: 'clide CLI not installed',
);
case CliInstallState.staleGui:
ctx.notify.warn(
'`clide` on your PATH points at the GUI app, not the CLI client — '
'a bare `clide` launches a second app. Run "clide: Install command '
'in PATH" to replace it.',
title: 'clide CLI is stale',
);
case CliInstallState.installed:
break;
}
}
@override
List<ContributionPoint> get contributions => [
CommandContribution(
id: 'clide.installCli',
command: 'clide.installCli',
title: "clide: Install 'clide' command in PATH",
run: (_) async {
final r = _resolved.install();
final ctx = _ctx;
if (r.ok) {
ctx?.notify.success(r.message, title: 'clide CLI installed');
return IpcResponse.ok(id: '', data: {
'installed': r.installedPath,
'onPath': r.onPath,
});
}
ctx?.notify.error(r.message, title: 'clide CLI install failed');
return IpcResponse.err(
id: '',
error: IpcError(
code: IpcExitCode.toolError,
kind: IpcErrorKind.toolError,
message: r.message,
),
);
},
),
];
}
+223
View File
@@ -0,0 +1,223 @@
/// Flutter-free logic for the "Install clide command in PATH" affordance
/// (T-212).
///
/// Detects whether the `clide` shell command resolves on PATH and points at
/// the real C client — not a stale symlink to the Flutter GUI runner, the
/// exact footgun a live dogfood hit: a `~/.local/bin/clide` symlink into the
/// GUI bundle launched a *second* app instead of querying the IPC socket. The
/// installer copies the bundled C client into a PATH dir, VS Code
/// "Install code command" style.
///
/// Kept Flutter-free (only `dart:io`) so it runs under `dart test`; the
/// builtin extension wraps it with the command + notification surfaces.
library;
import 'dart:io';
/// State of the `clide` shell command relative to the running GUI.
enum CliInstallState {
/// No `clide` resolves on PATH.
missing,
/// `clide` resolves but points at the Flutter GUI runner, not the C
/// client — running it launches a second app instead of querying.
staleGui,
/// `clide` resolves to something that is not the GUI — assumed good.
installed,
}
/// Result of [CliInstaller.inspect]: what `clide` on PATH points at.
class CliInstallStatus {
const CliInstallStatus(this.state, {this.pathEntry, this.resolvedTarget});
final CliInstallState state;
/// The `clide` entry found on PATH (the symlink/file itself), if any.
final String? pathEntry;
/// Where [pathEntry] resolves to after following symlinks, if any.
final String? resolvedTarget;
/// True when the user should (re)install — missing or stale.
bool get needsInstall => state != CliInstallState.installed;
}
/// Result of [CliInstaller.install].
class CliInstallResult {
const CliInstallResult({
required this.ok,
required this.message,
this.installedPath,
this.onPath = true,
});
final bool ok;
final String message;
final String? installedPath;
/// False when [installedPath]'s directory is not itself on PATH (the copy
/// succeeded but the user must add the dir to PATH to reach `clide`).
final bool onPath;
}
/// Copies the bundled C client onto PATH and reports what `clide` currently
/// resolves to. Every external dependency (the running executable, the
/// environment, candidate client locations, the target dir) is injectable so
/// the logic is unit-testable without a real install.
class CliInstaller {
CliInstaller({
required this.resolvedExecutable,
Map<String, String>? env,
List<String>? bundledClientCandidates,
String? installDir,
}) : env = env ?? Platform.environment,
bundledClientCandidates = bundledClientCandidates ?? _defaultBundledCandidates(resolvedExecutable, env ?? Platform.environment),
installDir = installDir ?? _defaultInstallDir(env ?? Platform.environment);
/// Path to the running Flutter GUI executable
/// (`Platform.resolvedExecutable`).
final String resolvedExecutable;
final Map<String, String> env;
/// Ordered locations to look for the bundled C client to install from.
final List<String> bundledClientCandidates;
/// Directory the C client is installed into (created if absent).
final String installDir;
/// First bundled C client candidate that exists, or null.
String? findBundledClient() {
for (final c in bundledClientCandidates) {
if (c.isNotEmpty && File(c).existsSync()) return c;
}
return null;
}
/// Inspect the current state of `clide` on PATH. Filesystem-only — never
/// execs the binary, since exec'ing a stale GUI symlink is exactly the bug
/// this guards against (it would launch a second app).
CliInstallStatus inspect() {
final found = _findOnPath('clide');
if (found == null) return const CliInstallStatus(CliInstallState.missing);
final resolved = _resolve(found);
if (_isGui(resolved)) {
return CliInstallStatus(CliInstallState.staleGui, pathEntry: found, resolvedTarget: resolved);
}
return CliInstallStatus(CliInstallState.installed, pathEntry: found, resolvedTarget: resolved);
}
/// Copy the bundled C client to `<installDir>/clide` (overwriting any stale
/// entry) and mark it executable.
CliInstallResult install() {
final src = findBundledClient();
if (src == null) {
return const CliInstallResult(
ok: false,
message: 'No bundled clide client found to install. Build with '
'`make build` so the C client ships inside the app bundle.',
);
}
final dest = '${_normalize(installDir)}/clide';
try {
Directory(installDir).createSync(recursive: true);
// Delete any existing entry first so a stale symlink (e.g. one into
// the GUI bundle) is replaced, not followed. typeSync never throws —
// a missing path reports notFound.
if (FileSystemEntity.typeSync(dest, followLinks: false) != FileSystemEntityType.notFound) {
File(dest).deleteSync();
}
File(src).copySync(dest);
_chmodExec(dest);
} on FileSystemException catch (e) {
return CliInstallResult(ok: false, message: 'Install failed: ${e.message}', installedPath: dest);
}
final onPath = _dirOnPath(installDir);
return CliInstallResult(
ok: true,
installedPath: dest,
onPath: onPath,
message: onPath ? 'Installed clide to $dest' : 'Installed clide to $dest — add $installDir to your PATH to use it.',
);
}
/// True when [path] is, or sits inside, the Flutter GUI bundle. The C
/// client is a standalone binary; the GUI runner ships alongside Flutter's
/// asset payload (`data/flutter_assets`) or inside a macOS `.app`.
bool _isGui(String path) {
if (path == _resolve(resolvedExecutable)) return true;
final dir = File(path).parent.path;
if (Directory('$dir/data/flutter_assets').existsSync()) return true;
if (path.contains('.app/Contents/')) return true;
return false;
}
String _resolve(String path) {
try {
return File(path).resolveSymbolicLinksSync();
} on FileSystemException {
return path;
}
}
void _chmodExec(String path) {
if (Platform.isWindows) return;
// dart:io has no chmod; copySync drops the source's +x under umask.
Process.runSync('chmod', ['755', path]);
}
bool _dirOnPath(String dir) {
final norm = _normalize(dir);
return _expandedPath().split(':').any((d) => d.isNotEmpty && _normalize(d) == norm);
}
String _normalize(String p) => p.length > 1 && p.endsWith('/') ? p.substring(0, p.length - 1) : p;
String? _findOnPath(String name) {
for (final dir in _expandedPath().split(':')) {
if (dir.isEmpty) continue;
final f = File('$dir/$name');
if (f.existsSync()) return f.path;
}
return null;
}
String _expandedPath() => expandedPath(
env['PATH'] ?? '',
macOS: Platform.isMacOS,
home: env['HOME'] ?? '',
);
static String _defaultInstallDir(Map<String, String> env) => '${env['HOME'] ?? ''}/.local/bin';
/// Where to find the C client to install from: a `CLIDE_CLI_BIN` dev
/// override first, then `<exe-dir>/clide-cli` — where `make build` drops it
/// inside the bundle (next to the GUI runner on Linux, in
/// `Contents/MacOS/` on macOS).
static List<String> _defaultBundledCandidates(String resolvedExecutable, Map<String, String> env) {
final exeDir = File(resolvedExecutable).parent.path;
return [
if ((env['CLIDE_CLI_BIN'] ?? '').isNotEmpty) env['CLIDE_CLI_BIN']!,
'$exeDir/clide-cli',
];
}
}
/// Expand a `PATH` value. Mirrors `toolchain_paths.dart`: macOS GUI apps
/// launch with a sparse PATH that omits the usual user/homebrew bins, so on
/// macOS we prepend those (de-duplicated) before scanning. A top-level,
/// platform-parameterized function so both branches are testable off-platform.
String expandedPath(String base, {required bool macOS, String home = ''}) {
if (!macOS) return base;
final extras = <String>[
if (home.isNotEmpty) '$home/.local/bin',
'/opt/homebrew/bin',
'/opt/homebrew/sbin',
'/usr/local/bin',
];
final existing = base.split(':').toSet();
final missing = extras.where((p) => !existing.contains(p));
if (missing.isEmpty) return base;
return [...missing, ...existing].join(':');
}
+3 -1
View File
@@ -5,6 +5,7 @@ import 'package:clide/test_app.dart';
import 'package:clide/builtin/canvas/canvas.dart';
import 'package:clide/builtin/claude/claude.dart';
import 'package:clide/builtin/claude_control/claude_control.dart';
import 'package:clide/builtin/cli_install/cli_install.dart';
import 'package:clide/builtin/decisions/decisions.dart';
import 'package:clide/builtin/default_layout/default_layout.dart';
import 'package:clide/builtin/diff/diff.dart';
@@ -375,7 +376,8 @@ Future<void> main() async {
..register(SettingsUiExtension())
..register(ExtensionsUiExtension())
..register(KeybindingsUiExtension())
..register(ClaudeControlExtension());
..register(ClaudeControlExtension())
..register(CliInstallExtension());
await services.extensions.activateAll();
@@ -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,
);
});
}
+236
View File
@@ -0,0 +1,236 @@
import 'dart:io';
import 'package:clide/kernel/src/cli_install.dart';
import 'package:flutter_test/flutter_test.dart';
/// Pure-logic tests for the "Install clide command in PATH" affordance
/// (T-212). All I/O is synchronous against temp dirs — every external
/// dependency of [CliInstaller] is injected, so no real install is touched.
///
/// Linux-only assertions: the resolver mirrors `toolchain_paths.dart`, whose
/// macOS branch injects homebrew/local bins into PATH; gating to Linux keeps
/// the expanded-PATH behaviour deterministic. The CI host is Linux.
void main() {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('clide_cli_install_'));
tearDown(() => tmp.deleteSync(recursive: true));
/// A stand-in executable file at [path], created with the given [contents].
File touchExec(String path, {String contents = '#!/bin/sh\n'}) {
final f = File(path)
..createSync(recursive: true)
..writeAsStringSync(contents);
if (!Platform.isWindows) Process.runSync('chmod', ['755', path]);
return f;
}
CliInstaller installer({
required String resolvedExecutable,
required Map<String, String> env,
List<String>? candidates,
String? installDir,
}) =>
CliInstaller(
resolvedExecutable: resolvedExecutable,
env: env,
bundledClientCandidates: candidates,
installDir: installDir,
);
group('findBundledClient', () {
test('returns the first candidate that exists', () {
final present = '${tmp.path}/clide-cli';
touchExec(present);
final i = installer(
resolvedExecutable: '${tmp.path}/clide',
env: {'PATH': ''},
candidates: ['${tmp.path}/missing', present],
);
expect(i.findBundledClient(), present);
});
test('returns null when no candidate exists', () {
final i = installer(
resolvedExecutable: '${tmp.path}/clide',
env: {'PATH': ''},
candidates: ['${tmp.path}/nope'],
);
expect(i.findBundledClient(), isNull);
});
});
group('inspect', () {
test('missing when no clide on PATH', () {
final binDir = Directory('${tmp.path}/bin')..createSync();
final i = installer(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': binDir.path},
);
expect(i.inspect().state, CliInstallState.missing);
});
test('installed when clide is a plain non-GUI binary', () {
final binDir = Directory('${tmp.path}/bin')..createSync();
touchExec('${binDir.path}/clide');
final i = installer(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': binDir.path},
);
final s = i.inspect();
expect(s.state, CliInstallState.installed);
expect(s.pathEntry, '${binDir.path}/clide');
});
test('staleGui when clide symlinks to the running GUI executable', () {
final gui = touchExec('${tmp.path}/gui/clide').path;
final binDir = Directory('${tmp.path}/bin')..createSync();
Link('${binDir.path}/clide').createSync(gui);
final i = installer(
resolvedExecutable: gui,
env: {'PATH': binDir.path},
);
final s = i.inspect();
expect(s.state, CliInstallState.staleGui);
expect(s.needsInstall, isTrue);
});
test('staleGui when clide sits beside a flutter_assets payload', () {
// A GUI bundle: runner next to data/flutter_assets/.
final bundle = Directory('${tmp.path}/bundle')..createSync();
Directory('${bundle.path}/data/flutter_assets').createSync(recursive: true);
touchExec('${bundle.path}/clide');
final binDir = Directory('${tmp.path}/bin')..createSync();
Link('${binDir.path}/clide').createSync('${bundle.path}/clide');
final i = installer(
resolvedExecutable: '${tmp.path}/other/clide', // unrelated GUI path
env: {'PATH': binDir.path},
);
expect(i.inspect().state, CliInstallState.staleGui);
});
});
group('defaults', () {
test('install dir and bundled candidates derive from env + exe dir', () {
final i = CliInstaller(
resolvedExecutable: '/opt/clide/bundle/clide',
env: const {'HOME': '/home/dev', 'CLIDE_CLI_BIN': '/dev/tree/clide'},
);
expect(i.installDir, '/home/dev/.local/bin');
// CLIDE_CLI_BIN override first, then the in-bundle path.
expect(i.bundledClientCandidates, ['/dev/tree/clide', '/opt/clide/bundle/clide-cli']);
});
test('omits the CLIDE_CLI_BIN candidate when unset', () {
final i = CliInstaller(
resolvedExecutable: '/opt/clide/bundle/clide',
env: const {'HOME': '/home/dev'},
);
expect(i.bundledClientCandidates, ['/opt/clide/bundle/clide-cli']);
});
});
group('expandedPath', () {
test('non-macOS returns PATH unchanged', () {
expect(expandedPath('/a:/b', macOS: false, home: '/home/x'), '/a:/b');
});
test('macOS prepends missing user + homebrew bins', () {
final out = expandedPath('/usr/bin', macOS: true, home: '/home/x').split(':');
expect(out, contains('/home/x/.local/bin'));
expect(out, contains('/opt/homebrew/bin'));
expect(out.last, '/usr/bin');
});
test('macOS does not duplicate entries already on PATH', () {
final out = expandedPath('/opt/homebrew/bin:/usr/bin', macOS: true, home: '');
expect('/opt/homebrew/bin'.allMatches(out).length, 1);
});
});
group('install', () {
test('fails clearly when no bundled client is present', () {
final i = installer(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': ''},
candidates: ['${tmp.path}/none'],
installDir: '${tmp.path}/bin',
);
final r = i.install();
expect(r.ok, isFalse);
expect(r.message, contains('No bundled clide client'));
});
test('copies the client, marks it executable, reports onPath', () {
final src = touchExec('${tmp.path}/bundle/clide-cli', contents: '#!/bin/sh\necho hi\n');
final binDir = '${tmp.path}/bin';
final i = installer(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': binDir},
candidates: [src.path],
installDir: binDir,
);
final r = i.install();
expect(r.ok, isTrue);
expect(r.onPath, isTrue);
expect(r.installedPath, '$binDir/clide');
final dest = File('$binDir/clide');
expect(dest.existsSync(), isTrue);
expect(dest.readAsStringSync(), src.readAsStringSync());
if (!Platform.isWindows) {
final mode = dest.statSync().mode;
expect(mode & 0x49, 0x49, reason: 'owner/group/other +x bits set');
}
});
test('overwrites a stale symlink rather than following it', () {
final src = touchExec('${tmp.path}/bundle/clide-cli', contents: 'NEW');
final gui = touchExec('${tmp.path}/gui/clide', contents: 'GUI').path;
final binDir = '${tmp.path}/bin';
Directory(binDir).createSync();
Link('$binDir/clide').createSync(gui); // stale symlink into the GUI
final i = installer(
resolvedExecutable: gui,
env: {'PATH': binDir},
candidates: [src.path],
installDir: binDir,
);
expect(i.install().ok, isTrue);
// The GUI binary must be untouched; the bin entry is now a real file.
expect(File(gui).readAsStringSync(), 'GUI');
expect(FileSystemEntity.isLinkSync('$binDir/clide'), isFalse);
expect(File('$binDir/clide').readAsStringSync(), 'NEW');
});
test('reports a FileSystemException as a failed result', () {
final src = touchExec('${tmp.path}/bundle/clide-cli');
// installDir path is occupied by a regular file → createSync throws.
final blocker = '${tmp.path}/blocked';
File(blocker).writeAsStringSync('not a dir');
final i = installer(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': ''},
candidates: [src.path],
installDir: blocker,
);
final r = i.install();
expect(r.ok, isFalse);
expect(r.message, contains('Install failed'));
});
test('reports onPath:false when the install dir is not on PATH', () {
final src = touchExec('${tmp.path}/bundle/clide-cli');
final installDir = '${tmp.path}/elsewhere';
final i = installer(
resolvedExecutable: '${tmp.path}/gui/clide',
env: {'PATH': '${tmp.path}/somewhere-else'},
candidates: [src.path],
installDir: installDir,
);
final r = i.install();
expect(r.ok, isTrue);
expect(r.onPath, isFalse);
expect(r.message, contains('add $installDir to your PATH'));
});
});
}