Add infra command surface
This commit is contained in:
parent
f7346b1afe
commit
95058f7f04
17 changed files with 1980 additions and 2 deletions
8
.project/kanban/done/DEW-0029.md
Normal file
8
.project/kanban/done/DEW-0029.md
Normal 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.
|
||||||
|
|
@ -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.
|
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
|
### Interactive TUI
|
||||||
|
|
||||||
`dew kanban tui` opens a full Trello-style terminal board with three modes:
|
`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
|
## Documentation
|
||||||
|
|
||||||
- [Full documentation index](./docs/index.md)
|
- [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
|
- [Kanban board](./docs/features/kanban.md) — CLI commands, TUI keybindings, ticket format
|
||||||
- [MCP server](./docs/features/mcp.md) — AI agent integration
|
- [MCP server](./docs/features/mcp.md) — AI agent integration
|
||||||
- [Configuration reference](./docs/config.md)
|
- [Configuration reference](./docs/config.md)
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ your-project/
|
||||||
Path-like values in `dew.yaml` are resolved relative to `.project/dew.yaml`
|
Path-like values in `dew.yaml` are resolved relative to `.project/dew.yaml`
|
||||||
unless they are absolute (for example, paths under `dew.vault`).
|
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
|
## Full Schema
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|
|
||||||
72
docs/features/infra.md
Normal file
72
docs/features/infra.md
Normal 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.
|
||||||
|
|
@ -9,6 +9,7 @@ Welcome to the documentation for the Dew project management tool!
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- [Kanban Board](./features/kanban.md) — Visualize and manage tasks in a column-based workflow
|
- [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
|
- [MCP Server](./features/mcp.md) — AI agent integration via the Model Context Protocol
|
||||||
|
|
||||||
## Package Architecture
|
## 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/cli` | The `dew` command-line tool. Wires all packages together at startup. |
|
||||||
| `packages/core` | Shared foundation: `DewCommand`, `DewToolCommand` mixin, `CommandRegistry`, and `DewConfig`. |
|
| `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/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. |
|
| `packages/mcp` | The MCP server. Collects tools from `CommandRegistry` and serves them over stdio. |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:args/command_runner.dart';
|
import 'package:args/command_runner.dart';
|
||||||
import 'package:dew_core/dew_core.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_kanban/dew_kanban.dart' as kanban;
|
||||||
import 'package:dew_mcp/dew_mcp.dart' as mcp;
|
import 'package:dew_mcp/dew_mcp.dart' as mcp;
|
||||||
import 'package:dew_vault/dew_vault.dart' as vault;
|
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 {
|
Future<void> main(List<String> args) async {
|
||||||
final commandRegistry = CommandRegistry();
|
final commandRegistry = CommandRegistry();
|
||||||
|
|
||||||
|
infra.registerCommands(commandRegistry);
|
||||||
kanban.registerCommands(commandRegistry);
|
kanban.registerCommands(commandRegistry);
|
||||||
vault.registerCommands(commandRegistry);
|
vault.registerCommands(commandRegistry);
|
||||||
mcp.registerCommands(commandRegistry);
|
mcp.registerCommands(commandRegistry);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ environment:
|
||||||
dependencies:
|
dependencies:
|
||||||
args: ^2.7.0
|
args: ^2.7.0
|
||||||
dew_core: ^0.1.0
|
dew_core: ^0.1.0
|
||||||
|
dew_infra: ^0.1.0
|
||||||
dew_kanban: ^0.1.0
|
dew_kanban: ^0.1.0
|
||||||
dew_vault: ^0.3.0
|
dew_vault: ^0.3.0
|
||||||
dew_mcp: ^0.1.0
|
dew_mcp: ^0.1.0
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:args/command_runner.dart';
|
import 'package:args/command_runner.dart';
|
||||||
import 'package:dew_core/dew_core.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_kanban/dew_kanban.dart' as kanban;
|
||||||
import 'package:dew_mcp/dew_mcp.dart' as mcp;
|
import 'package:dew_mcp/dew_mcp.dart' as mcp;
|
||||||
import 'package:dew_vault/dew_vault.dart' as vault;
|
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.
|
/// Builds the same CommandRunner as bin/dew.dart without actually running it.
|
||||||
CommandRunner<void> buildRunner() {
|
CommandRunner<void> buildRunner() {
|
||||||
final commandRegistry = CommandRegistry();
|
final commandRegistry = CommandRegistry();
|
||||||
|
infra.registerCommands(commandRegistry);
|
||||||
kanban.registerCommands(commandRegistry);
|
kanban.registerCommands(commandRegistry);
|
||||||
vault.registerCommands(commandRegistry);
|
vault.registerCommands(commandRegistry);
|
||||||
mcp.registerCommands(commandRegistry);
|
mcp.registerCommands(commandRegistry);
|
||||||
|
|
@ -26,9 +28,12 @@ void main() {
|
||||||
expect(buildRunner, returnsNormally);
|
expect(buildRunner, returnsNormally);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has kanban, vault, init, and mcp commands registered', () {
|
test('has core package commands registered', () {
|
||||||
final runner = buildRunner();
|
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 {
|
test('--help flag does not throw', () async {
|
||||||
|
|
|
||||||
20
packages/infra/lib/dew_infra.dart
Normal file
20
packages/infra/lib/dew_infra.dart
Normal 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));
|
||||||
|
}
|
||||||
692
packages/infra/lib/src/dew_infra_base.dart
Normal file
692
packages/infra/lib/src/dew_infra_base.dart
Normal 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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
244
packages/infra/lib/src/infra_repository.dart
Normal file
244
packages/infra/lib/src/infra_repository.dart
Normal 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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
466
packages/infra/lib/src/infra_runtime.dart
Normal file
466
packages/infra/lib/src/infra_runtime.dart
Normal 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');
|
||||||
|
}
|
||||||
198
packages/infra/lib/src/service_manifest.dart
Normal file
198
packages/infra/lib/src/service_manifest.dart
Normal 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.');
|
||||||
|
}
|
||||||
23
packages/infra/pubspec.yaml
Normal file
23
packages/infra/pubspec.yaml
Normal 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
|
||||||
186
packages/infra/test/dew_infra_test.dart
Normal file
186
packages/infra/test/dew_infra_test.dart
Normal 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"
|
||||||
|
''');
|
||||||
|
}
|
||||||
48
pubspec.lock
48
pubspec.lock
|
|
@ -257,6 +257,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.0"
|
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:
|
lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|
@ -353,6 +361,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.6"
|
version: "3.1.6"
|
||||||
|
podman:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: podman
|
||||||
|
sha256: "1be0decc40041c7bed4cc0149695392b7ad6cc1f358fab935c7866a1d6c82eff"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0"
|
||||||
pointycastle:
|
pointycastle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -409,6 +425,22 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.0"
|
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:
|
shelf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -529,6 +561,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.17"
|
version: "0.6.17"
|
||||||
|
toml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: toml
|
||||||
|
sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.18.0"
|
||||||
typed_data:
|
typed_data:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
@ -537,6 +577,14 @@ packages:
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
|
uri:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: uri
|
||||||
|
sha256: "889eea21e953187c6099802b7b4cf5219ba8f3518f604a1033064d45b1b8268a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.0.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ environment:
|
||||||
workspace:
|
workspace:
|
||||||
- packages/cli
|
- packages/cli
|
||||||
- packages/core
|
- packages/core
|
||||||
|
- packages/infra
|
||||||
- packages/kanban
|
- packages/kanban
|
||||||
- packages/mcp
|
- packages/mcp
|
||||||
- packages/vault
|
- packages/vault
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue