Compare commits
1 commit
| Author | SHA1 | Date | |
|---|---|---|---|
| 373e8c0949 |
138 changed files with 115 additions and 6698 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -1,8 +1,6 @@
|
||||||
# Dew Git Ignore
|
# https://dart.dev/guides/libraries/private-files
|
||||||
|
# Created by `dart pub`
|
||||||
## Dart
|
|
||||||
.dart_tool/
|
.dart_tool/
|
||||||
|
|
||||||
## JetBrains
|
# Compiled toolchain binaries
|
||||||
.idea/
|
.project/toolchain/
|
||||||
*.iml
|
|
||||||
|
|
|
||||||
3
.project/.gitignore
vendored
3
.project/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
/cache/
|
|
||||||
/secrets/
|
|
||||||
/toolchain/
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
# Dew Infrastructure Samples
|
|
||||||
|
|
||||||
This directory contains sample services that exercise `dew infra` using the same
|
|
||||||
layout a project would use for local infrastructure.
|
|
||||||
|
|
||||||
Each service lives under `services/<service-id>/` and is discovered from its
|
|
||||||
`manifest.yaml`.
|
|
||||||
|
|
||||||
Included samples:
|
|
||||||
|
|
||||||
- `postgresql-18`: single PostgreSQL container with a named data volume.
|
|
||||||
- `valkey-9`: cache container backed by a Quadlet volume.
|
|
||||||
- `rustfs`: S3-compatible object storage on a Quadlet network and volume.
|
|
||||||
- `keycloak`: multi-container service with PostgreSQL on a shared network.
|
|
||||||
- `app-pod`: Podman pod with web and sidecar containers.
|
|
||||||
- `local-api-build`: local image build consumed by a container Quadlet.
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# App Pod
|
|
||||||
|
|
||||||
Sample local pod service managed by `dew infra` and Podman Quadlets.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra validate app-pod
|
|
||||||
dew infra up app-pod
|
|
||||||
dew infra status app-pod
|
|
||||||
dew infra logs app-pod --lines 100
|
|
||||||
```
|
|
||||||
|
|
||||||
This sample shows a Podman pod with a web container and a sidecar container.
|
|
||||||
|
|
||||||
- web endpoint: `http://127.0.0.1:8088`
|
|
||||||
- pod: `dew_app-pod`
|
|
||||||
- web container: `dew_app-pod-web`
|
|
||||||
- sidecar container: `dew_app-pod-sidecar`
|
|
||||||
|
|
||||||
Stop it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra down app-pod
|
|
||||||
```
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Dew sample pod sidecar container
|
|
||||||
Requires=dew_app-pod.pod
|
|
||||||
After=dew_app-pod.pod
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=docker.io/library/busybox:1.37
|
|
||||||
ContainerName=dew_app-pod-sidecar
|
|
||||||
Pod=dew_app-pod.pod
|
|
||||||
Exec=sh -c "while true; do date; sleep 60; done"
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
TimeoutStartSec=900
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Dew sample pod web container
|
|
||||||
Requires=dew_app-pod.pod
|
|
||||||
After=dew_app-pod.pod
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=docker.io/library/nginx:alpine
|
|
||||||
ContainerName=dew_app-pod-web
|
|
||||||
Pod=dew_app-pod.pod
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
TimeoutStartSec=900
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
[Pod]
|
|
||||||
PodName=dew_app-pod
|
|
||||||
PublishPort=127.0.0.1:8088:80
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
id: app-pod
|
|
||||||
name: App Pod
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
type: podman-quadlet
|
|
||||||
|
|
||||||
quadlets:
|
|
||||||
- file: dew_app-pod.pod
|
|
||||||
unit: dew_app-pod-pod.service
|
|
||||||
- file: dew_app-pod-web.container
|
|
||||||
unit: dew_app-pod-web.service
|
|
||||||
container_name: dew_app-pod-web
|
|
||||||
- file: dew_app-pod-sidecar.container
|
|
||||||
unit: dew_app-pod-sidecar.service
|
|
||||||
container_name: dew_app-pod-sidecar
|
|
||||||
|
|
||||||
schemas:
|
|
||||||
configure: schemas/configure.schema.json
|
|
||||||
init: schemas/init.schema.json
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/samples/app-pod/configure.schema.json",
|
|
||||||
"title": "App Pod Sample Configuration",
|
|
||||||
"description": "Configuration values represented by the sample pod Quadlets.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"host_port": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 65535,
|
|
||||||
"default": 8088
|
|
||||||
},
|
|
||||||
"pod_name": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "dew_app-pod"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/samples/app-pod/init.schema.json",
|
|
||||||
"title": "App Pod Sample Initialization",
|
|
||||||
"description": "Initialization values for the sample pod service.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# Keycloak
|
|
||||||
|
|
||||||
Sample local Keycloak service managed by `dew infra` and Podman Quadlets.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra validate keycloak
|
|
||||||
dew infra up keycloak
|
|
||||||
dew infra status keycloak
|
|
||||||
dew infra logs keycloak --lines 100
|
|
||||||
```
|
|
||||||
|
|
||||||
This sample shows a multi-container service with an explicit Quadlet network and
|
|
||||||
a PostgreSQL dependency.
|
|
||||||
|
|
||||||
- Keycloak: `http://127.0.0.1:8080`
|
|
||||||
- admin user: `admin`
|
|
||||||
- admin password: `admin`
|
|
||||||
- database: `keycloak`
|
|
||||||
- database container: `dew_keycloak-postgresql`
|
|
||||||
- database volume: `dew_keycloak_postgresql_data`
|
|
||||||
|
|
||||||
Stop it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra down keycloak
|
|
||||||
```
|
|
||||||
|
|
||||||
The PostgreSQL volume is intentionally retained after stopping the service.
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Dew sample Keycloak PostgreSQL
|
|
||||||
Requires=dew_keycloak.network dew_keycloak-postgresql.volume
|
|
||||||
After=dew_keycloak.network dew_keycloak-postgresql.volume
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=docker.io/library/postgres:18
|
|
||||||
ContainerName=dew_keycloak-postgresql
|
|
||||||
Network=dew_keycloak.network
|
|
||||||
NetworkAlias=postgres
|
|
||||||
Volume=dew_keycloak-postgresql.volume:/var/lib/postgresql
|
|
||||||
Environment=POSTGRES_DB=keycloak
|
|
||||||
Environment=POSTGRES_USER=keycloak
|
|
||||||
Environment=POSTGRES_PASSWORD=keycloak_dev_password
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
TimeoutStartSec=900
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[Volume]
|
|
||||||
VolumeName=dew_keycloak_postgresql_data
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Dew sample Keycloak
|
|
||||||
Requires=dew_keycloak.network dew_keycloak-postgresql.container
|
|
||||||
After=dew_keycloak.network dew_keycloak-postgresql.container
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=quay.io/keycloak/keycloak:26.6.1
|
|
||||||
ContainerName=dew_keycloak
|
|
||||||
Network=dew_keycloak.network
|
|
||||||
PublishPort=127.0.0.1:8080:8080
|
|
||||||
Environment=KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
|
||||||
Environment=KC_BOOTSTRAP_ADMIN_PASSWORD=admin
|
|
||||||
Environment=KC_DB=postgres
|
|
||||||
Environment=KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak
|
|
||||||
Environment=KC_DB_USERNAME=keycloak
|
|
||||||
Environment=KC_DB_PASSWORD=keycloak_dev_password
|
|
||||||
Exec=start-dev
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
TimeoutStartSec=900
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[Network]
|
|
||||||
NetworkName=dew_keycloak
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
id: keycloak
|
|
||||||
name: Keycloak
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
type: podman-quadlet
|
|
||||||
|
|
||||||
quadlets:
|
|
||||||
- file: dew_keycloak.network
|
|
||||||
unit: dew_keycloak-network.service
|
|
||||||
- file: dew_keycloak-postgresql.volume
|
|
||||||
unit: dew_keycloak-postgresql-volume.service
|
|
||||||
- file: dew_keycloak-postgresql.container
|
|
||||||
unit: dew_keycloak-postgresql.service
|
|
||||||
container_name: dew_keycloak-postgresql
|
|
||||||
- file: dew_keycloak.container
|
|
||||||
unit: dew_keycloak.service
|
|
||||||
container_name: dew_keycloak
|
|
||||||
|
|
||||||
schemas:
|
|
||||||
configure: schemas/configure.schema.json
|
|
||||||
init: schemas/init.schema.json
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/samples/keycloak/configure.schema.json",
|
|
||||||
"title": "Keycloak Sample Configuration",
|
|
||||||
"description": "Configuration values represented by the sample Keycloak Quadlets.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"host_port": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 65535,
|
|
||||||
"default": 8080
|
|
||||||
},
|
|
||||||
"admin_username": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "admin"
|
|
||||||
},
|
|
||||||
"database": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "keycloak"
|
|
||||||
},
|
|
||||||
"database_volume": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "dew_keycloak_postgresql_data"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/samples/keycloak/init.schema.json",
|
|
||||||
"title": "Keycloak Sample Initialization",
|
|
||||||
"description": "Initialization values for the sample Keycloak service.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"realm": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "dew"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
FROM docker.io/library/nginx:alpine
|
|
||||||
|
|
||||||
RUN printf '{"service":"local-api-build","status":"ok"}\n' \
|
|
||||||
> /usr/share/nginx/html/index.html
|
|
||||||
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
# Local API Build
|
|
||||||
|
|
||||||
Sample local image build managed by `dew infra` and Podman Quadlets.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra validate local-api-build
|
|
||||||
dew infra up local-api-build
|
|
||||||
dew infra status local-api-build
|
|
||||||
dew infra logs local-api-build --lines 100
|
|
||||||
```
|
|
||||||
|
|
||||||
This sample shows a `.build` Quadlet that builds a local image from
|
|
||||||
`Containerfile`, then runs it through a `.container` Quadlet.
|
|
||||||
|
|
||||||
- endpoint: `http://127.0.0.1:8090`
|
|
||||||
- built image: `localhost/dew_local-api-build:latest`
|
|
||||||
- container: `dew_local-api-build`
|
|
||||||
|
|
||||||
Stop it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra down local-api-build
|
|
||||||
```
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
[Build]
|
|
||||||
ImageTag=localhost/dew_local-api-build:latest
|
|
||||||
File=Containerfile
|
|
||||||
SetWorkingDirectory=unit
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
RemainAfterExit=yes
|
|
||||||
TimeoutStartSec=900
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Dew sample local API build
|
|
||||||
Requires=dew_local-api-build.build
|
|
||||||
After=dew_local-api-build.build
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=dew_local-api-build.build
|
|
||||||
ContainerName=dew_local-api-build
|
|
||||||
PublishPort=127.0.0.1:8090:80
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
TimeoutStartSec=900
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
id: local-api-build
|
|
||||||
name: Local API Build
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
type: podman-quadlet
|
|
||||||
|
|
||||||
quadlets:
|
|
||||||
- file: dew_local-api-build.build
|
|
||||||
unit: dew_local-api-build-build.service
|
|
||||||
- file: dew_local-api-build.container
|
|
||||||
unit: dew_local-api-build.service
|
|
||||||
container_name: dew_local-api-build
|
|
||||||
|
|
||||||
files:
|
|
||||||
- Containerfile
|
|
||||||
|
|
||||||
schemas:
|
|
||||||
configure: schemas/configure.schema.json
|
|
||||||
init: schemas/init.schema.json
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/samples/local-api-build/configure.schema.json",
|
|
||||||
"title": "Local API Build Sample Configuration",
|
|
||||||
"description": "Configuration values represented by the sample local build Quadlets.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"host_port": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 65535,
|
|
||||||
"default": 8090
|
|
||||||
},
|
|
||||||
"image_tag": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "localhost/dew_local-api-build:latest"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/samples/local-api-build/init.schema.json",
|
|
||||||
"title": "Local API Build Sample Initialization",
|
|
||||||
"description": "Initialization values for the sample local build service.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# PostgreSQL 18
|
|
||||||
|
|
||||||
Sample local PostgreSQL 18 service managed by `dew infra` and Podman Quadlets.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra validate postgresql-18
|
|
||||||
dew infra up postgresql-18
|
|
||||||
dew infra status postgresql-18
|
|
||||||
dew infra logs postgresql-18 --lines 100
|
|
||||||
```
|
|
||||||
|
|
||||||
The sample binds PostgreSQL to `127.0.0.1:5432` with:
|
|
||||||
|
|
||||||
- database: `dew`
|
|
||||||
- user: `dew`
|
|
||||||
- password: `dew_dev_password`
|
|
||||||
- data volume: `dew_postgresql-18_data`
|
|
||||||
|
|
||||||
Stop it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra down postgresql-18
|
|
||||||
```
|
|
||||||
|
|
||||||
The named volume is intentionally retained after stopping the service.
|
|
||||||
|
|
||||||
Service-specific configure and init schemas live under `schemas/`. The manifest
|
|
||||||
declares the PostgreSQL container under its `quadlets` list.
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Dew sample PostgreSQL 18 database
|
|
||||||
Wants=network-online.target
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=docker.io/library/postgres:18
|
|
||||||
ContainerName=dew_postgresql-18
|
|
||||||
Environment=POSTGRES_DB=dew
|
|
||||||
Environment=POSTGRES_USER=dew
|
|
||||||
Environment=POSTGRES_PASSWORD=dew_dev_password
|
|
||||||
PublishPort=127.0.0.1:5432:5432
|
|
||||||
Volume=dew_postgresql-18_data:/var/lib/postgresql:Z
|
|
||||||
HealthCmd=pg_isready -U dew -d dew
|
|
||||||
HealthInterval=10s
|
|
||||||
HealthTimeout=5s
|
|
||||||
HealthRetries=5
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=on-failure
|
|
||||||
TimeoutStartSec=120
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
id: postgresql-18
|
|
||||||
name: PostgreSQL 18
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
type: podman-quadlet
|
|
||||||
|
|
||||||
quadlets:
|
|
||||||
- file: dew_postgresql-18.container
|
|
||||||
unit: dew_postgresql-18.service
|
|
||||||
container_name: dew_postgresql-18
|
|
||||||
dropins_dir: dew_postgresql-18.container.d
|
|
||||||
profiles_dir: dew_postgresql-18.profiles.d
|
|
||||||
|
|
||||||
schemas:
|
|
||||||
configure: schemas/configure.schema.json
|
|
||||||
init: schemas/init.schema.json
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/samples/postgresql-18/configure.schema.json",
|
|
||||||
"title": "PostgreSQL 18 Sample Configuration",
|
|
||||||
"description": "Configuration values represented by the sample PostgreSQL 18 Quadlet.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"host_port": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 65535,
|
|
||||||
"default": 5432
|
|
||||||
},
|
|
||||||
"database": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "dew"
|
|
||||||
},
|
|
||||||
"username": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "dew"
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 8,
|
|
||||||
"default": "dew_dev_password"
|
|
||||||
},
|
|
||||||
"volume": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "dew_postgresql-18_data"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/samples/postgresql-18/init.schema.json",
|
|
||||||
"title": "PostgreSQL 18 Sample Initialization",
|
|
||||||
"description": "Optional initialization notes for the sample PostgreSQL 18 service.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"create_database": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true
|
|
||||||
},
|
|
||||||
"seed_sample_data": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"type": "string",
|
|
||||||
"default": ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
# RustFS
|
|
||||||
|
|
||||||
Sample local RustFS object storage service managed by `dew infra` and Podman
|
|
||||||
Quadlets.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra validate rustfs
|
|
||||||
dew infra up rustfs
|
|
||||||
dew infra status rustfs
|
|
||||||
dew infra logs rustfs --lines 100
|
|
||||||
```
|
|
||||||
|
|
||||||
This sample shows a container on an explicit Quadlet network with a named
|
|
||||||
Quadlet volume.
|
|
||||||
|
|
||||||
- S3 API: `http://127.0.0.1:9000`
|
|
||||||
- console: `http://127.0.0.1:9001`
|
|
||||||
- user: `rustfsadmin`
|
|
||||||
- password: `rustfsadmin`
|
|
||||||
- data volume: `dew_rustfs_data`
|
|
||||||
|
|
||||||
Stop it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra down rustfs
|
|
||||||
```
|
|
||||||
|
|
||||||
The named volume is intentionally retained after stopping the service.
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Dew sample RustFS object storage
|
|
||||||
Requires=dew_rustfs.network dew_rustfs.volume
|
|
||||||
After=dew_rustfs.network dew_rustfs.volume
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=docker.io/rustfs/rustfs:latest
|
|
||||||
ContainerName=dew_rustfs
|
|
||||||
Network=dew_rustfs.network
|
|
||||||
PublishPort=127.0.0.1:9000:9000
|
|
||||||
PublishPort=127.0.0.1:9001:9001
|
|
||||||
Volume=dew_rustfs.volume:/data
|
|
||||||
Environment=RUSTFS_ACCESS_KEY=rustfsadmin
|
|
||||||
Environment=RUSTFS_SECRET_KEY=rustfsadmin
|
|
||||||
Environment=RUSTFS_CONSOLE_ENABLE=true
|
|
||||||
Exec=/data
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
TimeoutStartSec=900
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[Network]
|
|
||||||
NetworkName=dew_rustfs
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
[Volume]
|
|
||||||
VolumeName=dew_rustfs_data
|
|
||||||
User=10001
|
|
||||||
Group=10001
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
id: rustfs
|
|
||||||
name: RustFS
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
type: podman-quadlet
|
|
||||||
|
|
||||||
quadlets:
|
|
||||||
- file: dew_rustfs.network
|
|
||||||
unit: dew_rustfs-network.service
|
|
||||||
- file: dew_rustfs.volume
|
|
||||||
unit: dew_rustfs-volume.service
|
|
||||||
- file: dew_rustfs.container
|
|
||||||
unit: dew_rustfs.service
|
|
||||||
container_name: dew_rustfs
|
|
||||||
|
|
||||||
schemas:
|
|
||||||
configure: schemas/configure.schema.json
|
|
||||||
init: schemas/init.schema.json
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/samples/rustfs/configure.schema.json",
|
|
||||||
"title": "RustFS Sample Configuration",
|
|
||||||
"description": "Configuration values represented by the sample RustFS Quadlets.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"api_port": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 65535,
|
|
||||||
"default": 9000
|
|
||||||
},
|
|
||||||
"console_port": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 65535,
|
|
||||||
"default": 9001
|
|
||||||
},
|
|
||||||
"access_key": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "rustfsadmin"
|
|
||||||
},
|
|
||||||
"volume": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "dew_rustfs_data"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/samples/rustfs/init.schema.json",
|
|
||||||
"title": "RustFS Sample Initialization",
|
|
||||||
"description": "Initialization values for the sample RustFS service.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"bucket": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "dew"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
# Valkey 9
|
|
||||||
|
|
||||||
Sample local Valkey service managed by `dew infra` and Podman Quadlets.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra validate valkey-9
|
|
||||||
dew infra up valkey-9
|
|
||||||
dew infra status valkey-9
|
|
||||||
dew infra logs valkey-9 --lines 100
|
|
||||||
```
|
|
||||||
|
|
||||||
This sample shows a container backed by a named Quadlet volume.
|
|
||||||
|
|
||||||
- host port: `127.0.0.1:6379`
|
|
||||||
- container: `dew_valkey-9`
|
|
||||||
- data volume: `dew_valkey-9_data`
|
|
||||||
|
|
||||||
Stop it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra down valkey-9
|
|
||||||
```
|
|
||||||
|
|
||||||
The named volume is intentionally retained after stopping the service.
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
[Unit]
|
|
||||||
Description=Dew sample Valkey 9
|
|
||||||
Requires=dew_valkey-9.volume
|
|
||||||
After=dew_valkey-9.volume
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=docker.io/valkey/valkey:9.0.3-alpine
|
|
||||||
ContainerName=dew_valkey-9
|
|
||||||
PublishPort=127.0.0.1:6379:6379
|
|
||||||
Volume=dew_valkey-9.volume:/data
|
|
||||||
Exec=valkey-server --save 60 1 --loglevel warning
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=always
|
|
||||||
TimeoutStartSec=900
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
[Volume]
|
|
||||||
VolumeName=dew_valkey-9_data
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
id: valkey-9
|
|
||||||
name: Valkey 9
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
type: podman-quadlet
|
|
||||||
|
|
||||||
quadlets:
|
|
||||||
- file: dew_valkey-9.volume
|
|
||||||
unit: dew_valkey-9-volume.service
|
|
||||||
- file: dew_valkey-9.container
|
|
||||||
unit: dew_valkey-9.service
|
|
||||||
container_name: dew_valkey-9
|
|
||||||
|
|
||||||
schemas:
|
|
||||||
configure: schemas/configure.schema.json
|
|
||||||
init: schemas/init.schema.json
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/samples/valkey-9/configure.schema.json",
|
|
||||||
"title": "Valkey 9 Sample Configuration",
|
|
||||||
"description": "Configuration values represented by the sample Valkey Quadlets.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"host_port": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 65535,
|
|
||||||
"default": 6379
|
|
||||||
},
|
|
||||||
"volume": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"default": "dew_valkey-9_data"
|
|
||||||
},
|
|
||||||
"snapshot_seconds": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"default": 60
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/samples/valkey-9/init.schema.json",
|
|
||||||
"title": "Valkey 9 Sample Initialization",
|
|
||||||
"description": "Initialization values for the sample Valkey service.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
id: DEW-0029
|
|
||||||
title: Add infra command surface
|
|
||||||
type: task
|
|
||||||
created: 2026-05-05T02:02:35.678734Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Implement the initial dew infra command surface for project-local infrastructure services. Start with Quadlet/Podman operations while keeping runtime boundaries explicit so additional container runtimes can be added later.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
id: DEW-0030
|
|
||||||
title: Use YAML infra service manifests
|
|
||||||
type: task
|
|
||||||
created: 2026-05-05T03:04:56.996343Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Switch infra service manifests from metadata.toml to manifest.yaml and add a package-level JSON Schema for the service manifest contract.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
id: DEW-0031
|
|
||||||
title: Add PostgreSQL 18 infra sample
|
|
||||||
type: task
|
|
||||||
created: 2026-05-05T03:31:50.153499Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Add a sample Dew infrastructure service that brings up PostgreSQL 18 through a Podman Quadlet manifest, including manifest.yaml, schemas, profile/drop-in directories, and documentation.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
id: DEW-0032
|
|
||||||
title: Move PostgreSQL sample schemas into service schemas directory
|
|
||||||
type: task
|
|
||||||
created: 2026-05-05T03:38:10.315750Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Update the PostgreSQL 18 infra sample to store service-owned configure/init schemas under a schemas/ directory and adjust manifest references accordingly.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
id: DEW-0033
|
|
||||||
title: Use hyphenated PostgreSQL sample runtime names
|
|
||||||
type: task
|
|
||||||
created: 2026-05-05T03:40:20.957347Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Rename the PostgreSQL 18 infra sample runtime artifacts so the dew_ prefix is followed by the hyphenated service id postgresql-18.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
id: DEW-0034
|
|
||||||
title: Support multiple quadlets per infra service
|
|
||||||
type: task
|
|
||||||
created: 2026-05-05T03:57:12.644828Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Replace the single service/container manifest contract with a quadlets list so one Dew infra service can deploy multiple Podman Quadlet files across supported Quadlet types.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
id: DEW-0035
|
|
||||||
title: Add broader infra sample services
|
|
||||||
type: task
|
|
||||||
created: 2026-05-05T04:19:43.484054Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Add sample infrastructure services that exercise common Podman Quadlet patterns including volumes, networks, pods, multi-container dependencies, and local image builds.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
id: DEW-0036
|
|
||||||
title: Verify infra samples locally
|
|
||||||
type: task
|
|
||||||
created: 2026-05-05T04:28:49.175443Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Bring up the new sample infrastructure services with dew infra, verify they start and respond as expected, then stop them without deleting retained data volumes.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
id: DEW-0037
|
|
||||||
title: Expose infra operations through MCP tools
|
|
||||||
type: task
|
|
||||||
created: 2026-05-05T04:45:19.903814Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Add Dew MCP tools for every dew infra CLI path, including service discovery, show, validation, configure/init subpaths, lifecycle operations, logs, and delete.
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
---
|
|
||||||
id: DEW-0038
|
|
||||||
title: Release Dew 0.4.0
|
|
||||||
type: task
|
|
||||||
created: 2026-05-05T05:37:49.576656Z
|
|
||||||
---
|
|
||||||
|
|
||||||
Merge the infra epic into develop, prepare package metadata for the 0.4.0 release, publish packages to pub.dev in dependency order, tag the release, and push develop/tags.
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
dart 3.12.0
|
|
||||||
91
AGENTS.md
91
AGENTS.md
|
|
@ -1,91 +0,0 @@
|
||||||
# Dew Agent Guidance
|
|
||||||
|
|
||||||
This document provides guidelines and rules for agents contributing to the
|
|
||||||
Dew project. It outlines the expected behavior, coding standards, and
|
|
||||||
collaboration practices to ensure a productive and respectful environment for
|
|
||||||
all contributors.
|
|
||||||
|
|
||||||
## General Guidelines
|
|
||||||
|
|
||||||
- The references directory should be used for storing external resources,
|
|
||||||
temporary files, and other things the agent needs that aren't part of the
|
|
||||||
actual project. This can include things like documentation, clones of other
|
|
||||||
repositories, screenshots, and other resources that may be helpful for
|
|
||||||
development but are not part of the final codebase.
|
|
||||||
- Avoid duplicating existing code or functionality. The footprint of the
|
|
||||||
operating system should be as small as possible while still remaining
|
|
||||||
readable and maintainable. Generally this means all features should be
|
|
||||||
implemented as though they will be used by other features, and that code
|
|
||||||
should be reused where possible.
|
|
||||||
- All code should be well-documented, with clear comments explaining the
|
|
||||||
purpose and functionality of each component. This is especially important for
|
|
||||||
security-related features, where clarity can help prevent vulnerabilities.
|
|
||||||
- Use language-appropriate API documentation comments for public modules,
|
|
||||||
types, and functions (for example Rustdoc in Rust and doc comments in Dart).
|
|
||||||
- Follow each language and framework's idiomatic conventions and best practices
|
|
||||||
to ensure consistency and readability across the codebase.
|
|
||||||
- Prefer strong, explicit types (for example enums/sealed types) over ad-hoc
|
|
||||||
constants when modeling related sets of values.
|
|
||||||
- Use pattern matching and type-system features where available to handle
|
|
||||||
different cases clearly and safely.
|
|
||||||
- Constants are acceptable for defining fixed values that are not part of a
|
|
||||||
related set, such as configuration parameters or hardware addresses, but
|
|
||||||
should be used judiciously to avoid cluttering the codebase with unnecessary
|
|
||||||
constants.
|
|
||||||
|
|
||||||
## Project Management
|
|
||||||
|
|
||||||
- Every task should be clearly defined and documented in the
|
|
||||||
`.project/kanban/` board and managed through Dew.
|
|
||||||
- Ticket lifecycle operations (create, list, move, update, comment, archive,
|
|
||||||
links) should be performed through the Dew MCP tools when available.
|
|
||||||
- Do not manage ticket state by hand-editing board files unless doing one-off
|
|
||||||
migration, import, or recovery work.
|
|
||||||
- If a task is not documented, create a new ticket in Dew before starting work
|
|
||||||
so progress is tracked and visible.
|
|
||||||
- Where possible, `TODO` comments should reference a ticket by including the
|
|
||||||
ticket's unique identifier in the comment. This helps maintain a clear
|
|
||||||
connection between the code and the project management system, making it
|
|
||||||
easier to track progress and understand the context of each task.
|
|
||||||
- The `.project/kanban/{backlog,doing,done,archive}/` directories are the
|
|
||||||
board columns. Tickets in progress belong in `doing`, and completed work is
|
|
||||||
moved to `done`.
|
|
||||||
- Ticket filenames are `<PREFIX>-NNNN.md` and must match the ticket `id` in
|
|
||||||
frontmatter.
|
|
||||||
- Frontmatter should contain the following fields:
|
|
||||||
- `id`: A unique identifier for the task.
|
|
||||||
- `title`: A brief title summarizing the task.
|
|
||||||
- `type`: Ticket type identifier (for example `epic`, `story`, `task`,
|
|
||||||
`bug`, or `spike`).
|
|
||||||
- `created`: RFC3339 timestamp for ticket creation.
|
|
||||||
- After the front matter comes the markdown content, which should provide a
|
|
||||||
detailed description of the task, including any relevant information,
|
|
||||||
requirements, or context needed for completion.
|
|
||||||
- Do not include a `column` field in frontmatter; the column is determined by
|
|
||||||
the ticket file's directory.
|
|
||||||
- Comments may be stored in the ticket by appending them to the end of the
|
|
||||||
markdown content, separated from the description and other comments by a
|
|
||||||
horizontal rules (`---`) for clarity. Each comment should include the
|
|
||||||
author's name, and the content of the comment.
|
|
||||||
- Tickets that need attachments should have a directory created in
|
|
||||||
`.project/kanban/attachments/` with the same name as the ticket file (without
|
|
||||||
the `.md` extension), and the attachment should be stored in that directory.
|
|
||||||
The attachment can then be referenced in the markdown content of the ticket
|
|
||||||
using a relative path.
|
|
||||||
|
|
||||||
## Tooling and Commands
|
|
||||||
|
|
||||||
- This repository uses a root `justfile` for common development workflows. Run
|
|
||||||
`just --list` to see the available recipes.
|
|
||||||
- Respect the root `.editorconfig` for cross-editor whitespace and line-ending
|
|
||||||
behavior.
|
|
||||||
- Treat any analyzer/linter finding (`info`, `warning`, or `error`) as a
|
|
||||||
blocking issue: fix it before merging unless it is objectively impossible.
|
|
||||||
If a finding must remain, ask for explicit approval first and gate the exception
|
|
||||||
with a `TODO` comment that explains why the issue is intentionally deferred.
|
|
||||||
- Use `melos run format` to format Dart packages in the workspace.
|
|
||||||
- Use `melos run fix` to apply automatic fixes from Dart static analysis.
|
|
||||||
- Use `melos run analyze` to run Dart static analysis.
|
|
||||||
- Use `melos run test` to run Dart tests across workspace packages.
|
|
||||||
- Use `markdownlint-cli2 .` to check for markdown style issues in documentation
|
|
||||||
files so the repo's configured ignores are applied.
|
|
||||||
29
CHANGELOG.md
29
CHANGELOG.md
|
|
@ -7,28 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [0.4.0] - 2026-05-05
|
## [0.2.0] - 2026-05-02
|
||||||
|
|
||||||
### Added in 0.4.0
|
### Changed
|
||||||
|
|
||||||
- Added `dew infra` for project infrastructure service discovery, validation,
|
- Removed unsupported MCP `host` and `port` settings from generated `dew.yaml`.
|
||||||
configuration payloads, initialization payloads, lifecycle control, status,
|
- Updated MCP configuration docs to reflect stdio-only client setup.
|
||||||
logs, and cleanup.
|
- Added the `dew` executable mapping for pub activation.
|
||||||
- Added Podman Quadlet runtime support with a runtime boundary for future
|
|
||||||
container backends.
|
|
||||||
- Added manifest, configure, and init JSON Schema handling for infrastructure
|
|
||||||
services.
|
|
||||||
- Added sample infrastructure services for PostgreSQL 18, Valkey, RustFS,
|
|
||||||
Keycloak, pods, networks, volumes, and local image builds.
|
|
||||||
- Exposed every `dew infra` CLI path through Dew MCP tools, including
|
|
||||||
path-specific configure/init tools.
|
|
||||||
|
|
||||||
### Changed in 0.4.0
|
|
||||||
|
|
||||||
- Extended MCP tool discovery so commands can provide extra path-specific tools
|
|
||||||
beyond one tool per subcommand.
|
|
||||||
- Raised package versions to `0.4.0` for the infra release.
|
|
||||||
- Cleaned existing analyzer info findings in kanban and vault ahead of release.
|
|
||||||
|
|
||||||
## [0.1.0] - 2026-04-25
|
## [0.1.0] - 2026-04-25
|
||||||
|
|
||||||
|
|
@ -109,6 +94,6 @@ Full set of kanban subcommands, each also registered as an MCP tool automaticall
|
||||||
- `ProjectDirs` with injectable filesystem abstraction (`package:file`) for
|
- `ProjectDirs` with injectable filesystem abstraction (`package:file`) for
|
||||||
testable path resolution.
|
testable path resolution.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/artificerchris/dew/compare/v0.4.0...HEAD
|
[Unreleased]: https://github.com/artificerchris/dew/compare/v0.2.0...HEAD
|
||||||
[0.4.0]: https://github.com/artificerchris/dew/compare/v0.3.0...v0.4.0
|
[0.2.0]: https://github.com/artificerchris/dew/compare/v0.1.0...v0.2.0
|
||||||
[0.1.0]: https://github.com/artificerchris/dew/releases/tag/v0.1.0
|
[0.1.0]: https://github.com/artificerchris/dew/releases/tag/v0.1.0
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ Thank you for your interest in contributing! This guide covers everything you ne
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Dart SDK ^3.12.0** — verify with `dart --version`
|
- **Dart SDK ^3.11.4** — verify with `dart --version`
|
||||||
- **Melos** (optional, for workspace scripts) — `dart pub global activate melos`
|
- **Melos** (optional, for workspace scripts) — `dart pub global activate melos`
|
||||||
|
|
||||||
## Clone & setup
|
## Clone & setup
|
||||||
|
|
|
||||||
16
README.md
16
README.md
|
|
@ -18,12 +18,6 @@ stats board config tui
|
||||||
|
|
||||||
Tickets are stored as `.project/kanban/<column>/<ID>.md` files. Labels, milestones, typed bidirectional links, and inline comments are all first-class citizens. See the [Kanban documentation](./docs/features/kanban.md) for the full command reference.
|
Tickets are stored as `.project/kanban/<column>/<ID>.md` files. Labels, milestones, typed bidirectional links, and inline comments are all first-class citizens. See the [Kanban documentation](./docs/features/kanban.md) for the full command reference.
|
||||||
|
|
||||||
### Infrastructure
|
|
||||||
|
|
||||||
`dew infra` discovers services under `.project/infrastructure/services`, validates
|
|
||||||
their manifests and schemas, and manages Podman Quadlets through systemd. The
|
|
||||||
runtime boundary is explicit so other container backends can be added later.
|
|
||||||
|
|
||||||
### Interactive TUI
|
### Interactive TUI
|
||||||
|
|
||||||
`dew kanban tui` opens a full Trello-style terminal board with three modes:
|
`dew kanban tui` opens a full Trello-style terminal board with three modes:
|
||||||
|
|
@ -36,10 +30,7 @@ The TUI auto-refreshes when ticket files change on disk, so it stays in sync whe
|
||||||
|
|
||||||
### MCP Server
|
### MCP Server
|
||||||
|
|
||||||
`dew mcp serve` starts an MCP-compliant stdio server that exposes Dew commands
|
`dew mcp serve` starts an MCP-compliant stdio server that exposes every kanban command as an MCP tool. AI agents (GitHub Copilot, Claude, etc.) can create tickets, move cards, search, and comment — using the exact same logic as the CLI. No separate tool definitions needed: every command that mixes in `DewToolCommand` is registered automatically. See the [MCP documentation](./docs/features/mcp.md).
|
||||||
as MCP tools. AI agents (GitHub Copilot, Claude, etc.) can create tickets, move
|
|
||||||
cards, search, comment, and manage project infrastructure through the same logic
|
|
||||||
as the CLI. See the [MCP documentation](./docs/features/mcp.md).
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -47,7 +38,7 @@ as the CLI. See the [MCP documentation](./docs/features/mcp.md).
|
||||||
dart pub global activate dew
|
dart pub global activate dew
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Dart SDK ^3.12.0.
|
Requires Dart SDK ^3.11.4.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
|
|
@ -64,12 +55,11 @@ dew kanban tui
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Dew reads `.project/dew.yaml` for board columns, ticket types, ID prefix, and MCP server settings. Running `dew init .` generates this file with defaults. See the [Configuration documentation](./docs/config.md) for the full schema reference.
|
Dew reads `.project/dew.yaml` for board columns, ticket types, and ID prefix. Running `dew init .` generates this file with defaults. See the [Configuration documentation](./docs/config.md) for the full schema reference.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [Full documentation index](./docs/index.md)
|
- [Full documentation index](./docs/index.md)
|
||||||
- [Infrastructure](./docs/features/infra.md) — service manifests, Quadlet install, lifecycle commands
|
|
||||||
- [Kanban board](./docs/features/kanban.md) — CLI commands, TUI keybindings, ticket format
|
- [Kanban board](./docs/features/kanban.md) — CLI commands, TUI keybindings, ticket format
|
||||||
- [MCP server](./docs/features/mcp.md) — AI agent integration
|
- [MCP server](./docs/features/mcp.md) — AI agent integration
|
||||||
- [Configuration reference](./docs/config.md)
|
- [Configuration reference](./docs/config.md)
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,10 @@ your-project/
|
||||||
└── dew.yaml
|
└── dew.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Path-like values in `dew.yaml` are resolved relative to `.project/dew.yaml`
|
|
||||||
unless they are absolute (for example, paths under `dew.vault`).
|
|
||||||
|
|
||||||
Infrastructure services are not configured in `dew.yaml`; they are discovered
|
|
||||||
from `.project/infrastructure/services/*/manifest.yaml`.
|
|
||||||
|
|
||||||
## Full Schema
|
## Full Schema
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
dew:
|
dew:
|
||||||
mcp:
|
|
||||||
host: "localhost" # Hostname the MCP server binds to
|
|
||||||
port: 8080 # Port the MCP server listens on
|
|
||||||
|
|
||||||
kanban:
|
kanban:
|
||||||
prefix: "PROJ" # Short prefix used for ticket IDs (e.g. PROJ-42)
|
prefix: "PROJ" # Short prefix used for ticket IDs (e.g. PROJ-42)
|
||||||
|
|
||||||
|
|
@ -53,12 +43,8 @@ dew:
|
||||||
|
|
||||||
## Reference
|
## Reference
|
||||||
|
|
||||||
### `dew.mcp`
|
The MCP server currently has no project-level `dew.yaml` configuration. Configure
|
||||||
|
your MCP client to run `dew mcp serve`; see the [MCP documentation](./features/mcp.md).
|
||||||
| Field | Type | Default | Description |
|
|
||||||
| ------ | ------- | ------------- | --------------------------------- |
|
|
||||||
| `host` | string | `"localhost"` | Hostname the MCP server binds to. |
|
|
||||||
| `port` | integer | `8080` | Port the MCP server listens on. |
|
|
||||||
|
|
||||||
### `dew.kanban`
|
### `dew.kanban`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
# Infrastructure
|
|
||||||
|
|
||||||
`dew infra` manages project-local infrastructure services declared under
|
|
||||||
`.project/infrastructure`.
|
|
||||||
|
|
||||||
The initial runtime backend is Podman Quadlets installed into systemd search
|
|
||||||
paths. The command surface is runtime-oriented rather than Podman-specific so
|
|
||||||
future backends can be added without changing project manifests or common CLI
|
|
||||||
workflows.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
```text
|
|
||||||
.project/infrastructure/
|
|
||||||
└── services/
|
|
||||||
└── postgres/
|
|
||||||
├── manifest.yaml
|
|
||||||
├── app_postgres.container
|
|
||||||
├── app_postgres.container.d/
|
|
||||||
├── app_postgres.profiles.d/
|
|
||||||
├── schemas/
|
|
||||||
│ ├── configure.schema.json
|
|
||||||
│ └── init.schema.json
|
|
||||||
└── config/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Manifest
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
id: postgres
|
|
||||||
name: PostgreSQL
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
type: podman-quadlet
|
|
||||||
|
|
||||||
quadlets:
|
|
||||||
- file: app_postgres.container
|
|
||||||
unit: app_postgres.service
|
|
||||||
container_name: app_postgres
|
|
||||||
dropins_dir: app_postgres.container.d
|
|
||||||
profiles_dir: app_postgres.profiles.d
|
|
||||||
|
|
||||||
schemas:
|
|
||||||
configure: schemas/configure.schema.json
|
|
||||||
init: schemas/init.schema.json
|
|
||||||
```
|
|
||||||
|
|
||||||
The `quadlets` list can contain any supported Podman Quadlet source type:
|
|
||||||
`.artifact`, `.build`, `.container`, `.image`, `.kube`, `.network`, `.pod`, and
|
|
||||||
`.volume`. If `unit` is omitted, Dew derives the default generated systemd unit
|
|
||||||
from the Quadlet filename. Declare `unit` when the Quadlet file uses a
|
|
||||||
`ServiceName=` override.
|
|
||||||
|
|
||||||
Use `files` for non-Quadlet assets that must be installed beside the Quadlet
|
|
||||||
files, such as a `Containerfile` used by a `.build` unit.
|
|
||||||
|
|
||||||
The package-level schema for this file is
|
|
||||||
`packages/infra/schemas/service-manifest.schema.json`.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra list
|
|
||||||
dew infra show postgres
|
|
||||||
dew infra validate --all
|
|
||||||
dew infra configure postgres schema
|
|
||||||
dew infra configure postgres show
|
|
||||||
dew infra configure postgres apply --file config.json --set port=5432
|
|
||||||
dew infra init postgres schema
|
|
||||||
dew infra init postgres run --file init.json
|
|
||||||
dew infra install postgres
|
|
||||||
dew infra up postgres
|
|
||||||
dew infra status postgres
|
|
||||||
dew infra logs postgres --lines 200
|
|
||||||
dew infra down postgres
|
|
||||||
dew infra delete postgres --container
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `--dry-run` on mutating commands to print filesystem, systemctl, journalctl,
|
|
||||||
and podman actions without applying them. Use `--scope user` for the default
|
|
||||||
user systemd path or `--scope system` for `/etc/containers/systemd`.
|
|
||||||
|
|
||||||
`dew infra up` installs missing Quadlet files, reloads systemd, then starts the
|
|
||||||
declared units.
|
|
||||||
|
|
||||||
## Samples
|
|
||||||
|
|
||||||
The Dew repository includes sample service bringups under
|
|
||||||
`.project/infrastructure/services/`.
|
|
||||||
|
|
||||||
Available samples:
|
|
||||||
|
|
||||||
- `postgresql-18`: single PostgreSQL 18 container with a named data volume.
|
|
||||||
- `valkey-9`: cache container backed by a Quadlet volume.
|
|
||||||
- `rustfs`: S3-compatible object storage on a Quadlet network and volume.
|
|
||||||
- `keycloak`: multi-container Keycloak and PostgreSQL service on a shared
|
|
||||||
network.
|
|
||||||
- `app-pod`: Podman pod with web and sidecar containers.
|
|
||||||
- `local-api-build`: local image build consumed by a container Quadlet.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew infra validate postgresql-18
|
|
||||||
dew infra up postgresql-18
|
|
||||||
```
|
|
||||||
|
|
@ -6,26 +6,17 @@ The Dew Model Context Protocol (MCP) Server is a feature that allows AI agents t
|
||||||
|
|
||||||
The MCP feature is split across two packages to keep concerns separate:
|
The MCP feature is split across two packages to keep concerns separate:
|
||||||
|
|
||||||
- **`packages/core`** defines the `DewToolCommand` mixin and `McpToolProvider`
|
- **`packages/core`** defines the `DewToolCommand` mixin. Any command that mixes it in is automatically
|
||||||
interface. Commands that mix in `DewToolCommand` are automatically registered
|
registered as an MCP tool — the mixin derives the tool's JSON Schema directly from the command's own
|
||||||
as MCP tools, and commands that implement `McpToolProvider` can expose extra
|
`ArgParser`, so tools and CLI commands share a single definition.
|
||||||
path-specific tools.
|
|
||||||
- **`packages/mcp`** implements the actual server. It reads the list of tools from `CommandRegistry` and
|
- **`packages/mcp`** implements the actual server. It reads the list of tools from `CommandRegistry` and
|
||||||
serves them over stdio using the [dart\_mcp](https://pub.dev/packages/dart_mcp) package. Only the `cli`
|
serves them over stdio using the [dart\_mcp](https://pub.dev/packages/dart_mcp) package. Only the `cli`
|
||||||
package depends on `packages/mcp`; feature packages like `kanban` remain decoupled from the transport layer.
|
package depends on `packages/mcp`; feature packages like `kanban` remain decoupled from the transport layer.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
The MCP server is configured under the `mcp` key in `.project/dew.yaml`. By default it runs on `localhost` at port `8080`.
|
The MCP server currently has no `.project/dew.yaml` settings. Configure your MCP
|
||||||
|
client to launch `dew mcp serve`; the server communicates over stdio.
|
||||||
```yaml
|
|
||||||
dew:
|
|
||||||
mcp:
|
|
||||||
host: "localhost"
|
|
||||||
port: 8080
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [Configuration documentation](../config.md) for full details.
|
|
||||||
|
|
||||||
## Running the server
|
## Running the server
|
||||||
|
|
||||||
|
|
@ -75,29 +66,6 @@ The following tools are registered by the `kanban` package:
|
||||||
| `kanban_link_tickets` | Link two tickets with a typed relationship (bidirectional) |
|
| `kanban_link_tickets` | Link two tickets with a typed relationship (bidirectional) |
|
||||||
| `kanban_unlink_tickets` | Remove a link between two tickets (both sides) |
|
| `kanban_unlink_tickets` | Remove a link between two tickets (both sides) |
|
||||||
|
|
||||||
The following tools are registered by the `infra` package:
|
|
||||||
|
|
||||||
| Tool | Description |
|
|
||||||
| ---------------------------- | ------------------------------------------------ |
|
|
||||||
| `infra_list_services` | List infrastructure services |
|
|
||||||
| `infra_show_service` | Show manifest and runtime details |
|
|
||||||
| `infra_validate_services` | Validate one service or all services |
|
|
||||||
| `infra_configure_service` | CLI-default configure path placeholder |
|
|
||||||
| `infra_configure_schema` | Show the configure JSON Schema |
|
|
||||||
| `infra_configure_show` | Show the active configure payload |
|
|
||||||
| `infra_configure_apply` | Apply configure payload values |
|
|
||||||
| `infra_init_service` | CLI-default init path placeholder |
|
|
||||||
| `infra_init_schema` | Show the init JSON Schema |
|
|
||||||
| `infra_init_run` | Write an initialization payload |
|
|
||||||
| `infra_install_service` | Install service Quadlets |
|
|
||||||
| `infra_uninstall_service` | Uninstall service Quadlets |
|
|
||||||
| `infra_up_service` | Install, reload, and start services |
|
|
||||||
| `infra_down_service` | Stop services |
|
|
||||||
| `infra_restart_service` | Restart services |
|
|
||||||
| `infra_status_service` | Show service runtime status |
|
|
||||||
| `infra_logs` | Read service logs |
|
|
||||||
| `infra_delete_service` | Delete declared runtime artifacts |
|
|
||||||
|
|
||||||
### Link types
|
### Link types
|
||||||
|
|
||||||
`kanban_link_tickets` requires a `--type` argument. The inverse is written automatically on the target ticket.
|
`kanban_link_tickets` requires a `--type` argument. The inverse is written automatically on the target ticket.
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
# Dew Vault Secret Manager
|
|
||||||
|
|
||||||
Dew Vault stores project secrets as encrypted files under `.project/vault`.
|
|
||||||
By default the vault password is stored in `.project/secrets/dew.vault.password`.
|
|
||||||
|
|
||||||
## Config
|
|
||||||
|
|
||||||
Vault settings live in `.project/dew.yaml` under `dew.vault`.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
dew:
|
|
||||||
vault:
|
|
||||||
password_file: .project/secrets/dew.vault.password
|
|
||||||
storage_dir: .project/vault
|
|
||||||
generators:
|
|
||||||
postgres_password:
|
|
||||||
type: random_password
|
|
||||||
description: Generate random PostgreSQL passwords.
|
|
||||||
config:
|
|
||||||
length: 64
|
|
||||||
include_symbols: true
|
|
||||||
jwt_secret:
|
|
||||||
type: random_token
|
|
||||||
description: Generate JWT signing secrets.
|
|
||||||
config:
|
|
||||||
encoding: base64
|
|
||||||
bytes: 48
|
|
||||||
service_uuid:
|
|
||||||
type: uuid_v4
|
|
||||||
description: Generate stable-looking unique IDs.
|
|
||||||
```
|
|
||||||
|
|
||||||
`generators` maps a generator name to a built-in generator definition. Values
|
|
||||||
under `config` are defaults and can be overridden per command invocation or in
|
|
||||||
secret rotation metadata.
|
|
||||||
|
|
||||||
Built-in generator types are resolved inside Dew, so secrets can be generated
|
|
||||||
without depending on host binaries.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
Most commands support `--format [default|json]` (default is `default`) for
|
|
||||||
machine-friendly automation.
|
|
||||||
|
|
||||||
### Initialize Vault
|
|
||||||
|
|
||||||
Initialize vault directories and password file.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew vault init
|
|
||||||
dew vault init --password-file .project/secrets/dew.vault.password
|
|
||||||
dew vault init --storage-dir .project/vault
|
|
||||||
```
|
|
||||||
|
|
||||||
### List all secrets
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew vault list
|
|
||||||
dew vault list --format json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Set a secret
|
|
||||||
|
|
||||||
`set` stores or replaces a secret and optional metadata.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew vault set --name DB_PASSWORD --file /path/to/secret.txt
|
|
||||||
dew vault set --name DB_PASSWORD --env ENV_VAR_NAME
|
|
||||||
```
|
|
||||||
|
|
||||||
### Get a secret
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew vault get --name DB_PASSWORD
|
|
||||||
dew vault get --name DB_PASSWORD --format json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update a secret
|
|
||||||
|
|
||||||
`update` patches secret metadata and/or value. Omit value source flags to edit
|
|
||||||
metadata only.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew vault update --name DB_PASSWORD --metadata '{"rotation":{"enabled":true,"generator":"postgres_password","length":64}}'
|
|
||||||
dew vault update --name DB_PASSWORD --metadata-file .project/vault/db_password.meta.json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rename a secret
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew vault rename --from OLD_NAME --to NEW_NAME
|
|
||||||
dew vault rename --from OLD_NAME --to NEW_NAME --format json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Generate a secret value
|
|
||||||
|
|
||||||
`generate` uses configured generators without writing to the vault by default.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew vault generate --generator postgres_password --arg length=64 --arg include_symbols=true
|
|
||||||
dew vault generate --generator jwt_secret --arg bytes=64 --arg encoding=base64
|
|
||||||
dew vault generate --generator postgres_password --arg service=payments --arg username=app_user --format json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rotate secrets
|
|
||||||
|
|
||||||
`rotate` rewraps secrets with a new vault password when run without a name.
|
|
||||||
When run with a secret name, it rotates only that secret.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew vault rotate
|
|
||||||
dew vault rotate --name DB_PASSWORD
|
|
||||||
dew vault rotate --name DB_PASSWORD --format json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Delete a secret
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dew vault delete --name DB_PASSWORD
|
|
||||||
dew vault delete --name DB_PASSWORD --format json
|
|
||||||
```
|
|
||||||
|
|
||||||
### Metadata format for rotation-aware secrets
|
|
||||||
|
|
||||||
Attach arbitrary metadata and include rotation policy details. Example shape:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"rotation": {
|
|
||||||
"generator": "postgres_password",
|
|
||||||
"service": "payments",
|
|
||||||
"username": "app_user",
|
|
||||||
"length": 64
|
|
||||||
},
|
|
||||||
"notes": "Rotate monthly and update app config via sidecar"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Rotation flow:
|
|
||||||
|
|
||||||
1. Define a built-in generator in `dew.yaml` under `dew.vault.generators`.
|
|
||||||
2. Attach `rotation.generator` and generator args to secret metadata.
|
|
||||||
3. Run `dew vault rotate --name ...` to rotate one secret, or `dew vault rotate`
|
|
||||||
to rotate all secrets.
|
|
||||||
|
|
@ -9,7 +9,6 @@ Welcome to the documentation for the Dew project management tool!
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- [Kanban Board](./features/kanban.md) — Visualize and manage tasks in a column-based workflow
|
- [Kanban Board](./features/kanban.md) — Visualize and manage tasks in a column-based workflow
|
||||||
- [Infrastructure](./features/infra.md) — Manage local service manifests and Podman Quadlets
|
|
||||||
- [MCP Server](./features/mcp.md) — AI agent integration via the Model Context Protocol
|
- [MCP Server](./features/mcp.md) — AI agent integration via the Model Context Protocol
|
||||||
|
|
||||||
## Package Architecture
|
## Package Architecture
|
||||||
|
|
@ -20,15 +19,12 @@ Dew is structured as a Dart workspace with the following packages:
|
||||||
| ----------------- | -------------------------------------------------------------------------------------------- |
|
| ----------------- | -------------------------------------------------------------------------------------------- |
|
||||||
| `packages/cli` | The `dew` command-line tool. Wires all packages together at startup. |
|
| `packages/cli` | The `dew` command-line tool. Wires all packages together at startup. |
|
||||||
| `packages/core` | Shared foundation: `DewCommand`, `DewToolCommand` mixin, `CommandRegistry`, and `DewConfig`. |
|
| `packages/core` | Shared foundation: `DewCommand`, `DewToolCommand` mixin, `CommandRegistry`, and `DewConfig`. |
|
||||||
| `packages/infra` | Infrastructure service discovery, validation, and runtime lifecycle commands. |
|
|
||||||
| `packages/kanban` | Kanban board logic. Each command automatically registers itself as an MCP tool. |
|
| `packages/kanban` | Kanban board logic. Each command automatically registers itself as an MCP tool. |
|
||||||
| `packages/mcp` | The MCP server. Collects tools from `CommandRegistry` and serves them over stdio. |
|
| `packages/mcp` | The MCP server. Collects tools from `CommandRegistry` and serves them over stdio. |
|
||||||
|
|
||||||
### How commands become MCP tools
|
### How commands become MCP tools
|
||||||
|
|
||||||
Every CLI command that mixes in `DewToolCommand` is automatically registered as
|
Every CLI command that mixes in `DewToolCommand` is automatically registered as an MCP tool — no separate registration needed. The mixin derives the JSON Schema for the tool's input from the command's own `ArgParser`, so argument definitions are written exactly once.
|
||||||
an MCP tool. Commands that need more granular tool paths can also implement
|
|
||||||
`McpToolProvider` to expose additional tools.
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
ArgParser definition
|
ArgParser definition
|
||||||
|
|
@ -42,6 +38,6 @@ ArgParser definition
|
||||||
`DewConfig` in `core` is a thin wrapper around the raw YAML map. Feature packages add typed accessors via Dart extensions:
|
`DewConfig` in `core` is a thin wrapper around the raw YAML map. Feature packages add typed accessors via Dart extensions:
|
||||||
|
|
||||||
- `dew_kanban` defines `KanbanDewConfig` — exposes `context.config.kanban`
|
- `dew_kanban` defines `KanbanDewConfig` — exposes `context.config.kanban`
|
||||||
- `dew_mcp` defines `McpDewConfig` — exposes `context.config.mcp`
|
- `dew_mcp` currently has no project-level config values
|
||||||
|
|
||||||
This keeps feature-specific config classes out of `core` while leaving all call sites unchanged.
|
This keeps feature-specific config classes out of `core`.
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.4.0 — 2026-05-05
|
## 0.2.0 — 2026-05-02
|
||||||
|
|
||||||
Infra release.
|
- Added the `dew` executable mapping for pub activation.
|
||||||
|
- Bumped Dew package dependency constraints to `^0.2.0`.
|
||||||
- Added the `dew infra` command group to the CLI.
|
|
||||||
- Registered the infra package alongside kanban, MCP, and vault packages.
|
|
||||||
- Updated CLI dependencies for the `0.4.0` package set.
|
|
||||||
|
|
||||||
## 0.1.0 — 2026-04-25
|
## 0.1.0 — 2026-04-25
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,12 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:args/command_runner.dart';
|
import 'package:args/command_runner.dart';
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
import 'package:dew_infra/dew_infra.dart' as infra;
|
|
||||||
import 'package:dew_kanban/dew_kanban.dart' as kanban;
|
import 'package:dew_kanban/dew_kanban.dart' as kanban;
|
||||||
import 'package:dew_mcp/dew_mcp.dart' as mcp;
|
import 'package:dew_mcp/dew_mcp.dart' as mcp;
|
||||||
import 'package:dew_vault/dew_vault.dart' as vault;
|
|
||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
final commandRegistry = CommandRegistry();
|
final commandRegistry = CommandRegistry();
|
||||||
|
|
||||||
infra.registerCommands(commandRegistry);
|
|
||||||
kanban.registerCommands(commandRegistry);
|
kanban.registerCommands(commandRegistry);
|
||||||
vault.registerCommands(commandRegistry);
|
|
||||||
mcp.registerCommands(commandRegistry);
|
mcp.registerCommands(commandRegistry);
|
||||||
|
|
||||||
final runner = CommandRunner<void>('dew', 'A project management tool.');
|
final runner = CommandRunner<void>('dew', 'A project management tool.');
|
||||||
|
|
@ -22,10 +16,5 @@ Future<void> main(List<String> args) async {
|
||||||
runner.addCommand(command);
|
runner.addCommand(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
await runner.run(args);
|
await runner.run(args);
|
||||||
} on UsageException catch (error) {
|
|
||||||
stderr.writeln(error);
|
|
||||||
exitCode = 64;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
name: dew
|
name: dew
|
||||||
description: Command-line interface for the Dew project management tool.
|
description: Command-line interface for the Dew project management tool.
|
||||||
version: 0.4.0
|
version: 0.2.0
|
||||||
repository: https://github.com/artificerchris/dew
|
repository: https://github.com/artificerchris/dew
|
||||||
issue_tracker: https://github.com/artificerchris/dew/issues
|
issue_tracker: https://github.com/artificerchris/dew/issues
|
||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
|
executables:
|
||||||
|
dew: dew
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.4
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
args: ^2.7.0
|
args: ^2.7.0
|
||||||
dew_core: ^0.4.0
|
dew_core: ^0.2.0
|
||||||
dew_infra: ^0.4.0
|
dew_kanban: ^0.2.0
|
||||||
dew_kanban: ^0.4.0
|
dew_mcp: ^0.2.0
|
||||||
dew_vault: ^0.4.0
|
|
||||||
dew_mcp: ^0.4.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
lints: ^6.0.0
|
lints: ^6.0.0
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,13 @@
|
||||||
import 'package:args/command_runner.dart';
|
import 'package:args/command_runner.dart';
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
import 'package:dew_infra/dew_infra.dart' as infra;
|
|
||||||
import 'package:dew_kanban/dew_kanban.dart' as kanban;
|
import 'package:dew_kanban/dew_kanban.dart' as kanban;
|
||||||
import 'package:dew_mcp/dew_mcp.dart' as mcp;
|
import 'package:dew_mcp/dew_mcp.dart' as mcp;
|
||||||
import 'package:dew_vault/dew_vault.dart' as vault;
|
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
/// Builds the same CommandRunner as bin/dew.dart without actually running it.
|
/// Builds the same CommandRunner as bin/dew.dart without actually running it.
|
||||||
CommandRunner<void> buildRunner() {
|
CommandRunner<void> buildRunner() {
|
||||||
final commandRegistry = CommandRegistry();
|
final commandRegistry = CommandRegistry();
|
||||||
infra.registerCommands(commandRegistry);
|
|
||||||
kanban.registerCommands(commandRegistry);
|
kanban.registerCommands(commandRegistry);
|
||||||
vault.registerCommands(commandRegistry);
|
|
||||||
mcp.registerCommands(commandRegistry);
|
mcp.registerCommands(commandRegistry);
|
||||||
|
|
||||||
final runner = CommandRunner<void>('dew', 'A project management tool.');
|
final runner = CommandRunner<void>('dew', 'A project management tool.');
|
||||||
|
|
@ -28,12 +24,9 @@ void main() {
|
||||||
expect(buildRunner, returnsNormally);
|
expect(buildRunner, returnsNormally);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('has core package commands registered', () {
|
test('has kanban, init, and mcp commands registered', () {
|
||||||
final runner = buildRunner();
|
final runner = buildRunner();
|
||||||
expect(
|
expect(runner.commands.keys, containsAll(['kanban', 'init', 'mcp']));
|
||||||
runner.commands.keys,
|
|
||||||
containsAll(['infra', 'kanban', 'vault', 'init', 'mcp']),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('--help flag does not throw', () async {
|
test('--help flag does not throw', () async {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.4.0 — 2026-05-05
|
## 0.2.0 — 2026-05-02
|
||||||
|
|
||||||
Infra and MCP provider release.
|
- Removed stale MCP config references from core config documentation.
|
||||||
|
- Updated `dew init` defaults to generate only supported project config.
|
||||||
- Extended `CommandRegistry.mcpTools` to collect extra tools from
|
|
||||||
`McpToolProvider` command classes.
|
|
||||||
- Kept `InitCommand` analyzer-clean with the Dart SDK `3.12` lint set.
|
|
||||||
|
|
||||||
## 0.1.0 — 2026-04-25
|
## 0.1.0 — 2026-04-25
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:file/file.dart';
|
import 'package:file/file.dart';
|
||||||
import 'package:file/local.dart';
|
import 'package:file/local.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
@ -8,7 +6,7 @@ import 'package:yaml/yaml.dart';
|
||||||
/// Thin wrapper around the raw project YAML.
|
/// Thin wrapper around the raw project YAML.
|
||||||
///
|
///
|
||||||
/// Feature packages extend this class via Dart extension methods to expose
|
/// Feature packages extend this class via Dart extension methods to expose
|
||||||
/// typed configuration (e.g. [KanbanDewConfig.kanban], [McpDewConfig.mcp]).
|
/// typed configuration (e.g. [KanbanDewConfig.kanban]).
|
||||||
/// This keeps feature-specific config classes out of core.
|
/// This keeps feature-specific config classes out of core.
|
||||||
class DewConfig {
|
class DewConfig {
|
||||||
final YamlMap raw;
|
final YamlMap raw;
|
||||||
|
|
@ -36,42 +34,16 @@ class ProjectContext {
|
||||||
final String root;
|
final String root;
|
||||||
final DewConfig config;
|
final DewConfig config;
|
||||||
final FileSystem fs;
|
final FileSystem fs;
|
||||||
final String configFilePath;
|
|
||||||
|
|
||||||
const ProjectContext({
|
const ProjectContext({
|
||||||
required this.root,
|
required this.root,
|
||||||
required this.config,
|
required this.config,
|
||||||
required this.fs,
|
required this.fs,
|
||||||
required this.configFilePath,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Typed path helpers for this project's well-known directories.
|
/// Typed path helpers for this project's well-known directories.
|
||||||
ProjectDirs get dirs => ProjectDirs(root);
|
ProjectDirs get dirs => ProjectDirs(root);
|
||||||
|
|
||||||
/// Path to `.project/dew.yaml` used to bootstrap this context.
|
|
||||||
String get configPath => configFilePath;
|
|
||||||
|
|
||||||
/// Resolves configuration values that are file paths relative to this project's
|
|
||||||
/// `.project/dew.yaml` location.
|
|
||||||
String resolveConfigPath(String value) {
|
|
||||||
final expanded = _expandTilde(value);
|
|
||||||
if (p.isAbsolute(expanded)) return p.normalize(expanded);
|
|
||||||
final segments = p.split(p.normalize(expanded));
|
|
||||||
if (segments.isNotEmpty && segments.first == '.project') {
|
|
||||||
return p.normalize(
|
|
||||||
p.joinAll(
|
|
||||||
[
|
|
||||||
p.dirname(configPath),
|
|
||||||
'..',
|
|
||||||
'.project',
|
|
||||||
...segments.skip(1),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return p.normalize(p.join(p.dirname(configPath), expanded));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Walks up from [from] (defaults to [fs.currentDirectory]) until a
|
/// Walks up from [from] (defaults to [fs.currentDirectory]) until a
|
||||||
/// `.project/dew.yaml` is found.
|
/// `.project/dew.yaml` is found.
|
||||||
static Future<ProjectContext> find({
|
static Future<ProjectContext> find({
|
||||||
|
|
@ -82,13 +54,11 @@ class ProjectContext {
|
||||||
while (true) {
|
while (true) {
|
||||||
final configFile = fs.file(p.join(dir.path, '.project', 'dew.yaml'));
|
final configFile = fs.file(p.join(dir.path, '.project', 'dew.yaml'));
|
||||||
if (await configFile.exists()) {
|
if (await configFile.exists()) {
|
||||||
final path = configFile.path;
|
|
||||||
final yaml = loadYaml(await configFile.readAsString()) as YamlMap;
|
final yaml = loadYaml(await configFile.readAsString()) as YamlMap;
|
||||||
return ProjectContext(
|
return ProjectContext(
|
||||||
root: dir.path,
|
root: dir.path,
|
||||||
config: DewConfig.fromYaml(yaml),
|
config: DewConfig.fromYaml(yaml),
|
||||||
fs: fs,
|
fs: fs,
|
||||||
configFilePath: path,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final parent = dir.parent;
|
final parent = dir.parent;
|
||||||
|
|
@ -102,15 +72,3 @@ class ProjectContext {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _expandTilde(String input) {
|
|
||||||
if (!input.startsWith('~')) return input;
|
|
||||||
final home = Platform.environment['HOME'] ??
|
|
||||||
Platform.environment['USERPROFILE'] ??
|
|
||||||
'';
|
|
||||||
if (home.isEmpty) return input;
|
|
||||||
if (input.length == 1) return home;
|
|
||||||
final rest = input.substring(1);
|
|
||||||
if (rest.startsWith('/')) return p.join(home, rest.substring(1));
|
|
||||||
return p.join(home, rest);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -158,9 +158,6 @@ class CommandRegistry {
|
||||||
final tools = <McpTool>[];
|
final tools = <McpTool>[];
|
||||||
void collect(Command<void> cmd) {
|
void collect(Command<void> cmd) {
|
||||||
if (cmd is DewToolCommand) tools.add(cmd.toMcpTool());
|
if (cmd is DewToolCommand) tools.add(cmd.toMcpTool());
|
||||||
if (cmd is McpToolProvider) {
|
|
||||||
tools.addAll((cmd as McpToolProvider).tools);
|
|
||||||
}
|
|
||||||
for (final sub in cmd.subcommands.values) {
|
for (final sub in cmd.subcommands.values) {
|
||||||
collect(sub);
|
collect(sub);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,10 +31,6 @@ abstract interface class DewInitHook {
|
||||||
|
|
||||||
const _defaultDewYaml = '''
|
const _defaultDewYaml = '''
|
||||||
dew:
|
dew:
|
||||||
mcp:
|
|
||||||
host: "localhost"
|
|
||||||
port: 8080
|
|
||||||
|
|
||||||
kanban:
|
kanban:
|
||||||
prefix: "PROJ"
|
prefix: "PROJ"
|
||||||
ticket_types:
|
ticket_types:
|
||||||
|
|
@ -60,22 +56,12 @@ dew:
|
||||||
color: "green"
|
color: "green"
|
||||||
''';
|
''';
|
||||||
|
|
||||||
const _projectGitignore = '''
|
|
||||||
/secrets/
|
|
||||||
/toolchain/
|
|
||||||
/cache/
|
|
||||||
''';
|
|
||||||
|
|
||||||
class InitCommand extends Command<void> {
|
class InitCommand extends Command<void> {
|
||||||
final List<DewInitHook> _hooks;
|
final List<DewInitHook> _hooks;
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
InitCommand(
|
InitCommand(this._hooks, {FileSystem fs = const LocalFileSystem()})
|
||||||
List<DewInitHook> hooks, {
|
: _fs = fs {
|
||||||
FileSystem fs = const LocalFileSystem(),
|
|
||||||
}) : this._(hooks, fs);
|
|
||||||
|
|
||||||
InitCommand._(this._hooks, this._fs) {
|
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'path',
|
'path',
|
||||||
|
|
@ -107,7 +93,6 @@ class InitCommand extends Command<void> {
|
||||||
|
|
||||||
final projectDir = _fs.directory(p.join(projectRoot, '.project'));
|
final projectDir = _fs.directory(p.join(projectRoot, '.project'));
|
||||||
final configFile = _fs.file(p.join(projectDir.path, 'dew.yaml'));
|
final configFile = _fs.file(p.join(projectDir.path, 'dew.yaml'));
|
||||||
final gitignoreFile = _fs.file(p.join(projectDir.path, '.gitignore'));
|
|
||||||
|
|
||||||
await projectDir.create(recursive: true);
|
await projectDir.create(recursive: true);
|
||||||
|
|
||||||
|
|
@ -118,13 +103,6 @@ class InitCommand extends Command<void> {
|
||||||
print(' created .project/dew.yaml');
|
print(' created .project/dew.yaml');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (await gitignoreFile.exists()) {
|
|
||||||
print(' found .project/.gitignore (already exists, skipping)');
|
|
||||||
} else {
|
|
||||||
await gitignoreFile.writeAsString(_projectGitignore);
|
|
||||||
print(' created .project/.gitignore');
|
|
||||||
}
|
|
||||||
|
|
||||||
final config = DewConfig.fromYaml(
|
final config = DewConfig.fromYaml(
|
||||||
loadYaml(await configFile.readAsString()) as YamlMap,
|
loadYaml(await configFile.readAsString()) as YamlMap,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
name: dew_core
|
name: dew_core
|
||||||
description: Core shared types, interfaces, and configuration for the Dew project management tool.
|
description: Core shared types, interfaces, and configuration for the Dew project management tool.
|
||||||
version: 0.4.0
|
version: 0.2.0
|
||||||
repository: https://github.com/artificerchris/dew
|
repository: https://github.com/artificerchris/dew
|
||||||
issue_tracker: https://github.com/artificerchris/dew/issues
|
issue_tracker: https://github.com/artificerchris/dew/issues
|
||||||
resolution: workspace
|
resolution: workspace
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.11.4
|
||||||
|
|
||||||
# Add regular dependencies here.
|
# Add regular dependencies here.
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:args/command_runner.dart';
|
||||||
import 'package:dew_core/dew_core.dart';
|
import 'package:dew_core/dew_core.dart';
|
||||||
import 'package:file/memory.dart';
|
import 'package:file/memory.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
@ -37,9 +38,6 @@ void main() {
|
||||||
group('ProjectContext', () {
|
group('ProjectContext', () {
|
||||||
const configYaml = '''
|
const configYaml = '''
|
||||||
dew:
|
dew:
|
||||||
mcp:
|
|
||||||
host: localhost
|
|
||||||
port: 9090
|
|
||||||
kanban:
|
kanban:
|
||||||
prefix: TEST
|
prefix: TEST
|
||||||
ticket_types:
|
ticket_types:
|
||||||
|
|
@ -59,8 +57,7 @@ dew:
|
||||||
final ctx = await ProjectContext.find(fs: fs);
|
final ctx = await ProjectContext.find(fs: fs);
|
||||||
final dew = ctx.config.raw['dew'];
|
final dew = ctx.config.raw['dew'];
|
||||||
expect(dew['kanban']['prefix'], 'TEST');
|
expect(dew['kanban']['prefix'], 'TEST');
|
||||||
expect(dew['mcp']['host'], 'localhost');
|
expect(dew.containsKey('mcp'), isFalse);
|
||||||
expect(dew['mcp']['port'], 9090);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('find() locates config from a subdirectory', () async {
|
test('find() locates config from a subdirectory', () async {
|
||||||
|
|
@ -72,25 +69,22 @@ dew:
|
||||||
final ctx = await ProjectContext.find(fs: fs, from: fs.directory('/sub'));
|
final ctx = await ProjectContext.find(fs: fs, from: fs.directory('/sub'));
|
||||||
expect(ctx.root, '/');
|
expect(ctx.root, '/');
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('resolveConfigPath resolves paths relative to .project/dew.yaml', () async {
|
group('InitCommand', () {
|
||||||
|
test('generates dew.yaml without MCP host or port config', () async {
|
||||||
final fs = MemoryFileSystem();
|
final fs = MemoryFileSystem();
|
||||||
fs.directory('/foo/.project').createSync(recursive: true);
|
final runner = CommandRunner<void>('dew', 'test');
|
||||||
fs.file('/foo/.project/dew.yaml').writeAsStringSync(configYaml);
|
runner.addCommand(InitCommand(const [], fs: fs));
|
||||||
|
|
||||||
final ctx = await ProjectContext.find(fs: fs, from: fs.directory('/foo/.project/child'));
|
await runner.run(['init', '--path', '/project']);
|
||||||
expect(
|
|
||||||
ctx.resolveConfigPath('vault'),
|
final config = fs.file('/project/.project/dew.yaml').readAsStringSync();
|
||||||
'/foo/.project/vault',
|
expect(config, contains('dew:'));
|
||||||
);
|
expect(config, contains('kanban:'));
|
||||||
expect(
|
expect(config, isNot(contains('mcp:')));
|
||||||
ctx.resolveConfigPath('.project/vault'),
|
expect(config, isNot(contains('host:')));
|
||||||
'/foo/.project/vault',
|
expect(config, isNot(contains('port:')));
|
||||||
);
|
|
||||||
expect(
|
|
||||||
ctx.resolveConfigPath('/tmp/abs'),
|
|
||||||
'/tmp/abs',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
# Changelog
|
|
||||||
|
|
||||||
## 0.4.0 — 2026-05-05
|
|
||||||
|
|
||||||
Initial public release.
|
|
||||||
|
|
||||||
- Added infrastructure service discovery from `.project/infrastructure/services`.
|
|
||||||
- Added YAML service manifests with support for multiple Podman Quadlet files.
|
|
||||||
- Added manifest validation, JSON Schema validation, configuration payloads, and
|
|
||||||
initialization payloads.
|
|
||||||
- Added Podman Quadlet install, uninstall, up, down, restart, status, logs, and
|
|
||||||
delete operations behind a container runtime boundary.
|
|
||||||
- Added MCP tools for every `dew infra` CLI path.
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2026 Chris Gebhardt
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
# dew_infra
|
|
||||||
|
|
||||||
Infrastructure service management for the Dew CLI.
|
|
||||||
|
|
||||||
`dew_infra` discovers service manifests under `.project/infrastructure`,
|
|
||||||
validates referenced Quadlet files and JSON Schemas, and manages Podman
|
|
||||||
Quadlets through systemd. It is designed around a runtime boundary so Dew can
|
|
||||||
support additional container runtimes in later releases.
|
|
||||||
|
|
||||||
Most users should install the `dew` CLI package instead:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
dart pub global activate dew
|
|
||||||
```
|
|
||||||
|
|
||||||
See the main Dew documentation for the `dew infra` command reference:
|
|
||||||
|
|
||||||
<https://github.com/artificerchris/dew#readme>
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
MIT — see [LICENSE](LICENSE).
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
library;
|
|
||||||
|
|
||||||
export 'src/dew_infra_base.dart';
|
|
||||||
export 'src/infra_repository.dart';
|
|
||||||
export 'src/infra_runtime.dart';
|
|
||||||
export 'src/service_manifest.dart';
|
|
||||||
|
|
||||||
import 'package:dew_core/dew_core.dart';
|
|
||||||
import 'package:file/file.dart';
|
|
||||||
import 'package:file/local.dart';
|
|
||||||
|
|
||||||
import 'src/dew_infra_base.dart';
|
|
||||||
|
|
||||||
/// Registers all infrastructure commands into [registry].
|
|
||||||
void registerCommands(
|
|
||||||
CommandRegistry registry, {
|
|
||||||
FileSystem fs = const LocalFileSystem(),
|
|
||||||
}) {
|
|
||||||
registry.register(InfraCommand(fs: fs));
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,262 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:file/file.dart';
|
|
||||||
import 'package:file/local.dart';
|
|
||||||
import 'package:json_schema/json_schema.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
|
||||||
|
|
||||||
import 'service_manifest.dart';
|
|
||||||
|
|
||||||
/// Reads infrastructure manifests from `.project/infrastructure`.
|
|
||||||
class InfraRepository {
|
|
||||||
const InfraRepository({
|
|
||||||
required this.infraDir,
|
|
||||||
this.fs = const LocalFileSystem(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Absolute path to the infrastructure root.
|
|
||||||
final String infraDir;
|
|
||||||
|
|
||||||
/// File system abstraction for tests and non-local callers.
|
|
||||||
final FileSystem fs;
|
|
||||||
|
|
||||||
/// Absolute path to the service directory root.
|
|
||||||
String get servicesDir => p.join(infraDir, 'services');
|
|
||||||
|
|
||||||
/// Finds all service manifests below `services/*/manifest.yaml`.
|
|
||||||
Future<List<InfraServiceManifest>> list() async {
|
|
||||||
final root = fs.directory(servicesDir);
|
|
||||||
if (!await root.exists()) return const [];
|
|
||||||
|
|
||||||
final manifests = <InfraServiceManifest>[];
|
|
||||||
await for (final entity in root.list()) {
|
|
||||||
if (entity is! Directory) continue;
|
|
||||||
final manifest = fs.file(p.join(entity.path, 'manifest.yaml'));
|
|
||||||
if (!await manifest.exists()) continue;
|
|
||||||
manifests.add(
|
|
||||||
await loadFromManifestPath(manifest.path, serviceDir: entity.path),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
manifests.sort((a, b) => a.id.compareTo(b.id));
|
|
||||||
return manifests;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads a single service by command-line [id].
|
|
||||||
Future<InfraServiceManifest> get(String id) async {
|
|
||||||
final manifest = await find(id);
|
|
||||||
if (manifest == null) {
|
|
||||||
throw ArgumentError('Infrastructure service "$id" not found.');
|
|
||||||
}
|
|
||||||
return manifest;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads a single service by command-line [id], returning null if absent.
|
|
||||||
Future<InfraServiceManifest?> find(String id) async {
|
|
||||||
final manifestPath = p.join(servicesDir, id, 'manifest.yaml');
|
|
||||||
final file = fs.file(manifestPath);
|
|
||||||
if (!await file.exists()) return null;
|
|
||||||
return loadFromManifestPath(
|
|
||||||
manifestPath,
|
|
||||||
serviceDir: p.dirname(manifestPath),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parses the manifest at [manifestPath].
|
|
||||||
Future<InfraServiceManifest> loadFromManifestPath(
|
|
||||||
String manifestPath, {
|
|
||||||
required String serviceDir,
|
|
||||||
}) async {
|
|
||||||
final file = fs.file(manifestPath);
|
|
||||||
return InfraServiceManifest.parse(
|
|
||||||
contents: await file.readAsString(),
|
|
||||||
serviceDir: p.normalize(serviceDir),
|
|
||||||
manifestPath: p.normalize(manifestPath),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A validation issue found in an infrastructure manifest or referenced file.
|
|
||||||
class InfraValidationIssue {
|
|
||||||
const InfraValidationIssue({
|
|
||||||
required this.serviceId,
|
|
||||||
required this.path,
|
|
||||||
required this.message,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Service id, or the best available directory name when parsing failed.
|
|
||||||
final String serviceId;
|
|
||||||
|
|
||||||
/// Path where the issue was discovered.
|
|
||||||
final String path;
|
|
||||||
|
|
||||||
/// Human-readable issue.
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
/// Machine-readable issue.
|
|
||||||
Map<String, String> toJson() => {
|
|
||||||
'service': serviceId,
|
|
||||||
'path': path,
|
|
||||||
'message': message,
|
|
||||||
};
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => '$serviceId: $message ($path)';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Validates service manifests and their referenced files.
|
|
||||||
class InfraValidator {
|
|
||||||
const InfraValidator({this.fs = const LocalFileSystem()});
|
|
||||||
|
|
||||||
/// File system abstraction for tests and non-local callers.
|
|
||||||
final FileSystem fs;
|
|
||||||
|
|
||||||
/// Validates [manifest].
|
|
||||||
Future<List<InfraValidationIssue>> validate(
|
|
||||||
InfraServiceManifest manifest,
|
|
||||||
) async {
|
|
||||||
final issues = <InfraValidationIssue>[];
|
|
||||||
void issue(String path, String message) => issues.add(
|
|
||||||
InfraValidationIssue(
|
|
||||||
serviceId: manifest.id,
|
|
||||||
path: path,
|
|
||||||
message: message,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final dirId = p.basename(manifest.serviceDir);
|
|
||||||
if (manifest.id != dirId) {
|
|
||||||
issue(
|
|
||||||
manifest.manifestPath,
|
|
||||||
'id "${manifest.id}" must match directory "$dirId".',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (manifest.quadlets.isEmpty) {
|
|
||||||
issue(manifest.manifestPath, 'quadlets must contain at least one file.');
|
|
||||||
}
|
|
||||||
final quadletFiles = <String>{};
|
|
||||||
final quadletUnits = <String>{};
|
|
||||||
for (final quadlet in manifest.quadlets) {
|
|
||||||
if (!quadletFiles.add(quadlet.file)) {
|
|
||||||
issue(
|
|
||||||
manifest.manifestPath,
|
|
||||||
'quadlet file "${quadlet.file}" is declared more than once.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!quadletUnits.add(quadlet.serviceUnit)) {
|
|
||||||
issue(
|
|
||||||
manifest.manifestPath,
|
|
||||||
'quadlet unit "${quadlet.serviceUnit}" is declared more than once.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (!quadlet.serviceUnit.endsWith('.service')) {
|
|
||||||
issue(
|
|
||||||
manifest.manifestPath,
|
|
||||||
'quadlet unit "${quadlet.serviceUnit}" must end with .service.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await _requireFile(manifest, quadlet.filePath, issues);
|
|
||||||
await _requireDirectoryIfDeclared(
|
|
||||||
manifest,
|
|
||||||
quadlet.dropinsDirPath,
|
|
||||||
issues,
|
|
||||||
);
|
|
||||||
await _requireDirectoryIfDeclared(
|
|
||||||
manifest,
|
|
||||||
quadlet.profilesDirPath,
|
|
||||||
issues,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (final path in manifest.filePaths) {
|
|
||||||
await _requireFile(manifest, path, issues);
|
|
||||||
}
|
|
||||||
await _validateJsonSchema(
|
|
||||||
manifest,
|
|
||||||
label: 'configure schema',
|
|
||||||
path: manifest.configureSchemaPath,
|
|
||||||
issues: issues,
|
|
||||||
);
|
|
||||||
await _validateJsonSchema(
|
|
||||||
manifest,
|
|
||||||
label: 'init schema',
|
|
||||||
path: manifest.initSchemaPath,
|
|
||||||
issues: issues,
|
|
||||||
);
|
|
||||||
|
|
||||||
return issues;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _requireFile(
|
|
||||||
InfraServiceManifest manifest,
|
|
||||||
String path,
|
|
||||||
List<InfraValidationIssue> issues,
|
|
||||||
) async {
|
|
||||||
if (!await fs.file(path).exists()) {
|
|
||||||
issues.add(
|
|
||||||
InfraValidationIssue(
|
|
||||||
serviceId: manifest.id,
|
|
||||||
path: path,
|
|
||||||
message: 'Referenced file does not exist.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _requireDirectoryIfDeclared(
|
|
||||||
InfraServiceManifest manifest,
|
|
||||||
String? path,
|
|
||||||
List<InfraValidationIssue> issues,
|
|
||||||
) async {
|
|
||||||
if (path == null) return;
|
|
||||||
if (!await fs.directory(path).exists()) {
|
|
||||||
issues.add(
|
|
||||||
InfraValidationIssue(
|
|
||||||
serviceId: manifest.id,
|
|
||||||
path: path,
|
|
||||||
message: 'Referenced directory does not exist.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _validateJsonSchema(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required String label,
|
|
||||||
required String? path,
|
|
||||||
required List<InfraValidationIssue> issues,
|
|
||||||
}) async {
|
|
||||||
if (path == null) {
|
|
||||||
issues.add(
|
|
||||||
InfraValidationIssue(
|
|
||||||
serviceId: manifest.id,
|
|
||||||
path: manifest.manifestPath,
|
|
||||||
message: 'Missing $label path.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final file = fs.file(path);
|
|
||||||
if (!await file.exists()) {
|
|
||||||
issues.add(
|
|
||||||
InfraValidationIssue(
|
|
||||||
serviceId: manifest.id,
|
|
||||||
path: path,
|
|
||||||
message: 'Referenced $label does not exist.',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
final decoded = jsonDecode(await file.readAsString());
|
|
||||||
JsonSchema.create(decoded);
|
|
||||||
} catch (error) {
|
|
||||||
issues.add(
|
|
||||||
InfraValidationIssue(
|
|
||||||
serviceId: manifest.id,
|
|
||||||
path: path,
|
|
||||||
message: 'Invalid $label: $error',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,510 +0,0 @@
|
||||||
import 'dart:io' as io;
|
|
||||||
|
|
||||||
import 'package:file/file.dart';
|
|
||||||
import 'package:file/local.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
|
||||||
import 'package:podman/podman.dart' show PodmanClient;
|
|
||||||
|
|
||||||
import 'service_manifest.dart';
|
|
||||||
|
|
||||||
/// systemd scope for Quadlet units.
|
|
||||||
enum InfraScope {
|
|
||||||
/// User-level systemd and Podman Quadlet search paths.
|
|
||||||
user,
|
|
||||||
|
|
||||||
/// System-level systemd and Podman Quadlet search paths.
|
|
||||||
system;
|
|
||||||
|
|
||||||
/// Parses the CLI value.
|
|
||||||
static InfraScope parse(String value) => switch (value) {
|
|
||||||
'user' => user,
|
|
||||||
'system' => system,
|
|
||||||
_ => throw ArgumentError('Unknown infra scope "$value".'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Process result returned by [InfraProcessRunner].
|
|
||||||
class InfraProcessResult {
|
|
||||||
const InfraProcessResult({
|
|
||||||
required this.exitCode,
|
|
||||||
this.stdout = '',
|
|
||||||
this.stderr = '',
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Process exit code.
|
|
||||||
final int exitCode;
|
|
||||||
|
|
||||||
/// Captured stdout.
|
|
||||||
final String stdout;
|
|
||||||
|
|
||||||
/// Captured stderr.
|
|
||||||
final String stderr;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runs local processes for runtime backends.
|
|
||||||
abstract interface class InfraProcessRunner {
|
|
||||||
/// Runs [executable] with [arguments].
|
|
||||||
Future<InfraProcessResult> run(String executable, List<String> arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Local [io.Process.run] implementation.
|
|
||||||
class LocalInfraProcessRunner implements InfraProcessRunner {
|
|
||||||
const LocalInfraProcessRunner();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<InfraProcessResult> run(
|
|
||||||
String executable,
|
|
||||||
List<String> arguments,
|
|
||||||
) async {
|
|
||||||
final result = await io.Process.run(executable, arguments);
|
|
||||||
return InfraProcessResult(
|
|
||||||
exitCode: result.exitCode,
|
|
||||||
stdout: '${result.stdout}',
|
|
||||||
stderr: '${result.stderr}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Result of a runtime operation.
|
|
||||||
class InfraRuntimeResult {
|
|
||||||
const InfraRuntimeResult({
|
|
||||||
this.actions = const [],
|
|
||||||
this.exitCode = 0,
|
|
||||||
this.stdout = '',
|
|
||||||
this.stderr = '',
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Actions performed or planned.
|
|
||||||
final List<String> actions;
|
|
||||||
|
|
||||||
/// Runtime command exit code.
|
|
||||||
final int exitCode;
|
|
||||||
|
|
||||||
/// Captured stdout.
|
|
||||||
final String stdout;
|
|
||||||
|
|
||||||
/// Captured stderr.
|
|
||||||
final String stderr;
|
|
||||||
|
|
||||||
/// Machine-readable result.
|
|
||||||
Map<String, Object?> toJson() => {
|
|
||||||
'actions': actions,
|
|
||||||
'exit_code': exitCode,
|
|
||||||
'stdout': stdout,
|
|
||||||
'stderr': stderr,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Runtime backend boundary used by `dew infra`.
|
|
||||||
abstract interface class ContainerRuntime {
|
|
||||||
/// Runtime kind handled by this backend.
|
|
||||||
InfraRuntimeKind get kind;
|
|
||||||
|
|
||||||
/// Returns true when the service's runtime files are installed.
|
|
||||||
Future<bool> isInstalled(InfraServiceManifest manifest, InfraScope scope);
|
|
||||||
|
|
||||||
/// Installs service runtime files.
|
|
||||||
Future<InfraRuntimeResult> install(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool dryRun,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Uninstalls service runtime files.
|
|
||||||
Future<InfraRuntimeResult> uninstall(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool dryRun,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Reloads runtime service discovery.
|
|
||||||
Future<InfraRuntimeResult> reload({
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool dryRun,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Starts the service.
|
|
||||||
Future<InfraRuntimeResult> start(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool dryRun,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Stops the service.
|
|
||||||
Future<InfraRuntimeResult> stop(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool dryRun,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Restarts the service.
|
|
||||||
Future<InfraRuntimeResult> restart(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool dryRun,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Reads service status.
|
|
||||||
Future<InfraRuntimeResult> status(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Reads service logs.
|
|
||||||
Future<InfraRuntimeResult> logs(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool follow,
|
|
||||||
required int lines,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Deletes runtime artifacts.
|
|
||||||
Future<InfraRuntimeResult> delete(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool deleteContainer,
|
|
||||||
required bool deleteData,
|
|
||||||
required bool dryRun,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Factory for runtime backends.
|
|
||||||
class ContainerRuntimeRegistry {
|
|
||||||
const ContainerRuntimeRegistry(this.runtimes);
|
|
||||||
|
|
||||||
/// Registered runtimes.
|
|
||||||
final List<ContainerRuntime> runtimes;
|
|
||||||
|
|
||||||
/// Finds the runtime for [kind].
|
|
||||||
ContainerRuntime forKind(InfraRuntimeKind kind) => runtimes.firstWhere(
|
|
||||||
(runtime) => runtime.kind == kind,
|
|
||||||
orElse: () => throw StateError('No runtime registered for ${kind.id}.'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Podman Quadlet backend.
|
|
||||||
class PodmanQuadletRuntime implements ContainerRuntime {
|
|
||||||
PodmanQuadletRuntime({
|
|
||||||
this.fs = const LocalFileSystem(),
|
|
||||||
this.processRunner = const LocalInfraProcessRunner(),
|
|
||||||
Map<String, String>? environment,
|
|
||||||
PodmanClient Function()? podmanClientFactory,
|
|
||||||
}) : environment = environment ?? io.Platform.environment,
|
|
||||||
podmanClientFactory = podmanClientFactory ?? PodmanClient.new;
|
|
||||||
|
|
||||||
/// File system used for Quadlet file operations.
|
|
||||||
final FileSystem fs;
|
|
||||||
|
|
||||||
/// Process runner used for systemd, journalctl, and CLI cleanup commands.
|
|
||||||
final InfraProcessRunner processRunner;
|
|
||||||
|
|
||||||
/// Environment used for Quadlet search path resolution.
|
|
||||||
final Map<String, String> environment;
|
|
||||||
|
|
||||||
/// Podman API client factory reserved for backend operations.
|
|
||||||
final PodmanClient Function() podmanClientFactory;
|
|
||||||
|
|
||||||
@override
|
|
||||||
InfraRuntimeKind get kind => InfraRuntimeKind.podmanQuadlet;
|
|
||||||
|
|
||||||
/// Creates a Podman API client for future backend operations.
|
|
||||||
PodmanClient createPodmanClient() => podmanClientFactory();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> isInstalled(
|
|
||||||
InfraServiceManifest manifest,
|
|
||||||
InfraScope scope,
|
|
||||||
) async {
|
|
||||||
if (manifest.quadlets.isEmpty) return false;
|
|
||||||
for (final quadlet in manifest.quadlets) {
|
|
||||||
final target = _targetQuadletPath(quadlet, scope);
|
|
||||||
final exists =
|
|
||||||
await fs.link(target).exists() || await fs.file(target).exists();
|
|
||||||
if (!exists) return false;
|
|
||||||
}
|
|
||||||
for (final file in manifest.files) {
|
|
||||||
final target = _targetFilePath(file, scope);
|
|
||||||
final exists =
|
|
||||||
await fs.link(target).exists() || await fs.file(target).exists();
|
|
||||||
if (!exists) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<InfraRuntimeResult> install(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool dryRun,
|
|
||||||
}) async {
|
|
||||||
final actions = <String>[];
|
|
||||||
final targetDir = quadletSearchPath(scope, environment: environment);
|
|
||||||
await _action(
|
|
||||||
actions,
|
|
||||||
dryRun,
|
|
||||||
'create $targetDir',
|
|
||||||
() => fs.directory(targetDir).create(recursive: true),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (final quadlet in manifest.quadlets) {
|
|
||||||
await _link(
|
|
||||||
actions,
|
|
||||||
dryRun,
|
|
||||||
quadlet.filePath,
|
|
||||||
_targetQuadletPath(quadlet, scope),
|
|
||||||
);
|
|
||||||
|
|
||||||
final dropinsPath = quadlet.dropinsDirPath;
|
|
||||||
if (dropinsPath != null && await fs.directory(dropinsPath).exists()) {
|
|
||||||
final targetDropins = p.join(targetDir, p.basename(dropinsPath));
|
|
||||||
await _action(
|
|
||||||
actions,
|
|
||||||
dryRun,
|
|
||||||
'create $targetDropins',
|
|
||||||
() => fs.directory(targetDropins).create(recursive: true),
|
|
||||||
);
|
|
||||||
await for (final entity in fs.directory(dropinsPath).list()) {
|
|
||||||
if (entity is! File || p.extension(entity.path) != '.conf') continue;
|
|
||||||
await _link(
|
|
||||||
actions,
|
|
||||||
dryRun,
|
|
||||||
entity.path,
|
|
||||||
p.join(targetDropins, p.basename(entity.path)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (var i = 0; i < manifest.files.length; i++) {
|
|
||||||
await _link(
|
|
||||||
actions,
|
|
||||||
dryRun,
|
|
||||||
manifest.filePaths[i],
|
|
||||||
_targetFilePath(manifest.files[i], scope),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return InfraRuntimeResult(actions: actions);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<InfraRuntimeResult> uninstall(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool dryRun,
|
|
||||||
}) async {
|
|
||||||
final actions = <String>[];
|
|
||||||
final targetDir = quadletSearchPath(scope, environment: environment);
|
|
||||||
for (final quadlet in manifest.quadlets) {
|
|
||||||
await _deletePath(actions, dryRun, _targetQuadletPath(quadlet, scope));
|
|
||||||
final dropinsPath = quadlet.dropinsDirPath;
|
|
||||||
if (dropinsPath != null) {
|
|
||||||
await _deletePath(
|
|
||||||
actions,
|
|
||||||
dryRun,
|
|
||||||
p.join(targetDir, p.basename(dropinsPath)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (final file in manifest.files) {
|
|
||||||
await _deletePath(actions, dryRun, _targetFilePath(file, scope));
|
|
||||||
}
|
|
||||||
return InfraRuntimeResult(actions: actions);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<InfraRuntimeResult> start(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool dryRun,
|
|
||||||
}) async => _systemctl(scope, ['start', ...manifest.units], dryRun: dryRun);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<InfraRuntimeResult> stop(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool dryRun,
|
|
||||||
}) async => _systemctl(scope, ['stop', ...manifest.units], dryRun: dryRun);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<InfraRuntimeResult> restart(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool dryRun,
|
|
||||||
}) async => _systemctl(scope, ['restart', ...manifest.units], dryRun: dryRun);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<InfraRuntimeResult> status(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
}) async => _systemctl(scope, [
|
|
||||||
'status',
|
|
||||||
...manifest.units,
|
|
||||||
'--no-pager',
|
|
||||||
], dryRun: false);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<InfraRuntimeResult> logs(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool follow,
|
|
||||||
required int lines,
|
|
||||||
}) async {
|
|
||||||
final args = [
|
|
||||||
if (scope == InfraScope.user) '--user',
|
|
||||||
for (final unit in manifest.units) ...['-u', unit],
|
|
||||||
'-n',
|
|
||||||
'$lines',
|
|
||||||
if (follow) '-f',
|
|
||||||
];
|
|
||||||
final action = 'journalctl ${args.join(' ')}';
|
|
||||||
final result = await processRunner.run('journalctl', args);
|
|
||||||
return InfraRuntimeResult(
|
|
||||||
actions: [action],
|
|
||||||
exitCode: result.exitCode,
|
|
||||||
stdout: result.stdout,
|
|
||||||
stderr: result.stderr,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<InfraRuntimeResult> delete(
|
|
||||||
InfraServiceManifest manifest, {
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool deleteContainer,
|
|
||||||
required bool deleteData,
|
|
||||||
required bool dryRun,
|
|
||||||
}) async {
|
|
||||||
final actions = <String>[];
|
|
||||||
final outputs = <String>[];
|
|
||||||
final errors = <String>[];
|
|
||||||
var exitCode = 0;
|
|
||||||
|
|
||||||
if (deleteContainer) {
|
|
||||||
if (manifest.containerNames.isEmpty) {
|
|
||||||
actions.add('no container artifacts declared for ${manifest.id}');
|
|
||||||
}
|
|
||||||
for (final containerName in manifest.containerNames) {
|
|
||||||
final args = ['rm', '--ignore', '--force', containerName];
|
|
||||||
final action = 'podman ${args.join(' ')}';
|
|
||||||
actions.add(action);
|
|
||||||
if (!dryRun) {
|
|
||||||
final result = await processRunner.run('podman', args);
|
|
||||||
if (exitCode == 0) exitCode = result.exitCode;
|
|
||||||
if (result.stdout.trim().isNotEmpty) {
|
|
||||||
outputs.add(result.stdout.trim());
|
|
||||||
}
|
|
||||||
if (result.stderr.trim().isNotEmpty) {
|
|
||||||
errors.add(result.stderr.trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (deleteData) {
|
|
||||||
actions.add('delete data artifacts for ${manifest.id}');
|
|
||||||
}
|
|
||||||
if (actions.isEmpty) {
|
|
||||||
actions.add('no runtime artifacts requested for ${manifest.id}');
|
|
||||||
}
|
|
||||||
|
|
||||||
return InfraRuntimeResult(
|
|
||||||
actions: actions,
|
|
||||||
exitCode: exitCode,
|
|
||||||
stdout: outputs.join('\n'),
|
|
||||||
stderr: errors.join('\n'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<InfraRuntimeResult> reload({
|
|
||||||
required InfraScope scope,
|
|
||||||
required bool dryRun,
|
|
||||||
}) => _systemctl(scope, ['daemon-reload'], dryRun: dryRun);
|
|
||||||
|
|
||||||
String _targetQuadletPath(InfraQuadletManifest quadlet, InfraScope scope) =>
|
|
||||||
p.join(
|
|
||||||
quadletSearchPath(scope, environment: environment),
|
|
||||||
p.basename(quadlet.file),
|
|
||||||
);
|
|
||||||
|
|
||||||
String _targetFilePath(String file, InfraScope scope) =>
|
|
||||||
p.join(quadletSearchPath(scope, environment: environment), file);
|
|
||||||
|
|
||||||
Future<void> _link(
|
|
||||||
List<String> actions,
|
|
||||||
bool dryRun,
|
|
||||||
String source,
|
|
||||||
String target,
|
|
||||||
) async {
|
|
||||||
await _action(actions, dryRun, 'link $source -> $target', () async {
|
|
||||||
await fs.directory(p.dirname(target)).create(recursive: true);
|
|
||||||
await _deleteIfExists(target);
|
|
||||||
await fs.link(target).create(source, recursive: true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _deletePath(
|
|
||||||
List<String> actions,
|
|
||||||
bool dryRun,
|
|
||||||
String target,
|
|
||||||
) async {
|
|
||||||
await _action(
|
|
||||||
actions,
|
|
||||||
dryRun,
|
|
||||||
'delete $target',
|
|
||||||
() => _deleteIfExists(target),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _deleteIfExists(String target) async {
|
|
||||||
final type = await fs.type(target, followLinks: false);
|
|
||||||
if (type == FileSystemEntityType.notFound) return;
|
|
||||||
if (type == FileSystemEntityType.directory) {
|
|
||||||
await fs.directory(target).delete(recursive: true);
|
|
||||||
} else if (type == FileSystemEntityType.link) {
|
|
||||||
await fs.link(target).delete();
|
|
||||||
} else {
|
|
||||||
await fs.file(target).delete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _action(
|
|
||||||
List<String> actions,
|
|
||||||
bool dryRun,
|
|
||||||
String description,
|
|
||||||
Future<void> Function() apply,
|
|
||||||
) async {
|
|
||||||
actions.add(description);
|
|
||||||
if (!dryRun) await apply();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<InfraRuntimeResult> _systemctl(
|
|
||||||
InfraScope scope,
|
|
||||||
List<String> arguments, {
|
|
||||||
required bool dryRun,
|
|
||||||
}) async {
|
|
||||||
final args = [if (scope == InfraScope.user) '--user', ...arguments];
|
|
||||||
final action = 'systemctl ${args.join(' ')}';
|
|
||||||
if (dryRun) return InfraRuntimeResult(actions: [action]);
|
|
||||||
final result = await processRunner.run('systemctl', args);
|
|
||||||
return InfraRuntimeResult(
|
|
||||||
actions: [action],
|
|
||||||
exitCode: result.exitCode,
|
|
||||||
stdout: result.stdout,
|
|
||||||
stderr: result.stderr,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolves the Quadlet search path for [scope].
|
|
||||||
String quadletSearchPath(
|
|
||||||
InfraScope scope, {
|
|
||||||
Map<String, String> environment = const {},
|
|
||||||
}) {
|
|
||||||
if (scope == InfraScope.system) return '/etc/containers/systemd';
|
|
||||||
final xdgConfigHome = environment['XDG_CONFIG_HOME'];
|
|
||||||
if (xdgConfigHome != null && xdgConfigHome.isNotEmpty) {
|
|
||||||
return p.join(xdgConfigHome, 'containers', 'systemd');
|
|
||||||
}
|
|
||||||
final home = environment['HOME'] ?? environment['USERPROFILE'] ?? '';
|
|
||||||
return p.join(home.isEmpty ? '~' : home, '.config', 'containers', 'systemd');
|
|
||||||
}
|
|
||||||
|
|
@ -1,350 +0,0 @@
|
||||||
import 'package:path/path.dart' as p;
|
|
||||||
import 'package:yaml/yaml.dart';
|
|
||||||
|
|
||||||
/// Supported infrastructure runtime backends.
|
|
||||||
///
|
|
||||||
/// Dew starts with Podman Quadlets, but commands depend on this enum instead of
|
|
||||||
/// directly depending on Quadlet paths so additional runtimes can be introduced
|
|
||||||
/// without changing the command contract.
|
|
||||||
enum InfraRuntimeKind {
|
|
||||||
/// Podman Quadlet files consumed by systemd.
|
|
||||||
podmanQuadlet('podman-quadlet');
|
|
||||||
|
|
||||||
const InfraRuntimeKind(this.id);
|
|
||||||
|
|
||||||
/// Stable manifest identifier.
|
|
||||||
final String id;
|
|
||||||
|
|
||||||
static InfraRuntimeKind fromManifestValue(String? value) {
|
|
||||||
final normalized = value ?? podmanQuadlet.id;
|
|
||||||
return values.firstWhere(
|
|
||||||
(kind) => kind.id == normalized,
|
|
||||||
orElse: () => throw FormatException(
|
|
||||||
'Unsupported infrastructure runtime "$normalized".',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Supported Podman Quadlet source file kinds.
|
|
||||||
enum InfraQuadletKind {
|
|
||||||
/// Pulls an OCI artifact.
|
|
||||||
artifact('.artifact', '-artifact'),
|
|
||||||
|
|
||||||
/// Builds a container image from a Containerfile.
|
|
||||||
build('.build', '-build'),
|
|
||||||
|
|
||||||
/// Defines and manages a single container.
|
|
||||||
container('.container', ''),
|
|
||||||
|
|
||||||
/// Pulls and caches a container image.
|
|
||||||
image('.image', '-image'),
|
|
||||||
|
|
||||||
/// Deploys containers from Kubernetes YAML.
|
|
||||||
kube('.kube', ''),
|
|
||||||
|
|
||||||
/// Creates a Podman network.
|
|
||||||
network('.network', '-network'),
|
|
||||||
|
|
||||||
/// Creates a Podman pod.
|
|
||||||
pod('.pod', '-pod'),
|
|
||||||
|
|
||||||
/// Ensures a named Podman volume exists.
|
|
||||||
volume('.volume', '-volume');
|
|
||||||
|
|
||||||
const InfraQuadletKind(this.extension, this.defaultServiceSuffix);
|
|
||||||
|
|
||||||
/// Quadlet file extension, including the leading dot.
|
|
||||||
final String extension;
|
|
||||||
|
|
||||||
/// Suffix Podman adds to default generated service units.
|
|
||||||
final String defaultServiceSuffix;
|
|
||||||
|
|
||||||
/// Stable manifest identifier.
|
|
||||||
String get id => extension.substring(1);
|
|
||||||
|
|
||||||
/// Finds the kind represented by [file].
|
|
||||||
static InfraQuadletKind fromFile(String file) {
|
|
||||||
final extension = p.extension(file);
|
|
||||||
return values.firstWhere(
|
|
||||||
(kind) => kind.extension == extension,
|
|
||||||
orElse: () => throw FormatException(
|
|
||||||
'Unsupported Quadlet file extension "$extension".',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A single Podman Quadlet file declared by an infrastructure service.
|
|
||||||
class InfraQuadletManifest {
|
|
||||||
const InfraQuadletManifest({
|
|
||||||
required this.file,
|
|
||||||
required this.kind,
|
|
||||||
required this.serviceDir,
|
|
||||||
this.unit,
|
|
||||||
this.containerName,
|
|
||||||
this.dropinsDir,
|
|
||||||
this.profilesDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Relative path to the Quadlet source file.
|
|
||||||
final String file;
|
|
||||||
|
|
||||||
/// Quadlet type inferred from [file].
|
|
||||||
final InfraQuadletKind kind;
|
|
||||||
|
|
||||||
/// Absolute path to the service directory.
|
|
||||||
final String serviceDir;
|
|
||||||
|
|
||||||
/// Optional generated systemd unit override.
|
|
||||||
final String? unit;
|
|
||||||
|
|
||||||
/// Optional container name for cleanup operations.
|
|
||||||
final String? containerName;
|
|
||||||
|
|
||||||
/// Optional relative path to the Quadlet drop-ins directory.
|
|
||||||
final String? dropinsDir;
|
|
||||||
|
|
||||||
/// Optional relative path to profile drop-ins.
|
|
||||||
final String? profilesDir;
|
|
||||||
|
|
||||||
/// Absolute path to the Quadlet source file.
|
|
||||||
String get filePath => _resolve(file);
|
|
||||||
|
|
||||||
/// Absolute path to the declared drop-ins directory, if any.
|
|
||||||
String? get dropinsDirPath =>
|
|
||||||
dropinsDir == null ? null : _resolve(dropinsDir!);
|
|
||||||
|
|
||||||
/// Absolute path to the declared profiles directory, if any.
|
|
||||||
String? get profilesDirPath =>
|
|
||||||
profilesDir == null ? null : _resolve(profilesDir!);
|
|
||||||
|
|
||||||
/// Unit name Dew should manage for this Quadlet.
|
|
||||||
String get serviceUnit => unit ?? defaultServiceUnit;
|
|
||||||
|
|
||||||
/// Unit name generated by Podman when the Quadlet does not override it.
|
|
||||||
String get defaultServiceUnit {
|
|
||||||
final baseName = p.basenameWithoutExtension(file);
|
|
||||||
return '$baseName${kind.defaultServiceSuffix}.service';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Machine-readable summary.
|
|
||||||
Map<String, Object?> toJson() => {
|
|
||||||
'file': filePath,
|
|
||||||
'kind': kind.id,
|
|
||||||
'unit': serviceUnit,
|
|
||||||
'default_unit': defaultServiceUnit,
|
|
||||||
'container_name': containerName,
|
|
||||||
'dropins_dir': dropinsDirPath,
|
|
||||||
'profiles_dir': profilesDirPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
String _resolve(String value) => p.isAbsolute(value)
|
|
||||||
? p.normalize(value)
|
|
||||||
: p.normalize(p.join(serviceDir, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Infrastructure service metadata loaded from `manifest.yaml`.
|
|
||||||
class InfraServiceManifest {
|
|
||||||
const InfraServiceManifest({
|
|
||||||
required this.id,
|
|
||||||
required this.name,
|
|
||||||
required this.runtime,
|
|
||||||
required this.serviceDir,
|
|
||||||
required this.manifestPath,
|
|
||||||
required this.quadlets,
|
|
||||||
this.files = const [],
|
|
||||||
this.configureSchema,
|
|
||||||
this.initSchema,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// Stable service identifier used on the command line.
|
|
||||||
final String id;
|
|
||||||
|
|
||||||
/// Human-friendly display name.
|
|
||||||
final String name;
|
|
||||||
|
|
||||||
/// Runtime backend declared by the manifest.
|
|
||||||
final InfraRuntimeKind runtime;
|
|
||||||
|
|
||||||
/// Absolute path to the service directory.
|
|
||||||
final String serviceDir;
|
|
||||||
|
|
||||||
/// Absolute path to `manifest.yaml`.
|
|
||||||
final String manifestPath;
|
|
||||||
|
|
||||||
/// Quadlet files deployed for this service.
|
|
||||||
final List<InfraQuadletManifest> quadlets;
|
|
||||||
|
|
||||||
/// Additional files installed beside Quadlets for build contexts or assets.
|
|
||||||
final List<String> files;
|
|
||||||
|
|
||||||
/// Optional relative path to the configure JSON Schema.
|
|
||||||
final String? configureSchema;
|
|
||||||
|
|
||||||
/// Optional relative path to the init JSON Schema.
|
|
||||||
final String? initSchema;
|
|
||||||
|
|
||||||
/// Absolute path to the configure schema, if any.
|
|
||||||
String? get configureSchemaPath =>
|
|
||||||
configureSchema == null ? null : _resolve(configureSchema!);
|
|
||||||
|
|
||||||
/// Absolute path to the init schema, if any.
|
|
||||||
String? get initSchemaPath =>
|
|
||||||
initSchema == null ? null : _resolve(initSchema!);
|
|
||||||
|
|
||||||
/// Directory for active and generated service configuration.
|
|
||||||
String get configDir => p.join(serviceDir, 'config');
|
|
||||||
|
|
||||||
/// Active configure payload path.
|
|
||||||
String get activeConfigurePath => p.join(configDir, 'configure.json');
|
|
||||||
|
|
||||||
/// Active init payload path.
|
|
||||||
String get activeInitPath => p.join(configDir, 'init.json');
|
|
||||||
|
|
||||||
/// Absolute paths to additional installed files.
|
|
||||||
List<String> get filePaths => files.map(_resolve).toList();
|
|
||||||
|
|
||||||
/// Units generated by the declared Quadlet files.
|
|
||||||
List<String> get units =>
|
|
||||||
quadlets.map((quadlet) => quadlet.serviceUnit).toList();
|
|
||||||
|
|
||||||
/// Container names declared for cleanup operations.
|
|
||||||
List<String> get containerNames => quadlets
|
|
||||||
.map((quadlet) => quadlet.containerName)
|
|
||||||
.whereType<String>()
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
/// Decodes [contents] from YAML.
|
|
||||||
factory InfraServiceManifest.parse({
|
|
||||||
required String contents,
|
|
||||||
required String serviceDir,
|
|
||||||
required String manifestPath,
|
|
||||||
}) {
|
|
||||||
final map = _asMap(loadYaml(contents));
|
|
||||||
final schemas = _optionalSection(map, 'schemas');
|
|
||||||
final runtimeSection = _optionalSection(map, 'runtime');
|
|
||||||
final quadlets = _requiredList(
|
|
||||||
map,
|
|
||||||
'quadlets',
|
|
||||||
).map((value) => _parseQuadlet(value, serviceDir: serviceDir)).toList();
|
|
||||||
|
|
||||||
return InfraServiceManifest(
|
|
||||||
id: _requiredString(map, 'id'),
|
|
||||||
name: _requiredString(map, 'name'),
|
|
||||||
runtime: InfraRuntimeKind.fromManifestValue(
|
|
||||||
runtimeSection == null ? null : _optionalString(runtimeSection, 'type'),
|
|
||||||
),
|
|
||||||
serviceDir: serviceDir,
|
|
||||||
manifestPath: manifestPath,
|
|
||||||
quadlets: quadlets,
|
|
||||||
files: _optionalStringList(map, 'files'),
|
|
||||||
configureSchema: schemas == null
|
|
||||||
? null
|
|
||||||
: _optionalString(schemas, 'configure'),
|
|
||||||
initSchema: schemas == null ? null : _optionalString(schemas, 'init'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Machine-readable summary.
|
|
||||||
Map<String, Object?> toJson() => {
|
|
||||||
'id': id,
|
|
||||||
'name': name,
|
|
||||||
'runtime': runtime.id,
|
|
||||||
'manifest': manifestPath,
|
|
||||||
'units': units,
|
|
||||||
'quadlets': quadlets.map((quadlet) => quadlet.toJson()).toList(),
|
|
||||||
'files': filePaths,
|
|
||||||
'configure_schema': configureSchemaPath,
|
|
||||||
'init_schema': initSchemaPath,
|
|
||||||
'active_config': activeConfigurePath,
|
|
||||||
'active_init': activeInitPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
String _resolve(String value) => p.isAbsolute(value)
|
|
||||||
? p.normalize(value)
|
|
||||||
: p.normalize(p.join(serviceDir, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
InfraQuadletManifest _parseQuadlet(
|
|
||||||
dynamic value, {
|
|
||||||
required String serviceDir,
|
|
||||||
}) {
|
|
||||||
if (value is! Map) {
|
|
||||||
throw const FormatException(
|
|
||||||
'manifest.yaml field "quadlets" must list objects.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
final map = value.map((key, value) => MapEntry('$key', value));
|
|
||||||
final file = _requiredString(map, 'file');
|
|
||||||
return InfraQuadletManifest(
|
|
||||||
file: file,
|
|
||||||
kind: InfraQuadletKind.fromFile(file),
|
|
||||||
serviceDir: serviceDir,
|
|
||||||
unit: _optionalString(map, 'unit'),
|
|
||||||
containerName: _optionalString(map, 'container_name'),
|
|
||||||
dropinsDir: _optionalString(map, 'dropins_dir'),
|
|
||||||
profilesDir: _optionalString(map, 'profiles_dir'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> _asMap(dynamic value) {
|
|
||||||
if (value is YamlMap) {
|
|
||||||
return value.map((key, value) => MapEntry('$key', _asYamlValue(value)));
|
|
||||||
}
|
|
||||||
if (value is Map) {
|
|
||||||
return value.map((key, value) => MapEntry('$key', _asYamlValue(value)));
|
|
||||||
}
|
|
||||||
throw const FormatException('manifest.yaml must contain a YAML object.');
|
|
||||||
}
|
|
||||||
|
|
||||||
dynamic _asYamlValue(dynamic value) {
|
|
||||||
if (value is YamlMap || value is Map) return _asMap(value);
|
|
||||||
if (value is YamlList) return value.map(_asYamlValue).toList();
|
|
||||||
if (value is List) return value.map(_asYamlValue).toList();
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic>? _optionalSection(Map<String, dynamic> map, String key) {
|
|
||||||
final value = map[key];
|
|
||||||
if (value == null) return null;
|
|
||||||
if (value is Map) {
|
|
||||||
return value.map((key, value) => MapEntry('$key', value));
|
|
||||||
}
|
|
||||||
throw FormatException('manifest.yaml field "$key" must be an object.');
|
|
||||||
}
|
|
||||||
|
|
||||||
List<dynamic> _requiredList(Map<String, dynamic> map, String key) {
|
|
||||||
final value = map[key];
|
|
||||||
if (value is List) return value;
|
|
||||||
throw FormatException('manifest.yaml is missing list "$key".');
|
|
||||||
}
|
|
||||||
|
|
||||||
List<String> _optionalStringList(Map<String, dynamic> map, String key) {
|
|
||||||
final value = map[key];
|
|
||||||
if (value == null) return const [];
|
|
||||||
if (value is! List) {
|
|
||||||
throw FormatException('manifest.yaml field "$key" must be a list.');
|
|
||||||
}
|
|
||||||
return value.map((item) {
|
|
||||||
if (item is String && item.isNotEmpty) return item;
|
|
||||||
throw FormatException(
|
|
||||||
'manifest.yaml field "$key" must contain non-empty strings.',
|
|
||||||
);
|
|
||||||
}).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
String _requiredString(Map<String, dynamic> map, String key) {
|
|
||||||
final value = _optionalString(map, key);
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
throw FormatException('manifest.yaml is missing required string "$key".');
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _optionalString(Map<String, dynamic> map, String key) {
|
|
||||||
final value = map[key];
|
|
||||||
if (value == null) return null;
|
|
||||||
if (value is String) return value;
|
|
||||||
throw FormatException('manifest.yaml field "$key" must be a string.');
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
name: dew_infra
|
|
||||||
description: Infrastructure service management package for the Dew CLI.
|
|
||||||
version: 0.4.0
|
|
||||||
repository: https://github.com/artificerchris/dew
|
|
||||||
issue_tracker: https://github.com/artificerchris/dew/issues
|
|
||||||
resolution: workspace
|
|
||||||
|
|
||||||
environment:
|
|
||||||
sdk: ^3.12.0
|
|
||||||
|
|
||||||
dependencies:
|
|
||||||
args: ^2.7.0
|
|
||||||
dew_core: ^0.4.0
|
|
||||||
file: ^7.0.1
|
|
||||||
json_schema: ^5.2.2
|
|
||||||
path: ^1.9.0
|
|
||||||
podman: ^0.1.0
|
|
||||||
yaml: ^3.1.0
|
|
||||||
|
|
||||||
dev_dependencies:
|
|
||||||
lints: ^6.0.0
|
|
||||||
test: ^1.25.6
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"$id": "https://artificery.dev/dew/schemas/infra/service-manifest.schema.json",
|
|
||||||
"title": "Dew Infrastructure Service Manifest",
|
|
||||||
"description": "Schema for .project/infrastructure/services/<service-id>/manifest.yaml.",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["id", "name", "quadlets", "schemas"],
|
|
||||||
"properties": {
|
|
||||||
"id": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"pattern": "^[a-z0-9][a-z0-9_.-]*$"
|
|
||||||
},
|
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"runtime": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["type"],
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["podman-quadlet"],
|
|
||||||
"default": "podman-quadlet"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"quadlets": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["file"],
|
|
||||||
"properties": {
|
|
||||||
"file": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"pattern": "^[^/].*\\.(artifact|build|container|image|kube|network|pod|volume)$"
|
|
||||||
},
|
|
||||||
"unit": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"pattern": "^[^/]+\\.service$"
|
|
||||||
},
|
|
||||||
"container_name": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"dropins_dir": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"profiles_dir": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"files": {
|
|
||||||
"type": "array",
|
|
||||||
"description": "Additional files installed beside Quadlets, such as Containerfiles for build contexts.",
|
|
||||||
"items": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"pattern": "^[^/].*$"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"schemas": {
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["configure", "init"],
|
|
||||||
"properties": {
|
|
||||||
"configure": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"pattern": "^[^/].*\\.json$"
|
|
||||||
},
|
|
||||||
"init": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1,
|
|
||||||
"pattern": "^[^/].*\\.json$"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,349 +0,0 @@
|
||||||
import 'dart:convert';
|
|
||||||
import 'dart:io' as io;
|
|
||||||
|
|
||||||
import 'package:dew_core/dew_core.dart';
|
|
||||||
import 'package:dew_infra/dew_infra.dart';
|
|
||||||
import 'package:file/memory.dart';
|
|
||||||
import 'package:json_schema/json_schema.dart';
|
|
||||||
import 'package:test/test.dart';
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
group('Infra command registration', () {
|
|
||||||
test('registerCommands adds infra command', () {
|
|
||||||
final registry = CommandRegistry();
|
|
||||||
registerCommands(registry);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
registry.commands.map((command) => command.name),
|
|
||||||
contains('infra'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('infra command exposes core subcommands', () {
|
|
||||||
final command = InfraCommand();
|
|
||||||
|
|
||||||
expect(
|
|
||||||
command.subcommands.keys,
|
|
||||||
containsAll([
|
|
||||||
'list',
|
|
||||||
'show',
|
|
||||||
'validate',
|
|
||||||
'configure',
|
|
||||||
'init',
|
|
||||||
'install',
|
|
||||||
'uninstall',
|
|
||||||
'up',
|
|
||||||
'down',
|
|
||||||
'restart',
|
|
||||||
'status',
|
|
||||||
'logs',
|
|
||||||
'delete',
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('registerCommands exposes infra MCP tools for each CLI path', () {
|
|
||||||
final registry = CommandRegistry();
|
|
||||||
registerCommands(registry);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
registry.mcpTools.map((tool) => tool.name),
|
|
||||||
containsAll([
|
|
||||||
'infra_list_services',
|
|
||||||
'infra_show_service',
|
|
||||||
'infra_validate_services',
|
|
||||||
'infra_configure_service',
|
|
||||||
'infra_configure_schema',
|
|
||||||
'infra_configure_show',
|
|
||||||
'infra_configure_apply',
|
|
||||||
'infra_init_service',
|
|
||||||
'infra_init_schema',
|
|
||||||
'infra_init_run',
|
|
||||||
'infra_install_service',
|
|
||||||
'infra_uninstall_service',
|
|
||||||
'infra_up_service',
|
|
||||||
'infra_down_service',
|
|
||||||
'infra_restart_service',
|
|
||||||
'infra_status_service',
|
|
||||||
'infra_logs',
|
|
||||||
'infra_delete_service',
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('infra MCP tools discover services and apply schema values', () async {
|
|
||||||
final fs = MemoryFileSystem.test();
|
|
||||||
_writeProjectConfig(fs);
|
|
||||||
_writeService(fs);
|
|
||||||
final registry = CommandRegistry();
|
|
||||||
registerCommands(registry, fs: fs);
|
|
||||||
final tools = {for (final tool in registry.mcpTools) tool.name: tool};
|
|
||||||
|
|
||||||
final services =
|
|
||||||
jsonDecode(
|
|
||||||
await tools['infra_list_services']!.handler({
|
|
||||||
'project': '/project',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
as List<dynamic>;
|
|
||||||
expect(services.single['id'], 'postgres');
|
|
||||||
|
|
||||||
final schema =
|
|
||||||
jsonDecode(
|
|
||||||
await tools['infra_configure_schema']!.handler({
|
|
||||||
'project': '/project',
|
|
||||||
'service': 'postgres',
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
as Map<String, dynamic>;
|
|
||||||
expect(schema['schema'], containsPair('type', 'object'));
|
|
||||||
|
|
||||||
final applied =
|
|
||||||
jsonDecode(
|
|
||||||
await tools['infra_configure_apply']!.handler({
|
|
||||||
'project': '/project',
|
|
||||||
'service': 'postgres',
|
|
||||||
'values': {'port': 5432},
|
|
||||||
'set': ['credentials.user=dew'],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
as Map<String, dynamic>;
|
|
||||||
final config = applied['config'] as Map<String, dynamic>;
|
|
||||||
expect(config['port'], 5432);
|
|
||||||
expect(config['credentials'], containsPair('user', 'dew'));
|
|
||||||
expect(
|
|
||||||
fs
|
|
||||||
.file(
|
|
||||||
'/project/.project/infrastructure/services/postgres/config/configure.json',
|
|
||||||
)
|
|
||||||
.existsSync(),
|
|
||||||
isTrue,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('InfraRepository', () {
|
|
||||||
test('discovers service manifests', () async {
|
|
||||||
final fs = MemoryFileSystem.test();
|
|
||||||
_writeService(fs, includeNetwork: true);
|
|
||||||
|
|
||||||
final repository = InfraRepository(
|
|
||||||
infraDir: '/project/.project/infrastructure',
|
|
||||||
fs: fs,
|
|
||||||
);
|
|
||||||
|
|
||||||
final manifests = await repository.list();
|
|
||||||
expect(manifests, hasLength(1));
|
|
||||||
expect(manifests.single.id, 'postgres');
|
|
||||||
expect(manifests.single.runtime, InfraRuntimeKind.podmanQuadlet);
|
|
||||||
expect(manifests.single.units, [
|
|
||||||
'app_postgres.service',
|
|
||||||
'app_postgres-network.service',
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
manifests.single.quadlets.map((quadlet) => quadlet.filePath),
|
|
||||||
containsAll([
|
|
||||||
'/project/.project/infrastructure/services/postgres/app_postgres.container',
|
|
||||||
'/project/.project/infrastructure/services/postgres/app_postgres.network',
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('InfraValidator', () {
|
|
||||||
test('accepts a complete service manifest', () async {
|
|
||||||
final fs = MemoryFileSystem.test();
|
|
||||||
_writeService(fs);
|
|
||||||
final manifest = await InfraRepository(
|
|
||||||
infraDir: '/project/.project/infrastructure',
|
|
||||||
fs: fs,
|
|
||||||
).get('postgres');
|
|
||||||
|
|
||||||
final issues = await InfraValidator(fs: fs).validate(manifest);
|
|
||||||
|
|
||||||
expect(issues, isEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('reports service id and invalid quadlet units', () async {
|
|
||||||
final fs = MemoryFileSystem.test();
|
|
||||||
_writeService(fs, serviceId: 'wrong', unit: 'wrong');
|
|
||||||
final manifest =
|
|
||||||
await InfraRepository(
|
|
||||||
infraDir: '/project/.project/infrastructure',
|
|
||||||
fs: fs,
|
|
||||||
).loadFromManifestPath(
|
|
||||||
'/project/.project/infrastructure/services/postgres/manifest.yaml',
|
|
||||||
serviceDir: '/project/.project/infrastructure/services/postgres',
|
|
||||||
);
|
|
||||||
|
|
||||||
final issues = await InfraValidator(fs: fs).validate(manifest);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
issues.map((issue) => issue.message).join('\n'),
|
|
||||||
contains('must match directory'),
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
issues.map((issue) => issue.message).join('\n'),
|
|
||||||
contains('must end with .service'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('PodmanQuadletRuntime', () {
|
|
||||||
test(
|
|
||||||
'install dry-run reports symlink actions without writing files',
|
|
||||||
() async {
|
|
||||||
final fs = MemoryFileSystem.test();
|
|
||||||
_writeService(fs, includeNetwork: true, includeFile: true);
|
|
||||||
final manifest = await InfraRepository(
|
|
||||||
infraDir: '/project/.project/infrastructure',
|
|
||||||
fs: fs,
|
|
||||||
).get('postgres');
|
|
||||||
final runtime = PodmanQuadletRuntime(
|
|
||||||
fs: fs,
|
|
||||||
environment: const {'HOME': '/home/test'},
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = await runtime.install(
|
|
||||||
manifest,
|
|
||||||
scope: InfraScope.user,
|
|
||||||
dryRun: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result.actions.join('\n'), contains('app_postgres.container'));
|
|
||||||
expect(result.actions.join('\n'), contains('app_postgres.network'));
|
|
||||||
expect(result.actions.join('\n'), contains('Containerfile'));
|
|
||||||
expect(
|
|
||||||
await fs
|
|
||||||
.link(
|
|
||||||
'/home/test/.config/containers/systemd/app_postgres.container',
|
|
||||||
)
|
|
||||||
.exists(),
|
|
||||||
isFalse,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test('quadletSearchPath respects user and system scope', () {
|
|
||||||
expect(
|
|
||||||
quadletSearchPath(
|
|
||||||
InfraScope.user,
|
|
||||||
environment: const {'XDG_CONFIG_HOME': '/config'},
|
|
||||||
),
|
|
||||||
'/config/containers/systemd',
|
|
||||||
);
|
|
||||||
expect(quadletSearchPath(InfraScope.system), '/etc/containers/systemd');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group('service-manifest.schema.json', () {
|
|
||||||
test('validates the manifest contract shape', () {
|
|
||||||
final schema = JsonSchema.create(
|
|
||||||
jsonDecode(_schemaFile().readAsStringSync()),
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = schema.validate(_manifestObject());
|
|
||||||
|
|
||||||
expect(result.isValid, isTrue, reason: result.errors.join('\n'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _writeService(
|
|
||||||
MemoryFileSystem fs, {
|
|
||||||
String serviceId = 'postgres',
|
|
||||||
String unit = 'app_postgres.service',
|
|
||||||
bool includeNetwork = false,
|
|
||||||
bool includeFile = false,
|
|
||||||
}) {
|
|
||||||
final serviceDir = fs.directory(
|
|
||||||
'/project/.project/infrastructure/services/postgres',
|
|
||||||
)..createSync(recursive: true);
|
|
||||||
fs.directory('${serviceDir.path}/app_postgres.container.d').createSync();
|
|
||||||
fs.directory('${serviceDir.path}/app_postgres.profiles.d').createSync();
|
|
||||||
fs
|
|
||||||
.file('${serviceDir.path}/app_postgres.container')
|
|
||||||
.writeAsStringSync('[Container]\nImage=postgres:16\n');
|
|
||||||
if (includeNetwork) {
|
|
||||||
fs
|
|
||||||
.file('${serviceDir.path}/app_postgres.network')
|
|
||||||
.writeAsStringSync('[Network]\nNetworkName=app_postgres\n');
|
|
||||||
}
|
|
||||||
if (includeFile) {
|
|
||||||
fs
|
|
||||||
.file('${serviceDir.path}/Containerfile')
|
|
||||||
.writeAsStringSync('FROM scratch\n');
|
|
||||||
}
|
|
||||||
fs
|
|
||||||
.file('${serviceDir.path}/configure.schema.json')
|
|
||||||
.writeAsStringSync('{"type":"object"}');
|
|
||||||
fs
|
|
||||||
.file('${serviceDir.path}/init.schema.json')
|
|
||||||
.writeAsStringSync('{"type":"object"}');
|
|
||||||
final networkQuadlet = includeNetwork
|
|
||||||
? '''
|
|
||||||
- file: app_postgres.network
|
|
||||||
'''
|
|
||||||
: '';
|
|
||||||
final files = includeFile
|
|
||||||
? '''
|
|
||||||
files:
|
|
||||||
- Containerfile
|
|
||||||
|
|
||||||
'''
|
|
||||||
: '';
|
|
||||||
fs.file('${serviceDir.path}/manifest.yaml').writeAsStringSync('''
|
|
||||||
id: $serviceId
|
|
||||||
name: PostgreSQL
|
|
||||||
|
|
||||||
runtime:
|
|
||||||
type: podman-quadlet
|
|
||||||
|
|
||||||
quadlets:
|
|
||||||
- file: app_postgres.container
|
|
||||||
unit: $unit
|
|
||||||
container_name: app_postgres
|
|
||||||
dropins_dir: app_postgres.container.d
|
|
||||||
profiles_dir: app_postgres.profiles.d
|
|
||||||
$networkQuadlet
|
|
||||||
|
|
||||||
$files
|
|
||||||
schemas:
|
|
||||||
configure: configure.schema.json
|
|
||||||
init: init.schema.json
|
|
||||||
''');
|
|
||||||
}
|
|
||||||
|
|
||||||
void _writeProjectConfig(MemoryFileSystem fs) {
|
|
||||||
fs.directory('/project/.project').createSync(recursive: true);
|
|
||||||
fs.file('/project/.project/dew.yaml').writeAsStringSync('dew: {}\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, Object?> _manifestObject() => {
|
|
||||||
'id': 'postgres',
|
|
||||||
'name': 'PostgreSQL',
|
|
||||||
'runtime': {'type': 'podman-quadlet'},
|
|
||||||
'quadlets': [
|
|
||||||
{
|
|
||||||
'file': 'app_postgres.container',
|
|
||||||
'unit': 'app_postgres.service',
|
|
||||||
'container_name': 'app_postgres',
|
|
||||||
'dropins_dir': 'app_postgres.container.d',
|
|
||||||
'profiles_dir': 'app_postgres.profiles.d',
|
|
||||||
},
|
|
||||||
{'file': 'app_postgres.network'},
|
|
||||||
],
|
|
||||||
'files': ['Containerfile'],
|
|
||||||
'schemas': {'configure': 'configure.schema.json', 'init': 'init.schema.json'},
|
|
||||||
};
|
|
||||||
|
|
||||||
io.File _schemaFile() {
|
|
||||||
for (final path in [
|
|
||||||
'packages/infra/schemas/service-manifest.schema.json',
|
|
||||||
'schemas/service-manifest.schema.json',
|
|
||||||
]) {
|
|
||||||
final file = io.File(path);
|
|
||||||
if (file.existsSync()) return file;
|
|
||||||
}
|
|
||||||
throw StateError('Could not find service-manifest.schema.json.');
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## 0.4.0 — 2026-05-05
|
## 0.2.0 — 2026-05-02
|
||||||
|
|
||||||
Release alignment for the `0.4.0` Dew package set.
|
- Updated tests and fixtures for the generated config without unsupported MCP
|
||||||
|
`host` and `port` fields.
|
||||||
- Updated the `dew_core` dependency constraint for the infra release.
|
- Bumped the `dew_core` dependency constraint to `^0.2.0`.
|
||||||
- Applied analyzer fixes for current Dart lint recommendations.
|
|
||||||
|
|
||||||
## 0.1.0 — 2026-04-25
|
## 0.1.0 — 2026-04-25
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import '../ticket_store.dart';
|
||||||
class AddCommentCommand extends DewCommand with DewToolCommand {
|
class AddCommentCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
AddCommentCommand({this._fs = const LocalFileSystem()}) {
|
AddCommentCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'id',
|
'id',
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import '../ticket_store.dart';
|
||||||
class ArchiveCommand extends DewCommand with DewToolCommand {
|
class ArchiveCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
ArchiveCommand({this._fs = const LocalFileSystem()}) {
|
ArchiveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser.addOption(
|
argParser.addOption(
|
||||||
'id',
|
'id',
|
||||||
abbr: 'i',
|
abbr: 'i',
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import '../ticket_store.dart';
|
||||||
class BoardCommand extends DewCommand with DewToolCommand {
|
class BoardCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
BoardCommand({this._fs = const LocalFileSystem()}) {
|
BoardCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption('type', abbr: 't', help: 'Filter tickets to this type.')
|
..addOption('type', abbr: 't', help: 'Filter tickets to this type.')
|
||||||
..addOption('label', help: 'Filter tickets to this label.')
|
..addOption('label', help: 'Filter tickets to this label.')
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import '../ticket_store.dart';
|
||||||
class CreateCommand extends DewCommand with DewToolCommand {
|
class CreateCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
CreateCommand({this._fs = const LocalFileSystem()}) {
|
CreateCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.')
|
..addOption('title', abbr: 't', mandatory: true, help: 'Ticket title.')
|
||||||
..addOption(
|
..addOption(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import '../ticket_store.dart';
|
||||||
class DeleteCommand extends DewCommand with DewToolCommand {
|
class DeleteCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
DeleteCommand({this._fs = const LocalFileSystem()}) {
|
DeleteCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser.addOption(
|
argParser.addOption(
|
||||||
'id',
|
'id',
|
||||||
abbr: 'i',
|
abbr: 'i',
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import '../ticket_store.dart';
|
||||||
class GetCommand extends DewCommand with DewToolCommand {
|
class GetCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
GetCommand({this._fs = const LocalFileSystem()}) {
|
GetCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser.addOption(
|
argParser.addOption(
|
||||||
'id',
|
'id',
|
||||||
abbr: 'i',
|
abbr: 'i',
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import '../kanban_config.dart';
|
||||||
class GetConfigCommand extends DewCommand with DewToolCommand {
|
class GetConfigCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
GetConfigCommand({this._fs = const LocalFileSystem()});
|
GetConfigCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
|
||||||
@override
|
@override
|
||||||
final String name = 'config';
|
final String name = 'config';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import '../ticket_store.dart';
|
||||||
class LinkCommand extends DewCommand with DewToolCommand {
|
class LinkCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
LinkCommand({this._fs = const LocalFileSystem()}) {
|
LinkCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
|
..addOption('id', abbr: 'i', mandatory: true, help: 'Source ticket ID.')
|
||||||
..addOption(
|
..addOption(
|
||||||
|
|
@ -44,9 +44,8 @@ class LinkCommand extends DewCommand with DewToolCommand {
|
||||||
final targetId = '${args['target']}'.toUpperCase();
|
final targetId = '${args['target']}'.toUpperCase();
|
||||||
final type = '${args['type']}';
|
final type = '${args['type']}';
|
||||||
|
|
||||||
if (id == targetId) {
|
if (id == targetId)
|
||||||
throw ArgumentError('A ticket cannot be linked to itself.');
|
throw ArgumentError('A ticket cannot be linked to itself.');
|
||||||
}
|
|
||||||
|
|
||||||
final context = await ProjectContext.find(fs: _fs);
|
final context = await ProjectContext.find(fs: _fs);
|
||||||
final store = TicketStore(
|
final store = TicketStore(
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import '../ticket_store.dart';
|
||||||
class ListCommand extends DewCommand with DewToolCommand {
|
class ListCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
ListCommand({this._fs = const LocalFileSystem()}) {
|
ListCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'column',
|
'column',
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import '../ticket_store.dart';
|
||||||
class MoveCommand extends DewCommand with DewToolCommand {
|
class MoveCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
MoveCommand({this._fs = const LocalFileSystem()}) {
|
MoveCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.')
|
..addOption('id', abbr: 'i', mandatory: true, help: 'Ticket ID.')
|
||||||
..addOption(
|
..addOption(
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import '../ticket_store.dart';
|
||||||
class SearchCommand extends DewCommand with DewToolCommand {
|
class SearchCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
SearchCommand({this._fs = const LocalFileSystem()}) {
|
SearchCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs {
|
||||||
argParser
|
argParser
|
||||||
..addOption(
|
..addOption(
|
||||||
'query',
|
'query',
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import '../ticket_store.dart';
|
||||||
class StatsCommand extends DewCommand with DewToolCommand {
|
class StatsCommand extends DewCommand with DewToolCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
StatsCommand({this._fs = const LocalFileSystem()});
|
StatsCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
|
||||||
@override
|
@override
|
||||||
final String name = 'stats';
|
final String name = 'stats';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -214,7 +214,7 @@ class _Cell {
|
||||||
class TuiCommand extends DewCommand {
|
class TuiCommand extends DewCommand {
|
||||||
final FileSystem _fs;
|
final FileSystem _fs;
|
||||||
|
|
||||||
TuiCommand({this._fs = const LocalFileSystem()});
|
TuiCommand({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
final String name = 'tui';
|
final String name = 'tui';
|
||||||
|
|
@ -410,9 +410,8 @@ class TuiCommand extends DewCommand {
|
||||||
case ControlCharacter.arrowLeft:
|
case ControlCharacter.arrowLeft:
|
||||||
if (p.relationIdx > 0) p.relationIdx--;
|
if (p.relationIdx > 0) p.relationIdx--;
|
||||||
case ControlCharacter.arrowRight:
|
case ControlCharacter.arrowRight:
|
||||||
if (p.relationIdx < _linkRelations.length - 1) {
|
if (p.relationIdx < _linkRelations.length - 1)
|
||||||
p.relationIdx++;
|
p.relationIdx++;
|
||||||
}
|
|
||||||
case ControlCharacter.enter:
|
case ControlCharacter.enter:
|
||||||
try {
|
try {
|
||||||
await store.linkTickets(
|
await store.linkTickets(
|
||||||
|
|
@ -445,14 +444,12 @@ class TuiCommand extends DewCommand {
|
||||||
p.input = p.input.substring(0, p.input.length - 1);
|
p.input = p.input.substring(0, p.input.length - 1);
|
||||||
}
|
}
|
||||||
case ControlCharacter.arrowLeft:
|
case ControlCharacter.arrowLeft:
|
||||||
if (p.kind == _PromptKind.newTitle && p.typeIdx > 0) {
|
if (p.kind == _PromptKind.newTitle && p.typeIdx > 0)
|
||||||
p.typeIdx--;
|
p.typeIdx--;
|
||||||
}
|
|
||||||
case ControlCharacter.arrowRight:
|
case ControlCharacter.arrowRight:
|
||||||
if (p.kind == _PromptKind.newTitle &&
|
if (p.kind == _PromptKind.newTitle &&
|
||||||
p.typeIdx < config.ticketTypes.length - 1) {
|
p.typeIdx < config.ticketTypes.length - 1)
|
||||||
p.typeIdx++;
|
p.typeIdx++;
|
||||||
}
|
|
||||||
case ControlCharacter.enter:
|
case ControlCharacter.enter:
|
||||||
final trimmed = p.input.trim();
|
final trimmed = p.input.trim();
|
||||||
if (p.kind == _PromptKind.linkId) {
|
if (p.kind == _PromptKind.linkId) {
|
||||||
|
|
@ -715,9 +712,8 @@ class TuiCommand extends DewCommand {
|
||||||
case _EditorField.labels:
|
case _EditorField.labels:
|
||||||
if (es.itemCursor < es.labels.length - 1) es.itemCursor++;
|
if (es.itemCursor < es.labels.length - 1) es.itemCursor++;
|
||||||
case _EditorField.milestones:
|
case _EditorField.milestones:
|
||||||
if (es.itemCursor < es.milestones.length - 1) {
|
if (es.itemCursor < es.milestones.length - 1)
|
||||||
es.itemCursor++;
|
es.itemCursor++;
|
||||||
}
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1390,12 +1386,10 @@ class TuiCommand extends DewCommand {
|
||||||
kv('Type', ticket.type);
|
kv('Type', ticket.type);
|
||||||
kv('Column', ticket.column);
|
kv('Column', ticket.column);
|
||||||
kv('Created', _fmtDate(ticket.created));
|
kv('Created', _fmtDate(ticket.created));
|
||||||
if (ticket.milestones.isNotEmpty) {
|
if (ticket.milestones.isNotEmpty)
|
||||||
kv('Milestones', ticket.milestones.join(', '));
|
kv('Milestones', ticket.milestones.join(', '));
|
||||||
}
|
if (ticket.labels.isNotEmpty)
|
||||||
if (ticket.labels.isNotEmpty) {
|
|
||||||
kv('Labels', ticket.labels.map((l) => '#$l').join(' '));
|
kv('Labels', ticket.labels.map((l) => '#$l').join(' '));
|
||||||
}
|
|
||||||
if (ticket.links.isNotEmpty) {
|
if (ticket.links.isNotEmpty) {
|
||||||
for (final link in ticket.links) {
|
for (final link in ticket.links) {
|
||||||
kv(link.type, link.targetId);
|
kv(link.type, link.targetId);
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue