A Welcome 'Setup assistant' button opens a wizard that collects scenario / intent / target (+ approval & data-local toggles), then calls `chain init --answers` to preview the assembled plan and `--apply --force` to write the config — reusing the Rust deterministic engine, no logic duplicated. New SystemActions.chainInit; copyable errors via showFaiProcessError; EN+DE l10n. analyze clean; smoke test + existing welcome/sidebar tests pass. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
282 lines
11 KiB
Dart
282 lines
11 KiB
Dart
// System-level actions Studio invokes by spawning the `fai`
|
|
// binary or asking the OS to open a file. Used by Doctor's
|
|
// daemon-control + open-paths affordances so Windows operators
|
|
// who never touch a shell can restart the daemon, apply an
|
|
// update, or open a log file from inside the GUI.
|
|
//
|
|
// Cross-platform notes:
|
|
// - macOS uses `open` for files + URLs.
|
|
// - Linux uses `xdg-open`.
|
|
// - Windows uses `explorer` (file manager) and a quoted CLI
|
|
// call for processes.
|
|
//
|
|
// The `fai` binary is resolved from PATH first; falls back to
|
|
// `~/.chain/bin/chain` (Unix) or `%USERPROFILE%\.chain\bin\chain.exe`
|
|
// (Windows). The PowerShell installer puts both in PATH but
|
|
// some operators don't restart their shell after install — the
|
|
// fallback covers that case.
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
/// Sentinel returned in `_runFai(...).stderr` when no `fai`
|
|
/// binary could be located. UI layers should not display this
|
|
/// verbatim — they check [SystemActions.chainBinaryExists] first
|
|
/// and render a localized, actionable message instead.
|
|
const String kFaiBinaryNotFound = 'fai-binary-not-found';
|
|
|
|
/// Getting-started / install guide for the Ch∆In platform. Opened
|
|
/// in the OS browser as the fallback affordance when no `fai`
|
|
/// binary can be located on the machine.
|
|
const String kFaiInstallDocsUrl =
|
|
'https://git.flemming.ai/fai/chain#installation';
|
|
|
|
class SystemActions {
|
|
SystemActions._();
|
|
|
|
/// SharedPreferences key for an operator-chosen `fai` binary
|
|
/// path (set via the file picker when auto-detection fails).
|
|
static const String _kFaiBinaryPrefKey = 'system.chain_binary_path';
|
|
|
|
/// In-memory cache of the operator override, loaded once at
|
|
/// startup by [loadFaiBinaryOverride]. Null = no override.
|
|
static String? _faiBinaryOverride;
|
|
|
|
/// Restore the persisted `fai` binary override (if any) so it
|
|
/// is available before the first daemon action. Call once at
|
|
/// app start, alongside the other persisted-state loaders.
|
|
static Future<void> loadFaiBinaryOverride() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final p = prefs.getString(_kFaiBinaryPrefKey);
|
|
if (p != null && p.isNotEmpty && File(p).existsSync()) {
|
|
_faiBinaryOverride = p;
|
|
}
|
|
}
|
|
|
|
/// Persist an operator-chosen `fai` binary path. Used by the
|
|
/// "Locate `fai` binary…" affordance when auto-detection on
|
|
/// PATH and the canonical install dir both come up empty.
|
|
/// Returns false (and persists nothing) when [path] is empty
|
|
/// or does not point at an existing file.
|
|
static Future<bool> setFaiBinaryPath(String path) async {
|
|
if (path.isEmpty || !File(path).existsSync()) return false;
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString(_kFaiBinaryPrefKey, path);
|
|
_faiBinaryOverride = path;
|
|
return true;
|
|
}
|
|
|
|
/// True when a `fai` binary can be located (override, PATH, or
|
|
/// the canonical install dir). UI uses this to decide between
|
|
/// the normal daemon-control affordances and the "locate the
|
|
/// binary / read the install guide" recovery path.
|
|
static bool chainBinaryExists() => _faiExecutable() != null;
|
|
|
|
/// Ask the OS to open [path] in the default handler. On macOS
|
|
/// this opens text files in TextEdit, configs in the registered
|
|
/// editor, etc. Returns true on a clean spawn (process exited
|
|
/// non-zero is still surfaced as false).
|
|
static Future<({bool ok, String stderr})> openInOs(String path) async {
|
|
if (path.isEmpty) {
|
|
return (ok: false, stderr: 'empty path');
|
|
}
|
|
final cmd = _openCommand();
|
|
try {
|
|
final r = await Process.run(cmd.executable, [...cmd.args, path]);
|
|
if (r.exitCode == 0) return (ok: true, stderr: '');
|
|
return (ok: false, stderr: r.stderr.toString().trim());
|
|
} catch (e) {
|
|
return (ok: false, stderr: e.toString());
|
|
}
|
|
}
|
|
|
|
/// Reveal [path] in the platform file manager. Works for binary
|
|
/// files (the SQLite audit DB, the PID file) where `openInOs`
|
|
/// would fall flat — macOS refuses to "open" a file with no
|
|
/// registered app. macOS uses `open -R`; Windows
|
|
/// `explorer /select,`; Linux falls back to opening the parent
|
|
/// directory because there is no portable reveal-and-highlight.
|
|
static Future<({bool ok, String stderr})> revealInOs(String path) async {
|
|
if (path.isEmpty) {
|
|
return (ok: false, stderr: 'empty path');
|
|
}
|
|
try {
|
|
final ProcessResult r;
|
|
if (Platform.isMacOS) {
|
|
r = await Process.run('open', ['-R', path]);
|
|
} else if (Platform.isWindows) {
|
|
// /select, requires no space after the comma; explorer
|
|
// returns 1 even on success, so don't gate on exitCode.
|
|
r = await Process.run('explorer', ['/select,$path']);
|
|
return (ok: true, stderr: '');
|
|
} else {
|
|
// Linux: open the containing directory in the default
|
|
// file manager. Best portable approximation.
|
|
final parent = File(path).parent.path;
|
|
r = await Process.run('xdg-open', [parent]);
|
|
}
|
|
if (r.exitCode == 0) return (ok: true, stderr: '');
|
|
return (ok: false, stderr: r.stderr.toString().trim());
|
|
} catch (e) {
|
|
return (ok: false, stderr: e.toString());
|
|
}
|
|
}
|
|
|
|
/// Convenience: reveal binary files, open directories /
|
|
/// text-shaped paths normally. Doctor's path rows use this so
|
|
/// the operator never has to know which strategy fits which
|
|
/// kind of file.
|
|
static Future<({bool ok, String stderr})> openOrReveal(
|
|
String path, {
|
|
required bool isDirectory,
|
|
}) async {
|
|
if (isDirectory) return openInOs(path);
|
|
return revealInOs(path);
|
|
}
|
|
|
|
/// Run a `chain daemon ...` subcommand and surface the captured
|
|
/// output. Used by Doctor's "Restart daemon" button.
|
|
static Future<({bool ok, String stdout, String stderr})> chainDaemon(
|
|
List<String> args,
|
|
) async {
|
|
return _runFai(['daemon', ...args]);
|
|
}
|
|
|
|
/// Run a `chain init ...` subcommand. Used by the guided-setup
|
|
/// wizard to preview a plan (`--answers <file>`) and apply it
|
|
/// (`--answers <file> --apply --force`).
|
|
static Future<({bool ok, String stdout, String stderr})> chainInit(
|
|
List<String> args,
|
|
) async {
|
|
return _runFai(['init', ...args]);
|
|
}
|
|
|
|
/// Run `chain update apply --channel <c>`. Long-running on a slow
|
|
/// network — caller should show a spinner.
|
|
static Future<({bool ok, String stdout, String stderr})> chainUpdateApply(
|
|
String channel,
|
|
) async {
|
|
return _runFai(['update', 'apply', '--channel', channel]);
|
|
}
|
|
|
|
/// Switch the active channel pointer at `~/.chain/current-channel`.
|
|
/// The CLI also restarts the daemon for the new channel, so the
|
|
/// caller does not need a follow-up restart.
|
|
static Future<({bool ok, String stdout, String stderr})> chainChannelSwitch(
|
|
String channel,
|
|
) async {
|
|
return _runFai(['channel', 'switch', channel]);
|
|
}
|
|
|
|
/// Install the platform-native autostart unit for [channel]
|
|
/// (launchd plist on macOS, systemd-user unit on Linux). On
|
|
/// Windows the platform CLI returns a "not supported" exit
|
|
/// status that the caller surfaces as an error message.
|
|
static Future<({bool ok, String stdout, String stderr})> chainDaemonEnable(
|
|
String channel,
|
|
) async {
|
|
return _runFai(['daemon', 'enable', '--channel', channel]);
|
|
}
|
|
|
|
/// Remove the autostart unit installed by [chainDaemonEnable].
|
|
static Future<({bool ok, String stdout, String stderr})> chainDaemonDisable(
|
|
String channel,
|
|
) async {
|
|
return _runFai(['daemon', 'disable', '--channel', channel]);
|
|
}
|
|
|
|
/// Run `chain reset --yes`. Wipes operator state with the same
|
|
/// safety net as the CLI: stops every daemon (including off-
|
|
/// channel orphans), atomically backs `~/.chain/` up, recreates
|
|
/// it with `bin/`, `channels/`, `config.yaml`, `current-channel`,
|
|
/// and `registry-token` preserved. The daemon restarts on the
|
|
/// active channel before the call returns.
|
|
///
|
|
/// Studio's gRPC channel goes down for ~1s while the daemon
|
|
/// restarts — caller should follow up with a reconnect.
|
|
static Future<({bool ok, String stdout, String stderr})> chainReset({
|
|
bool keepModules = false,
|
|
bool keepData = false,
|
|
}) async {
|
|
final args = <String>['reset', '--yes'];
|
|
if (keepModules) args.add('--keep-modules');
|
|
if (keepData) args.add('--keep-data');
|
|
return _runFai(args);
|
|
}
|
|
|
|
static Future<({bool ok, String stdout, String stderr})> _runFai(
|
|
List<String> args,
|
|
) async {
|
|
final exe = _faiExecutable();
|
|
if (exe == null) {
|
|
// Sentinel, not a user-facing string. Callers detect this
|
|
// via `chainBinaryExists()` and render a localized,
|
|
// actionable recovery message (locate binary / install
|
|
// guide) instead of CLI jargon like "set CHAIN_BIN".
|
|
return (ok: false, stdout: '', stderr: kFaiBinaryNotFound);
|
|
}
|
|
try {
|
|
final r = await Process.run(exe, args);
|
|
return (
|
|
ok: r.exitCode == 0,
|
|
stdout: r.stdout.toString(),
|
|
stderr: r.stderr.toString(),
|
|
);
|
|
} catch (e) {
|
|
return (ok: false, stdout: '', stderr: e.toString());
|
|
}
|
|
}
|
|
|
|
static ({String executable, List<String> args}) _openCommand() {
|
|
if (Platform.isMacOS) return (executable: 'open', args: const []);
|
|
if (Platform.isWindows) return (executable: 'explorer', args: const []);
|
|
return (executable: 'xdg-open', args: const []);
|
|
}
|
|
|
|
/// Locate the `fai` binary. Order: operator override (set via
|
|
/// the file picker), $CHAIN_BIN, PATH, fallback to the canonical
|
|
/// install location under the user's home dir.
|
|
static String? _faiExecutable() {
|
|
final override = _faiBinaryOverride;
|
|
if (override != null &&
|
|
override.isNotEmpty &&
|
|
File(override).existsSync()) {
|
|
return override;
|
|
}
|
|
|
|
final fromEnv = Platform.environment['CHAIN_BIN'];
|
|
if (fromEnv != null && fromEnv.isNotEmpty && File(fromEnv).existsSync()) {
|
|
return fromEnv;
|
|
}
|
|
|
|
final isWindows = Platform.isWindows;
|
|
// Post-rename the entry-point binary on PATH is `chain`; still
|
|
// accept a legacy `fai` for installs that predate the rename.
|
|
final fromPath = _whichFai(isWindows ? 'chain.exe' : 'chain') ??
|
|
_whichFai(isWindows ? 'fai.exe' : 'fai');
|
|
if (fromPath != null) return fromPath;
|
|
|
|
final home = Platform.environment[isWindows ? 'USERPROFILE' : 'HOME'];
|
|
if (home == null || home.isEmpty) return null;
|
|
final fallback = isWindows
|
|
? '$home\\.chain\\bin\\chain.exe'
|
|
: '$home/.chain/bin/chain';
|
|
return File(fallback).existsSync() ? fallback : null;
|
|
}
|
|
|
|
/// Tiny PATH walker — we cannot rely on `which`/`where` being
|
|
/// reachable via Process.run on every platform.
|
|
static String? _whichFai(String name) {
|
|
final pathEnv = Platform.environment['PATH'];
|
|
if (pathEnv == null || pathEnv.isEmpty) return null;
|
|
final sep = Platform.isWindows ? ';' : ':';
|
|
final slash = Platform.isWindows ? '\\' : '/';
|
|
for (final dir in pathEnv.split(sep)) {
|
|
if (dir.isEmpty) continue;
|
|
final candidate = '$dir$slash$name';
|
|
if (File(candidate).existsSync()) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
}
|