diff --git a/.pql/changelog/ticket_history/2026-06.sql b/.pql/changelog/ticket_history/2026-06.sql index 9bed9d1c..e01dd7a0 100644 --- a/.pql/changelog/ticket_history/2026-06.sql +++ b/.pql/changelog/ticket_history/2026-06.sql @@ -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; diff --git a/.pql/changelog/tickets/2026-06.sql b/.pql/changelog/tickets/2026-06.sql index bb039269..b55622f7 100644 --- a/.pql/changelog/tickets/2026-06.sql +++ b/.pql/changelog/tickets/2026-06.sql @@ -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); diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cf2126e..f6fc6022 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 diff --git a/Makefile b/Makefile index cc727ad2..43ba366e 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/lib/builtin/cli_install/cli_install.dart b/lib/builtin/cli_install/cli_install.dart new file mode 100644 index 00000000..b968b883 --- /dev/null +++ b/lib/builtin/cli_install/cli_install.dart @@ -0,0 +1 @@ +export 'src/extension.dart'; diff --git a/lib/builtin/cli_install/src/extension.dart b/lib/builtin/cli_install/src/extension.dart new file mode 100644 index 00000000..bfbc8b39 --- /dev/null +++ b/lib/builtin/cli_install/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 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 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, + ), + ); + }, + ), + ]; +} diff --git a/lib/kernel/src/cli_install.dart b/lib/kernel/src/cli_install.dart new file mode 100644 index 00000000..5b46d76d --- /dev/null +++ b/lib/kernel/src/cli_install.dart @@ -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? env, + List? 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 env; + + /// Ordered locations to look for the bundled C client to install from. + final List 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 `/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 env) => '${env['HOME'] ?? ''}/.local/bin'; + + /// Where to find the C client to install from: a `CLIDE_CLI_BIN` dev + /// override first, then `/clide-cli` — where `make build` drops it + /// inside the bundle (next to the GUI runner on Linux, in + /// `Contents/MacOS/` on macOS). + static List _defaultBundledCandidates(String resolvedExecutable, Map 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 = [ + 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(':'); +} diff --git a/lib/main.dart b/lib/main.dart index 78ec26f6..f2374fd9 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 main() async { ..register(SettingsUiExtension()) ..register(ExtensionsUiExtension()) ..register(KeybindingsUiExtension()) - ..register(ClaudeControlExtension()); + ..register(ClaudeControlExtension()) + ..register(CliInstallExtension()); await services.extensions.activateAll(); diff --git a/test/builtin/cli_install/cli_install_extension_test.dart b/test/builtin/cli_install/cli_install_extension_test.dart new file mode 100644 index 00000000..9eb2b74b --- /dev/null +++ b/test/builtin/cli_install/cli_install_extension_test.dart @@ -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 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, + ); + }); +} diff --git a/test/kernel/cli_install_test.dart b/test/kernel/cli_install_test.dart new file mode 100644 index 00000000..2f85107a --- /dev/null +++ b/test/kernel/cli_install_test.dart @@ -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 env, + List? 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')); + }); + }); +}