Compare commits
17 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f34cec1934 | |||
| 1bc6a6447d | |||
| 69fa044e5b | |||
| f191a276a8 | |||
| 397aed251f | |||
| 9bc5779221 | |||
| 223aaba888 | |||
| 827a504bfc | |||
| 2f69bff301 | |||
| 7f5896ec5c | |||
| a6a86e6c29 | |||
| 95058f7f04 | |||
| f7346b1afe | |||
| 0cd08e78d3 | |||
| bd40758bb2 | |||
| 3819f8ad06 | |||
| 1281bd4092 |
138 changed files with 6698 additions and 115 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,6 +1,8 @@
|
||||||
# https://dart.dev/guides/libraries/private-files
|
# Dew Git Ignore
|
||||||
# Created by `dart pub`
|
|
||||||
|
## Dart
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
|
|
||||||
# Compiled toolchain binaries
|
## JetBrains
|
||||||
.project/toolchain/
|
.idea/
|
||||||
|
*.iml
|
||||||
|
|
|
||||||
3
.project/.gitignore
vendored
Normal file
3
.project/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/cache/
|
||||||
|
/secrets/
|
||||||
|
/toolchain/
|
||||||
16
.project/infrastructure/README.md
Normal file
16
.project/infrastructure/README.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# 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.
|
||||||
23
.project/infrastructure/services/app-pod/README.md
Normal file
23
.project/infrastructure/services/app-pod/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
[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
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
[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
|
||||||
6
.project/infrastructure/services/app-pod/dew_app-pod.pod
Normal file
6
.project/infrastructure/services/app-pod/dew_app-pod.pod
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
[Pod]
|
||||||
|
PodName=dew_app-pod
|
||||||
|
PublishPort=127.0.0.1:8088:80
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
19
.project/infrastructure/services/app-pod/manifest.yaml
Normal file
19
.project/infrastructure/services/app-pod/manifest.yaml
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"$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": {}
|
||||||
|
}
|
||||||
28
.project/infrastructure/services/keycloak/README.md
Normal file
28
.project/infrastructure/services/keycloak/README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
[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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[Volume]
|
||||||
|
VolumeName=dew_keycloak_postgresql_data
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
[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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[Network]
|
||||||
|
NetworkName=dew_keycloak
|
||||||
21
.project/infrastructure/services/keycloak/manifest.yaml
Normal file
21
.project/infrastructure/services/keycloak/manifest.yaml
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
FROM docker.io/library/nginx:alpine
|
||||||
|
|
||||||
|
RUN printf '{"service":"local-api-build","status":"ok"}\n' \
|
||||||
|
> /usr/share/nginx/html/index.html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
23
.project/infrastructure/services/local-api-build/README.md
Normal file
23
.project/infrastructure/services/local-api-build/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Build]
|
||||||
|
ImageTag=localhost/dew_local-api-build:latest
|
||||||
|
File=Containerfile
|
||||||
|
SetWorkingDirectory=unit
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
RemainAfterExit=yes
|
||||||
|
TimeoutStartSec=900
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
[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
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"$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": {}
|
||||||
|
}
|
||||||
28
.project/infrastructure/services/postgresql-18/README.md
Normal file
28
.project/infrastructure/services/postgresql-18/README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
[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
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
16
.project/infrastructure/services/postgresql-18/manifest.yaml
Normal file
16
.project/infrastructure/services/postgresql-18/manifest.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"$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": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
.project/infrastructure/services/rustfs/README.md
Normal file
28
.project/infrastructure/services/rustfs/README.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# 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.
|
||||||
23
.project/infrastructure/services/rustfs/dew_rustfs.container
Normal file
23
.project/infrastructure/services/rustfs/dew_rustfs.container
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
[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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[Network]
|
||||||
|
NetworkName=dew_rustfs
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
[Volume]
|
||||||
|
VolumeName=dew_rustfs_data
|
||||||
|
User=10001
|
||||||
|
Group=10001
|
||||||
18
.project/infrastructure/services/rustfs/manifest.yaml
Normal file
18
.project/infrastructure/services/rustfs/manifest.yaml
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"$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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
.project/infrastructure/services/valkey-9/README.md
Normal file
24
.project/infrastructure/services/valkey-9/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
[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
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
[Volume]
|
||||||
|
VolumeName=dew_valkey-9_data
|
||||||
16
.project/infrastructure/services/valkey-9/manifest.yaml
Normal file
16
.project/infrastructure/services/valkey-9/manifest.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"$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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"$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": {}
|
||||||
|
}
|
||||||
8
.project/kanban/done/DEW-0029.md
Normal file
8
.project/kanban/done/DEW-0029.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
id: DEW-0029
|
||||||
|
title: Add infra command surface
|
||||||
|
type: task
|
||||||
|
created: 2026-05-05T02:02:35.678734Z
|
||||||
|
---
|
||||||
|
|
||||||
|
Implement the initial dew infra command surface for project-local infrastructure services. Start with Quadlet/Podman operations while keeping runtime boundaries explicit so additional container runtimes can be added later.
|
||||||
8
.project/kanban/done/DEW-0030.md
Normal file
8
.project/kanban/done/DEW-0030.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
8
.project/kanban/done/DEW-0031.md
Normal file
8
.project/kanban/done/DEW-0031.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
8
.project/kanban/done/DEW-0032.md
Normal file
8
.project/kanban/done/DEW-0032.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
8
.project/kanban/done/DEW-0033.md
Normal file
8
.project/kanban/done/DEW-0033.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
8
.project/kanban/done/DEW-0034.md
Normal file
8
.project/kanban/done/DEW-0034.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
8
.project/kanban/done/DEW-0035.md
Normal file
8
.project/kanban/done/DEW-0035.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
8
.project/kanban/done/DEW-0036.md
Normal file
8
.project/kanban/done/DEW-0036.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
8
.project/kanban/done/DEW-0037.md
Normal file
8
.project/kanban/done/DEW-0037.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
8
.project/kanban/done/DEW-0038.md
Normal file
8
.project/kanban/done/DEW-0038.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
1
.tool-versions
Normal file
1
.tool-versions
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
dart 3.12.0
|
||||||
91
AGENTS.md
Normal file
91
AGENTS.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# 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.
|
||||||
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -7,13 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.2.0] - 2026-05-02
|
## [0.4.0] - 2026-05-05
|
||||||
|
|
||||||
### Changed
|
### Added in 0.4.0
|
||||||
|
|
||||||
- Removed unsupported MCP `host` and `port` settings from generated `dew.yaml`.
|
- Added `dew infra` for project infrastructure service discovery, validation,
|
||||||
- Updated MCP configuration docs to reflect stdio-only client setup.
|
configuration payloads, initialization payloads, lifecycle control, status,
|
||||||
- Added the `dew` executable mapping for pub activation.
|
logs, and cleanup.
|
||||||
|
- 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
|
||||||
|
|
||||||
|
|
@ -94,6 +109,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.2.0...HEAD
|
[Unreleased]: https://github.com/artificerchris/dew/compare/v0.4.0...HEAD
|
||||||
[0.2.0]: https://github.com/artificerchris/dew/compare/v0.1.0...v0.2.0
|
[0.4.0]: https://github.com/artificerchris/dew/compare/v0.3.0...v0.4.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
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Thank you for your interest in contributing! This guide covers everything you ne
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Dart SDK ^3.11.4** — verify with `dart --version`
|
- **Dart SDK ^3.12.0** — 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
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -18,6 +18,12 @@ stats board config tui
|
||||||
|
|
||||||
Tickets are stored as `.project/kanban/<column>/<ID>.md` files. Labels, milestones, typed bidirectional links, and inline comments are all first-class citizens. See the [Kanban documentation](./docs/features/kanban.md) for the full command reference.
|
Tickets are stored as `.project/kanban/<column>/<ID>.md` files. Labels, milestones, typed bidirectional links, and inline comments are all first-class citizens. See the [Kanban documentation](./docs/features/kanban.md) for the full command reference.
|
||||||
|
|
||||||
|
### Infrastructure
|
||||||
|
|
||||||
|
`dew infra` discovers services under `.project/infrastructure/services`, validates
|
||||||
|
their manifests and schemas, and manages Podman Quadlets through systemd. The
|
||||||
|
runtime boundary is explicit so other container backends can be added later.
|
||||||
|
|
||||||
### Interactive TUI
|
### Interactive TUI
|
||||||
|
|
||||||
`dew kanban tui` opens a full Trello-style terminal board with three modes:
|
`dew kanban tui` opens a full Trello-style terminal board with three modes:
|
||||||
|
|
@ -30,7 +36,10 @@ 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 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).
|
`dew mcp serve` starts an MCP-compliant stdio server that exposes Dew commands
|
||||||
|
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
|
||||||
|
|
||||||
|
|
@ -38,7 +47,7 @@ The TUI auto-refreshes when ticket files change on disk, so it stays in sync whe
|
||||||
dart pub global activate dew
|
dart pub global activate dew
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Dart SDK ^3.11.4.
|
Requires Dart SDK ^3.12.0.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
|
@ -55,11 +64,12 @@ dew kanban tui
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## 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)
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,20 @@ 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)
|
||||||
|
|
||||||
|
|
@ -43,8 +53,12 @@ dew:
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
The MCP server currently has no project-level `dew.yaml` configuration. Configure
|
### `dew.mcp`
|
||||||
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`
|
||||||
|
|
||||||
|
|
|
||||||
104
docs/features/infra.md
Normal file
104
docs/features/infra.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
# 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
|
||||||
|
```
|
||||||
|
|
@ -6,17 +6,26 @@ 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. Any command that mixes it in is automatically
|
- **`packages/core`** defines the `DewToolCommand` mixin and `McpToolProvider`
|
||||||
registered as an MCP tool — the mixin derives the tool's JSON Schema directly from the command's own
|
interface. Commands that mix in `DewToolCommand` are automatically registered
|
||||||
`ArgParser`, so tools and CLI commands share a single definition.
|
as MCP tools, and commands that implement `McpToolProvider` can expose extra
|
||||||
|
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 currently has no `.project/dew.yaml` settings. Configure your MCP
|
The MCP server is configured under the `mcp` key in `.project/dew.yaml`. By default it runs on `localhost` at port `8080`.
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -66,6 +75,29 @@ 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.
|
||||||
|
|
|
||||||
144
docs/features/vault.md
Normal file
144
docs/features/vault.md
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -9,6 +9,7 @@ Welcome to the documentation for the Dew project management tool!
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- [Kanban Board](./features/kanban.md) — Visualize and manage tasks in a column-based workflow
|
- [Kanban Board](./features/kanban.md) — Visualize and manage tasks in a column-based workflow
|
||||||
|
- [Infrastructure](./features/infra.md) — Manage local service manifests and Podman Quadlets
|
||||||
- [MCP Server](./features/mcp.md) — AI agent integration via the Model Context Protocol
|
- [MCP Server](./features/mcp.md) — AI agent integration via the Model Context Protocol
|
||||||
|
|
||||||
## Package Architecture
|
## Package Architecture
|
||||||
|
|
@ -19,12 +20,15 @@ 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 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.
|
Every CLI command that mixes in `DewToolCommand` is automatically registered as
|
||||||
|
an MCP tool. Commands that need more granular tool paths can also implement
|
||||||
|
`McpToolProvider` to expose additional tools.
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ArgParser definition
|
ArgParser definition
|
||||||
|
|
@ -38,6 +42,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` currently has no project-level config values
|
- `dew_mcp` defines `McpDewConfig` — exposes `context.config.mcp`
|
||||||
|
|
||||||
This keeps feature-specific config classes out of `core`.
|
This keeps feature-specific config classes out of `core` while leaving all call sites unchanged.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.2.0 — 2026-05-02
|
## 0.4.0 — 2026-05-05
|
||||||
|
|
||||||
- Added the `dew` executable mapping for pub activation.
|
Infra release.
|
||||||
- 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
|
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.');
|
||||||
|
|
@ -16,5 +22,10 @@ Future<void> main(List<String> args) async {
|
||||||
runner.addCommand(command);
|
runner.addCommand(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
await runner.run(args);
|
try {
|
||||||
|
await runner.run(args);
|
||||||
|
} on UsageException catch (error) {
|
||||||
|
stderr.writeln(error);
|
||||||
|
exitCode = 64;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,20 @@
|
||||||
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.2.0
|
version: 0.4.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.11.4
|
sdk: ^3.12.0
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
args: ^2.7.0
|
args: ^2.7.0
|
||||||
dew_core: ^0.2.0
|
dew_core: ^0.4.0
|
||||||
dew_kanban: ^0.2.0
|
dew_infra: ^0.4.0
|
||||||
dew_mcp: ^0.2.0
|
dew_kanban: ^0.4.0
|
||||||
|
dew_vault: ^0.4.0
|
||||||
|
dew_mcp: ^0.4.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: ^6.0.0
|
lints: ^6.0.0
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,17 @@
|
||||||
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.');
|
||||||
|
|
@ -24,9 +28,12 @@ void main() {
|
||||||
expect(buildRunner, returnsNormally);
|
expect(buildRunner, returnsNormally);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has kanban, init, and mcp commands registered', () {
|
test('has core package commands registered', () {
|
||||||
final runner = buildRunner();
|
final runner = buildRunner();
|
||||||
expect(runner.commands.keys, containsAll(['kanban', 'init', 'mcp']));
|
expect(
|
||||||
|
runner.commands.keys,
|
||||||
|
containsAll(['infra', 'kanban', 'vault', 'init', 'mcp']),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('--help flag does not throw', () async {
|
test('--help flag does not throw', () async {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.2.0 — 2026-05-02
|
## 0.4.0 — 2026-05-05
|
||||||
|
|
||||||
- Removed stale MCP config references from core config documentation.
|
Infra and MCP provider release.
|
||||||
- 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
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;
|
||||||
|
|
@ -6,7 +8,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]).
|
/// typed configuration (e.g. [KanbanDewConfig.kanban], [McpDewConfig.mcp]).
|
||||||
/// 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;
|
||||||
|
|
@ -34,16 +36,42 @@ 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({
|
||||||
|
|
@ -54,11 +82,13 @@ 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;
|
||||||
|
|
@ -72,3 +102,15 @@ 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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -158,6 +158,9 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ 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:
|
||||||
|
|
@ -56,12 +60,22 @@ 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(this._hooks, {FileSystem fs = const LocalFileSystem()})
|
InitCommand(
|
||||||
: _fs = fs {
|
List<DewInitHook> hooks, {
|
||||||
|
FileSystem fs = const LocalFileSystem(),
|
||||||
|
}) : this._(hooks, fs);
|
||||||
|
|
||||||
|
InitCommand._(this._hooks, this._fs) {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'path',
|
'path',
|
||||||
|
|
@ -93,6 +107,7 @@ 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);
|
||||||
|
|
||||||
|
|
@ -103,6 +118,13 @@ 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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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.2.0
|
version: 0.4.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.11.4
|
sdk: ^3.12.0
|
||||||
|
|
||||||
# Add regular dependencies here.
|
# Add regular dependencies here.
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
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';
|
||||||
|
|
@ -38,6 +37,9 @@ 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:
|
||||||
|
|
@ -57,7 +59,8 @@ 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.containsKey('mcp'), isFalse);
|
expect(dew['mcp']['host'], 'localhost');
|
||||||
|
expect(dew['mcp']['port'], 9090);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('find() locates config from a subdirectory', () async {
|
test('find() locates config from a subdirectory', () async {
|
||||||
|
|
@ -69,22 +72,25 @@ 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, '/');
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
group('InitCommand', () {
|
test('resolveConfigPath resolves paths relative to .project/dew.yaml', () async {
|
||||||
test('generates dew.yaml without MCP host or port config', () async {
|
|
||||||
final fs = MemoryFileSystem();
|
final fs = MemoryFileSystem();
|
||||||
final runner = CommandRunner<void>('dew', 'test');
|
fs.directory('/foo/.project').createSync(recursive: true);
|
||||||
runner.addCommand(InitCommand(const [], fs: fs));
|
fs.file('/foo/.project/dew.yaml').writeAsStringSync(configYaml);
|
||||||
|
|
||||||
await runner.run(['init', '--path', '/project']);
|
final ctx = await ProjectContext.find(fs: fs, from: fs.directory('/foo/.project/child'));
|
||||||
|
expect(
|
||||||
final config = fs.file('/project/.project/dew.yaml').readAsStringSync();
|
ctx.resolveConfigPath('vault'),
|
||||||
expect(config, contains('dew:'));
|
'/foo/.project/vault',
|
||||||
expect(config, contains('kanban:'));
|
);
|
||||||
expect(config, isNot(contains('mcp:')));
|
expect(
|
||||||
expect(config, isNot(contains('host:')));
|
ctx.resolveConfigPath('.project/vault'),
|
||||||
expect(config, isNot(contains('port:')));
|
'/foo/.project/vault',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ctx.resolveConfigPath('/tmp/abs'),
|
||||||
|
'/tmp/abs',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
packages/infra/CHANGELOG.md
Normal file
13
packages/infra/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# 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.
|
||||||
21
packages/infra/LICENSE
Normal file
21
packages/infra/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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.
|
||||||
22
packages/infra/README.md
Normal file
22
packages/infra/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# 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).
|
||||||
20
packages/infra/lib/dew_infra.dart
Normal file
20
packages/infra/lib/dew_infra.dart
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
library;
|
||||||
|
|
||||||
|
export 'src/dew_infra_base.dart';
|
||||||
|
export 'src/infra_repository.dart';
|
||||||
|
export 'src/infra_runtime.dart';
|
||||||
|
export 'src/service_manifest.dart';
|
||||||
|
|
||||||
|
import 'package:dew_core/dew_core.dart';
|
||||||
|
import 'package:file/file.dart';
|
||||||
|
import 'package:file/local.dart';
|
||||||
|
|
||||||
|
import 'src/dew_infra_base.dart';
|
||||||
|
|
||||||
|
/// Registers all infrastructure commands into [registry].
|
||||||
|
void registerCommands(
|
||||||
|
CommandRegistry registry, {
|
||||||
|
FileSystem fs = const LocalFileSystem(),
|
||||||
|
}) {
|
||||||
|
registry.register(InfraCommand(fs: fs));
|
||||||
|
}
|
||||||
1362
packages/infra/lib/src/dew_infra_base.dart
Normal file
1362
packages/infra/lib/src/dew_infra_base.dart
Normal file
File diff suppressed because it is too large
Load diff
262
packages/infra/lib/src/infra_repository.dart
Normal file
262
packages/infra/lib/src/infra_repository.dart
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
510
packages/infra/lib/src/infra_runtime.dart
Normal file
510
packages/infra/lib/src/infra_runtime.dart
Normal file
|
|
@ -0,0 +1,510 @@
|
||||||
|
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');
|
||||||
|
}
|
||||||
350
packages/infra/lib/src/service_manifest.dart
Normal file
350
packages/infra/lib/src/service_manifest.dart
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
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.');
|
||||||
|
}
|
||||||
22
packages/infra/pubspec.yaml
Normal file
22
packages/infra/pubspec.yaml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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
|
||||||
91
packages/infra/schemas/service-manifest.schema.json
Normal file
91
packages/infra/schemas/service-manifest.schema.json
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
{
|
||||||
|
"$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$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
349
packages/infra/test/dew_infra_test.dart
Normal file
349
packages/infra/test/dew_infra_test.dart
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
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.');
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.2.0 — 2026-05-02
|
## 0.4.0 — 2026-05-05
|
||||||
|
|
||||||
- Updated tests and fixtures for the generated config without unsupported MCP
|
Release alignment for the `0.4.0` Dew package set.
|
||||||
`host` and `port` fields.
|
|
||||||
- Bumped the `dew_core` dependency constraint to `^0.2.0`.
|
- Updated the `dew_core` dependency constraint for the infra release.
|
||||||
|
- Applied analyzer fixes for current Dart lint recommendations.
|
||||||
|
|
||||||
## 0.1.0 — 2026-04-25
|
## 0.1.0 — 2026-04-25
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
AddCommentCommand({this._fs = const LocalFileSystem()}) {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'id',
|
'id',
|
||||||
|
|
|
||||||
|
|
@ -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({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
ArchiveCommand({this._fs = const LocalFileSystem()}) {
|
||||||
argParser.addOption(
|
argParser.addOption(
|
||||||
'id',
|
'id',
|
||||||
abbr: 'i',
|
abbr: 'i',
|
||||||
|
|
|
||||||
|
|
@ -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({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
BoardCommand({this._fs = const LocalFileSystem()}) {
|
||||||
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.')
|
||||||
|
|
|
||||||
|
|
@ -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({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
CreateCommand({this._fs = const LocalFileSystem()}) {
|
||||||
argParser
|
argParser
|
||||||
..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.')
|
..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.')
|
||||||
..addOption(
|
..addOption(
|
||||||
|
|
|
||||||
|
|
@ -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({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
DeleteCommand({this._fs = const LocalFileSystem()}) {
|
||||||
argParser.addOption(
|
argParser.addOption(
|
||||||
'id',
|
'id',
|
||||||
abbr: 'i',
|
abbr: 'i',
|
||||||
|
|
|
||||||
|
|
@ -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({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
GetCommand({this._fs = const LocalFileSystem()}) {
|
||||||
argParser.addOption(
|
argParser.addOption(
|
||||||
'id',
|
'id',
|
||||||
abbr: 'i',
|
abbr: 'i',
|
||||||
|
|
|
||||||
|
|
@ -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({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
|
GetConfigCommand({this._fs = const LocalFileSystem()});
|
||||||
@override
|
@override
|
||||||
final String name = 'config';
|
final String name = 'config';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
LinkCommand({this._fs = const LocalFileSystem()}) {
|
||||||
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,8 +44,9 @@ 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(
|
||||||
|
|
|
||||||
|
|
@ -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({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
ListCommand({this._fs = const LocalFileSystem()}) {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'column',
|
'column',
|
||||||
|
|
|
||||||
|
|
@ -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({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
MoveCommand({this._fs = const LocalFileSystem()}) {
|
||||||
argParser
|
argParser
|
||||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.')
|
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.')
|
||||||
..addOption(
|
..addOption(
|
||||||
|
|
|
||||||
|
|
@ -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({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
SearchCommand({this._fs = const LocalFileSystem()}) {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'query',
|
'query',
|
||||||
|
|
|
||||||
|
|
@ -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({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
|
StatsCommand({this._fs = const LocalFileSystem()});
|
||||||
@override
|
@override
|
||||||
final String name = 'stats';
|
final String name = 'stats';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@ class _Cell {
|
||||||
class TuiCommand extends DewCommand {
|
class TuiCommand extends DewCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
TuiCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
|
TuiCommand({this._fs = const LocalFileSystem()});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String name = 'tui';
|
final String name = 'tui';
|
||||||
|
|
@ -410,8 +410,9 @@ 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(
|
||||||
|
|
@ -444,12 +445,14 @@ 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) {
|
||||||
|
|
@ -712,8 +715,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
@ -1386,10 +1390,12 @@ 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
Loading…
Add table
Reference in a new issue