Raise SDK floor and resolve vault paths from config

This commit is contained in:
Chris Hendrickson 2026-05-04 21:58:26 -04:00
parent 0cd08e78d3
commit f7346b1afe
22 changed files with 184 additions and 95 deletions

1
.tool-versions Normal file
View file

@ -0,0 +1 @@
dart 3.12.0

View file

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

View file

@ -38,7 +38,7 @@ The TUI auto-refreshes when ticket files change on disk, so it stays in sync whe
dart pub global activate dew
```
Requires Dart SDK ^3.11.4.
Requires Dart SDK ^3.12.0.
## Quick start

View file

@ -10,6 +10,9 @@ your-project/
└── dew.yaml
```
Path-like values in `dew.yaml` are resolved relative to `.project/dew.yaml`
unless they are absolute (for example, paths under `dew.vault`).
## Full Schema
```yaml

View file

@ -6,7 +6,7 @@ issue_tracker: https://github.com/artificerchris/dew/issues
resolution: workspace
environment:
sdk: ^3.11.4
sdk: ^3.12.0
dependencies:
args: ^2.7.0

View file

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:file/file.dart';
import 'package:file/local.dart';
import 'package:path/path.dart' as p;
@ -34,16 +36,42 @@ class ProjectContext {
final String root;
final DewConfig config;
final FileSystem fs;
final String configFilePath;
const ProjectContext({
required this.root,
required this.config,
required this.fs,
required this.configFilePath,
});
/// Typed path helpers for this project's well-known directories.
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
/// `.project/dew.yaml` is found.
static Future<ProjectContext> find({
@ -54,11 +82,13 @@ class ProjectContext {
while (true) {
final configFile = fs.file(p.join(dir.path, '.project', 'dew.yaml'));
if (await configFile.exists()) {
final path = configFile.path;
final yaml = loadYaml(await configFile.readAsString()) as YamlMap;
return ProjectContext(
root: dir.path,
config: DewConfig.fromYaml(yaml),
fs: fs,
configFilePath: path,
);
}
final parent = dir.parent;
@ -72,3 +102,15 @@ class ProjectContext {
}
}
}
String _expandTilde(String input) {
if (!input.startsWith('~')) return input;
final home = Platform.environment['HOME'] ??
Platform.environment['USERPROFILE'] ??
'';
if (home.isEmpty) return input;
if (input.length == 1) return home;
final rest = input.substring(1);
if (rest.startsWith('/')) return p.join(home, rest.substring(1));
return p.join(home, rest);
}

View file

@ -6,7 +6,7 @@ issue_tracker: https://github.com/artificerchris/dew/issues
resolution: workspace
environment:
sdk: ^3.11.4
sdk: ^3.12.0
# Add regular dependencies here.
dependencies:

View file

@ -72,5 +72,25 @@ dew:
final ctx = await ProjectContext.find(fs: fs, from: fs.directory('/sub'));
expect(ctx.root, '/');
});
test('resolveConfigPath resolves paths relative to .project/dew.yaml', () async {
final fs = MemoryFileSystem();
fs.directory('/foo/.project').createSync(recursive: true);
fs.file('/foo/.project/dew.yaml').writeAsStringSync(configYaml);
final ctx = await ProjectContext.find(fs: fs, from: fs.directory('/foo/.project/child'));
expect(
ctx.resolveConfigPath('vault'),
'/foo/.project/vault',
);
expect(
ctx.resolveConfigPath('.project/vault'),
'/foo/.project/vault',
);
expect(
ctx.resolveConfigPath('/tmp/abs'),
'/tmp/abs',
);
});
});
}

View file

@ -6,7 +6,7 @@ issue_tracker: https://github.com/artificerchris/dew/issues
resolution: workspace
environment:
sdk: ^3.11.4
sdk: ^3.12.0
# Add regular dependencies here.
dependencies:

View file

@ -6,7 +6,7 @@ issue_tracker: https://github.com/artificerchris/dew/issues
resolution: workspace
environment:
sdk: ^3.11.4
sdk: ^3.12.0
# Add regular dependencies here.
dependencies:

View file

@ -41,8 +41,8 @@ class DeleteCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs);
final config = context.config.vault;
final store = VaultStore(
storageDir: resolveProjectPath(context.root, config.storageDir),
passwordFilePath: resolveProjectPath(context.root, config.passwordFile),
storageDir: context.resolveConfigPath(config.storageDir),
passwordFilePath: context.resolveConfigPath(config.passwordFile),
fs: context.fs,
);
await store.delete(secretName);

View file

@ -37,8 +37,8 @@ class GetCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs);
final config = context.config.vault;
final store = VaultStore(
storageDir: resolveProjectPath(context.root, config.storageDir),
passwordFilePath: resolveProjectPath(context.root, config.passwordFile),
storageDir: context.resolveConfigPath(config.storageDir),
passwordFilePath: context.resolveConfigPath(config.passwordFile),
fs: context.fs,
);

View file

@ -46,8 +46,8 @@ class VaultInitCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs);
final store = VaultStore(
storageDir: resolveProjectPath(context.root, storageDir),
passwordFilePath: resolveProjectPath(context.root, passwordFile),
storageDir: context.resolveConfigPath(storageDir),
passwordFilePath: context.resolveConfigPath(passwordFile),
fs: context.fs,
);
await store.initialize();

View file

@ -33,8 +33,8 @@ class ListCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs);
final config = context.config.vault;
final store = VaultStore(
storageDir: resolveProjectPath(context.root, config.storageDir),
passwordFilePath: resolveProjectPath(context.root, config.passwordFile),
storageDir: context.resolveConfigPath(config.storageDir),
passwordFilePath: context.resolveConfigPath(config.passwordFile),
fs: context.fs,
);
final names = await store.listSecretNames();

View file

@ -46,8 +46,8 @@ class RenameCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs);
final config = context.config.vault;
final store = VaultStore(
storageDir: resolveProjectPath(context.root, config.storageDir),
passwordFilePath: resolveProjectPath(context.root, config.passwordFile),
storageDir: context.resolveConfigPath(config.storageDir),
passwordFilePath: context.resolveConfigPath(config.passwordFile),
fs: context.fs,
);
await store.rename(from, to);

View file

@ -38,8 +38,8 @@ class RotateCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs);
final config = context.config.vault;
final store = VaultStore(
storageDir: resolveProjectPath(context.root, config.storageDir),
passwordFilePath: resolveProjectPath(context.root, config.passwordFile),
storageDir: context.resolveConfigPath(config.storageDir),
passwordFilePath: context.resolveConfigPath(config.passwordFile),
fs: context.fs,
);

View file

@ -46,8 +46,8 @@ class SetCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs);
final config = context.config.vault;
final store = VaultStore(
storageDir: resolveProjectPath(context.root, config.storageDir),
passwordFilePath: resolveProjectPath(context.root, config.passwordFile),
storageDir: context.resolveConfigPath(config.storageDir),
passwordFilePath: context.resolveConfigPath(config.passwordFile),
fs: context.fs,
);

View file

@ -55,8 +55,8 @@ class UpdateCommand extends DewCommand with DewToolCommand {
final context = await ProjectContext.find(fs: _fs);
final config = context.config.vault;
final store = VaultStore(
storageDir: resolveProjectPath(context.root, config.storageDir),
passwordFilePath: resolveProjectPath(context.root, config.passwordFile),
storageDir: context.resolveConfigPath(config.storageDir),
passwordFilePath: context.resolveConfigPath(config.passwordFile),
fs: context.fs,
);

View file

@ -6,7 +6,7 @@ issue_tracker: https://github.com/artificerchris/dew/issues
resolution: workspace
environment:
sdk: ^3.11.4
sdk: ^3.12.0
dependencies:
dew_core: ^0.1.0

View file

@ -48,8 +48,7 @@ void main() {
final tools = registry.mcpTools.map((t) => t.name).toSet();
expect(
tools,
containsAll(
{
containsAll({
'vault_init',
'vault_set_secret',
'vault_get_secret',
@ -59,8 +58,7 @@ void main() {
'vault_generate_secret',
'vault_list_secrets',
'vault_delete_secret',
},
),
}),
);
});
});
@ -151,11 +149,17 @@ void main() {
);
expect(initResult['message'], 'Vault initialised.');
expect(initResult['initialized'], isTrue);
expect(initResult['password_file'], '/.project/secrets/dew.vault.password');
expect(
initResult['password_file'],
'/.project/secrets/dew.vault.password',
);
expect(initResult['storage_dir'], '/.project/vault');
expect(await fs.directory('/.project/vault').exists(), isTrue);
expect(await fs.file('/.project/secrets/dew.vault.password').exists(), isTrue);
expect(
await fs.file('/.project/secrets/dew.vault.password').exists(),
isTrue,
);
});
test('init command accepts custom password and storage paths', () async {
@ -168,18 +172,25 @@ void main() {
);
expect(
customInit['password_file'],
'/.custom/secrets/custom.vault.password',
'/.project/.custom/secrets/custom.vault.password',
);
expect(customInit['storage_dir'], '/.project/.custom/vault-store');
expect(
await fs.directory('/.project/.custom/vault-store').exists(),
isTrue,
);
expect(
customInit['storage_dir'],
'/.custom/vault-store',
await fs
.file('/.project/.custom/secrets/custom.vault.password')
.exists(),
isTrue,
);
expect(await fs.directory('/.custom/vault-store').exists(), isTrue);
expect(await fs.file('/.custom/secrets/custom.vault.password').exists(), isTrue);
});
test('set command accepts metadata from file', () async {
fs.file('/meta.json').writeAsStringSync(
fs
.file('/meta.json')
.writeAsStringSync(
'{"rotation":{"generator":"postgres_password","length":16},"notes":"from-file"}',
);
await tools['vault_set_secret']!.handler({
@ -242,7 +253,9 @@ void main() {
});
test('update command accepts metadata file', () async {
fs.file('/meta-update.json').writeAsStringSync(
fs
.file('/meta-update.json')
.writeAsStringSync(
'{"rotation":{"generator":"postgres_password","length":24},"notes":"file-metadata"}',
);
await tools['vault_set_secret']!.handler({
@ -289,7 +302,10 @@ void main() {
expect(listResult['secrets'], isNot(contains('LEGACY_KEY')));
await expectLater(
tools['vault_get_secret']!.handler({'name': 'LEGACY_KEY', 'format': 'json'}),
tools['vault_get_secret']!.handler({
'name': 'LEGACY_KEY',
'format': 'json',
}),
throwsA(isA<ArgumentError>()),
);
@ -320,7 +336,9 @@ void main() {
);
});
test('rotate command uses rotation metadata and can rotate vault password', () async {
test(
'rotate command uses rotation metadata and can rotate vault password',
() async {
await tools['vault_set_secret']!.handler({
'name': 'service_db_password',
'file': '/seed.txt',
@ -364,7 +382,8 @@ void main() {
);
expect(allRotated['scope'], 'vault');
expect(allRotated['rotated_count'], 2);
});
},
);
});
group('vault crypto helpers', () {
@ -373,10 +392,14 @@ void main() {
const value = 'very-sensitive';
final encoded = VaultCrypto.encryptToEnvelope(value, password: password);
expect(encoded['version'], 1);
final decoded = VaultCrypto.decryptFromEnvelope(encoded, password: password);
final decoded = VaultCrypto.decryptFromEnvelope(
encoded,
password: password,
);
expect(decoded, value);
expect(
() => VaultCrypto.decryptFromEnvelope(encoded, password: 'bad-password'),
() =>
VaultCrypto.decryptFromEnvelope(encoded, password: 'bad-password'),
throwsA(isA<ArgumentError>()),
);
});

View file

@ -618,4 +618,4 @@ packages:
source: hosted
version: "2.2.4"
sdks:
dart: ">=3.11.4 <4.0.0"
dart: ">=3.12.0 <4.0.0"

View file

@ -5,7 +5,7 @@ repository: https://github.com/artificerchris/dew
publish_to: none
environment:
sdk: ^3.11.4
sdk: ^3.12.0
workspace:
- packages/cli
- packages/core