Compare commits

..

1 commit
v0.4.0 ... main

Author SHA1 Message Date
373e8c0949 Release 0.2.0 2026-05-02 14:02:39 -04:00
138 changed files with 115 additions and 6698 deletions

10
.gitignore vendored
View file

@ -1,8 +1,6 @@
# Dew Git Ignore # https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
## Dart
.dart_tool/ .dart_tool/
## JetBrains # Compiled toolchain binaries
.idea/ .project/toolchain/
*.iml

3
.project/.gitignore vendored
View file

@ -1,3 +0,0 @@
/cache/
/secrets/
/toolchain/

View file

@ -1,16 +0,0 @@
# Dew Infrastructure Samples
This directory contains sample services that exercise `dew infra` using the same
layout a project would use for local infrastructure.
Each service lives under `services/<service-id>/` and is discovered from its
`manifest.yaml`.
Included samples:
- `postgresql-18`: single PostgreSQL container with a named data volume.
- `valkey-9`: cache container backed by a Quadlet volume.
- `rustfs`: S3-compatible object storage on a Quadlet network and volume.
- `keycloak`: multi-container service with PostgreSQL on a shared network.
- `app-pod`: Podman pod with web and sidecar containers.
- `local-api-build`: local image build consumed by a container Quadlet.

View file

@ -1,23 +0,0 @@
# App Pod
Sample local pod service managed by `dew infra` and Podman Quadlets.
```bash
dew infra validate app-pod
dew infra up app-pod
dew infra status app-pod
dew infra logs app-pod --lines 100
```
This sample shows a Podman pod with a web container and a sidecar container.
- web endpoint: `http://127.0.0.1:8088`
- pod: `dew_app-pod`
- web container: `dew_app-pod-web`
- sidecar container: `dew_app-pod-sidecar`
Stop it with:
```bash
dew infra down app-pod
```

View file

@ -1,17 +0,0 @@
[Unit]
Description=Dew sample pod sidecar container
Requires=dew_app-pod.pod
After=dew_app-pod.pod
[Container]
Image=docker.io/library/busybox:1.37
ContainerName=dew_app-pod-sidecar
Pod=dew_app-pod.pod
Exec=sh -c "while true; do date; sleep 60; done"
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=default.target

View file

@ -1,16 +0,0 @@
[Unit]
Description=Dew sample pod web container
Requires=dew_app-pod.pod
After=dew_app-pod.pod
[Container]
Image=docker.io/library/nginx:alpine
ContainerName=dew_app-pod-web
Pod=dew_app-pod.pod
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=default.target

View file

@ -1,6 +0,0 @@
[Pod]
PodName=dew_app-pod
PublishPort=127.0.0.1:8088:80
[Install]
WantedBy=default.target

View file

@ -1,19 +0,0 @@
id: app-pod
name: App Pod
runtime:
type: podman-quadlet
quadlets:
- file: dew_app-pod.pod
unit: dew_app-pod-pod.service
- file: dew_app-pod-web.container
unit: dew_app-pod-web.service
container_name: dew_app-pod-web
- file: dew_app-pod-sidecar.container
unit: dew_app-pod-sidecar.service
container_name: dew_app-pod-sidecar
schemas:
configure: schemas/configure.schema.json
init: schemas/init.schema.json

View file

@ -1,21 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/samples/app-pod/configure.schema.json",
"title": "App Pod Sample Configuration",
"description": "Configuration values represented by the sample pod Quadlets.",
"type": "object",
"additionalProperties": false,
"properties": {
"host_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 8088
},
"pod_name": {
"type": "string",
"minLength": 1,
"default": "dew_app-pod"
}
}
}

View file

@ -1,9 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/samples/app-pod/init.schema.json",
"title": "App Pod Sample Initialization",
"description": "Initialization values for the sample pod service.",
"type": "object",
"additionalProperties": false,
"properties": {}
}

View file

@ -1,28 +0,0 @@
# Keycloak
Sample local Keycloak service managed by `dew infra` and Podman Quadlets.
```bash
dew infra validate keycloak
dew infra up keycloak
dew infra status keycloak
dew infra logs keycloak --lines 100
```
This sample shows a multi-container service with an explicit Quadlet network and
a PostgreSQL dependency.
- Keycloak: `http://127.0.0.1:8080`
- admin user: `admin`
- admin password: `admin`
- database: `keycloak`
- database container: `dew_keycloak-postgresql`
- database volume: `dew_keycloak_postgresql_data`
Stop it with:
```bash
dew infra down keycloak
```
The PostgreSQL volume is intentionally retained after stopping the service.

View file

@ -1,21 +0,0 @@
[Unit]
Description=Dew sample Keycloak PostgreSQL
Requires=dew_keycloak.network dew_keycloak-postgresql.volume
After=dew_keycloak.network dew_keycloak-postgresql.volume
[Container]
Image=docker.io/library/postgres:18
ContainerName=dew_keycloak-postgresql
Network=dew_keycloak.network
NetworkAlias=postgres
Volume=dew_keycloak-postgresql.volume:/var/lib/postgresql
Environment=POSTGRES_DB=keycloak
Environment=POSTGRES_USER=keycloak
Environment=POSTGRES_PASSWORD=keycloak_dev_password
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=default.target

View file

@ -1,2 +0,0 @@
[Volume]
VolumeName=dew_keycloak_postgresql_data

View file

@ -1,24 +0,0 @@
[Unit]
Description=Dew sample Keycloak
Requires=dew_keycloak.network dew_keycloak-postgresql.container
After=dew_keycloak.network dew_keycloak-postgresql.container
[Container]
Image=quay.io/keycloak/keycloak:26.6.1
ContainerName=dew_keycloak
Network=dew_keycloak.network
PublishPort=127.0.0.1:8080:8080
Environment=KC_BOOTSTRAP_ADMIN_USERNAME=admin
Environment=KC_BOOTSTRAP_ADMIN_PASSWORD=admin
Environment=KC_DB=postgres
Environment=KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak
Environment=KC_DB_USERNAME=keycloak
Environment=KC_DB_PASSWORD=keycloak_dev_password
Exec=start-dev
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=default.target

View file

@ -1,2 +0,0 @@
[Network]
NetworkName=dew_keycloak

View file

@ -1,21 +0,0 @@
id: keycloak
name: Keycloak
runtime:
type: podman-quadlet
quadlets:
- file: dew_keycloak.network
unit: dew_keycloak-network.service
- file: dew_keycloak-postgresql.volume
unit: dew_keycloak-postgresql-volume.service
- file: dew_keycloak-postgresql.container
unit: dew_keycloak-postgresql.service
container_name: dew_keycloak-postgresql
- file: dew_keycloak.container
unit: dew_keycloak.service
container_name: dew_keycloak
schemas:
configure: schemas/configure.schema.json
init: schemas/init.schema.json

View file

@ -1,31 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/samples/keycloak/configure.schema.json",
"title": "Keycloak Sample Configuration",
"description": "Configuration values represented by the sample Keycloak Quadlets.",
"type": "object",
"additionalProperties": false,
"properties": {
"host_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 8080
},
"admin_username": {
"type": "string",
"minLength": 1,
"default": "admin"
},
"database": {
"type": "string",
"minLength": 1,
"default": "keycloak"
},
"database_volume": {
"type": "string",
"minLength": 1,
"default": "dew_keycloak_postgresql_data"
}
}
}

View file

@ -1,15 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/samples/keycloak/init.schema.json",
"title": "Keycloak Sample Initialization",
"description": "Initialization values for the sample Keycloak service.",
"type": "object",
"additionalProperties": false,
"properties": {
"realm": {
"type": "string",
"minLength": 1,
"default": "dew"
}
}
}

View file

@ -1,6 +0,0 @@
FROM docker.io/library/nginx:alpine
RUN printf '{"service":"local-api-build","status":"ok"}\n' \
> /usr/share/nginx/html/index.html
EXPOSE 80

View file

@ -1,23 +0,0 @@
# Local API Build
Sample local image build managed by `dew infra` and Podman Quadlets.
```bash
dew infra validate local-api-build
dew infra up local-api-build
dew infra status local-api-build
dew infra logs local-api-build --lines 100
```
This sample shows a `.build` Quadlet that builds a local image from
`Containerfile`, then runs it through a `.container` Quadlet.
- endpoint: `http://127.0.0.1:8090`
- built image: `localhost/dew_local-api-build:latest`
- container: `dew_local-api-build`
Stop it with:
```bash
dew infra down local-api-build
```

View file

@ -1,11 +0,0 @@
[Build]
ImageTag=localhost/dew_local-api-build:latest
File=Containerfile
SetWorkingDirectory=unit
[Service]
RemainAfterExit=yes
TimeoutStartSec=900
[Install]
WantedBy=default.target

View file

@ -1,16 +0,0 @@
[Unit]
Description=Dew sample local API build
Requires=dew_local-api-build.build
After=dew_local-api-build.build
[Container]
Image=dew_local-api-build.build
ContainerName=dew_local-api-build
PublishPort=127.0.0.1:8090:80
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=default.target

View file

@ -1,19 +0,0 @@
id: local-api-build
name: Local API Build
runtime:
type: podman-quadlet
quadlets:
- file: dew_local-api-build.build
unit: dew_local-api-build-build.service
- file: dew_local-api-build.container
unit: dew_local-api-build.service
container_name: dew_local-api-build
files:
- Containerfile
schemas:
configure: schemas/configure.schema.json
init: schemas/init.schema.json

View file

@ -1,21 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/samples/local-api-build/configure.schema.json",
"title": "Local API Build Sample Configuration",
"description": "Configuration values represented by the sample local build Quadlets.",
"type": "object",
"additionalProperties": false,
"properties": {
"host_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 8090
},
"image_tag": {
"type": "string",
"minLength": 1,
"default": "localhost/dew_local-api-build:latest"
}
}
}

View file

@ -1,9 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/samples/local-api-build/init.schema.json",
"title": "Local API Build Sample Initialization",
"description": "Initialization values for the sample local build service.",
"type": "object",
"additionalProperties": false,
"properties": {}
}

View file

@ -1,28 +0,0 @@
# PostgreSQL 18
Sample local PostgreSQL 18 service managed by `dew infra` and Podman Quadlets.
```bash
dew infra validate postgresql-18
dew infra up postgresql-18
dew infra status postgresql-18
dew infra logs postgresql-18 --lines 100
```
The sample binds PostgreSQL to `127.0.0.1:5432` with:
- database: `dew`
- user: `dew`
- password: `dew_dev_password`
- data volume: `dew_postgresql-18_data`
Stop it with:
```bash
dew infra down postgresql-18
```
The named volume is intentionally retained after stopping the service.
Service-specific configure and init schemas live under `schemas/`. The manifest
declares the PostgreSQL container under its `quadlets` list.

View file

@ -1,24 +0,0 @@
[Unit]
Description=Dew sample PostgreSQL 18 database
Wants=network-online.target
After=network-online.target
[Container]
Image=docker.io/library/postgres:18
ContainerName=dew_postgresql-18
Environment=POSTGRES_DB=dew
Environment=POSTGRES_USER=dew
Environment=POSTGRES_PASSWORD=dew_dev_password
PublishPort=127.0.0.1:5432:5432
Volume=dew_postgresql-18_data:/var/lib/postgresql:Z
HealthCmd=pg_isready -U dew -d dew
HealthInterval=10s
HealthTimeout=5s
HealthRetries=5
[Service]
Restart=on-failure
TimeoutStartSec=120
[Install]
WantedBy=default.target

View file

@ -1,16 +0,0 @@
id: postgresql-18
name: PostgreSQL 18
runtime:
type: podman-quadlet
quadlets:
- file: dew_postgresql-18.container
unit: dew_postgresql-18.service
container_name: dew_postgresql-18
dropins_dir: dew_postgresql-18.container.d
profiles_dir: dew_postgresql-18.profiles.d
schemas:
configure: schemas/configure.schema.json
init: schemas/init.schema.json

View file

@ -1,36 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/samples/postgresql-18/configure.schema.json",
"title": "PostgreSQL 18 Sample Configuration",
"description": "Configuration values represented by the sample PostgreSQL 18 Quadlet.",
"type": "object",
"additionalProperties": false,
"properties": {
"host_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 5432
},
"database": {
"type": "string",
"minLength": 1,
"default": "dew"
},
"username": {
"type": "string",
"minLength": 1,
"default": "dew"
},
"password": {
"type": "string",
"minLength": 8,
"default": "dew_dev_password"
},
"volume": {
"type": "string",
"minLength": 1,
"default": "dew_postgresql-18_data"
}
}
}

View file

@ -1,22 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/samples/postgresql-18/init.schema.json",
"title": "PostgreSQL 18 Sample Initialization",
"description": "Optional initialization notes for the sample PostgreSQL 18 service.",
"type": "object",
"additionalProperties": false,
"properties": {
"create_database": {
"type": "boolean",
"default": true
},
"seed_sample_data": {
"type": "boolean",
"default": false
},
"notes": {
"type": "string",
"default": ""
}
}
}

View file

@ -1,28 +0,0 @@
# RustFS
Sample local RustFS object storage service managed by `dew infra` and Podman
Quadlets.
```bash
dew infra validate rustfs
dew infra up rustfs
dew infra status rustfs
dew infra logs rustfs --lines 100
```
This sample shows a container on an explicit Quadlet network with a named
Quadlet volume.
- S3 API: `http://127.0.0.1:9000`
- console: `http://127.0.0.1:9001`
- user: `rustfsadmin`
- password: `rustfsadmin`
- data volume: `dew_rustfs_data`
Stop it with:
```bash
dew infra down rustfs
```
The named volume is intentionally retained after stopping the service.

View file

@ -1,23 +0,0 @@
[Unit]
Description=Dew sample RustFS object storage
Requires=dew_rustfs.network dew_rustfs.volume
After=dew_rustfs.network dew_rustfs.volume
[Container]
Image=docker.io/rustfs/rustfs:latest
ContainerName=dew_rustfs
Network=dew_rustfs.network
PublishPort=127.0.0.1:9000:9000
PublishPort=127.0.0.1:9001:9001
Volume=dew_rustfs.volume:/data
Environment=RUSTFS_ACCESS_KEY=rustfsadmin
Environment=RUSTFS_SECRET_KEY=rustfsadmin
Environment=RUSTFS_CONSOLE_ENABLE=true
Exec=/data
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=default.target

View file

@ -1,2 +0,0 @@
[Network]
NetworkName=dew_rustfs

View file

@ -1,4 +0,0 @@
[Volume]
VolumeName=dew_rustfs_data
User=10001
Group=10001

View file

@ -1,18 +0,0 @@
id: rustfs
name: RustFS
runtime:
type: podman-quadlet
quadlets:
- file: dew_rustfs.network
unit: dew_rustfs-network.service
- file: dew_rustfs.volume
unit: dew_rustfs-volume.service
- file: dew_rustfs.container
unit: dew_rustfs.service
container_name: dew_rustfs
schemas:
configure: schemas/configure.schema.json
init: schemas/init.schema.json

View file

@ -1,32 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/samples/rustfs/configure.schema.json",
"title": "RustFS Sample Configuration",
"description": "Configuration values represented by the sample RustFS Quadlets.",
"type": "object",
"additionalProperties": false,
"properties": {
"api_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 9000
},
"console_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 9001
},
"access_key": {
"type": "string",
"minLength": 1,
"default": "rustfsadmin"
},
"volume": {
"type": "string",
"minLength": 1,
"default": "dew_rustfs_data"
}
}
}

View file

@ -1,15 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/samples/rustfs/init.schema.json",
"title": "RustFS Sample Initialization",
"description": "Initialization values for the sample RustFS service.",
"type": "object",
"additionalProperties": false,
"properties": {
"bucket": {
"type": "string",
"minLength": 1,
"default": "dew"
}
}
}

View file

@ -1,24 +0,0 @@
# Valkey 9
Sample local Valkey service managed by `dew infra` and Podman Quadlets.
```bash
dew infra validate valkey-9
dew infra up valkey-9
dew infra status valkey-9
dew infra logs valkey-9 --lines 100
```
This sample shows a container backed by a named Quadlet volume.
- host port: `127.0.0.1:6379`
- container: `dew_valkey-9`
- data volume: `dew_valkey-9_data`
Stop it with:
```bash
dew infra down valkey-9
```
The named volume is intentionally retained after stopping the service.

View file

@ -1,18 +0,0 @@
[Unit]
Description=Dew sample Valkey 9
Requires=dew_valkey-9.volume
After=dew_valkey-9.volume
[Container]
Image=docker.io/valkey/valkey:9.0.3-alpine
ContainerName=dew_valkey-9
PublishPort=127.0.0.1:6379:6379
Volume=dew_valkey-9.volume:/data
Exec=valkey-server --save 60 1 --loglevel warning
[Service]
Restart=always
TimeoutStartSec=900
[Install]
WantedBy=default.target

View file

@ -1,2 +0,0 @@
[Volume]
VolumeName=dew_valkey-9_data

View file

@ -1,16 +0,0 @@
id: valkey-9
name: Valkey 9
runtime:
type: podman-quadlet
quadlets:
- file: dew_valkey-9.volume
unit: dew_valkey-9-volume.service
- file: dew_valkey-9.container
unit: dew_valkey-9.service
container_name: dew_valkey-9
schemas:
configure: schemas/configure.schema.json
init: schemas/init.schema.json

View file

@ -1,26 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/samples/valkey-9/configure.schema.json",
"title": "Valkey 9 Sample Configuration",
"description": "Configuration values represented by the sample Valkey Quadlets.",
"type": "object",
"additionalProperties": false,
"properties": {
"host_port": {
"type": "integer",
"minimum": 1,
"maximum": 65535,
"default": 6379
},
"volume": {
"type": "string",
"minLength": 1,
"default": "dew_valkey-9_data"
},
"snapshot_seconds": {
"type": "integer",
"minimum": 1,
"default": 60
}
}
}

View file

@ -1,9 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/samples/valkey-9/init.schema.json",
"title": "Valkey 9 Sample Initialization",
"description": "Initialization values for the sample Valkey service.",
"type": "object",
"additionalProperties": false,
"properties": {}
}

View file

@ -1,8 +0,0 @@
---
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

@ -1,8 +0,0 @@
---
id: DEW-0030
title: Use YAML infra service manifests
type: task
created: 2026-05-05T03:04:56.996343Z
---
Switch infra service manifests from metadata.toml to manifest.yaml and add a package-level JSON Schema for the service manifest contract.

View file

@ -1,8 +0,0 @@
---
id: DEW-0031
title: Add PostgreSQL 18 infra sample
type: task
created: 2026-05-05T03:31:50.153499Z
---
Add a sample Dew infrastructure service that brings up PostgreSQL 18 through a Podman Quadlet manifest, including manifest.yaml, schemas, profile/drop-in directories, and documentation.

View file

@ -1,8 +0,0 @@
---
id: DEW-0032
title: Move PostgreSQL sample schemas into service schemas directory
type: task
created: 2026-05-05T03:38:10.315750Z
---
Update the PostgreSQL 18 infra sample to store service-owned configure/init schemas under a schemas/ directory and adjust manifest references accordingly.

View file

@ -1,8 +0,0 @@
---
id: DEW-0033
title: Use hyphenated PostgreSQL sample runtime names
type: task
created: 2026-05-05T03:40:20.957347Z
---
Rename the PostgreSQL 18 infra sample runtime artifacts so the dew_ prefix is followed by the hyphenated service id postgresql-18.

View file

@ -1,8 +0,0 @@
---
id: DEW-0034
title: Support multiple quadlets per infra service
type: task
created: 2026-05-05T03:57:12.644828Z
---
Replace the single service/container manifest contract with a quadlets list so one Dew infra service can deploy multiple Podman Quadlet files across supported Quadlet types.

View file

@ -1,8 +0,0 @@
---
id: DEW-0035
title: Add broader infra sample services
type: task
created: 2026-05-05T04:19:43.484054Z
---
Add sample infrastructure services that exercise common Podman Quadlet patterns including volumes, networks, pods, multi-container dependencies, and local image builds.

View file

@ -1,8 +0,0 @@
---
id: DEW-0036
title: Verify infra samples locally
type: task
created: 2026-05-05T04:28:49.175443Z
---
Bring up the new sample infrastructure services with dew infra, verify they start and respond as expected, then stop them without deleting retained data volumes.

View file

@ -1,8 +0,0 @@
---
id: DEW-0037
title: Expose infra operations through MCP tools
type: task
created: 2026-05-05T04:45:19.903814Z
---
Add Dew MCP tools for every dew infra CLI path, including service discovery, show, validation, configure/init subpaths, lifecycle operations, logs, and delete.

View file

@ -1,8 +0,0 @@
---
id: DEW-0038
title: Release Dew 0.4.0
type: task
created: 2026-05-05T05:37:49.576656Z
---
Merge the infra epic into develop, prepare package metadata for the 0.4.0 release, publish packages to pub.dev in dependency order, tag the release, and push develop/tags.

View file

@ -1 +0,0 @@
dart 3.12.0

View file

@ -1,91 +0,0 @@
# Dew Agent Guidance
This document provides guidelines and rules for agents contributing to the
Dew project. It outlines the expected behavior, coding standards, and
collaboration practices to ensure a productive and respectful environment for
all contributors.
## General Guidelines
- The references directory should be used for storing external resources,
temporary files, and other things the agent needs that aren't part of the
actual project. This can include things like documentation, clones of other
repositories, screenshots, and other resources that may be helpful for
development but are not part of the final codebase.
- Avoid duplicating existing code or functionality. The footprint of the
operating system should be as small as possible while still remaining
readable and maintainable. Generally this means all features should be
implemented as though they will be used by other features, and that code
should be reused where possible.
- All code should be well-documented, with clear comments explaining the
purpose and functionality of each component. This is especially important for
security-related features, where clarity can help prevent vulnerabilities.
- Use language-appropriate API documentation comments for public modules,
types, and functions (for example Rustdoc in Rust and doc comments in Dart).
- Follow each language and framework's idiomatic conventions and best practices
to ensure consistency and readability across the codebase.
- Prefer strong, explicit types (for example enums/sealed types) over ad-hoc
constants when modeling related sets of values.
- Use pattern matching and type-system features where available to handle
different cases clearly and safely.
- Constants are acceptable for defining fixed values that are not part of a
related set, such as configuration parameters or hardware addresses, but
should be used judiciously to avoid cluttering the codebase with unnecessary
constants.
## Project Management
- Every task should be clearly defined and documented in the
`.project/kanban/` board and managed through Dew.
- Ticket lifecycle operations (create, list, move, update, comment, archive,
links) should be performed through the Dew MCP tools when available.
- Do not manage ticket state by hand-editing board files unless doing one-off
migration, import, or recovery work.
- If a task is not documented, create a new ticket in Dew before starting work
so progress is tracked and visible.
- Where possible, `TODO` comments should reference a ticket by including the
ticket's unique identifier in the comment. This helps maintain a clear
connection between the code and the project management system, making it
easier to track progress and understand the context of each task.
- The `.project/kanban/{backlog,doing,done,archive}/` directories are the
board columns. Tickets in progress belong in `doing`, and completed work is
moved to `done`.
- Ticket filenames are `<PREFIX>-NNNN.md` and must match the ticket `id` in
frontmatter.
- Frontmatter should contain the following fields:
- `id`: A unique identifier for the task.
- `title`: A brief title summarizing the task.
- `type`: Ticket type identifier (for example `epic`, `story`, `task`,
`bug`, or `spike`).
- `created`: RFC3339 timestamp for ticket creation.
- After the front matter comes the markdown content, which should provide a
detailed description of the task, including any relevant information,
requirements, or context needed for completion.
- Do not include a `column` field in frontmatter; the column is determined by
the ticket file's directory.
- Comments may be stored in the ticket by appending them to the end of the
markdown content, separated from the description and other comments by a
horizontal rules (`---`) for clarity. Each comment should include the
author's name, and the content of the comment.
- Tickets that need attachments should have a directory created in
`.project/kanban/attachments/` with the same name as the ticket file (without
the `.md` extension), and the attachment should be stored in that directory.
The attachment can then be referenced in the markdown content of the ticket
using a relative path.
## Tooling and Commands
- This repository uses a root `justfile` for common development workflows. Run
`just --list` to see the available recipes.
- Respect the root `.editorconfig` for cross-editor whitespace and line-ending
behavior.
- Treat any analyzer/linter finding (`info`, `warning`, or `error`) as a
blocking issue: fix it before merging unless it is objectively impossible.
If a finding must remain, ask for explicit approval first and gate the exception
with a `TODO` comment that explains why the issue is intentionally deferred.
- Use `melos run format` to format Dart packages in the workspace.
- Use `melos run fix` to apply automatic fixes from Dart static analysis.
- Use `melos run analyze` to run Dart static analysis.
- Use `melos run test` to run Dart tests across workspace packages.
- Use `markdownlint-cli2 .` to check for markdown style issues in documentation
files so the repo's configured ignores are applied.

View file

@ -7,28 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.4.0] - 2026-05-05 ## [0.2.0] - 2026-05-02
### Added in 0.4.0 ### Changed
- Added `dew infra` for project infrastructure service discovery, validation, - Removed unsupported MCP `host` and `port` settings from generated `dew.yaml`.
configuration payloads, initialization payloads, lifecycle control, status, - Updated MCP configuration docs to reflect stdio-only client setup.
logs, and cleanup. - Added the `dew` executable mapping for pub activation.
- Added Podman Quadlet runtime support with a runtime boundary for future
container backends.
- Added manifest, configure, and init JSON Schema handling for infrastructure
services.
- Added sample infrastructure services for PostgreSQL 18, Valkey, RustFS,
Keycloak, pods, networks, volumes, and local image builds.
- Exposed every `dew infra` CLI path through Dew MCP tools, including
path-specific configure/init tools.
### Changed in 0.4.0
- Extended MCP tool discovery so commands can provide extra path-specific tools
beyond one tool per subcommand.
- Raised package versions to `0.4.0` for the infra release.
- Cleaned existing analyzer info findings in kanban and vault ahead of release.
## [0.1.0] - 2026-04-25 ## [0.1.0] - 2026-04-25
@ -109,6 +94,6 @@ Full set of kanban subcommands, each also registered as an MCP tool automaticall
- `ProjectDirs` with injectable filesystem abstraction (`package:file`) for - `ProjectDirs` with injectable filesystem abstraction (`package:file`) for
testable path resolution. testable path resolution.
[Unreleased]: https://github.com/artificerchris/dew/compare/v0.4.0...HEAD [Unreleased]: https://github.com/artificerchris/dew/compare/v0.2.0...HEAD
[0.4.0]: https://github.com/artificerchris/dew/compare/v0.3.0...v0.4.0 [0.2.0]: https://github.com/artificerchris/dew/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/artificerchris/dew/releases/tag/v0.1.0 [0.1.0]: https://github.com/artificerchris/dew/releases/tag/v0.1.0

View file

@ -4,7 +4,7 @@ Thank you for your interest in contributing! This guide covers everything you ne
## Prerequisites ## Prerequisites
- **Dart SDK ^3.12.0** — verify with `dart --version` - **Dart SDK ^3.11.4** — verify with `dart --version`
- **Melos** (optional, for workspace scripts) — `dart pub global activate melos` - **Melos** (optional, for workspace scripts) — `dart pub global activate melos`
## Clone & setup ## Clone & setup

View file

@ -18,12 +18,6 @@ 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:
@ -36,10 +30,7 @@ The TUI auto-refreshes when ticket files change on disk, so it stays in sync whe
### MCP Server ### MCP Server
`dew mcp serve` starts an MCP-compliant stdio server that exposes Dew commands `dew mcp serve` starts an MCP-compliant stdio server that exposes every kanban command as an MCP tool. AI agents (GitHub Copilot, Claude, etc.) can create tickets, move cards, search, and comment — using the exact same logic as the CLI. No separate tool definitions needed: every command that mixes in `DewToolCommand` is registered automatically. See the [MCP documentation](./docs/features/mcp.md).
as MCP tools. AI agents (GitHub Copilot, Claude, etc.) can create tickets, move
cards, search, comment, and manage project infrastructure through the same logic
as the CLI. See the [MCP documentation](./docs/features/mcp.md).
## Installation ## Installation
@ -47,7 +38,7 @@ as the CLI. See the [MCP documentation](./docs/features/mcp.md).
dart pub global activate dew dart pub global activate dew
``` ```
Requires Dart SDK ^3.12.0. Requires Dart SDK ^3.11.4.
## Quick start ## Quick start
@ -64,12 +55,11 @@ dew kanban tui
## Configuration ## Configuration
Dew reads `.project/dew.yaml` for board columns, ticket types, ID prefix, and MCP server settings. Running `dew init .` generates this file with defaults. See the [Configuration documentation](./docs/config.md) for the full schema reference. Dew reads `.project/dew.yaml` for board columns, ticket types, and ID prefix. Running `dew init .` generates this file with defaults. See the [Configuration documentation](./docs/config.md) for the full schema reference.
## 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)

View file

@ -10,20 +10,10 @@ your-project/
└── dew.yaml └── 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`).
Infrastructure services are not configured in `dew.yaml`; they are discovered
from `.project/infrastructure/services/*/manifest.yaml`.
## Full Schema ## Full Schema
```yaml ```yaml
dew: dew:
mcp:
host: "localhost" # Hostname the MCP server binds to
port: 8080 # Port the MCP server listens on
kanban: kanban:
prefix: "PROJ" # Short prefix used for ticket IDs (e.g. PROJ-42) prefix: "PROJ" # Short prefix used for ticket IDs (e.g. PROJ-42)
@ -53,12 +43,8 @@ dew:
## Reference ## Reference
### `dew.mcp` The MCP server currently has no project-level `dew.yaml` configuration. Configure
your MCP client to run `dew mcp serve`; see the [MCP documentation](./features/mcp.md).
| Field | Type | Default | Description |
| ------ | ------- | ------------- | --------------------------------- |
| `host` | string | `"localhost"` | Hostname the MCP server binds to. |
| `port` | integer | `8080` | Port the MCP server listens on. |
### `dew.kanban` ### `dew.kanban`

View file

@ -1,104 +0,0 @@
# 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/
├── manifest.yaml
├── app_postgres.container
├── app_postgres.container.d/
├── app_postgres.profiles.d/
├── schemas/
│ ├── configure.schema.json
│ └── init.schema.json
└── config/
```
## Manifest
```yaml
id: postgres
name: PostgreSQL
runtime:
type: podman-quadlet
quadlets:
- file: app_postgres.container
unit: app_postgres.service
container_name: app_postgres
dropins_dir: app_postgres.container.d
profiles_dir: app_postgres.profiles.d
schemas:
configure: schemas/configure.schema.json
init: schemas/init.schema.json
```
The `quadlets` list can contain any supported Podman Quadlet source type:
`.artifact`, `.build`, `.container`, `.image`, `.kube`, `.network`, `.pod`, and
`.volume`. If `unit` is omitted, Dew derives the default generated systemd unit
from the Quadlet filename. Declare `unit` when the Quadlet file uses a
`ServiceName=` override.
Use `files` for non-Quadlet assets that must be installed beside the Quadlet
files, such as a `Containerfile` used by a `.build` unit.
The package-level schema for this file is
`packages/infra/schemas/service-manifest.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
declared units.
## Samples
The Dew repository includes sample service bringups under
`.project/infrastructure/services/`.
Available samples:
- `postgresql-18`: single PostgreSQL 18 container with a named data volume.
- `valkey-9`: cache container backed by a Quadlet volume.
- `rustfs`: S3-compatible object storage on a Quadlet network and volume.
- `keycloak`: multi-container Keycloak and PostgreSQL service on a shared
network.
- `app-pod`: Podman pod with web and sidecar containers.
- `local-api-build`: local image build consumed by a container Quadlet.
```bash
dew infra validate postgresql-18
dew infra up postgresql-18
```

View file

@ -6,26 +6,17 @@ The Dew Model Context Protocol (MCP) Server is a feature that allows AI agents t
The MCP feature is split across two packages to keep concerns separate: The MCP feature is split across two packages to keep concerns separate:
- **`packages/core`** defines the `DewToolCommand` mixin and `McpToolProvider` - **`packages/core`** defines the `DewToolCommand` mixin. Any command that mixes it in is automatically
interface. Commands that mix in `DewToolCommand` are automatically registered registered as an MCP tool — the mixin derives the tool's JSON Schema directly from the command's own
as MCP tools, and commands that implement `McpToolProvider` can expose extra `ArgParser`, so tools and CLI commands share a single definition.
path-specific tools.
- **`packages/mcp`** implements the actual server. It reads the list of tools from `CommandRegistry` and - **`packages/mcp`** implements the actual server. It reads the list of tools from `CommandRegistry` and
serves them over stdio using the [dart\_mcp](https://pub.dev/packages/dart_mcp) package. Only the `cli` serves them over stdio using the [dart\_mcp](https://pub.dev/packages/dart_mcp) package. Only the `cli`
package depends on `packages/mcp`; feature packages like `kanban` remain decoupled from the transport layer. package depends on `packages/mcp`; feature packages like `kanban` remain decoupled from the transport layer.
## Configuration ## Configuration
The MCP server is configured under the `mcp` key in `.project/dew.yaml`. By default it runs on `localhost` at port `8080`. The MCP server currently has no `.project/dew.yaml` settings. Configure your MCP
client to launch `dew mcp serve`; the server communicates over stdio.
```yaml
dew:
mcp:
host: "localhost"
port: 8080
```
See the [Configuration documentation](../config.md) for full details.
## Running the server ## Running the server
@ -75,29 +66,6 @@ The following tools are registered by the `kanban` package:
| `kanban_link_tickets` | Link two tickets with a typed relationship (bidirectional) | | `kanban_link_tickets` | Link two tickets with a typed relationship (bidirectional) |
| `kanban_unlink_tickets` | Remove a link between two tickets (both sides) | | `kanban_unlink_tickets` | Remove a link between two tickets (both sides) |
The following tools are registered by the `infra` package:
| Tool | Description |
| ---------------------------- | ------------------------------------------------ |
| `infra_list_services` | List infrastructure services |
| `infra_show_service` | Show manifest and runtime details |
| `infra_validate_services` | Validate one service or all services |
| `infra_configure_service` | CLI-default configure path placeholder |
| `infra_configure_schema` | Show the configure JSON Schema |
| `infra_configure_show` | Show the active configure payload |
| `infra_configure_apply` | Apply configure payload values |
| `infra_init_service` | CLI-default init path placeholder |
| `infra_init_schema` | Show the init JSON Schema |
| `infra_init_run` | Write an initialization payload |
| `infra_install_service` | Install service Quadlets |
| `infra_uninstall_service` | Uninstall service Quadlets |
| `infra_up_service` | Install, reload, and start services |
| `infra_down_service` | Stop services |
| `infra_restart_service` | Restart services |
| `infra_status_service` | Show service runtime status |
| `infra_logs` | Read service logs |
| `infra_delete_service` | Delete declared runtime artifacts |
### Link types ### Link types
`kanban_link_tickets` requires a `--type` argument. The inverse is written automatically on the target ticket. `kanban_link_tickets` requires a `--type` argument. The inverse is written automatically on the target ticket.

View file

@ -1,144 +0,0 @@
# Dew Vault Secret Manager
Dew Vault stores project secrets as encrypted files under `.project/vault`.
By default the vault password is stored in `.project/secrets/dew.vault.password`.
## Config
Vault settings live in `.project/dew.yaml` under `dew.vault`.
```yaml
dew:
vault:
password_file: .project/secrets/dew.vault.password
storage_dir: .project/vault
generators:
postgres_password:
type: random_password
description: Generate random PostgreSQL passwords.
config:
length: 64
include_symbols: true
jwt_secret:
type: random_token
description: Generate JWT signing secrets.
config:
encoding: base64
bytes: 48
service_uuid:
type: uuid_v4
description: Generate stable-looking unique IDs.
```
`generators` maps a generator name to a built-in generator definition. Values
under `config` are defaults and can be overridden per command invocation or in
secret rotation metadata.
Built-in generator types are resolved inside Dew, so secrets can be generated
without depending on host binaries.
## Commands
Most commands support `--format [default|json]` (default is `default`) for
machine-friendly automation.
### Initialize Vault
Initialize vault directories and password file.
```bash
dew vault init
dew vault init --password-file .project/secrets/dew.vault.password
dew vault init --storage-dir .project/vault
```
### List all secrets
```bash
dew vault list
dew vault list --format json
```
### Set a secret
`set` stores or replaces a secret and optional metadata.
```bash
dew vault set --name DB_PASSWORD --file /path/to/secret.txt
dew vault set --name DB_PASSWORD --env ENV_VAR_NAME
```
### Get a secret
```bash
dew vault get --name DB_PASSWORD
dew vault get --name DB_PASSWORD --format json
```
### Update a secret
`update` patches secret metadata and/or value. Omit value source flags to edit
metadata only.
```bash
dew vault update --name DB_PASSWORD --metadata '{"rotation":{"enabled":true,"generator":"postgres_password","length":64}}'
dew vault update --name DB_PASSWORD --metadata-file .project/vault/db_password.meta.json
```
### Rename a secret
```bash
dew vault rename --from OLD_NAME --to NEW_NAME
dew vault rename --from OLD_NAME --to NEW_NAME --format json
```
### Generate a secret value
`generate` uses configured generators without writing to the vault by default.
```bash
dew vault generate --generator postgres_password --arg length=64 --arg include_symbols=true
dew vault generate --generator jwt_secret --arg bytes=64 --arg encoding=base64
dew vault generate --generator postgres_password --arg service=payments --arg username=app_user --format json
```
### Rotate secrets
`rotate` rewraps secrets with a new vault password when run without a name.
When run with a secret name, it rotates only that secret.
```bash
dew vault rotate
dew vault rotate --name DB_PASSWORD
dew vault rotate --name DB_PASSWORD --format json
```
### Delete a secret
```bash
dew vault delete --name DB_PASSWORD
dew vault delete --name DB_PASSWORD --format json
```
### Metadata format for rotation-aware secrets
Attach arbitrary metadata and include rotation policy details. Example shape:
```json
{
"rotation": {
"generator": "postgres_password",
"service": "payments",
"username": "app_user",
"length": 64
},
"notes": "Rotate monthly and update app config via sidecar"
}
```
Rotation flow:
1. Define a built-in generator in `dew.yaml` under `dew.vault.generators`.
2. Attach `rotation.generator` and generator args to secret metadata.
3. Run `dew vault rotate --name ...` to rotate one secret, or `dew vault rotate`
to rotate all secrets.

View file

@ -9,7 +9,6 @@ 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
@ -20,15 +19,12 @@ 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. |
### How commands become MCP tools ### How commands become MCP tools
Every CLI command that mixes in `DewToolCommand` is automatically registered as Every CLI command that mixes in `DewToolCommand` is automatically registered as an MCP tool — no separate registration needed. The mixin derives the JSON Schema for the tool's input from the command's own `ArgParser`, so argument definitions are written exactly once.
an MCP tool. Commands that need more granular tool paths can also implement
`McpToolProvider` to expose additional tools.
```text ```text
ArgParser definition ArgParser definition
@ -42,6 +38,6 @@ ArgParser definition
`DewConfig` in `core` is a thin wrapper around the raw YAML map. Feature packages add typed accessors via Dart extensions: `DewConfig` in `core` is a thin wrapper around the raw YAML map. Feature packages add typed accessors via Dart extensions:
- `dew_kanban` defines `KanbanDewConfig` — exposes `context.config.kanban` - `dew_kanban` defines `KanbanDewConfig` — exposes `context.config.kanban`
- `dew_mcp` defines `McpDewConfig` — exposes `context.config.mcp` - `dew_mcp` currently has no project-level config values
This keeps feature-specific config classes out of `core` while leaving all call sites unchanged. This keeps feature-specific config classes out of `core`.

View file

@ -1,12 +1,9 @@
# Changelog # Changelog
## 0.4.0 — 2026-05-05 ## 0.2.0 — 2026-05-02
Infra release. - Added the `dew` executable mapping for pub activation.
- Bumped Dew package dependency constraints to `^0.2.0`.
- Added the `dew infra` command group to the CLI.
- Registered the infra package alongside kanban, MCP, and vault packages.
- Updated CLI dependencies for the `0.4.0` package set.
## 0.1.0 — 2026-04-25 ## 0.1.0 — 2026-04-25

View file

@ -1,18 +1,12 @@
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;
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);
mcp.registerCommands(commandRegistry); mcp.registerCommands(commandRegistry);
final runner = CommandRunner<void>('dew', 'A project management tool.'); final runner = CommandRunner<void>('dew', 'A project management tool.');
@ -22,10 +16,5 @@ Future<void> main(List<String> args) async {
runner.addCommand(command); runner.addCommand(command);
} }
try {
await runner.run(args); await runner.run(args);
} on UsageException catch (error) {
stderr.writeln(error);
exitCode = 64;
}
} }

View file

@ -1,20 +1,21 @@
name: dew name: dew
description: Command-line interface for the Dew project management tool. description: Command-line interface for the Dew project management tool.
version: 0.4.0 version: 0.2.0
repository: https://github.com/artificerchris/dew repository: https://github.com/artificerchris/dew
issue_tracker: https://github.com/artificerchris/dew/issues issue_tracker: https://github.com/artificerchris/dew/issues
resolution: workspace resolution: workspace
executables:
dew: dew
environment: environment:
sdk: ^3.12.0 sdk: ^3.11.4
dependencies: dependencies:
args: ^2.7.0 args: ^2.7.0
dew_core: ^0.4.0 dew_core: ^0.2.0
dew_infra: ^0.4.0 dew_kanban: ^0.2.0
dew_kanban: ^0.4.0 dew_mcp: ^0.2.0
dew_vault: ^0.4.0
dew_mcp: ^0.4.0
dev_dependencies: dev_dependencies:
lints: ^6.0.0 lints: ^6.0.0

View file

@ -1,17 +1,13 @@
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:test/test.dart'; 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);
mcp.registerCommands(commandRegistry); mcp.registerCommands(commandRegistry);
final runner = CommandRunner<void>('dew', 'A project management tool.'); final runner = CommandRunner<void>('dew', 'A project management tool.');
@ -28,12 +24,9 @@ void main() {
expect(buildRunner, returnsNormally); expect(buildRunner, returnsNormally);
}); });
test('has core package commands registered', () { test('has kanban, init, and mcp commands registered', () {
final runner = buildRunner(); final runner = buildRunner();
expect( expect(runner.commands.keys, containsAll(['kanban', 'init', 'mcp']));
runner.commands.keys,
containsAll(['infra', 'kanban', 'vault', 'init', 'mcp']),
);
}); });
test('--help flag does not throw', () async { test('--help flag does not throw', () async {

View file

@ -1,12 +1,9 @@
# Changelog # Changelog
## 0.4.0 — 2026-05-05 ## 0.2.0 — 2026-05-02
Infra and MCP provider release. - Removed stale MCP config references from core config documentation.
- Updated `dew init` defaults to generate only supported project config.
- Extended `CommandRegistry.mcpTools` to collect extra tools from
`McpToolProvider` command classes.
- Kept `InitCommand` analyzer-clean with the Dart SDK `3.12` lint set.
## 0.1.0 — 2026-04-25 ## 0.1.0 — 2026-04-25

View file

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:file/local.dart'; import 'package:file/local.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
@ -8,7 +6,7 @@ import 'package:yaml/yaml.dart';
/// Thin wrapper around the raw project YAML. /// Thin wrapper around the raw project YAML.
/// ///
/// Feature packages extend this class via Dart extension methods to expose /// Feature packages extend this class via Dart extension methods to expose
/// typed configuration (e.g. [KanbanDewConfig.kanban], [McpDewConfig.mcp]). /// typed configuration (e.g. [KanbanDewConfig.kanban]).
/// This keeps feature-specific config classes out of core. /// This keeps feature-specific config classes out of core.
class DewConfig { class DewConfig {
final YamlMap raw; final YamlMap raw;
@ -36,42 +34,16 @@ class ProjectContext {
final String root; final String root;
final DewConfig config; final DewConfig config;
final FileSystem fs; final FileSystem fs;
final String configFilePath;
const ProjectContext({ const ProjectContext({
required this.root, required this.root,
required this.config, required this.config,
required this.fs, required this.fs,
required this.configFilePath,
}); });
/// Typed path helpers for this project's well-known directories. /// Typed path helpers for this project's well-known directories.
ProjectDirs get dirs => ProjectDirs(root); ProjectDirs get dirs => ProjectDirs(root);
/// Path to `.project/dew.yaml` used to bootstrap this context.
String get configPath => configFilePath;
/// Resolves configuration values that are file paths relative to this project's
/// `.project/dew.yaml` location.
String resolveConfigPath(String value) {
final expanded = _expandTilde(value);
if (p.isAbsolute(expanded)) return p.normalize(expanded);
final segments = p.split(p.normalize(expanded));
if (segments.isNotEmpty && segments.first == '.project') {
return p.normalize(
p.joinAll(
[
p.dirname(configPath),
'..',
'.project',
...segments.skip(1),
],
),
);
}
return p.normalize(p.join(p.dirname(configPath), expanded));
}
/// Walks up from [from] (defaults to [fs.currentDirectory]) until a /// Walks up from [from] (defaults to [fs.currentDirectory]) until a
/// `.project/dew.yaml` is found. /// `.project/dew.yaml` is found.
static Future<ProjectContext> find({ static Future<ProjectContext> find({
@ -82,13 +54,11 @@ class ProjectContext {
while (true) { while (true) {
final configFile = fs.file(p.join(dir.path, '.project', 'dew.yaml')); final configFile = fs.file(p.join(dir.path, '.project', 'dew.yaml'));
if (await configFile.exists()) { if (await configFile.exists()) {
final path = configFile.path;
final yaml = loadYaml(await configFile.readAsString()) as YamlMap; final yaml = loadYaml(await configFile.readAsString()) as YamlMap;
return ProjectContext( return ProjectContext(
root: dir.path, root: dir.path,
config: DewConfig.fromYaml(yaml), config: DewConfig.fromYaml(yaml),
fs: fs, fs: fs,
configFilePath: path,
); );
} }
final parent = dir.parent; final parent = dir.parent;
@ -102,15 +72,3 @@ class ProjectContext {
} }
} }
} }
String _expandTilde(String input) {
if (!input.startsWith('~')) return input;
final home = Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
'';
if (home.isEmpty) return input;
if (input.length == 1) return home;
final rest = input.substring(1);
if (rest.startsWith('/')) return p.join(home, rest.substring(1));
return p.join(home, rest);
}

View file

@ -158,9 +158,6 @@ class CommandRegistry {
final tools = <McpTool>[]; final tools = <McpTool>[];
void collect(Command<void> cmd) { void collect(Command<void> cmd) {
if (cmd is DewToolCommand) tools.add(cmd.toMcpTool()); if (cmd is DewToolCommand) tools.add(cmd.toMcpTool());
if (cmd is McpToolProvider) {
tools.addAll((cmd as McpToolProvider).tools);
}
for (final sub in cmd.subcommands.values) { for (final sub in cmd.subcommands.values) {
collect(sub); collect(sub);
} }

View file

@ -31,10 +31,6 @@ abstract interface class DewInitHook {
const _defaultDewYaml = ''' const _defaultDewYaml = '''
dew: dew:
mcp:
host: "localhost"
port: 8080
kanban: kanban:
prefix: "PROJ" prefix: "PROJ"
ticket_types: ticket_types:
@ -60,22 +56,12 @@ dew:
color: "green" color: "green"
'''; ''';
const _projectGitignore = '''
/secrets/
/toolchain/
/cache/
''';
class InitCommand extends Command<void> { class InitCommand extends Command<void> {
final List<DewInitHook> _hooks; final List<DewInitHook> _hooks;
final FileSystem _fs; final FileSystem _fs;
InitCommand( InitCommand(this._hooks, {FileSystem fs = const LocalFileSystem()})
List<DewInitHook> hooks, { : _fs = fs {
FileSystem fs = const LocalFileSystem(),
}) : this._(hooks, fs);
InitCommand._(this._hooks, this._fs) {
argParser argParser
..addOption( ..addOption(
'path', 'path',
@ -107,7 +93,6 @@ class InitCommand extends Command<void> {
final projectDir = _fs.directory(p.join(projectRoot, '.project')); final projectDir = _fs.directory(p.join(projectRoot, '.project'));
final configFile = _fs.file(p.join(projectDir.path, 'dew.yaml')); final configFile = _fs.file(p.join(projectDir.path, 'dew.yaml'));
final gitignoreFile = _fs.file(p.join(projectDir.path, '.gitignore'));
await projectDir.create(recursive: true); await projectDir.create(recursive: true);
@ -118,13 +103,6 @@ class InitCommand extends Command<void> {
print(' created .project/dew.yaml'); print(' created .project/dew.yaml');
} }
if (await gitignoreFile.exists()) {
print(' found .project/.gitignore (already exists, skipping)');
} else {
await gitignoreFile.writeAsString(_projectGitignore);
print(' created .project/.gitignore');
}
final config = DewConfig.fromYaml( final config = DewConfig.fromYaml(
loadYaml(await configFile.readAsString()) as YamlMap, loadYaml(await configFile.readAsString()) as YamlMap,
); );

View file

@ -1,12 +1,12 @@
name: dew_core name: dew_core
description: Core shared types, interfaces, and configuration for the Dew project management tool. description: Core shared types, interfaces, and configuration for the Dew project management tool.
version: 0.4.0 version: 0.2.0
repository: https://github.com/artificerchris/dew repository: https://github.com/artificerchris/dew
issue_tracker: https://github.com/artificerchris/dew/issues issue_tracker: https://github.com/artificerchris/dew/issues
resolution: workspace resolution: workspace
environment: environment:
sdk: ^3.12.0 sdk: ^3.11.4
# Add regular dependencies here. # Add regular dependencies here.
dependencies: dependencies:

View file

@ -1,3 +1,4 @@
import 'package:args/command_runner.dart';
import 'package:dew_core/dew_core.dart'; import 'package:dew_core/dew_core.dart';
import 'package:file/memory.dart'; import 'package:file/memory.dart';
import 'package:test/test.dart'; import 'package:test/test.dart';
@ -37,9 +38,6 @@ void main() {
group('ProjectContext', () { group('ProjectContext', () {
const configYaml = ''' const configYaml = '''
dew: dew:
mcp:
host: localhost
port: 9090
kanban: kanban:
prefix: TEST prefix: TEST
ticket_types: ticket_types:
@ -59,8 +57,7 @@ dew:
final ctx = await ProjectContext.find(fs: fs); final ctx = await ProjectContext.find(fs: fs);
final dew = ctx.config.raw['dew']; final dew = ctx.config.raw['dew'];
expect(dew['kanban']['prefix'], 'TEST'); expect(dew['kanban']['prefix'], 'TEST');
expect(dew['mcp']['host'], 'localhost'); expect(dew.containsKey('mcp'), isFalse);
expect(dew['mcp']['port'], 9090);
}); });
test('find() locates config from a subdirectory', () async { test('find() locates config from a subdirectory', () async {
@ -72,25 +69,22 @@ dew:
final ctx = await ProjectContext.find(fs: fs, from: fs.directory('/sub')); final ctx = await ProjectContext.find(fs: fs, from: fs.directory('/sub'));
expect(ctx.root, '/'); expect(ctx.root, '/');
}); });
});
test('resolveConfigPath resolves paths relative to .project/dew.yaml', () async { group('InitCommand', () {
test('generates dew.yaml without MCP host or port config', () async {
final fs = MemoryFileSystem(); final fs = MemoryFileSystem();
fs.directory('/foo/.project').createSync(recursive: true); final runner = CommandRunner<void>('dew', 'test');
fs.file('/foo/.project/dew.yaml').writeAsStringSync(configYaml); runner.addCommand(InitCommand(const [], fs: fs));
final ctx = await ProjectContext.find(fs: fs, from: fs.directory('/foo/.project/child')); await runner.run(['init', '--path', '/project']);
expect(
ctx.resolveConfigPath('vault'), final config = fs.file('/project/.project/dew.yaml').readAsStringSync();
'/foo/.project/vault', expect(config, contains('dew:'));
); expect(config, contains('kanban:'));
expect( expect(config, isNot(contains('mcp:')));
ctx.resolveConfigPath('.project/vault'), expect(config, isNot(contains('host:')));
'/foo/.project/vault', expect(config, isNot(contains('port:')));
);
expect(
ctx.resolveConfigPath('/tmp/abs'),
'/tmp/abs',
);
}); });
}); });
} }

View file

@ -1,13 +0,0 @@
# Changelog
## 0.4.0 — 2026-05-05
Initial public release.
- Added infrastructure service discovery from `.project/infrastructure/services`.
- Added YAML service manifests with support for multiple Podman Quadlet files.
- Added manifest validation, JSON Schema validation, configuration payloads, and
initialization payloads.
- Added Podman Quadlet install, uninstall, up, down, restart, status, logs, and
delete operations behind a container runtime boundary.
- Added MCP tools for every `dew infra` CLI path.

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Chris Gebhardt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,22 +0,0 @@
# dew_infra
Infrastructure service management for the Dew CLI.
`dew_infra` discovers service manifests under `.project/infrastructure`,
validates referenced Quadlet files and JSON Schemas, and manages Podman
Quadlets through systemd. It is designed around a runtime boundary so Dew can
support additional container runtimes in later releases.
Most users should install the `dew` CLI package instead:
```sh
dart pub global activate dew
```
See the main Dew documentation for the `dew infra` command reference:
<https://github.com/artificerchris/dew#readme>
## License
MIT — see [LICENSE](LICENSE).

View file

@ -1,20 +0,0 @@
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));
}

File diff suppressed because it is too large Load diff

View file

@ -1,262 +0,0 @@
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/*/manifest.yaml`.
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 manifest = fs.file(p.join(entity.path, 'manifest.yaml'));
if (!await manifest.exists()) continue;
manifests.add(
await loadFromManifestPath(manifest.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 manifestPath = p.join(servicesDir, id, 'manifest.yaml');
final file = fs.file(manifestPath);
if (!await file.exists()) return null;
return loadFromManifestPath(
manifestPath,
serviceDir: p.dirname(manifestPath),
);
}
/// Parses the manifest at [manifestPath].
Future<InfraServiceManifest> loadFromManifestPath(
String manifestPath, {
required String serviceDir,
}) async {
final file = fs.file(manifestPath);
return InfraServiceManifest.parse(
contents: await file.readAsString(),
serviceDir: p.normalize(serviceDir),
manifestPath: p.normalize(manifestPath),
);
}
}
/// 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.manifestPath,
'id "${manifest.id}" must match directory "$dirId".',
);
}
if (manifest.quadlets.isEmpty) {
issue(manifest.manifestPath, 'quadlets must contain at least one file.');
}
final quadletFiles = <String>{};
final quadletUnits = <String>{};
for (final quadlet in manifest.quadlets) {
if (!quadletFiles.add(quadlet.file)) {
issue(
manifest.manifestPath,
'quadlet file "${quadlet.file}" is declared more than once.',
);
}
if (!quadletUnits.add(quadlet.serviceUnit)) {
issue(
manifest.manifestPath,
'quadlet unit "${quadlet.serviceUnit}" is declared more than once.',
);
}
if (!quadlet.serviceUnit.endsWith('.service')) {
issue(
manifest.manifestPath,
'quadlet unit "${quadlet.serviceUnit}" must end with .service.',
);
}
await _requireFile(manifest, quadlet.filePath, issues);
await _requireDirectoryIfDeclared(
manifest,
quadlet.dropinsDirPath,
issues,
);
await _requireDirectoryIfDeclared(
manifest,
quadlet.profilesDirPath,
issues,
);
}
for (final path in manifest.filePaths) {
await _requireFile(manifest, path, 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.manifestPath,
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

@ -1,510 +0,0 @@
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 {
if (manifest.quadlets.isEmpty) return false;
for (final quadlet in manifest.quadlets) {
final target = _targetQuadletPath(quadlet, scope);
final exists =
await fs.link(target).exists() || await fs.file(target).exists();
if (!exists) return false;
}
for (final file in manifest.files) {
final target = _targetFilePath(file, scope);
final exists =
await fs.link(target).exists() || await fs.file(target).exists();
if (!exists) return false;
}
return true;
}
@override
Future<InfraRuntimeResult> install(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async {
final actions = <String>[];
final targetDir = quadletSearchPath(scope, environment: environment);
await _action(
actions,
dryRun,
'create $targetDir',
() => fs.directory(targetDir).create(recursive: true),
);
for (final quadlet in manifest.quadlets) {
await _link(
actions,
dryRun,
quadlet.filePath,
_targetQuadletPath(quadlet, scope),
);
final dropinsPath = quadlet.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 || p.extension(entity.path) != '.conf') continue;
await _link(
actions,
dryRun,
entity.path,
p.join(targetDropins, p.basename(entity.path)),
);
}
}
}
for (var i = 0; i < manifest.files.length; i++) {
await _link(
actions,
dryRun,
manifest.filePaths[i],
_targetFilePath(manifest.files[i], scope),
);
}
return InfraRuntimeResult(actions: actions);
}
@override
Future<InfraRuntimeResult> uninstall(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async {
final actions = <String>[];
final targetDir = quadletSearchPath(scope, environment: environment);
for (final quadlet in manifest.quadlets) {
await _deletePath(actions, dryRun, _targetQuadletPath(quadlet, scope));
final dropinsPath = quadlet.dropinsDirPath;
if (dropinsPath != null) {
await _deletePath(
actions,
dryRun,
p.join(targetDir, p.basename(dropinsPath)),
);
}
}
for (final file in manifest.files) {
await _deletePath(actions, dryRun, _targetFilePath(file, scope));
}
return InfraRuntimeResult(actions: actions);
}
@override
Future<InfraRuntimeResult> start(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async => _systemctl(scope, ['start', ...manifest.units], dryRun: dryRun);
@override
Future<InfraRuntimeResult> stop(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async => _systemctl(scope, ['stop', ...manifest.units], dryRun: dryRun);
@override
Future<InfraRuntimeResult> restart(
InfraServiceManifest manifest, {
required InfraScope scope,
required bool dryRun,
}) async => _systemctl(scope, ['restart', ...manifest.units], dryRun: dryRun);
@override
Future<InfraRuntimeResult> status(
InfraServiceManifest manifest, {
required InfraScope scope,
}) async => _systemctl(scope, [
'status',
...manifest.units,
'--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',
for (final unit in manifest.units) ...['-u', 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) {
if (manifest.containerNames.isEmpty) {
actions.add('no container artifacts declared for ${manifest.id}');
}
for (final containerName in manifest.containerNames) {
final args = ['rm', '--ignore', '--force', containerName];
final action = 'podman ${args.join(' ')}';
actions.add(action);
if (!dryRun) {
final result = await processRunner.run('podman', args);
if (exitCode == 0) 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 _targetQuadletPath(InfraQuadletManifest quadlet, InfraScope scope) =>
p.join(
quadletSearchPath(scope, environment: environment),
p.basename(quadlet.file),
);
String _targetFilePath(String file, InfraScope scope) =>
p.join(quadletSearchPath(scope, environment: environment), file);
Future<void> _link(
List<String> actions,
bool dryRun,
String source,
String target,
) async {
await _action(actions, dryRun, 'link $source -> $target', () async {
await fs.directory(p.dirname(target)).create(recursive: true);
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

@ -1,350 +0,0 @@
import 'package:path/path.dart' as p;
import 'package:yaml/yaml.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".',
),
);
}
}
/// Supported Podman Quadlet source file kinds.
enum InfraQuadletKind {
/// Pulls an OCI artifact.
artifact('.artifact', '-artifact'),
/// Builds a container image from a Containerfile.
build('.build', '-build'),
/// Defines and manages a single container.
container('.container', ''),
/// Pulls and caches a container image.
image('.image', '-image'),
/// Deploys containers from Kubernetes YAML.
kube('.kube', ''),
/// Creates a Podman network.
network('.network', '-network'),
/// Creates a Podman pod.
pod('.pod', '-pod'),
/// Ensures a named Podman volume exists.
volume('.volume', '-volume');
const InfraQuadletKind(this.extension, this.defaultServiceSuffix);
/// Quadlet file extension, including the leading dot.
final String extension;
/// Suffix Podman adds to default generated service units.
final String defaultServiceSuffix;
/// Stable manifest identifier.
String get id => extension.substring(1);
/// Finds the kind represented by [file].
static InfraQuadletKind fromFile(String file) {
final extension = p.extension(file);
return values.firstWhere(
(kind) => kind.extension == extension,
orElse: () => throw FormatException(
'Unsupported Quadlet file extension "$extension".',
),
);
}
}
/// A single Podman Quadlet file declared by an infrastructure service.
class InfraQuadletManifest {
const InfraQuadletManifest({
required this.file,
required this.kind,
required this.serviceDir,
this.unit,
this.containerName,
this.dropinsDir,
this.profilesDir,
});
/// Relative path to the Quadlet source file.
final String file;
/// Quadlet type inferred from [file].
final InfraQuadletKind kind;
/// Absolute path to the service directory.
final String serviceDir;
/// Optional generated systemd unit override.
final String? unit;
/// Optional container name for cleanup operations.
final String? containerName;
/// Optional relative path to the Quadlet drop-ins directory.
final String? dropinsDir;
/// Optional relative path to profile drop-ins.
final String? profilesDir;
/// Absolute path to the Quadlet source file.
String get filePath => _resolve(file);
/// 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!);
/// Unit name Dew should manage for this Quadlet.
String get serviceUnit => unit ?? defaultServiceUnit;
/// Unit name generated by Podman when the Quadlet does not override it.
String get defaultServiceUnit {
final baseName = p.basenameWithoutExtension(file);
return '$baseName${kind.defaultServiceSuffix}.service';
}
/// Machine-readable summary.
Map<String, Object?> toJson() => {
'file': filePath,
'kind': kind.id,
'unit': serviceUnit,
'default_unit': defaultServiceUnit,
'container_name': containerName,
'dropins_dir': dropinsDirPath,
'profiles_dir': profilesDirPath,
};
String _resolve(String value) => p.isAbsolute(value)
? p.normalize(value)
: p.normalize(p.join(serviceDir, value));
}
/// Infrastructure service metadata loaded from `manifest.yaml`.
class InfraServiceManifest {
const InfraServiceManifest({
required this.id,
required this.name,
required this.runtime,
required this.serviceDir,
required this.manifestPath,
required this.quadlets,
this.files = const [],
this.configureSchema,
this.initSchema,
});
/// Stable service identifier used on the command line.
final String id;
/// Human-friendly display name.
final String name;
/// Runtime backend declared by the manifest.
final InfraRuntimeKind runtime;
/// Absolute path to the service directory.
final String serviceDir;
/// Absolute path to `manifest.yaml`.
final String manifestPath;
/// Quadlet files deployed for this service.
final List<InfraQuadletManifest> quadlets;
/// Additional files installed beside Quadlets for build contexts or assets.
final List<String> files;
/// 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 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');
/// Absolute paths to additional installed files.
List<String> get filePaths => files.map(_resolve).toList();
/// Units generated by the declared Quadlet files.
List<String> get units =>
quadlets.map((quadlet) => quadlet.serviceUnit).toList();
/// Container names declared for cleanup operations.
List<String> get containerNames => quadlets
.map((quadlet) => quadlet.containerName)
.whereType<String>()
.toList();
/// Decodes [contents] from YAML.
factory InfraServiceManifest.parse({
required String contents,
required String serviceDir,
required String manifestPath,
}) {
final map = _asMap(loadYaml(contents));
final schemas = _optionalSection(map, 'schemas');
final runtimeSection = _optionalSection(map, 'runtime');
final quadlets = _requiredList(
map,
'quadlets',
).map((value) => _parseQuadlet(value, serviceDir: serviceDir)).toList();
return InfraServiceManifest(
id: _requiredString(map, 'id'),
name: _requiredString(map, 'name'),
runtime: InfraRuntimeKind.fromManifestValue(
runtimeSection == null ? null : _optionalString(runtimeSection, 'type'),
),
serviceDir: serviceDir,
manifestPath: manifestPath,
quadlets: quadlets,
files: _optionalStringList(map, 'files'),
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,
'runtime': runtime.id,
'manifest': manifestPath,
'units': units,
'quadlets': quadlets.map((quadlet) => quadlet.toJson()).toList(),
'files': filePaths,
'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));
}
InfraQuadletManifest _parseQuadlet(
dynamic value, {
required String serviceDir,
}) {
if (value is! Map) {
throw const FormatException(
'manifest.yaml field "quadlets" must list objects.',
);
}
final map = value.map((key, value) => MapEntry('$key', value));
final file = _requiredString(map, 'file');
return InfraQuadletManifest(
file: file,
kind: InfraQuadletKind.fromFile(file),
serviceDir: serviceDir,
unit: _optionalString(map, 'unit'),
containerName: _optionalString(map, 'container_name'),
dropinsDir: _optionalString(map, 'dropins_dir'),
profilesDir: _optionalString(map, 'profiles_dir'),
);
}
Map<String, dynamic> _asMap(dynamic value) {
if (value is YamlMap) {
return value.map((key, value) => MapEntry('$key', _asYamlValue(value)));
}
if (value is Map) {
return value.map((key, value) => MapEntry('$key', _asYamlValue(value)));
}
throw const FormatException('manifest.yaml must contain a YAML object.');
}
dynamic _asYamlValue(dynamic value) {
if (value is YamlMap || value is Map) return _asMap(value);
if (value is YamlList) return value.map(_asYamlValue).toList();
if (value is List) return value.map(_asYamlValue).toList();
return value;
}
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('manifest.yaml field "$key" must be an object.');
}
List<dynamic> _requiredList(Map<String, dynamic> map, String key) {
final value = map[key];
if (value is List) return value;
throw FormatException('manifest.yaml is missing list "$key".');
}
List<String> _optionalStringList(Map<String, dynamic> map, String key) {
final value = map[key];
if (value == null) return const [];
if (value is! List) {
throw FormatException('manifest.yaml field "$key" must be a list.');
}
return value.map((item) {
if (item is String && item.isNotEmpty) return item;
throw FormatException(
'manifest.yaml field "$key" must contain non-empty strings.',
);
}).toList();
}
String _requiredString(Map<String, dynamic> map, String key) {
final value = _optionalString(map, key);
if (value == null || value.isEmpty) {
throw FormatException('manifest.yaml 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('manifest.yaml field "$key" must be a string.');
}

View file

@ -1,22 +0,0 @@
name: dew_infra
description: Infrastructure service management package for the Dew CLI.
version: 0.4.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.4.0
file: ^7.0.1
json_schema: ^5.2.2
path: ^1.9.0
podman: ^0.1.0
yaml: ^3.1.0
dev_dependencies:
lints: ^6.0.0
test: ^1.25.6

View file

@ -1,91 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://artificery.dev/dew/schemas/infra/service-manifest.schema.json",
"title": "Dew Infrastructure Service Manifest",
"description": "Schema for .project/infrastructure/services/<service-id>/manifest.yaml.",
"type": "object",
"additionalProperties": false,
"required": ["id", "name", "quadlets", "schemas"],
"properties": {
"id": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z0-9][a-z0-9_.-]*$"
},
"name": {
"type": "string",
"minLength": 1
},
"runtime": {
"type": "object",
"additionalProperties": false,
"required": ["type"],
"properties": {
"type": {
"type": "string",
"enum": ["podman-quadlet"],
"default": "podman-quadlet"
}
}
},
"quadlets": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["file"],
"properties": {
"file": {
"type": "string",
"minLength": 1,
"pattern": "^[^/].*\\.(artifact|build|container|image|kube|network|pod|volume)$"
},
"unit": {
"type": "string",
"minLength": 1,
"pattern": "^[^/]+\\.service$"
},
"container_name": {
"type": "string",
"minLength": 1
},
"dropins_dir": {
"type": "string",
"minLength": 1
},
"profiles_dir": {
"type": "string",
"minLength": 1
}
}
}
},
"files": {
"type": "array",
"description": "Additional files installed beside Quadlets, such as Containerfiles for build contexts.",
"items": {
"type": "string",
"minLength": 1,
"pattern": "^[^/].*$"
}
},
"schemas": {
"type": "object",
"additionalProperties": false,
"required": ["configure", "init"],
"properties": {
"configure": {
"type": "string",
"minLength": 1,
"pattern": "^[^/].*\\.json$"
},
"init": {
"type": "string",
"minLength": 1,
"pattern": "^[^/].*\\.json$"
}
}
}
}
}

View file

@ -1,349 +0,0 @@
import 'dart:convert';
import 'dart:io' as io;
import 'package:dew_core/dew_core.dart';
import 'package:dew_infra/dew_infra.dart';
import 'package:file/memory.dart';
import 'package:json_schema/json_schema.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',
]),
);
});
test('registerCommands exposes infra MCP tools for each CLI path', () {
final registry = CommandRegistry();
registerCommands(registry);
expect(
registry.mcpTools.map((tool) => tool.name),
containsAll([
'infra_list_services',
'infra_show_service',
'infra_validate_services',
'infra_configure_service',
'infra_configure_schema',
'infra_configure_show',
'infra_configure_apply',
'infra_init_service',
'infra_init_schema',
'infra_init_run',
'infra_install_service',
'infra_uninstall_service',
'infra_up_service',
'infra_down_service',
'infra_restart_service',
'infra_status_service',
'infra_logs',
'infra_delete_service',
]),
);
});
test('infra MCP tools discover services and apply schema values', () async {
final fs = MemoryFileSystem.test();
_writeProjectConfig(fs);
_writeService(fs);
final registry = CommandRegistry();
registerCommands(registry, fs: fs);
final tools = {for (final tool in registry.mcpTools) tool.name: tool};
final services =
jsonDecode(
await tools['infra_list_services']!.handler({
'project': '/project',
}),
)
as List<dynamic>;
expect(services.single['id'], 'postgres');
final schema =
jsonDecode(
await tools['infra_configure_schema']!.handler({
'project': '/project',
'service': 'postgres',
}),
)
as Map<String, dynamic>;
expect(schema['schema'], containsPair('type', 'object'));
final applied =
jsonDecode(
await tools['infra_configure_apply']!.handler({
'project': '/project',
'service': 'postgres',
'values': {'port': 5432},
'set': ['credentials.user=dew'],
}),
)
as Map<String, dynamic>;
final config = applied['config'] as Map<String, dynamic>;
expect(config['port'], 5432);
expect(config['credentials'], containsPair('user', 'dew'));
expect(
fs
.file(
'/project/.project/infrastructure/services/postgres/config/configure.json',
)
.existsSync(),
isTrue,
);
});
});
group('InfraRepository', () {
test('discovers service manifests', () async {
final fs = MemoryFileSystem.test();
_writeService(fs, includeNetwork: true);
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.units, [
'app_postgres.service',
'app_postgres-network.service',
]);
expect(
manifests.single.quadlets.map((quadlet) => quadlet.filePath),
containsAll([
'/project/.project/infrastructure/services/postgres/app_postgres.container',
'/project/.project/infrastructure/services/postgres/app_postgres.network',
]),
);
});
});
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 invalid quadlet units', () async {
final fs = MemoryFileSystem.test();
_writeService(fs, serviceId: 'wrong', unit: 'wrong');
final manifest =
await InfraRepository(
infraDir: '/project/.project/infrastructure',
fs: fs,
).loadFromManifestPath(
'/project/.project/infrastructure/services/postgres/manifest.yaml',
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 end with .service'),
);
});
});
group('PodmanQuadletRuntime', () {
test(
'install dry-run reports symlink actions without writing files',
() async {
final fs = MemoryFileSystem.test();
_writeService(fs, includeNetwork: true, includeFile: true);
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(result.actions.join('\n'), contains('app_postgres.network'));
expect(result.actions.join('\n'), contains('Containerfile'));
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');
});
});
group('service-manifest.schema.json', () {
test('validates the manifest contract shape', () {
final schema = JsonSchema.create(
jsonDecode(_schemaFile().readAsStringSync()),
);
final result = schema.validate(_manifestObject());
expect(result.isValid, isTrue, reason: result.errors.join('\n'));
});
});
}
void _writeService(
MemoryFileSystem fs, {
String serviceId = 'postgres',
String unit = 'app_postgres.service',
bool includeNetwork = false,
bool includeFile = false,
}) {
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');
if (includeNetwork) {
fs
.file('${serviceDir.path}/app_postgres.network')
.writeAsStringSync('[Network]\nNetworkName=app_postgres\n');
}
if (includeFile) {
fs
.file('${serviceDir.path}/Containerfile')
.writeAsStringSync('FROM scratch\n');
}
fs
.file('${serviceDir.path}/configure.schema.json')
.writeAsStringSync('{"type":"object"}');
fs
.file('${serviceDir.path}/init.schema.json')
.writeAsStringSync('{"type":"object"}');
final networkQuadlet = includeNetwork
? '''
- file: app_postgres.network
'''
: '';
final files = includeFile
? '''
files:
- Containerfile
'''
: '';
fs.file('${serviceDir.path}/manifest.yaml').writeAsStringSync('''
id: $serviceId
name: PostgreSQL
runtime:
type: podman-quadlet
quadlets:
- file: app_postgres.container
unit: $unit
container_name: app_postgres
dropins_dir: app_postgres.container.d
profiles_dir: app_postgres.profiles.d
$networkQuadlet
$files
schemas:
configure: configure.schema.json
init: init.schema.json
''');
}
void _writeProjectConfig(MemoryFileSystem fs) {
fs.directory('/project/.project').createSync(recursive: true);
fs.file('/project/.project/dew.yaml').writeAsStringSync('dew: {}\n');
}
Map<String, Object?> _manifestObject() => {
'id': 'postgres',
'name': 'PostgreSQL',
'runtime': {'type': 'podman-quadlet'},
'quadlets': [
{
'file': 'app_postgres.container',
'unit': 'app_postgres.service',
'container_name': 'app_postgres',
'dropins_dir': 'app_postgres.container.d',
'profiles_dir': 'app_postgres.profiles.d',
},
{'file': 'app_postgres.network'},
],
'files': ['Containerfile'],
'schemas': {'configure': 'configure.schema.json', 'init': 'init.schema.json'},
};
io.File _schemaFile() {
for (final path in [
'packages/infra/schemas/service-manifest.schema.json',
'schemas/service-manifest.schema.json',
]) {
final file = io.File(path);
if (file.existsSync()) return file;
}
throw StateError('Could not find service-manifest.schema.json.');
}

View file

@ -1,11 +1,10 @@
# Changelog # Changelog
## 0.4.0 — 2026-05-05 ## 0.2.0 — 2026-05-02
Release alignment for the `0.4.0` Dew package set. - Updated tests and fixtures for the generated config without unsupported MCP
`host` and `port` fields.
- Updated the `dew_core` dependency constraint for the infra release. - Bumped the `dew_core` dependency constraint to `^0.2.0`.
- Applied analyzer fixes for current Dart lint recommendations.
## 0.1.0 — 2026-04-25 ## 0.1.0 — 2026-04-25

View file

@ -8,7 +8,7 @@ import '../ticket_store.dart';
class AddCommentCommand extends DewCommand with DewToolCommand { class AddCommentCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
AddCommentCommand({this._fs = const LocalFileSystem()}) { AddCommentCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption( ..addOption(
'id', 'id',

View file

@ -9,7 +9,7 @@ import '../ticket_store.dart';
class ArchiveCommand extends DewCommand with DewToolCommand { class ArchiveCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
ArchiveCommand({this._fs = const LocalFileSystem()}) { ArchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser.addOption( argParser.addOption(
'id', 'id',
abbr: 'i', abbr: 'i',

View file

@ -9,7 +9,7 @@ import '../ticket_store.dart';
class BoardCommand extends DewCommand with DewToolCommand { class BoardCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
BoardCommand({this._fs = const LocalFileSystem()}) { BoardCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('type', abbr: 't', help: 'Filter tickets to this type.') ..addOption('type', abbr: 't', help: 'Filter tickets to this type.')
..addOption('label', help: 'Filter tickets to this label.') ..addOption('label', help: 'Filter tickets to this label.')

View file

@ -8,7 +8,7 @@ import '../ticket_store.dart';
class CreateCommand extends DewCommand with DewToolCommand { class CreateCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
CreateCommand({this._fs = const LocalFileSystem()}) { CreateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.') ..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.')
..addOption( ..addOption(

View file

@ -8,7 +8,7 @@ import '../ticket_store.dart';
class DeleteCommand extends DewCommand with DewToolCommand { class DeleteCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
DeleteCommand({this._fs = const LocalFileSystem()}) { DeleteCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser.addOption( argParser.addOption(
'id', 'id',
abbr: 'i', abbr: 'i',

View file

@ -9,7 +9,7 @@ import '../ticket_store.dart';
class GetCommand extends DewCommand with DewToolCommand { class GetCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
GetCommand({this._fs = const LocalFileSystem()}) { GetCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser.addOption( argParser.addOption(
'id', 'id',
abbr: 'i', abbr: 'i',

View file

@ -6,7 +6,7 @@ import '../kanban_config.dart';
class GetConfigCommand extends DewCommand with DewToolCommand { class GetConfigCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
GetConfigCommand({this._fs = const LocalFileSystem()}); GetConfigCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
@override @override
final String name = 'config'; final String name = 'config';

View file

@ -9,7 +9,7 @@ import '../ticket_store.dart';
class LinkCommand extends DewCommand with DewToolCommand { class LinkCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
LinkCommand({this._fs = const LocalFileSystem()}) { LinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.') ..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
..addOption( ..addOption(
@ -44,9 +44,8 @@ class LinkCommand extends DewCommand with DewToolCommand {
final targetId = '${args['target']}'.toUpperCase(); final targetId = '${args['target']}'.toUpperCase();
final type = '${args['type']}'; final type = '${args['type']}';
if (id == targetId) { if (id == targetId)
throw ArgumentError('A ticket cannot be linked to itself.'); throw ArgumentError('A ticket cannot be linked to itself.');
}
final context = await ProjectContext.find(fs: _fs); final context = await ProjectContext.find(fs: _fs);
final store = TicketStore( final store = TicketStore(

View file

@ -9,7 +9,7 @@ import '../ticket_store.dart';
class ListCommand extends DewCommand with DewToolCommand { class ListCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
ListCommand({this._fs = const LocalFileSystem()}) { ListCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption( ..addOption(
'column', 'column',

View file

@ -8,7 +8,7 @@ import '../ticket_store.dart';
class MoveCommand extends DewCommand with DewToolCommand { class MoveCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
MoveCommand({this._fs = const LocalFileSystem()}) { MoveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.') ..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.')
..addOption( ..addOption(

View file

@ -8,7 +8,7 @@ import '../ticket_store.dart';
class SearchCommand extends DewCommand with DewToolCommand { class SearchCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
SearchCommand({this._fs = const LocalFileSystem()}) { SearchCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
argParser argParser
..addOption( ..addOption(
'query', 'query',

View file

@ -8,7 +8,7 @@ import '../ticket_store.dart';
class StatsCommand extends DewCommand with DewToolCommand { class StatsCommand extends DewCommand with DewToolCommand {
final FileSystem _fs; final FileSystem _fs;
StatsCommand({this._fs = const LocalFileSystem()}); StatsCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
@override @override
final String name = 'stats'; final String name = 'stats';

View file

@ -214,7 +214,7 @@ class _Cell {
class TuiCommand extends DewCommand { class TuiCommand extends DewCommand {
final FileSystem _fs; final FileSystem _fs;
TuiCommand({this._fs = const LocalFileSystem()}); TuiCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
@override @override
final String name = 'tui'; final String name = 'tui';
@ -410,9 +410,8 @@ class TuiCommand extends DewCommand {
case ControlCharacter.arrowLeft: case ControlCharacter.arrowLeft:
if (p.relationIdx > 0) p.relationIdx--; if (p.relationIdx > 0) p.relationIdx--;
case ControlCharacter.arrowRight: case ControlCharacter.arrowRight:
if (p.relationIdx < _linkRelations.length - 1) { if (p.relationIdx < _linkRelations.length - 1)
p.relationIdx++; p.relationIdx++;
}
case ControlCharacter.enter: case ControlCharacter.enter:
try { try {
await store.linkTickets( await store.linkTickets(
@ -445,14 +444,12 @@ class TuiCommand extends DewCommand {
p.input = p.input.substring(0, p.input.length - 1); p.input = p.input.substring(0, p.input.length - 1);
} }
case ControlCharacter.arrowLeft: case ControlCharacter.arrowLeft:
if (p.kind == _PromptKind.newTitle && p.typeIdx > 0) { if (p.kind == _PromptKind.newTitle && p.typeIdx > 0)
p.typeIdx--; p.typeIdx--;
}
case ControlCharacter.arrowRight: case ControlCharacter.arrowRight:
if (p.kind == _PromptKind.newTitle && if (p.kind == _PromptKind.newTitle &&
p.typeIdx < config.ticketTypes.length - 1) { p.typeIdx < config.ticketTypes.length - 1)
p.typeIdx++; p.typeIdx++;
}
case ControlCharacter.enter: case ControlCharacter.enter:
final trimmed = p.input.trim(); final trimmed = p.input.trim();
if (p.kind == _PromptKind.linkId) { if (p.kind == _PromptKind.linkId) {
@ -715,9 +712,8 @@ class TuiCommand extends DewCommand {
case _EditorField.labels: case _EditorField.labels:
if (es.itemCursor < es.labels.length - 1) es.itemCursor++; if (es.itemCursor < es.labels.length - 1) es.itemCursor++;
case _EditorField.milestones: case _EditorField.milestones:
if (es.itemCursor < es.milestones.length - 1) { if (es.itemCursor < es.milestones.length - 1)
es.itemCursor++; es.itemCursor++;
}
default: default:
break; break;
} }
@ -1390,12 +1386,10 @@ class TuiCommand extends DewCommand {
kv('Type', ticket.type); kv('Type', ticket.type);
kv('Column', ticket.column); kv('Column', ticket.column);
kv('Created', _fmtDate(ticket.created)); kv('Created', _fmtDate(ticket.created));
if (ticket.milestones.isNotEmpty) { if (ticket.milestones.isNotEmpty)
kv('Milestones', ticket.milestones.join(', ')); kv('Milestones', ticket.milestones.join(', '));
} if (ticket.labels.isNotEmpty)
if (ticket.labels.isNotEmpty) {
kv('Labels', ticket.labels.map((l) => '#$l').join(' ')); kv('Labels', ticket.labels.map((l) => '#$l').join(' '));
}
if (ticket.links.isNotEmpty) { if (ticket.links.isNotEmpty) {
for (final link in ticket.links) { for (final link in ticket.links) {
kv(link.type, link.targetId); kv(link.type, link.targetId);

Some files were not shown because too many files have changed in this diff Show more