feat(vault): scaffold vault package and command registration

This commit is contained in:
Chris Hendrickson 2026-05-03 13:13:27 -04:00
parent 07e8c98c7c
commit 1281bd4092
22 changed files with 766 additions and 3 deletions

169
docs/features/vault.md Normal file
View file

@ -0,0 +1,169 @@
# Dew Vault Secret Manager
Dew Vault is a secret manager for your projects. It helps you keep secrets out of
version control by storing each secret as an encrypted file 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 (for example `postgres_password`) to a built-in
generator definition. Values under `config` are defaults and can be overridden per
run or stored 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 the vault storage and metadata.
```bash
dew vault init
dew vault init --password-file .project/secrets/dew.vault.password
```
### List all secrets
List stored 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 <secret-name> # Prompts for secret value
dew vault set <secret-name> --env ENV_VAR_NAME # Uses value from environment variable
dew vault set <secret-name> --file /path/to/secret.txt # Uses value from file
echo "secret value" | dew vault set <secret-name> # Uses piped stdin
# Include metadata for automated rotation requirements
dew vault set DB_PASSWORD --metadata '{"rotation":{"enabled":true,"generator":"postgres_password","length":64}}'
dew vault set DB_PASSWORD --metadata-file .project/vault/db_password.meta.json
```
### Get a secret
`get` retrieves a secret by name.
```bash
dew vault get <secret-name>
dew vault get <secret-name> --format json
```
### Update a secret
`update` patches secret metadata and/or value. Omit value source flags to edit
metadata only.
```bash
dew vault update <secret-name> --env ROLLED_PASSWORD
dew vault update <secret-name> --metadata '{"rotation":{"enabled":false}}'
dew vault update <secret-name> --metadata-file .project/vault/db_password.meta.json
```
### Rename a secret
`rename` changes a secret identifier while preserving value and metadata.
```bash
dew vault rename OLD_NAME NEW_NAME
dew vault rename OLD_NAME NEW_NAME --format json
```
### Generate a secret value
`generate` runs a built-in generator without writing to the vault by default.
```bash
dew vault generate postgres_password --length 64 --include_symbols
dew vault generate jwt_secret --bytes 64 --encoding base64
dew vault generate postgres_password --service payments --username app_user --format json
```
Pipe generated output directly into `set` when needed:
```bash
dew vault generate postgres_password --service payments | dew vault set DB_PASSWORD
```
### 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. For a secret with rotation
metadata (`rotation.enabled: true` and `rotation.generator`), Dew invokes that
configured built-in generator using the provided rotation values.
```bash
dew vault rotate
dew vault rotate <secret-name>
dew vault rotate <secret-name> --format json
```
### Delete a secret
`delete` removes a secret and metadata from the vault.
```bash
dew vault delete <secret-name>
dew vault delete <secret-name> --format json
```
### Metadata format for rotation-aware secrets
Attach arbitrary metadata and include rotation policy details. Example shape:
```json
{
"rotation": {
"enabled": true,
"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 the secret metadata.
3. Run `dew vault rotate <secret-name>` to rotate one secret, or `dew vault rotate`
to rotate all configured secrets.

View file

@ -2,11 +2,13 @@ import 'package:args/command_runner.dart';
import 'package:dew_core/dew_core.dart';
import 'package:dew_kanban/dew_kanban.dart' as kanban;
import 'package:dew_mcp/dew_mcp.dart' as mcp;
import 'package:dew_vault/dew_vault.dart' as vault;
Future<void> main(List<String> args) async {
final commandRegistry = CommandRegistry();
kanban.registerCommands(commandRegistry);
vault.registerCommands(commandRegistry);
mcp.registerCommands(commandRegistry);
final runner = CommandRunner<void>('dew', 'A project management tool.');

View file

@ -12,6 +12,7 @@ dependencies:
args: ^2.7.0
dew_core: ^0.1.0
dew_kanban: ^0.1.0
dew_vault: ^0.1.0
dew_mcp: ^0.1.0
dev_dependencies:

View file

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

View file

@ -0,0 +1,9 @@
# Changelog
## 0.2.0 — 2026-05-03
Initial feature stub package for vault support.
- Added `dew vault` command registration and MCP tool wiring.
- Added vault subcommands for init/list/get/set/update/rename/rotate/generate/delete.
- Added `VaultInitHook` scaffold for `.project/vault` and `.project/secrets`.

21
packages/vault/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 artificery-dev
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.

16
packages/vault/README.md Normal file
View file

@ -0,0 +1,16 @@
# dew_vault
Vault feature package for the [Dew](https://github.com/artificerchris/dew) project
management tool.
This package provides the `dew vault` command surface and registers Vault commands
as MCP tools through `DewToolCommand`.
## Status
This package is currently a scaffold/stub to establish the command and MCP
wiring. It does not yet implement full encrypted secret storage or rotation logic.
## License
MIT — see [LICENSE](LICENSE).

View file

@ -0,0 +1,19 @@
library;
export 'src/dew_vault_base.dart';
import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:dew_vault/src/dew_vault_base.dart';
import 'package:dew_vault/src/vault_init_hook.dart';
/// Registers all Vault commands and init hooks into [registry].
void registerCommands(
CommandRegistry registry, {
FileSystem fs = const LocalFileSystem(),
}) {
registry.register(VaultCommand());
registry.registerInitHook(VaultInitHook(fs: fs));
}

View file

@ -0,0 +1,30 @@
import 'dart:convert';
String renderVaultOutput({
String format = 'default',
required String message,
Map<String, dynamic>? json,
}) {
if (format == 'json') {
final payload = <String, dynamic>{
'message': message,
if (json != null) ...json,
};
return const JsonEncoder.withIndent(' ').convert(payload);
}
return message;
}
String requireStringArg(Map<String, dynamic> args, String key) {
final value = args[key];
if (value == null || value.toString().trim().isEmpty) {
throw ArgumentError('Missing required argument: --$key');
}
return value.toString();
}
String formatFromArgs(Map<String, dynamic> args) {
final value = args['format'];
if (value == null || value.toString().trim().isEmpty) return 'default';
return value.toString();
}

View file

@ -0,0 +1,42 @@
import 'package:dew_core/dew_core.dart';
import '../command_output.dart';
class DeleteCommand extends DewCommand with DewToolCommand {
DeleteCommand() {
argParser
..addOption(
'name',
abbr: 'n',
mandatory: true,
help: 'Secret name.',
)
..addOption(
'format',
defaultsTo: 'default',
allowed: ['default', 'json'],
help: 'Output format for this command.',
);
}
@override
final String name = 'delete';
@override
final String description = 'Delete a secret.';
@override
final String toolName = 'vault_delete_secret';
@override
Future<String> callAsTool(Map<String, dynamic> args) async {
final format = formatFromArgs(args);
final secretName = requireStringArg(args, 'name');
return renderVaultOutput(
format: format,
message: 'Delete stub executed.',
json: {'secret': secretName},
);
}
}

View file

@ -0,0 +1,51 @@
import 'package:dew_core/dew_core.dart';
import '../command_output.dart';
class GenerateCommand extends DewCommand with DewToolCommand {
GenerateCommand() {
argParser
..addOption(
'generator',
abbr: 'g',
mandatory: true,
help: 'Generator ID.',
)
..addMultiOption(
'arg',
help: 'Generator option as key=value. Repeat as needed.',
)
..addOption(
'format',
defaultsTo: 'default',
allowed: ['default', 'json'],
help: 'Output format for this command.',
);
}
@override
final String name = 'generate';
@override
final String description = 'Generate a new secret value using a built-in generator.';
@override
final String toolName = 'vault_generate_secret';
@override
Future<String> callAsTool(Map<String, dynamic> args) async {
final format = formatFromArgs(args);
final generator = requireStringArg(args, 'generator');
final overrides = args['arg'] as List<dynamic>?;
return renderVaultOutput(
format: format,
message: 'Generate stub output.',
json: {
'generator': generator,
'options': overrides == null ? const <String>[] : overrides,
'value': '<generated>',
},
);
}
}

View file

@ -0,0 +1,37 @@
import 'package:dew_core/dew_core.dart';
import '../command_output.dart';
class GetCommand extends DewCommand with DewToolCommand {
GetCommand() {
argParser
..addOption('name', abbr: 'n', mandatory: true, help: 'Secret name.')
..addOption(
'format',
defaultsTo: 'default',
allowed: ['default', 'json'],
help: 'Output format for this command.',
);
}
@override
final String name = 'get';
@override
final String description = 'Get a secret value.';
@override
final String toolName = 'vault_get_secret';
@override
Future<String> callAsTool(Map<String, dynamic> args) async {
final format = formatFromArgs(args);
final secretName = requireStringArg(args, 'name');
return renderVaultOutput(
format: format,
message: 'Get stub value: [redacted].',
json: {'secret': secretName},
);
}
}

View file

@ -0,0 +1,51 @@
import 'package:dew_core/dew_core.dart';
import '../command_output.dart';
class VaultInitCommand extends DewCommand with DewToolCommand {
VaultInitCommand() {
argParser
..addOption(
'password-file',
abbr: 'p',
defaultsTo: '.project/secrets/dew.vault.password',
help: 'Path to the vault password file to record in config.',
)
..addOption(
'storage-dir',
defaultsTo: '.project/vault',
help: 'Directory where encrypted secret files are stored.',
)
..addOption(
'format',
defaultsTo: 'default',
allowed: ['default', 'json'],
help: 'Output format for this command.',
);
}
@override
final String name = 'init';
@override
final String description = 'Initialise vault directories and defaults.';
@override
final String toolName = 'vault_init';
@override
Future<String> callAsTool(Map<String, dynamic> args) async {
final format = formatFromArgs(args);
final passwordFile = requireStringArg(args, 'password-file');
final storageDir = requireStringArg(args, 'storage-dir');
return renderVaultOutput(
format: format,
message: 'Vault init stub completed.',
json: {
'password_file': passwordFile,
'storage_dir': storageDir,
'initialized': true,
},
);
}
}

View file

@ -0,0 +1,34 @@
import 'package:dew_core/dew_core.dart';
import '../command_output.dart';
class ListCommand extends DewCommand with DewToolCommand {
ListCommand() {
argParser.addOption(
'format',
defaultsTo: 'default',
allowed: ['default', 'json'],
help: 'Output format for this command.',
);
}
@override
final String name = 'list';
@override
final String description = 'List vault secrets.';
@override
final String toolName = 'vault_list_secrets';
@override
Future<String> callAsTool(Map<String, dynamic> args) async {
final format = formatFromArgs(args);
return renderVaultOutput(
format: format,
message: 'No secrets found (stubbed vault).',
json: {'secrets': <String>[], 'count': 0},
);
}
}

View file

@ -0,0 +1,47 @@
import 'package:dew_core/dew_core.dart';
import '../command_output.dart';
class RenameCommand extends DewCommand with DewToolCommand {
RenameCommand() {
argParser
..addOption(
'from',
mandatory: true,
help: 'Current secret name.',
)
..addOption(
'to',
mandatory: true,
help: 'New secret name.',
)
..addOption(
'format',
defaultsTo: 'default',
allowed: ['default', 'json'],
help: 'Output format for this command.',
);
}
@override
final String name = 'rename';
@override
final String description = 'Rename a secret while preserving value and metadata.';
@override
final String toolName = 'vault_rename_secret';
@override
Future<String> callAsTool(Map<String, dynamic> args) async {
final format = formatFromArgs(args);
final from = requireStringArg(args, 'from');
final to = requireStringArg(args, 'to');
return renderVaultOutput(
format: format,
message: 'Rename stub executed.',
json: {'from': from, 'to': to},
);
}
}

View file

@ -0,0 +1,38 @@
import 'package:dew_core/dew_core.dart';
import '../command_output.dart';
class RotateCommand extends DewCommand with DewToolCommand {
RotateCommand() {
argParser
..addOption('name', help: 'Secret name to rotate; omit to rotate vault password.')
..addOption(
'format',
defaultsTo: 'default',
allowed: ['default', 'json'],
help: 'Output format for this command.',
);
}
@override
final String name = 'rotate';
@override
final String description =
'Rotate vault password or a single secret value (stub).';
@override
final String toolName = 'vault_rotate_secret';
@override
Future<String> callAsTool(Map<String, dynamic> args) async {
final format = formatFromArgs(args);
final target = args['name']?.toString();
return renderVaultOutput(
format: format,
message: 'Rotate stub executed.',
json: {'target': target ?? '<all>', 'scope': target == null ? 'vault' : 'secret'},
);
}
}

View file

@ -0,0 +1,52 @@
import 'package:dew_core/dew_core.dart';
import '../command_output.dart';
class SetCommand extends DewCommand with DewToolCommand {
SetCommand() {
argParser
..addOption(
'name',
abbr: 'n',
mandatory: true,
help: 'Secret name.',
)
..addOption('env', help: 'Use value from an environment variable.')
..addOption('file', help: 'Use value from file path.')
..addOption('metadata', help: 'JSON object to save as metadata.')
..addOption('metadata-file', help: 'Path to JSON metadata file.')
..addOption(
'format',
defaultsTo: 'default',
allowed: ['default', 'json'],
help: 'Output format for this command.',
);
}
@override
final String name = 'set';
@override
final String description = 'Set a secret value and optional metadata.';
@override
final String toolName = 'vault_set_secret';
@override
Future<String> callAsTool(Map<String, dynamic> args) async {
final format = formatFromArgs(args);
final secretName = requireStringArg(args, 'name');
final source =
args['env'] ?? args['file'] ?? args['metadata'] ?? args['metadata-file'];
return renderVaultOutput(
format: format,
message: 'Set stub executed.',
json: {
'secret': secretName,
'source': source == null ? 'interactive' : source.toString(),
'status': 'set',
},
);
}
}

View file

@ -0,0 +1,50 @@
import 'package:dew_core/dew_core.dart';
import '../command_output.dart';
class UpdateCommand extends DewCommand with DewToolCommand {
UpdateCommand() {
argParser
..addOption(
'name',
abbr: 'n',
mandatory: true,
help: 'Secret name.',
)
..addOption('env', help: 'Use value from an environment variable.')
..addOption('file', help: 'Use value from file path.')
..addOption('metadata', help: 'JSON object to save as metadata.')
..addOption('metadata-file', help: 'Path to JSON metadata file.')
..addOption(
'format',
defaultsTo: 'default',
allowed: ['default', 'json'],
help: 'Output format for this command.',
);
}
@override
final String name = 'update';
@override
final String description = 'Update a secret value and/or metadata.';
@override
final String toolName = 'vault_update_secret';
@override
Future<String> callAsTool(Map<String, dynamic> args) async {
final format = formatFromArgs(args);
final secretName = requireStringArg(args, 'name');
final mode = args['env'] != null || args['file'] != null ? 'value-update' : 'metadata-only';
return renderVaultOutput(
format: format,
message: 'Update stub executed.',
json: {
'secret': secretName,
'mode': mode,
},
);
}
}

View file

@ -0,0 +1,35 @@
import 'package:dew_core/dew_core.dart';
import 'commands/delete_command.dart';
import 'commands/generate_command.dart';
import 'commands/get_command.dart';
import 'commands/init_command.dart';
import 'commands/list_command.dart';
import 'commands/rename_command.dart';
import 'commands/rotate_command.dart';
import 'commands/set_command.dart';
import 'commands/update_command.dart';
/// Top-level CLI command for all Vault operations.
class VaultCommand extends DewCommand {
VaultCommand() {
addSubcommand(VaultInitCommand());
addSubcommand(ListCommand());
addSubcommand(SetCommand());
addSubcommand(GetCommand());
addSubcommand(UpdateCommand());
addSubcommand(RenameCommand());
addSubcommand(RotateCommand());
addSubcommand(GenerateCommand());
addSubcommand(DeleteCommand());
}
@override
final String name = 'vault';
@override
final String description = 'Manage project secrets with a local vault.';
@override
Future<void> run() async => printUsage();
}

View file

@ -0,0 +1,39 @@
import 'package:dew_core/dew_core.dart';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:path/path.dart' as p;
class VaultInitHook implements DewInitHook {
final FileSystem _fs;
VaultInitHook({FileSystem fs = const LocalFileSystem()}) : _fs = fs;
@override
Future<void> onInit(
String projectRoot,
DewConfig config,
DewInitOptions options,
) async {
final vaultDir = p.join(projectRoot, '.project', 'vault');
final secretDir = p.join(projectRoot, '.project', 'secrets');
await _createDir(vaultDir, options.gitkeep);
await _createDir(secretDir, options.gitkeep);
}
Future<void> _createDir(String path, bool withGitkeep) async {
final dir = _fs.directory(path);
final existed = await dir.exists();
await dir.create(recursive: true);
final relPath = p.join('.project', p.basename(path));
if (existed) {
print(' found $relPath/');
} else {
print(' created $relPath/');
if (withGitkeep) {
await _fs.file(p.join(path, '.gitkeep')).writeAsString('');
print(' created $relPath/.gitkeep');
}
}
}
}

View file

@ -0,0 +1,18 @@
name: dew_vault
description: Vault feature package for the Dew project management tool.
version: 0.2.0
repository: https://github.com/artificerchris/dew
issue_tracker: https://github.com/artificerchris/dew/issues
resolution: workspace
environment:
sdk: ^3.11.4
dependencies:
dew_core: ^0.2.0
file: ^7.0.1
path: ^1.9.0
dev_dependencies:
lints: ^6.0.0
test: ^1.25.6

View file

@ -6,12 +6,12 @@ publish_to: none
environment:
sdk: ^3.11.4
workspace:
- packages/cli
- packages/core
- packages/kanban
- packages/mcp
- packages/vault
dev_dependencies:
file: ^7.0.1