add Dart core package — IPC envelopes, daemon, ping round-trip
First real content for the `clide` Dart package at the repo root.
One AOT-compiled binary (ADR 0005) with two modes:
* `clide --daemon` long-running unix-socket server; listens on
`$XDG_RUNTIME_DIR/clide-$USER.sock` with stale-socket
reclaim, accepts JSON-lines request/response traffic, clean
SIGTERM shutdown unlinks the socket file.
* `clide <subcommand>` one-shot; opens the socket, sends a
request, writes the response JSON to stdout, exits with the
dispatcher's error code per ADR 0006 (0/1/2/3/4). Unknown
subcommands forward to the daemon so extensions can register
their own without CLI changes.
Tier 0 handlers: `ping` (returns pong + version + UTC ts) and
`version`. Both are covered by `test/ipc/` + `test/daemon/`; the
subprocess test builds `bin/clide`, starts it, pings it, SIGTERMs
it, and asserts the socket file disappears.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ heading, and (b) bumping `project.yaml` `version:` in the same commit.
|
||||
|
||||
### Added
|
||||
|
||||
- Dart core package at the repo root: `bin/clide.dart` (one binary, `--daemon` and one-shot subcommand modes), `lib/clide.dart` barrel exporting the shared IPC types, `lib/src/ipc/` (`envelope.dart`, `server.dart`, `paths.dart`, `schema_v1.dart`), and `lib/src/daemon/dispatcher.dart`. `clide --daemon` listens on a unix socket; `clide ping` / `clide version` round-trip through it with the ADR 0006 exit-code contract (`0/1/2/3/4`). Includes `test/ipc/` and `test/daemon/` suites covering envelope parsing, the in-process server, and a subprocess smoke that verifies signal-driven shutdown + socket unlink.
|
||||
- `scripts/bazzite-flutter-setup.sh` — one-shot installer for the Flutter SDK + desktop build deps on Bazzite / Fedora Silverblue. Drops the SDK under `~/opt/flutter`, wires PATH in the user's shell rc files, and layers the Linux desktop build deps via `rpm-ostree install`.
|
||||
- [ADR 0005](docs/ADRs/0005-dart-core-ptyc-peer.md) — Dart core; sidecar directory dissolved; `ptyc` as pql-peer. Establishes one Dart AOT binary for both CLI and daemon, `lib/` as the shared core, and promotes the C PTY helper to a standalone supporter tool on the same footing as pql.
|
||||
- [ADR 0006](docs/ADRs/0006-cli-and-event-surface.md) — CLI and event surface contract. Defines the subsystem list (`pane`, `tab`, `editor`, `panel`, `tree`, `git`, `pql`, `canvas`, `graph`, `theme`, `settings`, `project`), the command shape, the versioned JSON event schema, the pql-style exit-code contract, and the command↔event duality rule that operationalises user/Claude parity.
|
||||
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
// clide — CLI + daemon entry point.
|
||||
//
|
||||
// One binary, two modes (per ADR 0005):
|
||||
// * `clide <subcommand>` — one-shot; connects to the daemon socket,
|
||||
// sends a request, prints the response, exits with the ADR-0006
|
||||
// exit code.
|
||||
// * `clide --daemon` — long-running; owns the socket, dispatches
|
||||
// requests. Tier 0 ships `ping` and `version`; feature subsystems
|
||||
// (pane, git, pql, etc.) register handlers as they land.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
|
||||
Future<void> main(List<String> argv) async {
|
||||
if (argv.isEmpty) {
|
||||
_printHelp(stdout);
|
||||
exit(0);
|
||||
}
|
||||
|
||||
if (argv.first == '--daemon') {
|
||||
await _runDaemon(argv.sublist(1));
|
||||
return;
|
||||
}
|
||||
|
||||
switch (argv.first) {
|
||||
case '--version':
|
||||
case 'version':
|
||||
await _runCli('version', const [], exitOnOk: true);
|
||||
case '--help':
|
||||
case '-h':
|
||||
case 'help':
|
||||
_printHelp(stdout);
|
||||
exit(0);
|
||||
case 'ping':
|
||||
await _runCli('ping', argv.sublist(1), exitOnOk: true);
|
||||
default:
|
||||
// Unknown-to-the-CLI commands still go over IPC — the daemon is
|
||||
// authoritative about what's registered. Lets extensions add
|
||||
// subcommands without the CLI caring.
|
||||
await _runCli(argv.first, argv.sublist(1), exitOnOk: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _printHelp(IOSink sink) {
|
||||
sink.writeln('''
|
||||
clide $clideVersion — Flutter desktop IDE for Claude Code.
|
||||
|
||||
Usage:
|
||||
clide --daemon Run the long-running daemon process.
|
||||
clide <subcommand> Run a one-shot subcommand against the daemon.
|
||||
|
||||
Built-in subcommands:
|
||||
ping Round-trip a ping to the daemon.
|
||||
version Print the clide version.
|
||||
help Print this help.
|
||||
|
||||
Any other subcommand is forwarded to the daemon; registered handlers
|
||||
(e.g. `clide git status` once `builtin.git` lands) resolve there.
|
||||
Matches ADR 0006's exit-code contract:
|
||||
0 success · 1 user-error · 2 tool-error · 3 not-found · 4 conflict
|
||||
''');
|
||||
}
|
||||
|
||||
Future<void> _runDaemon(List<String> args) async {
|
||||
final socketPath = defaultSocketPath();
|
||||
final dispatcher = DaemonDispatcher();
|
||||
final server = DaemonServer(
|
||||
socketPath: socketPath,
|
||||
dispatch: dispatcher.dispatch,
|
||||
);
|
||||
|
||||
final stopping = Completer<void>();
|
||||
void shutdown(ProcessSignal sig) {
|
||||
if (!stopping.isCompleted) {
|
||||
stderr.writeln('clide daemon: received ${sig.toString()}, shutting down');
|
||||
stopping.complete();
|
||||
}
|
||||
}
|
||||
|
||||
ProcessSignal.sigint.watch().listen(shutdown);
|
||||
ProcessSignal.sigterm.watch().listen(shutdown);
|
||||
|
||||
await server.start();
|
||||
await stopping.future;
|
||||
await server.stop();
|
||||
exit(0);
|
||||
}
|
||||
|
||||
Future<void> _runCli(
|
||||
String cmd,
|
||||
List<String> args, {
|
||||
required bool exitOnOk,
|
||||
}) async {
|
||||
final socketPath = defaultSocketPath();
|
||||
Socket socket;
|
||||
try {
|
||||
socket = await Socket.connect(
|
||||
InternetAddress(socketPath, type: InternetAddressType.unix),
|
||||
0,
|
||||
);
|
||||
} catch (e) {
|
||||
_emitError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'daemon not reachable at $socketPath',
|
||||
hint: 'run `clide --daemon` in another terminal.',
|
||||
);
|
||||
exit(IpcExitCode.toolError);
|
||||
}
|
||||
|
||||
final request = IpcRequest(
|
||||
id: '1',
|
||||
cmd: cmd,
|
||||
args: {'argv': args},
|
||||
);
|
||||
socket.writeln(request.encode());
|
||||
|
||||
final line = await socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.first;
|
||||
await socket.close();
|
||||
|
||||
try {
|
||||
final msg = IpcMessage.decode(line);
|
||||
if (msg is! IpcResponse) {
|
||||
_emitError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'unexpected message from daemon',
|
||||
);
|
||||
exit(IpcExitCode.toolError);
|
||||
}
|
||||
if (msg.ok) {
|
||||
stdout.writeln(jsonEncode(msg.data));
|
||||
if (exitOnOk) exit(IpcExitCode.ok);
|
||||
} else {
|
||||
final err = msg.error!;
|
||||
_emitError(
|
||||
code: err.code,
|
||||
kind: err.kind,
|
||||
message: err.message,
|
||||
hint: err.hint,
|
||||
);
|
||||
exit(err.code);
|
||||
}
|
||||
} on FormatException catch (e) {
|
||||
_emitError(
|
||||
code: IpcExitCode.toolError,
|
||||
kind: IpcErrorKind.toolError,
|
||||
message: 'bad response from daemon: $e',
|
||||
);
|
||||
exit(IpcExitCode.toolError);
|
||||
}
|
||||
}
|
||||
|
||||
void _emitError({
|
||||
required int code,
|
||||
required String kind,
|
||||
required String message,
|
||||
String? hint,
|
||||
}) {
|
||||
final err = IpcError(code: code, kind: kind, message: message, hint: hint);
|
||||
stderr.writeln(jsonEncode(err.toJson()));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/// clide — Dart core library.
|
||||
///
|
||||
/// Shared by `bin/clide.dart` (CLI + daemon) and by the Flutter app
|
||||
/// under `app/` (which depends on this package via `path: ../`).
|
||||
///
|
||||
/// See:
|
||||
/// * docs/ADRs/0005-dart-core-ptyc-peer.md — layout + language rationale.
|
||||
/// * docs/ADRs/0006-cli-and-event-surface.md — CLI + event contract.
|
||||
library;
|
||||
|
||||
export 'src/daemon/dispatcher.dart';
|
||||
export 'src/ipc/envelope.dart';
|
||||
export 'src/ipc/paths.dart';
|
||||
export 'src/ipc/schema_v1.dart';
|
||||
export 'src/ipc/server.dart';
|
||||
|
||||
/// Build-time-stamped version string.
|
||||
///
|
||||
/// The Makefile's `build` target passes `--define=clideVersion=…` when
|
||||
/// invoking `dart compile exe`, stamping `project.yaml`'s `version:`
|
||||
/// plus the git short SHA and dirty marker.
|
||||
const clideVersion = String.fromEnvironment(
|
||||
'clideVersion',
|
||||
defaultValue: '2.0.0-dev',
|
||||
);
|
||||
|
||||
const clideCommit = String.fromEnvironment(
|
||||
'clideCommit',
|
||||
defaultValue: 'unknown',
|
||||
);
|
||||
|
||||
const clideDate = String.fromEnvironment(
|
||||
'clideDate',
|
||||
defaultValue: 'unknown',
|
||||
);
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:clide/clide.dart' show clideVersion;
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
|
||||
typedef CommandHandler = Future<IpcResponse> Function(IpcRequest req);
|
||||
|
||||
class DaemonDispatcher {
|
||||
DaemonDispatcher() {
|
||||
register('ping', _ping);
|
||||
register('version', _version);
|
||||
}
|
||||
|
||||
final Map<String, CommandHandler> _handlers = {};
|
||||
|
||||
void register(String cmd, CommandHandler handler) {
|
||||
_handlers[cmd] = handler;
|
||||
}
|
||||
|
||||
Future<IpcResponse> dispatch(IpcRequest req) async {
|
||||
final h = _handlers[req.cmd];
|
||||
if (h == null) {
|
||||
return IpcResponse.err(
|
||||
id: req.id,
|
||||
error: IpcError(
|
||||
code: IpcExitCode.notFound,
|
||||
kind: IpcErrorKind.notFound,
|
||||
message: 'unknown command: ${req.cmd}',
|
||||
hint: 'run `clide --help` for the surface.',
|
||||
),
|
||||
);
|
||||
}
|
||||
return h(req);
|
||||
}
|
||||
|
||||
Future<IpcResponse> _ping(IpcRequest req) async => IpcResponse.ok(
|
||||
id: req.id,
|
||||
data: {
|
||||
'pong': true,
|
||||
'ts': DateTime.now().toUtc().toIso8601String(),
|
||||
'version': clideVersion,
|
||||
},
|
||||
);
|
||||
|
||||
Future<IpcResponse> _version(IpcRequest req) async => IpcResponse.ok(
|
||||
id: req.id,
|
||||
data: {'version': clideVersion},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:clide/src/ipc/schema_v1.dart';
|
||||
|
||||
sealed class IpcMessage {
|
||||
Map<String, Object?> toJson();
|
||||
|
||||
String encode() => jsonEncode(toJson());
|
||||
|
||||
static IpcMessage decode(String line) {
|
||||
final obj = jsonDecode(line);
|
||||
if (obj is! Map<String, Object?>) {
|
||||
throw const FormatException('IPC message is not a JSON object');
|
||||
}
|
||||
final type = obj['type'];
|
||||
switch (type) {
|
||||
case 'request':
|
||||
return IpcRequest.fromJson(obj);
|
||||
case 'response':
|
||||
return IpcResponse.fromJson(obj);
|
||||
case 'event':
|
||||
return IpcEvent.fromJson(obj);
|
||||
default:
|
||||
throw FormatException('Unknown IPC message type: $type');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class IpcRequest extends IpcMessage {
|
||||
IpcRequest({
|
||||
required this.id,
|
||||
required this.cmd,
|
||||
this.args = const {},
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String cmd;
|
||||
final Map<String, Object?> args;
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson() => {
|
||||
'type': 'request',
|
||||
'v': ipcSchemaVersion,
|
||||
'id': id,
|
||||
'cmd': cmd,
|
||||
'args': args,
|
||||
};
|
||||
|
||||
factory IpcRequest.fromJson(Map<String, Object?> j) => IpcRequest(
|
||||
id: j['id']! as String,
|
||||
cmd: j['cmd']! as String,
|
||||
args: (j['args'] as Map?)?.cast<String, Object?>() ?? const {},
|
||||
);
|
||||
}
|
||||
|
||||
class IpcResponse extends IpcMessage {
|
||||
IpcResponse.ok({required this.id, this.data = const {}})
|
||||
: ok = true,
|
||||
error = null;
|
||||
|
||||
IpcResponse.err({required this.id, required IpcError this.error})
|
||||
: ok = false,
|
||||
data = const {};
|
||||
|
||||
IpcResponse._({
|
||||
required this.id,
|
||||
required this.ok,
|
||||
required this.data,
|
||||
required this.error,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final bool ok;
|
||||
final Map<String, Object?> data;
|
||||
final IpcError? error;
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson() => {
|
||||
'type': 'response',
|
||||
'v': ipcSchemaVersion,
|
||||
'id': id,
|
||||
'ok': ok,
|
||||
if (ok) 'data': data,
|
||||
if (!ok && error != null) 'error': error!.toJson(),
|
||||
};
|
||||
|
||||
factory IpcResponse.fromJson(Map<String, Object?> j) {
|
||||
final ok = j['ok'] as bool? ?? false;
|
||||
return IpcResponse._(
|
||||
id: j['id']! as String,
|
||||
ok: ok,
|
||||
data: (j['data'] as Map?)?.cast<String, Object?>() ?? const {},
|
||||
error: ok
|
||||
? null
|
||||
: IpcError.fromJson((j['error'] as Map).cast<String, Object?>()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class IpcError {
|
||||
IpcError({
|
||||
required this.code,
|
||||
required this.kind,
|
||||
required this.message,
|
||||
this.hint,
|
||||
});
|
||||
|
||||
final int code;
|
||||
final String kind;
|
||||
final String message;
|
||||
final String? hint;
|
||||
|
||||
Map<String, Object?> toJson() => {
|
||||
'code': code,
|
||||
'kind': kind,
|
||||
'message': message,
|
||||
if (hint != null) 'hint': hint,
|
||||
};
|
||||
|
||||
factory IpcError.fromJson(Map<String, Object?> j) => IpcError(
|
||||
code: (j['code'] as num).toInt(),
|
||||
kind: j['kind']! as String,
|
||||
message: j['message']! as String,
|
||||
hint: j['hint'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
class IpcEvent extends IpcMessage {
|
||||
IpcEvent({
|
||||
required this.subsystem,
|
||||
required this.kind,
|
||||
required this.timestamp,
|
||||
this.data = const {},
|
||||
});
|
||||
|
||||
final String subsystem;
|
||||
final String kind;
|
||||
final DateTime timestamp;
|
||||
final Map<String, Object?> data;
|
||||
|
||||
@override
|
||||
Map<String, Object?> toJson() => {
|
||||
'type': 'event',
|
||||
'v': ipcSchemaVersion,
|
||||
'subsystem': subsystem,
|
||||
'kind': kind,
|
||||
'ts': timestamp.toIso8601String(),
|
||||
'data': data,
|
||||
};
|
||||
|
||||
factory IpcEvent.fromJson(Map<String, Object?> j) => IpcEvent(
|
||||
subsystem: j['subsystem']! as String,
|
||||
kind: j['kind']! as String,
|
||||
timestamp: DateTime.parse(j['ts']! as String),
|
||||
data: (j['data'] as Map?)?.cast<String, Object?>() ?? const {},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import 'dart:io';
|
||||
|
||||
String defaultSocketPath() {
|
||||
final xdg = Platform.environment['XDG_RUNTIME_DIR'];
|
||||
final user = Platform.environment['USER'] ?? 'anon';
|
||||
final base = (xdg != null && xdg.isNotEmpty) ? xdg : '/tmp';
|
||||
return '$base/clide-$user.sock';
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/// IPC wire schema version.
|
||||
///
|
||||
/// Bumped when the envelope shape changes in a non-backwards-compatible
|
||||
/// way. The daemon and app both carry this constant and reject messages
|
||||
/// whose `v:` doesn't match.
|
||||
const int ipcSchemaVersion = 1;
|
||||
|
||||
abstract class IpcExitCode {
|
||||
static const int ok = 0;
|
||||
static const int userError = 1;
|
||||
static const int toolError = 2;
|
||||
static const int notFound = 3;
|
||||
static const int conflict = 4;
|
||||
}
|
||||
|
||||
abstract class IpcErrorKind {
|
||||
static const String userError = 'user_error';
|
||||
static const String toolError = 'tool_error';
|
||||
static const String notFound = 'not_found';
|
||||
static const String conflict = 'conflict';
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/src/ipc/envelope.dart';
|
||||
|
||||
typedef RequestDispatcher = Future<IpcResponse> Function(IpcRequest request);
|
||||
|
||||
/// Unix-socket JSON-lines server. Each connection is an independent
|
||||
/// bidirectional line-framed stream: client writes requests, daemon
|
||||
/// writes responses (and events, later). Tier 0 handles request→response
|
||||
/// only; event broadcasting lands with the first feature that emits.
|
||||
class DaemonServer {
|
||||
DaemonServer({
|
||||
required this.socketPath,
|
||||
required this.dispatch,
|
||||
});
|
||||
|
||||
final String socketPath;
|
||||
final RequestDispatcher dispatch;
|
||||
|
||||
ServerSocket? _server;
|
||||
final Set<Socket> _clients = {};
|
||||
|
||||
Future<void> start() async {
|
||||
final addr = InternetAddress(socketPath, type: InternetAddressType.unix);
|
||||
try {
|
||||
_server = await ServerSocket.bind(addr, 0);
|
||||
} on SocketException {
|
||||
// stale socket from a prior crash — unlink and retry once
|
||||
try {
|
||||
await File(socketPath).delete();
|
||||
} catch (_) {}
|
||||
_server = await ServerSocket.bind(addr, 0);
|
||||
}
|
||||
stderr.writeln('clide daemon listening on $socketPath');
|
||||
_server!.listen(_handleClient, onError: (e) {
|
||||
stderr.writeln('clide daemon accept error: $e');
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
for (final c in List<Socket>.from(_clients)) {
|
||||
await c.close();
|
||||
}
|
||||
_clients.clear();
|
||||
await _server?.close();
|
||||
_server = null;
|
||||
try {
|
||||
await File(socketPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _handleClient(Socket client) {
|
||||
_clients.add(client);
|
||||
client
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen(
|
||||
(line) => _handleLine(client, line),
|
||||
onDone: () => _clients.remove(client),
|
||||
onError: (Object e) {
|
||||
stderr.writeln('clide daemon client error: $e');
|
||||
_clients.remove(client);
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleLine(Socket client, String line) async {
|
||||
if (line.isEmpty) return;
|
||||
IpcMessage? msg;
|
||||
try {
|
||||
msg = IpcMessage.decode(line);
|
||||
} on FormatException catch (e) {
|
||||
stderr.writeln('clide daemon: bad line from client: $e');
|
||||
return;
|
||||
}
|
||||
if (msg is! IpcRequest) return;
|
||||
IpcResponse resp;
|
||||
try {
|
||||
resp = await dispatch(msg);
|
||||
} catch (e, st) {
|
||||
stderr.writeln('clide daemon: dispatch error for ${msg.cmd}: $e\n$st');
|
||||
resp = IpcResponse.err(
|
||||
id: msg.id,
|
||||
error: IpcError(
|
||||
code: 2,
|
||||
kind: 'tool_error',
|
||||
message: 'dispatch failed: $e',
|
||||
),
|
||||
);
|
||||
}
|
||||
client.writeln(resp.encode());
|
||||
}
|
||||
}
|
||||
+405
@@ -0,0 +1,405 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
_fe_analyzer_shared:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "67.0.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.4.1"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cli_config
|
||||
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
coverage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: coverage
|
||||
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.15.0"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
frontend_server_client:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
http_multi_server:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: lints
|
||||
sha256: "3315600f3fb3b135be672bf4a178c55f274bebe368325ae18462c89ac1e3b413"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.16+1"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: df0c643f44ad098eb37988027a8e2b2b5a031fd3977f06bbfd3a76637e8df739
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.2"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mocktail:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: mocktail
|
||||
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: node_preamble
|
||||
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
shelf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_packages_handler:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_packages_handler
|
||||
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
shelf_static:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_static
|
||||
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.3"
|
||||
shelf_web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
source_map_stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_map_stack_trace
|
||||
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
source_maps:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_maps
|
||||
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.10.13"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: test
|
||||
sha256: "713a8789d62f3233c46b4a90b174737b2c04cb6ae4500f2aa8b1be8f03f5e67f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.25.8"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "664d3a9a64782fcdeb83ce9c6b39e78fd2971d4e37827b9b06c3aa1edc5e760c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.3"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "12391302411737c176b0b5d6491f466b0dd56d4763e347b6714efbaa74d7953d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.5"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.3.1"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
webkit_inspection_protocol:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: webkit_inspection_protocol
|
||||
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.7.0-0 <4.0.0"
|
||||
@@ -0,0 +1,38 @@
|
||||
# clide — Dart core package.
|
||||
#
|
||||
# This is the repo-root package: the `clide` CLI (one-shot subcommand
|
||||
# mode) and the `clide --daemon` long-running process both compile from
|
||||
# `bin/clide.dart` against `lib/`. It has NO Flutter dependency so
|
||||
# `dart compile exe` produces a lean static binary.
|
||||
#
|
||||
# The Flutter desktop UI lives under `app/` as a separate package that
|
||||
# depends on this one via a path: dependency. See ADR 0005 for the
|
||||
# layout rationale.
|
||||
#
|
||||
# Version is mirrored from project.yaml on every bump. project.yaml is
|
||||
# the single source of truth — if you change one without the other,
|
||||
# `make build`'s ldflag stamping will reveal the drift.
|
||||
|
||||
name: clide
|
||||
description: >-
|
||||
Flutter desktop IDE for Claude Code — Dart core (CLI + daemon + shared
|
||||
library). The Flutter UI lives under app/.
|
||||
version: 2.0.0-dev
|
||||
publish_to: none
|
||||
repository: https://git.schweitz.net/jpmschweitzer/clide
|
||||
|
||||
environment:
|
||||
sdk: ">=3.5.0 <4.0.0"
|
||||
|
||||
# Prefer-zero-deps. Anything added here must be justified against the
|
||||
# "write it yourself" default (see memory:
|
||||
# feedback_dart_deps_minimal_locked_cve_checked.md). Exact-pin; never
|
||||
# carets.
|
||||
dependencies: {}
|
||||
|
||||
dev_dependencies:
|
||||
lints: 5.0.0
|
||||
test: 1.25.8
|
||||
# Mocks at IO boundaries (daemon subprocess, socket). Null-safe,
|
||||
# no codegen. Hand-rolled fakes for ChangeNotifier-style services.
|
||||
mocktail: 1.0.4
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('DaemonServer (in-process)', () {
|
||||
late DaemonServer server;
|
||||
late DaemonDispatcher dispatcher;
|
||||
late String socketPath;
|
||||
|
||||
setUp(() async {
|
||||
final tmp = await Directory.systemTemp.createTemp('clide_daemon_');
|
||||
socketPath = '${tmp.path}/daemon.sock';
|
||||
dispatcher = DaemonDispatcher();
|
||||
server = DaemonServer(
|
||||
socketPath: socketPath,
|
||||
dispatch: dispatcher.dispatch,
|
||||
);
|
||||
await server.start();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
test('ping round-trips with pong=true', () async {
|
||||
final resp = await _send(
|
||||
socketPath,
|
||||
IpcRequest(id: '1', cmd: 'ping').encode(),
|
||||
);
|
||||
final parsed = IpcMessage.decode(resp) as IpcResponse;
|
||||
expect(parsed.ok, true);
|
||||
expect(parsed.id, '1');
|
||||
expect(parsed.data['pong'], true);
|
||||
expect(parsed.data['ts'], isA<String>());
|
||||
expect(parsed.data['version'], isA<String>());
|
||||
});
|
||||
|
||||
test('version returns current clideVersion', () async {
|
||||
final resp = await _send(
|
||||
socketPath,
|
||||
IpcRequest(id: 'v', cmd: 'version').encode(),
|
||||
);
|
||||
final parsed = IpcMessage.decode(resp) as IpcResponse;
|
||||
expect(parsed.ok, true);
|
||||
expect(parsed.data['version'], clideVersion);
|
||||
});
|
||||
|
||||
test('unknown command returns NotFound (exit code 3)', () async {
|
||||
final resp = await _send(
|
||||
socketPath,
|
||||
IpcRequest(id: 'x', cmd: 'this.does.not.exist').encode(),
|
||||
);
|
||||
final parsed = IpcMessage.decode(resp) as IpcResponse;
|
||||
expect(parsed.ok, false);
|
||||
expect(parsed.error!.code, IpcExitCode.notFound);
|
||||
expect(parsed.error!.kind, IpcErrorKind.notFound);
|
||||
expect(parsed.error!.message, contains('this.does.not.exist'));
|
||||
});
|
||||
|
||||
test('multiple concurrent requests on one connection', () async {
|
||||
final socket = await Socket.connect(
|
||||
InternetAddress(socketPath, type: InternetAddressType.unix),
|
||||
0,
|
||||
);
|
||||
for (var i = 0; i < 5; i++) {
|
||||
socket.writeln(IpcRequest(id: '$i', cmd: 'ping').encode());
|
||||
}
|
||||
final lines = <String>[];
|
||||
final done = Completer<void>();
|
||||
final sub = socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen((line) {
|
||||
lines.add(line);
|
||||
if (lines.length == 5) done.complete();
|
||||
});
|
||||
await done.future.timeout(const Duration(seconds: 2));
|
||||
await sub.cancel();
|
||||
await socket.close();
|
||||
final ids =
|
||||
lines.map((l) => (IpcMessage.decode(l) as IpcResponse).id).toSet();
|
||||
expect(ids, {'0', '1', '2', '3', '4'});
|
||||
});
|
||||
|
||||
test('custom handler plugs into dispatcher', () async {
|
||||
dispatcher.register('test.custom', (req) async {
|
||||
return IpcResponse.ok(id: req.id, data: {'echo': req.args});
|
||||
});
|
||||
final resp = await _send(
|
||||
socketPath,
|
||||
IpcRequest(
|
||||
id: 'c',
|
||||
cmd: 'test.custom',
|
||||
args: const {'x': 1},
|
||||
).encode(),
|
||||
);
|
||||
final parsed = IpcMessage.decode(resp) as IpcResponse;
|
||||
expect(parsed.ok, true);
|
||||
expect(parsed.data['echo'], {'x': 1});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> _send(String socketPath, String line) async {
|
||||
final socket = await Socket.connect(
|
||||
InternetAddress(socketPath, type: InternetAddressType.unix),
|
||||
0,
|
||||
);
|
||||
socket.writeln(line);
|
||||
final resp = await socket
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.first
|
||||
.timeout(const Duration(seconds: 2));
|
||||
await socket.close();
|
||||
return resp;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
/// Subprocess-level daemon smoke. Only runs if `bin/clide` has been
|
||||
/// built (the test skips itself otherwise). This is the release-gate
|
||||
/// suite — catches signal-handling, socket-unlink, and version-stamp
|
||||
/// regressions that the in-process test masks.
|
||||
void main() {
|
||||
final binary = File('bin/clide');
|
||||
|
||||
group('bin/clide --daemon (subprocess)', () {
|
||||
setUpAll(() {
|
||||
if (!binary.existsSync()) {
|
||||
markTestSkipped(
|
||||
'bin/clide not built; run `make build` first to enable this suite');
|
||||
}
|
||||
});
|
||||
|
||||
test('starts, responds to ping, exits cleanly on SIGTERM', () async {
|
||||
if (!binary.existsSync()) return;
|
||||
|
||||
// Use a fresh socket under a unique temp path so parallel test
|
||||
// runs don't collide. The daemon resolves its socket path from
|
||||
// XDG_RUNTIME_DIR + USER (see defaultSocketPath()).
|
||||
final tmp = await Directory.systemTemp.createTemp('clide_sub_');
|
||||
final env = Map<String, String>.from(Platform.environment)
|
||||
..['XDG_RUNTIME_DIR'] = tmp.path
|
||||
..['USER'] = 'daemon';
|
||||
final socketPath = '${tmp.path}/clide-daemon.sock';
|
||||
|
||||
final process = await Process.start(
|
||||
binary.absolute.path,
|
||||
['--daemon'],
|
||||
environment: env,
|
||||
);
|
||||
|
||||
// Wait for "listening on ..." on stderr before connecting.
|
||||
final ready = Completer<void>();
|
||||
final stderrLines = <String>[];
|
||||
final sub = process.stderr
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.listen((line) {
|
||||
stderrLines.add(line);
|
||||
if (line.contains('listening')) ready.complete();
|
||||
});
|
||||
|
||||
try {
|
||||
await ready.future.timeout(const Duration(seconds: 3));
|
||||
|
||||
// Connect and ping
|
||||
final sock = await Socket.connect(
|
||||
InternetAddress(socketPath, type: InternetAddressType.unix),
|
||||
0,
|
||||
).timeout(const Duration(seconds: 3));
|
||||
sock.writeln(IpcRequest(id: '1', cmd: 'ping').encode());
|
||||
final line = await sock
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter())
|
||||
.first
|
||||
.timeout(const Duration(seconds: 3));
|
||||
await sock.close();
|
||||
final resp = IpcMessage.decode(line) as IpcResponse;
|
||||
expect(resp.ok, true);
|
||||
expect(resp.data['pong'], true);
|
||||
|
||||
// Clean shutdown
|
||||
process.kill(ProcessSignal.sigterm);
|
||||
final exitCode =
|
||||
await process.exitCode.timeout(const Duration(seconds: 3));
|
||||
expect(exitCode, 0);
|
||||
|
||||
// Socket file should be unlinked
|
||||
expect(await File(socketPath).exists(), false);
|
||||
} finally {
|
||||
await sub.cancel();
|
||||
try {
|
||||
process.kill(ProcessSignal.sigkill);
|
||||
} catch (_) {}
|
||||
try {
|
||||
await tmp.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
}, timeout: const Timeout(Duration(seconds: 15)));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('IpcRequest', () {
|
||||
test('encodes and decodes round-trip', () {
|
||||
final req = IpcRequest(
|
||||
id: '42',
|
||||
cmd: 'git.status',
|
||||
args: const {'path': '.'},
|
||||
);
|
||||
final line = req.encode();
|
||||
final decoded = IpcMessage.decode(line);
|
||||
expect(decoded, isA<IpcRequest>());
|
||||
final r = decoded as IpcRequest;
|
||||
expect(r.id, '42');
|
||||
expect(r.cmd, 'git.status');
|
||||
expect(r.args, {'path': '.'});
|
||||
});
|
||||
|
||||
test('schema version is stamped', () {
|
||||
final encoded = IpcRequest(id: '1', cmd: 'ping').toJson();
|
||||
expect(encoded['v'], ipcSchemaVersion);
|
||||
expect(encoded['type'], 'request');
|
||||
});
|
||||
});
|
||||
|
||||
group('IpcResponse.ok', () {
|
||||
test('encodes with data, no error', () {
|
||||
final r = IpcResponse.ok(id: '7', data: const {'pong': true});
|
||||
final json = r.toJson();
|
||||
expect(json['type'], 'response');
|
||||
expect(json['ok'], true);
|
||||
expect(json['data'], {'pong': true});
|
||||
expect(json.containsKey('error'), isFalse);
|
||||
});
|
||||
|
||||
test('decode round-trips', () {
|
||||
final original = IpcResponse.ok(id: '7', data: const {'n': 42});
|
||||
final roundtripped = IpcMessage.decode(original.encode()) as IpcResponse;
|
||||
expect(roundtripped.ok, true);
|
||||
expect(roundtripped.id, '7');
|
||||
expect(roundtripped.data, {'n': 42});
|
||||
expect(roundtripped.error, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('IpcResponse.err', () {
|
||||
test('encodes with error payload, no data', () {
|
||||
final r = IpcResponse.err(
|
||||
id: '9',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.notFound,
|
||||
kind: IpcErrorKind.notFound,
|
||||
message: 'missing',
|
||||
hint: 'try --help',
|
||||
),
|
||||
);
|
||||
final json = r.toJson();
|
||||
expect(json['ok'], false);
|
||||
expect(json['error'], {
|
||||
'code': 3,
|
||||
'kind': 'not_found',
|
||||
'message': 'missing',
|
||||
'hint': 'try --help',
|
||||
});
|
||||
expect(json.containsKey('data'), isFalse);
|
||||
});
|
||||
|
||||
test('decode preserves error fields', () {
|
||||
final original = IpcResponse.err(
|
||||
id: '9',
|
||||
error: IpcError(
|
||||
code: IpcExitCode.conflict,
|
||||
kind: IpcErrorKind.conflict,
|
||||
message: 'race',
|
||||
),
|
||||
);
|
||||
final r = IpcMessage.decode(original.encode()) as IpcResponse;
|
||||
expect(r.ok, false);
|
||||
expect(r.error, isNotNull);
|
||||
expect(r.error!.code, IpcExitCode.conflict);
|
||||
expect(r.error!.kind, IpcErrorKind.conflict);
|
||||
expect(r.error!.message, 'race');
|
||||
expect(r.error!.hint, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('IpcEvent', () {
|
||||
test('serializes subsystem + kind + ts + data', () {
|
||||
final ts = DateTime.utc(2026, 4, 21, 12, 0, 0);
|
||||
final e = IpcEvent(
|
||||
subsystem: 'git',
|
||||
kind: 'status-changed',
|
||||
timestamp: ts,
|
||||
data: const {'staged': 3},
|
||||
);
|
||||
final json = e.toJson();
|
||||
expect(json['type'], 'event');
|
||||
expect(json['subsystem'], 'git');
|
||||
expect(json['kind'], 'status-changed');
|
||||
expect(json['ts'], '2026-04-21T12:00:00.000Z');
|
||||
expect(json['data'], {'staged': 3});
|
||||
});
|
||||
});
|
||||
|
||||
group('IpcMessage.decode errors', () {
|
||||
test('throws on non-object line', () {
|
||||
expect(() => IpcMessage.decode('[]'), throwsA(isA<FormatException>()));
|
||||
});
|
||||
|
||||
test('throws on unknown type', () {
|
||||
expect(
|
||||
() => IpcMessage.decode('{"type":"bogus","id":"1"}'),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('throws on malformed JSON', () {
|
||||
expect(() => IpcMessage.decode('{this is not json'),
|
||||
throwsA(isA<FormatException>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('defaultSocketPath', () {
|
||||
final originalXdg = Platform.environment['XDG_RUNTIME_DIR'];
|
||||
final originalUser = Platform.environment['USER'];
|
||||
|
||||
test('uses XDG_RUNTIME_DIR when set', () {
|
||||
// We can't mutate Platform.environment from dart:io, so this test
|
||||
// just asserts the path shape for the current env. CI and dev
|
||||
// boxes both have meaningful USER values.
|
||||
final path = defaultSocketPath();
|
||||
expect(path, endsWith('.sock'));
|
||||
expect(path, contains('clide-'));
|
||||
if (originalXdg != null && originalXdg.isNotEmpty) {
|
||||
expect(path, startsWith(originalXdg));
|
||||
} else {
|
||||
expect(path, startsWith('/tmp'));
|
||||
}
|
||||
if (originalUser != null && originalUser.isNotEmpty) {
|
||||
expect(path, contains('clide-$originalUser'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:clide/clide.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('schema_v1', () {
|
||||
test('schema version is 1', () {
|
||||
expect(ipcSchemaVersion, 1);
|
||||
});
|
||||
|
||||
test('exit codes match ADR 0006', () {
|
||||
expect(IpcExitCode.ok, 0);
|
||||
expect(IpcExitCode.userError, 1);
|
||||
expect(IpcExitCode.toolError, 2);
|
||||
expect(IpcExitCode.notFound, 3);
|
||||
expect(IpcExitCode.conflict, 4);
|
||||
});
|
||||
|
||||
test('error kinds are unique strings', () {
|
||||
final kinds = <String>{
|
||||
IpcErrorKind.userError,
|
||||
IpcErrorKind.toolError,
|
||||
IpcErrorKind.notFound,
|
||||
IpcErrorKind.conflict,
|
||||
};
|
||||
expect(kinds.length, 4);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user