Add infra command surface

This commit is contained in:
Chris Hendrickson 2026-05-04 22:14:38 -04:00
parent f7346b1afe
commit 95058f7f04
17 changed files with 1980 additions and 2 deletions

View file

@ -0,0 +1,8 @@
---
id: DEW-0029
title: Add infra command surface
type: task
created: 2026-05-05T02:02:35.678734Z
---
Implement the initial dew infra command surface for project-local infrastructure services. Start with Quadlet/Podman operations while keeping runtime boundaries explicit so additional container runtimes can be added later.

View file

@ -18,6 +18,12 @@ stats board config tui
Tickets are stored as `.project/kanban/<column>/<ID>.md` files. Labels, milestones, typed bidirectional links, and inline comments are all first-class citizens. See the [Kanban documentation](./docs/features/kanban.md) for the full command reference.
### Infrastructure
`dew infra` discovers services under `.project/infrastructure/services`, validates
their manifests and schemas, and manages Podman Quadlets through systemd. The
runtime boundary is explicit so other container backends can be added later.
### Interactive TUI
`dew kanban tui` opens a full Trello-style terminal board with three modes:
@ -60,6 +66,7 @@ Dew reads `.project/dew.yaml` for board columns, ticket types, ID prefix, and MC
## Documentation
- [Full documentation index](./docs/index.md)
- [Infrastructure](./docs/features/infra.md) — service manifests, Quadlet install, lifecycle commands
- [Kanban board](./docs/features/kanban.md) — CLI commands, TUI keybindings, ticket format
- [MCP server](./docs/features/mcp.md) — AI agent integration
- [Configuration reference](./docs/config.md)

View file

@ -13,6 +13,9 @@ your-project/
Path-like values in `dew.yaml` are resolved relative to `.project/dew.yaml`
unless they are absolute (for example, paths under `dew.vault`).
Infrastructure services are not configured in `dew.yaml`; they are discovered
from `.project/infrastructure/services/*/metadata.toml`.
## Full Schema
```yaml

72
docs/features/infra.md Normal file
View file

@ -0,0 +1,72 @@
# Infrastructure
`dew infra` manages project-local infrastructure services declared under
`.project/infrastructure`.
The initial runtime backend is Podman Quadlets installed into systemd search
paths. The command surface is runtime-oriented rather than Podman-specific so
future backends can be added without changing project manifests or common CLI
workflows.
## Layout
```text
.project/infrastructure/
└── services/
└── postgres/
├── metadata.toml
├── app_postgres.container
├── app_postgres.container.d/
├── app_postgres.profiles.d/
├── configure.schema.json
├── init.schema.json
└── config/
```
## Manifest
```toml
[service]
id = "postgres"
name = "PostgreSQL"
unit = "app_postgres.service"
container_name = "app_postgres"
[runtime]
type = "podman-quadlet"
[container]
file = "app_postgres.container"
dropins_dir = "app_postgres.container.d"
profiles_dir = "app_postgres.profiles.d"
[schemas]
configure = "configure.schema.json"
init = "init.schema.json"
```
## Commands
```bash
dew infra list
dew infra show postgres
dew infra validate --all
dew infra configure postgres schema
dew infra configure postgres show
dew infra configure postgres apply --file config.json --set port=5432
dew infra init postgres schema
dew infra init postgres run --file init.json
dew infra install postgres
dew infra up postgres
dew infra status postgres
dew infra logs postgres --lines 200
dew infra down postgres
dew infra delete postgres --container
```
Use `--dry-run` on mutating commands to print filesystem, systemctl, journalctl,
and podman actions without applying them. Use `--scope user` for the default
user systemd path or `--scope system` for `/etc/containers/systemd`.
`dew infra up` installs missing Quadlet files, reloads systemd, then starts the
unit.

View file

@ -9,6 +9,7 @@ Welcome to the documentation for the Dew project management tool!
### Features
- [Kanban Board](./features/kanban.md) — Visualize and manage tasks in a column-based workflow
- [Infrastructure](./features/infra.md) — Manage local service manifests and Podman Quadlets
- [MCP Server](./features/mcp.md) — AI agent integration via the Model Context Protocol
## Package Architecture
@ -19,6 +20,7 @@ Dew is structured as a Dart workspace with the following packages:
| ----------------- | -------------------------------------------------------------------------------------------- |
| `packages/cli` | The `dew` command-line tool. Wires all packages together at startup. |
| `packages/core` | Shared foundation: `DewCommand`, `DewToolCommand` mixin, `CommandRegistry`, and `DewConfig`. |
| `packages/infra` | Infrastructure service discovery, validation, and runtime lifecycle commands. |
| `packages/kanban` | Kanban board logic. Each command automatically registers itself as an MCP tool. |
| `packages/mcp` | The MCP server. Collects tools from `CommandRegistry` and serves them over stdio. |

View file

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:dew_core/dew_core.dart';
import 'package:dew_infra/dew_infra.dart' as infra;
import 'package:dew_kanban/dew_kanban.dart' as kanban;
import 'package:dew_mcp/dew_mcp.dart' as mcp;
import 'package:dew_vault/dew_vault.dart' as vault;
@ -9,6 +10,7 @@ import 'package:dew_vault/dew_vault.dart' as vault;
Future<void> main(List<String> args) async {
final commandRegistry = CommandRegistry();
infra.registerCommands(commandRegistry);
kanban.registerCommands(commandRegistry);
vault.registerCommands(commandRegistry);
mcp.registerCommands(commandRegistry);

View file

@ -11,6 +11,7 @@ environment:
dependencies:
args: ^2.7.0
dew_core: ^0.1.0
dew_infra: ^0.1.0
dew_kanban: ^0.1.0
dew_vault: ^0.3.0
dew_mcp: ^0.1.0

View file

@ -1,5 +1,6 @@
import 'package:args/command_runner.dart';
import 'package:dew_core/dew_core.dart';
import 'package:dew_infra/dew_infra.dart' as infra;
import 'package:dew_kanban/dew_kanban.dart' as kanban;
import 'package:dew_mcp/dew_mcp.dart' as mcp;
import 'package:dew_vault/dew_vault.dart' as vault;
@ -8,6 +9,7 @@ import 'package:test/test.dart';
/// Builds the same CommandRunner as bin/dew.dart without actually running it.
CommandRunner<void> buildRunner() {
final commandRegistry = CommandRegistry();
infra.registerCommands(commandRegistry);
kanban.registerCommands(commandRegistry);
vault.registerCommands(commandRegistry);
mcp.registerCommands(commandRegistry);
@ -26,9 +28,12 @@ void main() {
expect(buildRunner, returnsNormally);
});
test('has kanban, vault, init, and mcp commands registered', () {
test('has core package commands registered', () {
final runner = buildRunner();
expect(runner.commands.keys, containsAll(['kanban', 'vault', 'init', 'mcp']));
expect(
runner.commands.keys,
containsAll(['infra', 'kanban', 'vault', 'init', 'mcp']),
);
});
test('--help flag does not throw', () async {

View file

@ -0,0 +1,20 @@
library;
export 'src/dew_infra_base.dart';
export 'src/infra_repository.dart';
export 'src/infra_runtime.dart';
export 'src/service_manifest.dart';
import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'src/dew_infra_base.dart';
/// Registers all infrastructure commands into [registry].
void registerCommands(
CommandRegistry registry, {
FileSystem fs = const LocalFileSystem(),
}) {
registry.register(InfraCommand(fs: fs));
}

View file

@ -0,0 +1,692 @@
import 'dart:convert';
import 'dart:io' as io;
import 'package:args/args.dart';
import 'package:args/command_runner.dart';
import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:json_schema/json_schema.dart';
import 'package:path/path.dart' as p;
import 'infra_repository.dart';
import 'infra_runtime.dart';
import 'service_manifest.dart';
/// Root `dew infra` command.
class InfraCommand extends DewCommand {
InfraCommand({
FileSystem fs = const LocalFileSystem(),
ContainerRuntimeRegistry? runtimeRegistry,
}) : runtimeRegistry =
runtimeRegistry ??
ContainerRuntimeRegistry([PodmanQuadletRuntime(fs: fs)]) {
argParser
..addOption(
'project',
help:
'Project root. Defaults to walking upward until .project/dew.yaml is found.',
)
..addOption(
'infra-dir',
help: 'Infrastructure root. Defaults to .project/infrastructure.',
)
..addFlag('json', negatable: false, help: 'Machine-readable output.')
..addFlag(
'dry-run',
negatable: false,
help: 'Print intended actions without applying them.',
)
..addFlag(
'yes',
negatable: false,
help: 'Skip confirmation for operations that change state.',
)
..addOption(
'scope',
allowed: ['user', 'system'],
defaultsTo: 'user',
help: 'Quadlet/systemd scope.',
);
addSubcommand(
InfraListCommand(fs: fs, runtimeRegistry: this.runtimeRegistry),
);
addSubcommand(
InfraShowCommand(fs: fs, runtimeRegistry: this.runtimeRegistry),
);
addSubcommand(
InfraValidateCommand(fs: fs, runtimeRegistry: this.runtimeRegistry),
);
addSubcommand(
InfraConfigureCommand(fs: fs, runtimeRegistry: this.runtimeRegistry),
);
addSubcommand(
InfraInitCommand(fs: fs, runtimeRegistry: this.runtimeRegistry),
);
for (final commandName in [
'install',
'uninstall',
'up',
'down',
'restart',
'status',
]) {
addSubcommand(
InfraRuntimeCommand(
commandName,
fs: fs,
runtimeRegistry: this.runtimeRegistry,
),
);
}
addSubcommand(
InfraLogsCommand(fs: fs, runtimeRegistry: this.runtimeRegistry),
);
addSubcommand(
InfraDeleteCommand(fs: fs, runtimeRegistry: this.runtimeRegistry),
);
}
final ContainerRuntimeRegistry runtimeRegistry;
@override
final String name = 'infra';
@override
final String description = 'Manage project infrastructure services.';
@override
Future<void> run() async => printUsage();
}
abstract class _InfraSubcommand extends DewCommand {
_InfraSubcommand({required this.fs, required this.runtimeRegistry});
final FileSystem fs;
final ContainerRuntimeRegistry runtimeRegistry;
Future<_InfraEnvironment> _environment() async {
final options = _infraOptions();
final projectArg = options['project'] as String?;
final projectDirectory = projectArg == null
? null
: fs.directory(_resolveFromCwd(projectArg));
final projectContext = await ProjectContext.find(
fs: fs,
from: projectDirectory,
);
final infraArg = options['infra-dir'] as String?;
final infraDir = infraArg == null
? p.join(projectContext.root, '.project', 'infrastructure')
: _resolveProjectPath(projectContext.root, infraArg);
return _InfraEnvironment(
projectContext: projectContext,
options: options,
repository: InfraRepository(infraDir: infraDir, fs: fs),
validator: InfraValidator(fs: fs),
runtimeRegistry: runtimeRegistry,
scope: InfraScope.parse(options['scope'] as String),
json: options['json'] as bool,
dryRun: options['dry-run'] as bool,
yes: options['yes'] as bool,
);
}
ArgResults _infraOptions() => (parent as InfraCommand).argResults!;
String _requiredServiceArg([String? usage]) {
final rest = argResults?.rest ?? const [];
if (rest.isEmpty) {
usageException(usage ?? 'Missing service.');
}
return rest.first;
}
String _resolveFromCwd(String value) {
if (p.isAbsolute(value)) return p.normalize(value);
return p.normalize(p.join(fs.currentDirectory.path, value));
}
String _resolveProjectPath(String root, String value) {
if (p.isAbsolute(value)) return p.normalize(value);
return p.normalize(p.join(root, value));
}
}
class _InfraEnvironment {
const _InfraEnvironment({
required this.projectContext,
required this.options,
required this.repository,
required this.validator,
required this.runtimeRegistry,
required this.scope,
required this.json,
required this.dryRun,
required this.yes,
});
final ProjectContext projectContext;
final ArgResults options;
final InfraRepository repository;
final InfraValidator validator;
final ContainerRuntimeRegistry runtimeRegistry;
final InfraScope scope;
final bool json;
final bool dryRun;
final bool yes;
ContainerRuntime runtimeFor(InfraServiceManifest manifest) =>
runtimeRegistry.forKind(manifest.runtime);
}
class InfraListCommand extends _InfraSubcommand {
InfraListCommand({required super.fs, required super.runtimeRegistry});
@override
final String name = 'list';
@override
final String description = 'List infrastructure services.';
@override
Future<void> run() async {
final env = await _environment();
final manifests = await env.repository.list();
if (env.json) {
print(
jsonEncode(manifests.map((manifest) => manifest.toJson()).toList()),
);
return;
}
if (manifests.isEmpty) {
print('No infrastructure services found.');
return;
}
for (final manifest in manifests) {
print('${manifest.id}\t${manifest.name}\t${manifest.unit}');
}
}
}
class InfraShowCommand extends _InfraSubcommand {
InfraShowCommand({required super.fs, required super.runtimeRegistry});
@override
final String name = 'show';
@override
final String description = 'Show service manifest and runtime details.';
@override
Future<void> run() async {
final service = _requiredServiceArg('Usage: dew infra show <service>.');
final env = await _environment();
final manifest = await env.repository.get(service);
final runtime = env.runtimeFor(manifest);
final installed = await runtime.isInstalled(manifest, env.scope);
final details = {
...manifest.toJson(),
'installed': installed,
'install_target': quadletSearchPath(
env.scope,
environment: io.Platform.environment,
),
'scope': env.scope.name,
};
if (env.json) {
print(jsonEncode(details));
return;
}
for (final entry in details.entries) {
if (entry.value == null) continue;
print('${entry.key}: ${entry.value}');
}
}
}
class InfraValidateCommand extends _InfraSubcommand {
InfraValidateCommand({required super.fs, required super.runtimeRegistry}) {
argParser.addFlag('all', negatable: false, help: 'Validate all services.');
}
@override
final String name = 'validate';
@override
final String description = 'Validate service manifests and referenced files.';
@override
Future<void> run() async {
final env = await _environment();
final rest = argResults?.rest ?? const [];
final target = rest.isEmpty ? null : rest.first;
final validateAll =
(argResults?['all'] as bool? ?? false) || target == null;
final manifests = validateAll
? await env.repository.list()
: [await env.repository.get(target)];
final issues = <InfraValidationIssue>[];
for (final manifest in manifests) {
issues.addAll(await env.validator.validate(manifest));
}
if (env.json) {
print(
jsonEncode({
'valid': issues.isEmpty,
'issues': issues.map((issue) => issue.toJson()).toList(),
}),
);
} else if (issues.isEmpty) {
print('Infrastructure manifests are valid.');
} else {
for (final issue in issues) {
print(issue);
}
}
if (issues.isNotEmpty) io.exitCode = 1;
}
}
class InfraConfigureCommand extends _InfraSubcommand {
InfraConfigureCommand({required super.fs, required super.runtimeRegistry}) {
argParser
..addOption('file', help: 'JSON configuration file for apply.')
..addMultiOption('set', help: 'Set a dotted configuration key.');
}
@override
final String name = 'configure';
@override
final String description =
'Inspect or apply service configuration schema values.';
@override
Future<void> run() async {
final rest = argResults?.rest ?? const [];
if (rest.isEmpty) {
usageException(
'Usage: dew infra configure <service> [schema|show|apply].',
);
}
final env = await _environment();
final manifest = await env.repository.get(rest.first);
final action = rest.length > 1 ? rest[1] : 'tui';
switch (action) {
case 'schema':
await _printSchema(env, manifest.configureSchemaPath);
case 'show':
await _printPayload(env, manifest.activeConfigurePath);
case 'apply':
await _applyPayload(
env,
schemaPath: manifest.configureSchemaPath,
outputPath: manifest.activeConfigurePath,
);
case 'tui':
usageException(
'Interactive schema editor is not implemented yet. '
'Use "dew infra configure ${manifest.id} schema|show|apply".',
);
default:
usageException('Unknown configure action "$action".');
}
}
}
class InfraInitCommand extends _InfraSubcommand {
InfraInitCommand({required super.fs, required super.runtimeRegistry}) {
argParser
..addOption('file', help: 'JSON initialization file for run.')
..addMultiOption('set', help: 'Set a dotted initialization key.');
}
@override
final String name = 'init';
@override
final String description = 'Inspect or run service initialization options.';
@override
Future<void> run() async {
final rest = argResults?.rest ?? const [];
if (rest.isEmpty) {
usageException('Usage: dew infra init <service> [schema|run].');
}
final env = await _environment();
final manifest = await env.repository.get(rest.first);
final action = rest.length > 1 ? rest[1] : 'tui';
switch (action) {
case 'schema':
await _printSchema(env, manifest.initSchemaPath);
case 'run':
await _applyPayload(
env,
schemaPath: manifest.initSchemaPath,
outputPath: manifest.activeInitPath,
);
case 'tui':
usageException(
'Interactive schema editor is not implemented yet. '
'Use "dew infra init ${manifest.id} schema|run".',
);
default:
usageException('Unknown init action "$action".');
}
}
}
class InfraRuntimeCommand extends _InfraSubcommand {
InfraRuntimeCommand(
this.name, {
required super.fs,
required super.runtimeRegistry,
}) {
argParser.addFlag('all', negatable: false, help: 'Apply to all services.');
}
@override
final String name;
@override
String get description => switch (name) {
'install' => 'Install service runtime files.',
'uninstall' => 'Uninstall service runtime files.',
'up' => 'Install, reload, and start services.',
'down' => 'Stop services.',
'restart' => 'Restart services.',
'status' => 'Show service status.',
_ => 'Manage infrastructure services.',
};
@override
Future<void> run() async {
final env = await _environment();
final manifests = await _targetServices(env, allowAll: true);
final results = <Map<String, Object?>>[];
for (final manifest in manifests) {
final runtime = env.runtimeFor(manifest);
final result = await _runRuntimeCommand(env, manifest, runtime);
_printRuntimeResult(env, manifest, result);
results.add({'service': manifest.id, ...result.toJson()});
}
if (env.json) print(jsonEncode(results));
}
Future<InfraRuntimeResult> _runRuntimeCommand(
_InfraEnvironment env,
InfraServiceManifest manifest,
ContainerRuntime runtime,
) async {
switch (name) {
case 'install':
return runtime.install(manifest, scope: env.scope, dryRun: env.dryRun);
case 'uninstall':
return runtime.uninstall(
manifest,
scope: env.scope,
dryRun: env.dryRun,
);
case 'up':
final results = <InfraRuntimeResult>[];
if (!await runtime.isInstalled(manifest, env.scope)) {
results.add(
await runtime.install(
manifest,
scope: env.scope,
dryRun: env.dryRun,
),
);
}
results.add(await runtime.reload(scope: env.scope, dryRun: env.dryRun));
results.add(
await runtime.start(manifest, scope: env.scope, dryRun: env.dryRun),
);
return _combineRuntimeResults(results);
case 'down':
return runtime.stop(manifest, scope: env.scope, dryRun: env.dryRun);
case 'restart':
return runtime.restart(manifest, scope: env.scope, dryRun: env.dryRun);
case 'status':
return runtime.status(manifest, scope: env.scope);
default:
throw StateError('Unknown runtime command $name.');
}
}
}
class InfraLogsCommand extends _InfraSubcommand {
InfraLogsCommand({required super.fs, required super.runtimeRegistry}) {
argParser
..addFlag('follow', abbr: 'f', negatable: false, help: 'Follow logs.')
..addOption('lines', defaultsTo: '200', help: 'Number of log lines.');
}
@override
final String name = 'logs';
@override
final String description = 'Show service logs.';
@override
Future<void> run() async {
final env = await _environment();
final manifest = await env.repository.get(
_requiredServiceArg('Usage: dew infra logs <service>.'),
);
final lines = int.tryParse(argResults?['lines'] as String? ?? '') ?? 200;
final result = await env
.runtimeFor(manifest)
.logs(
manifest,
scope: env.scope,
follow: argResults?['follow'] as bool? ?? false,
lines: lines,
);
_printRuntimeResult(env, manifest, result);
if (env.json) {
print(jsonEncode({'service': manifest.id, ...result.toJson()}));
}
}
}
class InfraDeleteCommand extends _InfraSubcommand {
InfraDeleteCommand({required super.fs, required super.runtimeRegistry}) {
argParser
..addFlag(
'container',
negatable: false,
help: 'Delete the named container runtime artifact.',
)
..addFlag(
'data',
negatable: false,
help: 'Delete service data artifacts. Requires --yes.',
)
..addFlag('all', negatable: false, help: 'Apply to all services.');
}
@override
final String name = 'delete';
@override
final String description = 'Delete service runtime artifacts.';
@override
Future<void> run() async {
final env = await _environment();
final deleteData = argResults?['data'] as bool? ?? false;
if (deleteData && !env.yes) {
usageException('--data requires --yes.');
}
final manifests = await _targetServices(env, allowAll: true);
final results = <Map<String, Object?>>[];
for (final manifest in manifests) {
final result = await env
.runtimeFor(manifest)
.delete(
manifest,
scope: env.scope,
deleteContainer: argResults?['container'] as bool? ?? false,
deleteData: deleteData,
dryRun: env.dryRun,
);
_printRuntimeResult(env, manifest, result);
results.add({'service': manifest.id, ...result.toJson()});
}
if (env.json) print(jsonEncode(results));
}
}
Future<List<InfraServiceManifest>> _targetServices(
_InfraEnvironment env, {
required bool allowAll,
}) async {
final all = (env.options.command?['all'] as bool?) ?? false;
final rest = env.options.command?.rest ?? const <String>[];
if (all) return env.repository.list();
if (rest.isEmpty) {
throw UsageException('Missing service. Use a service id or --all.', '');
}
return [await env.repository.get(rest.first)];
}
Future<void> _printSchema(_InfraEnvironment env, String? schemaPath) async {
if (schemaPath == null) throw ArgumentError('No schema path is declared.');
final file = env.repository.fs.file(schemaPath);
if (!await file.exists()) {
throw ArgumentError('Schema not found: $schemaPath');
}
print(await file.readAsString());
}
Future<void> _printPayload(_InfraEnvironment env, String path) async {
final file = env.repository.fs.file(path);
if (!await file.exists()) {
if (env.json) {
print(jsonEncode(<String, Object?>{}));
} else {
print('No active configuration found at $path.');
}
return;
}
print(await file.readAsString());
}
Future<void> _applyPayload(
_InfraEnvironment env, {
required String? schemaPath,
required String outputPath,
}) async {
final payload = <String, dynamic>{};
final command = env.options.command!;
final filePath = command['file'] as String?;
if (filePath != null) {
payload.addAll(_readJsonObject(env, _resolveAgainstProject(env, filePath)));
}
for (final assignment in command['set'] as List<String>? ?? const []) {
_applyAssignment(payload, assignment);
}
await _validatePayload(env, schemaPath, payload);
if (env.dryRun) {
print('Would write $outputPath');
return;
}
final file = env.repository.fs.file(outputPath);
await file.parent.create(recursive: true);
await file.writeAsString(const JsonEncoder.withIndent(' ').convert(payload));
if (env.json) {
print(jsonEncode({'path': outputPath, 'config': payload}));
} else {
print('Wrote $outputPath');
}
}
Map<String, dynamic> _readJsonObject(_InfraEnvironment env, String path) {
final file = env.repository.fs.file(path);
final decoded = jsonDecode(file.readAsStringSync());
if (decoded is Map) {
return decoded.map((key, value) => MapEntry('$key', value));
}
throw FormatException('Expected JSON object in $path.');
}
Future<void> _validatePayload(
_InfraEnvironment env,
String? schemaPath,
Map<String, dynamic> payload,
) async {
if (schemaPath == null) return;
final schemaFile = env.repository.fs.file(schemaPath);
if (!await schemaFile.exists()) return;
final decoded = jsonDecode(await schemaFile.readAsString());
final result = JsonSchema.create(decoded).validate(payload);
if (!result.isValid) {
throw FormatException(result.errors.map((error) => '$error').join('\n'));
}
}
void _applyAssignment(Map<String, dynamic> payload, String assignment) {
final index = assignment.indexOf('=');
if (index <= 0) {
throw FormatException('Expected key=value assignment, got "$assignment".');
}
final key = assignment.substring(0, index);
final value = _parseValue(assignment.substring(index + 1));
var current = payload;
final parts = key.split('.');
for (final part in parts.take(parts.length - 1)) {
current =
current.putIfAbsent(part, () => <String, dynamic>{})
as Map<String, dynamic>;
}
current[parts.last] = value;
}
Object? _parseValue(String value) {
if (value == 'null') return null;
if (value == 'true') return true;
if (value == 'false') return false;
return int.tryParse(value) ?? double.tryParse(value) ?? value;
}
String _resolveAgainstProject(_InfraEnvironment env, String value) {
if (p.isAbsolute(value)) return p.normalize(value);
return p.normalize(p.join(env.projectContext.root, value));
}
void _printRuntimeResult(
_InfraEnvironment env,
InfraServiceManifest manifest,
InfraRuntimeResult result,
) {
if (env.json) return;
for (final action in result.actions) {
print('${manifest.id}: $action');
}
if (result.stdout.trim().isNotEmpty) print(result.stdout.trim());
if (result.stderr.trim().isNotEmpty) io.stderr.writeln(result.stderr.trim());
if (result.exitCode != 0) io.exitCode = result.exitCode;
}
InfraRuntimeResult _combineRuntimeResults(List<InfraRuntimeResult> results) {
final actions = <String>[];
final stdout = <String>[];
final stderr = <String>[];
var exitCode = 0;
for (final result in results) {
actions.addAll(result.actions);
if (result.stdout.trim().isNotEmpty) stdout.add(result.stdout.trim());
if (result.stderr.trim().isNotEmpty) stderr.add(result.stderr.trim());
if (result.exitCode != 0) exitCode = result.exitCode;
}
return InfraRuntimeResult(
actions: actions,
exitCode: exitCode,
stdout: stdout.join('\n'),
stderr: stderr.join('\n'),
);
}

View file

@ -0,0 +1,244 @@
import 'dart:convert';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:json_schema/json_schema.dart';
import 'package:path/path.dart' as p;
import 'service_manifest.dart';
/// Reads infrastructure manifests from `.project/infrastructure`.
class InfraRepository {
const InfraRepository({
required this.infraDir,
this.fs = const LocalFileSystem(),
});
/// Absolute path to the infrastructure root.
final String infraDir;
/// File system abstraction for tests and non-local callers.
final FileSystem fs;
/// Absolute path to the service directory root.
String get servicesDir => p.join(infraDir, 'services');
/// Finds all service manifests below `services/*/metadata.toml`.
Future<List<InfraServiceManifest>> list() async {
final root = fs.directory(servicesDir);
if (!await root.exists()) return const [];
final manifests = <InfraServiceManifest>[];
await for (final entity in root.list()) {
if (entity is! Directory) continue;
final metadata = fs.file(p.join(entity.path, 'metadata.toml'));
if (!await metadata.exists()) continue;
manifests.add(
await loadFromMetadataPath(metadata.path, serviceDir: entity.path),
);
}
manifests.sort((a, b) => a.id.compareTo(b.id));
return manifests;
}
/// Loads a single service by command-line [id].
Future<InfraServiceManifest> get(String id) async {
final manifest = await find(id);
if (manifest == null) {
throw ArgumentError('Infrastructure service "$id" not found.');
}
return manifest;
}
/// Loads a single service by command-line [id], returning null if absent.
Future<InfraServiceManifest?> find(String id) async {
final metadataPath = p.join(servicesDir, id, 'metadata.toml');
final file = fs.file(metadataPath);
if (!await file.exists()) return null;
return loadFromMetadataPath(
metadataPath,
serviceDir: p.dirname(metadataPath),
);
}
/// Parses the manifest at [metadataPath].
Future<InfraServiceManifest> loadFromMetadataPath(
String metadataPath, {
required String serviceDir,
}) async {
final file = fs.file(metadataPath);
return InfraServiceManifest.parse(
contents: await file.readAsString(),
serviceDir: p.normalize(serviceDir),
metadataPath: p.normalize(metadataPath),
);
}
}
/// A validation issue found in an infrastructure manifest or referenced file.
class InfraValidationIssue {
const InfraValidationIssue({
required this.serviceId,
required this.path,
required this.message,
});
/// Service id, or the best available directory name when parsing failed.
final String serviceId;
/// Path where the issue was discovered.
final String path;
/// Human-readable issue.
final String message;
/// Machine-readable issue.
Map<String, String> toJson() => {
'service': serviceId,
'path': path,
'message': message,
};
@override
String toString() => '$serviceId: $message ($path)';
}
/// Validates service manifests and their referenced files.
class InfraValidator {
const InfraValidator({this.fs = const LocalFileSystem()});
/// File system abstraction for tests and non-local callers.
final FileSystem fs;
/// Validates [manifest].
Future<List<InfraValidationIssue>> validate(
InfraServiceManifest manifest,
) async {
final issues = <InfraValidationIssue>[];
void issue(String path, String message) => issues.add(
InfraValidationIssue(
serviceId: manifest.id,
path: path,
message: message,
),
);
final dirId = p.basename(manifest.serviceDir);
if (manifest.id != dirId) {
issue(
manifest.metadataPath,
'service.id "${manifest.id}" must match directory "$dirId".',
);
}
if (!manifest.unit.endsWith('.service')) {
issue(manifest.metadataPath, 'service.unit must end with .service.');
}
if (manifest.unit != manifest.expectedUnit) {
issue(
manifest.metadataPath,
'service.unit "${manifest.unit}" must match container file unit '
'"${manifest.expectedUnit}".',
);
}
await _requireFile(manifest, manifest.containerFilePath, issues);
await _requireDirectoryIfDeclared(
manifest,
manifest.dropinsDirPath,
issues,
);
await _requireDirectoryIfDeclared(
manifest,
manifest.profilesDirPath,
issues,
);
await _validateJsonSchema(
manifest,
label: 'configure schema',
path: manifest.configureSchemaPath,
issues: issues,
);
await _validateJsonSchema(
manifest,
label: 'init schema',
path: manifest.initSchemaPath,
issues: issues,
);
return issues;
}
Future<void> _requireFile(
InfraServiceManifest manifest,
String path,
List<InfraValidationIssue> issues,
) async {
if (!await fs.file(path).exists()) {
issues.add(
InfraValidationIssue(
serviceId: manifest.id,
path: path,
message: 'Referenced file does not exist.',
),
);
}
}
Future<void> _requireDirectoryIfDeclared(
InfraServiceManifest manifest,
String? path,
List<InfraValidationIssue> issues,
) async {
if (path == null) return;
if (!await fs.directory(path).exists()) {
issues.add(
InfraValidationIssue(
serviceId: manifest.id,
path: path,
message: 'Referenced directory does not exist.',
),
);
}
}
Future<void> _validateJsonSchema(
InfraServiceManifest manifest, {
required String label,
required String? path,
required List<InfraValidationIssue> issues,
}) async {
if (path == null) {
issues.add(
InfraValidationIssue(
serviceId: manifest.id,
path: manifest.metadataPath,
message: 'Missing $label path.',
),
);
return;
}
final file = fs.file(path);
if (!await file.exists()) {
issues.add(
InfraValidationIssue(
serviceId: manifest.id,
path: path,
message: 'Referenced $label does not exist.',
),
);
return;
}
try {
final decoded = jsonDecode(await file.readAsString());
JsonSchema.create(decoded);
} catch (error) {
issues.add(
InfraValidationIssue(
serviceId: manifest.id,
path: path,
message: 'Invalid $label: $error',
),
);
}
}
}

View file

@ -0,0 +1,466 @@
import 'dart:io' as io;
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:path/path.dart' as p;
import 'package:podman/podman.dart' show PodmanClient;
import 'service_manifest.dart';
/// systemd scope for Quadlet units.
enum InfraScope {
/// User-level systemd and Podman Quadlet search paths.
user,
/// System-level systemd and Podman Quadlet search paths.
system;
/// Parses the CLI value.
static InfraScope parse(String value) => switch (value) {
'user' => user,
'system' => system,
_ => throw ArgumentError('Unknown infra scope "$value".'),
};
}
/// Process result returned by [InfraProcessRunner].
class InfraProcessResult {
const InfraProcessResult({
required this.exitCode,
this.stdout = '',
this.stderr = '',
});
/// Process exit code.
final int exitCode;
/// Captured stdout.
final String stdout;
/// Captured stderr.
final String stderr;
}
/// Runs local processes for runtime backends.
abstract interface class InfraProcessRunner {
/// Runs [executable] with [arguments].
Future<InfraProcessResult> run(String executable, List<String> arguments);
}
/// Local [io.Process.run] implementation.
class LocalInfraProcessRunner implements InfraProcessRunner {
const LocalInfraProcessRunner();
@override
Future<InfraProcessResult> run(
String executable,
List<String> arguments,
) async {
final result = await io.Process.run(executable, arguments);
return InfraProcessResult(
exitCode: result.exitCode,
stdout: '${result.stdout}',
stderr: '${result.stderr}',
);
}
}
/// Result of a runtime operation.
class InfraRuntimeResult {
const InfraRuntimeResult({
this.actions = const [],
this.exitCode = 0,
this.stdout = '',
this.stderr = '',
});
/// Actions performed or planned.
final List<String> actions;
/// Runtime command exit code.
final int exitCode;
/// Captured stdout.
final String stdout;
/// Captured stderr.
final String stderr;
/// Machine-readable result.
Map<String, Object?> toJson() => {
'actions': actions,
'exit_code': exitCode,
'stdout': stdout,
'stderr': stderr,
};
}
/// Runtime backend boundary used by `dew infra`.
abstract interface class ContainerRuntime {
/// Runtime kind handled by this backend.
InfraRuntimeKind get kind;
/// Returns true when the service's runtime files are installed.
Future<bool> isInstalled(InfraServiceManifest manifest, InfraScope scope);
/// Installs service runtime files.
Future<InfraRuntimeResult> install(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
});
/// Uninstalls service runtime files.
Future<InfraRuntimeResult> uninstall(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
});
/// Reloads runtime service discovery.
Future<InfraRuntimeResult> reload({
required InfraScope scope,
required bool dryRun,
});
/// Starts the service.
Future<InfraRuntimeResult> start(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
});
/// Stops the service.
Future<InfraRuntimeResult> stop(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
});
/// Restarts the service.
Future<InfraRuntimeResult> restart(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
});
/// Reads service status.
Future<InfraRuntimeResult> status(
InfraServiceManifest manifest, {
required InfraScope scope,
});
/// Reads service logs.
Future<InfraRuntimeResult> logs(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool follow,
required int lines,
});
/// Deletes runtime artifacts.
Future<InfraRuntimeResult> delete(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool deleteContainer,
required bool deleteData,
required bool dryRun,
});
}
/// Factory for runtime backends.
class ContainerRuntimeRegistry {
const ContainerRuntimeRegistry(this.runtimes);
/// Registered runtimes.
final List<ContainerRuntime> runtimes;
/// Finds the runtime for [kind].
ContainerRuntime forKind(InfraRuntimeKind kind) => runtimes.firstWhere(
(runtime) => runtime.kind == kind,
orElse: () => throw StateError('No runtime registered for ${kind.id}.'),
);
}
/// Podman Quadlet backend.
class PodmanQuadletRuntime implements ContainerRuntime {
PodmanQuadletRuntime({
this.fs = const LocalFileSystem(),
this.processRunner = const LocalInfraProcessRunner(),
Map<String, String>? environment,
PodmanClient Function()? podmanClientFactory,
}) : environment = environment ?? io.Platform.environment,
podmanClientFactory = podmanClientFactory ?? PodmanClient.new;
/// File system used for Quadlet file operations.
final FileSystem fs;
/// Process runner used for systemd, journalctl, and CLI cleanup commands.
final InfraProcessRunner processRunner;
/// Environment used for Quadlet search path resolution.
final Map<String, String> environment;
/// Podman API client factory reserved for backend operations.
final PodmanClient Function() podmanClientFactory;
@override
InfraRuntimeKind get kind => InfraRuntimeKind.podmanQuadlet;
/// Creates a Podman API client for future backend operations.
PodmanClient createPodmanClient() => podmanClientFactory();
@override
Future<bool> isInstalled(
InfraServiceManifest manifest,
InfraScope scope,
) async =>
await fs.link(_targetContainerPath(manifest, scope)).exists() ||
await fs.file(_targetContainerPath(manifest, scope)).exists();
@override
Future<InfraRuntimeResult> install(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async {
final actions = <String>[];
final targetDir = quadletSearchPath(scope, environment: environment);
final targetFile = _targetContainerPath(manifest, scope);
await _action(
actions,
dryRun,
'create $targetDir',
() => fs.directory(targetDir).create(recursive: true),
);
await _link(actions, dryRun, manifest.containerFilePath, targetFile);
final dropinsPath = manifest.dropinsDirPath;
if (dropinsPath != null && await fs.directory(dropinsPath).exists()) {
final targetDropins = p.join(targetDir, p.basename(dropinsPath));
await _action(
actions,
dryRun,
'create $targetDropins',
() => fs.directory(targetDropins).create(recursive: true),
);
await for (final entity in fs.directory(dropinsPath).list()) {
if (entity is! File) continue;
await _link(
actions,
dryRun,
entity.path,
p.join(targetDropins, p.basename(entity.path)),
);
}
}
return InfraRuntimeResult(actions: actions);
}
@override
Future<InfraRuntimeResult> uninstall(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async {
final actions = <String>[];
await _deletePath(actions, dryRun, _targetContainerPath(manifest, scope));
final dropinsPath = manifest.dropinsDirPath;
if (dropinsPath != null) {
await _deletePath(
actions,
dryRun,
p.join(
quadletSearchPath(scope, environment: environment),
p.basename(dropinsPath),
),
);
}
return InfraRuntimeResult(actions: actions);
}
@override
Future<InfraRuntimeResult> start(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async => _systemctl(scope, ['start', manifest.unit], dryRun: dryRun);
@override
Future<InfraRuntimeResult> stop(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async => _systemctl(scope, ['stop', manifest.unit], dryRun: dryRun);
@override
Future<InfraRuntimeResult> restart(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async => _systemctl(scope, ['restart', manifest.unit], dryRun: dryRun);
@override
Future<InfraRuntimeResult> status(
InfraServiceManifest manifest, {
required InfraScope scope,
}) async =>
_systemctl(scope, ['status', manifest.unit, '--no-pager'], dryRun: false);
@override
Future<InfraRuntimeResult> logs(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool follow,
required int lines,
}) async {
final args = [
if (scope == InfraScope.user) '--user',
'-u',
manifest.unit,
'-n',
'$lines',
if (follow) '-f',
];
final action = 'journalctl ${args.join(' ')}';
final result = await processRunner.run('journalctl', args);
return InfraRuntimeResult(
actions: [action],
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
);
}
@override
Future<InfraRuntimeResult> delete(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool deleteContainer,
required bool deleteData,
required bool dryRun,
}) async {
final actions = <String>[];
final outputs = <String>[];
final errors = <String>[];
var exitCode = 0;
if (deleteContainer) {
final args = ['rm', '--ignore', '--force', manifest.containerName];
final action = 'podman ${args.join(' ')}';
actions.add(action);
if (!dryRun) {
final result = await processRunner.run('podman', args);
exitCode = result.exitCode;
if (result.stdout.trim().isNotEmpty) outputs.add(result.stdout.trim());
if (result.stderr.trim().isNotEmpty) errors.add(result.stderr.trim());
}
}
if (deleteData) {
actions.add('delete data artifacts for ${manifest.id}');
}
if (actions.isEmpty) {
actions.add('no runtime artifacts requested for ${manifest.id}');
}
return InfraRuntimeResult(
actions: actions,
exitCode: exitCode,
stdout: outputs.join('\n'),
stderr: errors.join('\n'),
);
}
@override
Future<InfraRuntimeResult> reload({
required InfraScope scope,
required bool dryRun,
}) => _systemctl(scope, ['daemon-reload'], dryRun: dryRun);
String _targetContainerPath(
InfraServiceManifest manifest,
InfraScope scope,
) => p.join(
quadletSearchPath(scope, environment: environment),
p.basename(manifest.containerFile),
);
Future<void> _link(
List<String> actions,
bool dryRun,
String source,
String target,
) async {
await _action(actions, dryRun, 'link $source -> $target', () async {
await _deleteIfExists(target);
await fs.link(target).create(source, recursive: true);
});
}
Future<void> _deletePath(
List<String> actions,
bool dryRun,
String target,
) async {
await _action(
actions,
dryRun,
'delete $target',
() => _deleteIfExists(target),
);
}
Future<void> _deleteIfExists(String target) async {
final type = await fs.type(target, followLinks: false);
if (type == FileSystemEntityType.notFound) return;
if (type == FileSystemEntityType.directory) {
await fs.directory(target).delete(recursive: true);
} else if (type == FileSystemEntityType.link) {
await fs.link(target).delete();
} else {
await fs.file(target).delete();
}
}
Future<void> _action(
List<String> actions,
bool dryRun,
String description,
Future<void> Function() apply,
) async {
actions.add(description);
if (!dryRun) await apply();
}
Future<InfraRuntimeResult> _systemctl(
InfraScope scope,
List<String> arguments, {
required bool dryRun,
}) async {
final args = [if (scope == InfraScope.user) '--user', ...arguments];
final action = 'systemctl ${args.join(' ')}';
if (dryRun) return InfraRuntimeResult(actions: [action]);
final result = await processRunner.run('systemctl', args);
return InfraRuntimeResult(
actions: [action],
exitCode: result.exitCode,
stdout: result.stdout,
stderr: result.stderr,
);
}
}
/// Resolves the Quadlet search path for [scope].
String quadletSearchPath(
InfraScope scope, {
Map<String, String> environment = const {},
}) {
if (scope == InfraScope.system) return '/etc/containers/systemd';
final xdgConfigHome = environment['XDG_CONFIG_HOME'];
if (xdgConfigHome != null && xdgConfigHome.isNotEmpty) {
return p.join(xdgConfigHome, 'containers', 'systemd');
}
final home = environment['HOME'] ?? environment['USERPROFILE'] ?? '';
return p.join(home.isEmpty ? '~' : home, '.config', 'containers', 'systemd');
}

View file

@ -0,0 +1,198 @@
import 'package:path/path.dart' as p;
import 'package:toml/toml.dart';
/// Supported infrastructure runtime backends.
///
/// Dew starts with Podman Quadlets, but commands depend on this enum instead of
/// directly depending on Quadlet paths so additional runtimes can be introduced
/// without changing the command contract.
enum InfraRuntimeKind {
/// Podman Quadlet files consumed by systemd.
podmanQuadlet('podman-quadlet');
const InfraRuntimeKind(this.id);
/// Stable manifest identifier.
final String id;
static InfraRuntimeKind fromManifestValue(String? value) {
final normalized = value ?? podmanQuadlet.id;
return values.firstWhere(
(kind) => kind.id == normalized,
orElse: () => throw FormatException(
'Unsupported infrastructure runtime "$normalized".',
),
);
}
}
/// Infrastructure service metadata loaded from `metadata.toml`.
class InfraServiceManifest {
const InfraServiceManifest({
required this.id,
required this.name,
required this.unit,
required this.containerName,
required this.runtime,
required this.serviceDir,
required this.metadataPath,
required this.containerFile,
this.dropinsDir,
this.profilesDir,
this.configureSchema,
this.initSchema,
});
/// Stable service identifier used on the command line.
final String id;
/// Human-friendly display name.
final String name;
/// systemd unit name generated by the runtime backend.
final String unit;
/// Container name used for runtime cleanup/status operations.
final String containerName;
/// Runtime backend declared by the manifest.
final InfraRuntimeKind runtime;
/// Absolute path to the service directory.
final String serviceDir;
/// Absolute path to `metadata.toml`.
final String metadataPath;
/// Relative path to the primary Quadlet/container definition.
final String containerFile;
/// Optional relative path to the Quadlet drop-ins directory.
final String? dropinsDir;
/// Optional relative path to profile drop-ins.
final String? profilesDir;
/// Optional relative path to the configure JSON Schema.
final String? configureSchema;
/// Optional relative path to the init JSON Schema.
final String? initSchema;
/// Absolute path to the primary container file.
String get containerFilePath => _resolve(containerFile);
/// Absolute path to the declared drop-ins directory, if any.
String? get dropinsDirPath =>
dropinsDir == null ? null : _resolve(dropinsDir!);
/// Absolute path to the declared profiles directory, if any.
String? get profilesDirPath =>
profilesDir == null ? null : _resolve(profilesDir!);
/// Absolute path to the configure schema, if any.
String? get configureSchemaPath =>
configureSchema == null ? null : _resolve(configureSchema!);
/// Absolute path to the init schema, if any.
String? get initSchemaPath =>
initSchema == null ? null : _resolve(initSchema!);
/// Directory for active and generated service configuration.
String get configDir => p.join(serviceDir, 'config');
/// Active configure payload path.
String get activeConfigurePath => p.join(configDir, 'configure.json');
/// Active init payload path.
String get activeInitPath => p.join(configDir, 'init.json');
/// Unit name expected from the primary Quadlet filename.
String get expectedUnit =>
'${p.basenameWithoutExtension(containerFile)}.service';
/// Decodes [contents] from TOML.
factory InfraServiceManifest.parse({
required String contents,
required String serviceDir,
required String metadataPath,
}) {
final map = TomlDocument.parse(contents).toMap();
final service = _section(map, 'service');
final container = _section(map, 'container');
final schemas = _optionalSection(map, 'schemas');
final runtimeSection = _optionalSection(map, 'runtime');
return InfraServiceManifest(
id: _requiredString(service, 'id'),
name: _requiredString(service, 'name'),
unit: _requiredString(service, 'unit'),
containerName: _requiredString(service, 'container_name'),
runtime: InfraRuntimeKind.fromManifestValue(
runtimeSection == null ? null : _optionalString(runtimeSection, 'type'),
),
serviceDir: serviceDir,
metadataPath: metadataPath,
containerFile: _requiredString(container, 'file'),
dropinsDir: _optionalString(container, 'dropins_dir'),
profilesDir: _optionalString(container, 'profiles_dir'),
configureSchema: schemas == null
? null
: _optionalString(schemas, 'configure'),
initSchema: schemas == null ? null : _optionalString(schemas, 'init'),
);
}
/// Machine-readable summary.
Map<String, Object?> toJson() => {
'id': id,
'name': name,
'unit': unit,
'container_name': containerName,
'runtime': runtime.id,
'metadata': metadataPath,
'container_file': containerFilePath,
'dropins_dir': dropinsDirPath,
'profiles_dir': profilesDirPath,
'configure_schema': configureSchemaPath,
'init_schema': initSchemaPath,
'active_config': activeConfigurePath,
'active_init': activeInitPath,
};
String _resolve(String value) => p.isAbsolute(value)
? p.normalize(value)
: p.normalize(p.join(serviceDir, value));
}
Map<String, dynamic> _section(Map<String, dynamic> map, String key) {
final value = map[key];
if (value is Map) {
return value.map((key, value) => MapEntry('$key', value));
}
throw FormatException('metadata.toml is missing [$key].');
}
Map<String, dynamic>? _optionalSection(Map<String, dynamic> map, String key) {
final value = map[key];
if (value == null) return null;
if (value is Map) {
return value.map((key, value) => MapEntry('$key', value));
}
throw FormatException('metadata.toml section [$key] must be a table.');
}
String _requiredString(Map<String, dynamic> map, String key) {
final value = _optionalString(map, key);
if (value == null || value.isEmpty) {
throw FormatException('metadata.toml is missing required string "$key".');
}
return value;
}
String? _optionalString(Map<String, dynamic> map, String key) {
final value = map[key];
if (value == null) return null;
if (value is String) return value;
throw FormatException('metadata.toml field "$key" must be a string.');
}

View file

@ -0,0 +1,23 @@
name: dew_infra
description: Infrastructure service management package for the Dew CLI.
publish_to: none
version: 0.1.0
repository: https://github.com/artificerchris/dew
issue_tracker: https://github.com/artificerchris/dew/issues
resolution: workspace
environment:
sdk: ^3.12.0
dependencies:
args: ^2.7.0
dew_core: ^0.1.0
file: ^7.0.1
json_schema: ^5.2.2
path: ^1.9.0
podman: ^0.1.0
toml: ^0.18.0
dev_dependencies:
lints: ^6.0.0
test: ^1.25.6

View file

@ -0,0 +1,186 @@
import 'package:dew_core/dew_core.dart';
import 'package:dew_infra/dew_infra.dart';
import 'package:file/memory.dart';
import 'package:test/test.dart';
void main() {
group('Infra command registration', () {
test('registerCommands adds infra command', () {
final registry = CommandRegistry();
registerCommands(registry);
expect(
registry.commands.map((command) => command.name),
contains('infra'),
);
});
test('infra command exposes core subcommands', () {
final command = InfraCommand();
expect(
command.subcommands.keys,
containsAll([
'list',
'show',
'validate',
'configure',
'init',
'install',
'uninstall',
'up',
'down',
'restart',
'status',
'logs',
'delete',
]),
);
});
});
group('InfraRepository', () {
test('discovers service metadata', () async {
final fs = MemoryFileSystem.test();
_writeService(fs);
final repository = InfraRepository(
infraDir: '/project/.project/infrastructure',
fs: fs,
);
final manifests = await repository.list();
expect(manifests, hasLength(1));
expect(manifests.single.id, 'postgres');
expect(manifests.single.runtime, InfraRuntimeKind.podmanQuadlet);
expect(
manifests.single.containerFilePath,
'/project/.project/infrastructure/services/postgres/app_postgres.container',
);
});
});
group('InfraValidator', () {
test('accepts a complete service manifest', () async {
final fs = MemoryFileSystem.test();
_writeService(fs);
final manifest = await InfraRepository(
infraDir: '/project/.project/infrastructure',
fs: fs,
).get('postgres');
final issues = await InfraValidator(fs: fs).validate(manifest);
expect(issues, isEmpty);
});
test('reports service id and unit mismatches', () async {
final fs = MemoryFileSystem.test();
_writeService(fs, serviceId: 'wrong', unit: 'wrong.service');
final manifest =
await InfraRepository(
infraDir: '/project/.project/infrastructure',
fs: fs,
).loadFromMetadataPath(
'/project/.project/infrastructure/services/postgres/metadata.toml',
serviceDir: '/project/.project/infrastructure/services/postgres',
);
final issues = await InfraValidator(fs: fs).validate(manifest);
expect(
issues.map((issue) => issue.message).join('\n'),
contains('must match directory'),
);
expect(
issues.map((issue) => issue.message).join('\n'),
contains('must match container file unit'),
);
});
});
group('PodmanQuadletRuntime', () {
test(
'install dry-run reports symlink actions without writing files',
() async {
final fs = MemoryFileSystem.test();
_writeService(fs);
final manifest = await InfraRepository(
infraDir: '/project/.project/infrastructure',
fs: fs,
).get('postgres');
final runtime = PodmanQuadletRuntime(
fs: fs,
environment: const {'HOME': '/home/test'},
);
final result = await runtime.install(
manifest,
scope: InfraScope.user,
dryRun: true,
);
expect(result.actions.join('\n'), contains('app_postgres.container'));
expect(
await fs
.link(
'/home/test/.config/containers/systemd/app_postgres.container',
)
.exists(),
isFalse,
);
},
);
test('quadletSearchPath respects user and system scope', () {
expect(
quadletSearchPath(
InfraScope.user,
environment: const {'XDG_CONFIG_HOME': '/config'},
),
'/config/containers/systemd',
);
expect(quadletSearchPath(InfraScope.system), '/etc/containers/systemd');
});
});
}
void _writeService(
MemoryFileSystem fs, {
String serviceId = 'postgres',
String unit = 'app_postgres.service',
}) {
final serviceDir = fs.directory(
'/project/.project/infrastructure/services/postgres',
)..createSync(recursive: true);
fs.directory('${serviceDir.path}/app_postgres.container.d').createSync();
fs.directory('${serviceDir.path}/app_postgres.profiles.d').createSync();
fs
.file('${serviceDir.path}/app_postgres.container')
.writeAsStringSync('[Container]\nImage=postgres:16\n');
fs
.file('${serviceDir.path}/configure.schema.json')
.writeAsStringSync('{"type":"object"}');
fs
.file('${serviceDir.path}/init.schema.json')
.writeAsStringSync('{"type":"object"}');
fs.file('${serviceDir.path}/metadata.toml').writeAsStringSync('''
[service]
id = "$serviceId"
name = "PostgreSQL"
unit = "$unit"
container_name = "app_postgres"
[runtime]
type = "podman-quadlet"
[container]
file = "app_postgres.container"
dropins_dir = "app_postgres.container.d"
profiles_dir = "app_postgres.profiles.d"
[schemas]
configure = "configure.schema.json"
init = "init.schema.json"
''');
}

View file

@ -257,6 +257,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.0"
json_schema:
dependency: transitive
description:
name: json_schema
sha256: f37d9c3fdfe8c9aae55fdfd5af815d24ce63c3a0f6a2c1f0982c30f43643fa1a
url: "https://pub.dev"
source: hosted
version: "5.2.2"
lints:
dependency: "direct dev"
description:
@ -353,6 +361,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.6"
podman:
dependency: transitive
description:
name: podman
sha256: "1be0decc40041c7bed4cc0149695392b7ad6cc1f358fab935c7866a1d6c82eff"
url: "https://pub.dev"
source: hosted
version: "0.1.0"
pointycastle:
dependency: transitive
description:
@ -409,6 +425,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
quiver:
dependency: transitive
description:
name: quiver
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
url: "https://pub.dev"
source: hosted
version: "3.2.2"
rfc_6901:
dependency: transitive
description:
name: rfc_6901
sha256: "6a43b1858dca2febaf93e15639aa6b0c49ccdfd7647775f15a499f872b018154"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
shelf:
dependency: transitive
description:
@ -529,6 +561,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.6.17"
toml:
dependency: transitive
description:
name: toml
sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
url: "https://pub.dev"
source: hosted
version: "0.18.0"
typed_data:
dependency: transitive
description:
@ -537,6 +577,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
uri:
dependency: transitive
description:
name: uri
sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
vm_service:
dependency: transitive
description:

View file

@ -9,6 +9,7 @@ environment:
workspace:
- packages/cli
- packages/core
- packages/infra
- packages/kanban
- packages/mcp
- packages/vault