diff --git a/lib/src/cli/cli_runner.dart b/lib/src/cli/cli_runner.dart index 0a6f8107..086ab6b7 100644 --- a/lib/src/cli/cli_runner.dart +++ b/lib/src/cli/cli_runner.dart @@ -220,12 +220,12 @@ USAGE: BOOTSTRAP: setup Create a new Flutter/Dart app with zuraffa deps wired in - init Wire zuraffa dependencies + scaffold a test entity + init Alias of initialize — wire deps + scaffold a test entity CORE COMMANDS: make Canonical architecture/code generation command feature Wrapper over `make --preset=feature` - initialize Alias of init — wire deps + scaffold a test entity + initialize Wire zuraffa dependencies + scaffold a test entity entity Create and manage Zorphy entities config Manage ZFA configuration doctor Check your environment and v5 migration readiness diff --git a/lib/src/commands/initialize_command.dart b/lib/src/commands/initialize_command.dart index 66060b66..e72c31d8 100644 --- a/lib/src/commands/initialize_command.dart +++ b/lib/src/commands/initialize_command.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:args/args.dart'; +import 'package:args/command_runner.dart'; import 'package:path/path.dart' as path; import '../config/zfa_config.dart'; import '../core/dependencies/dependency_wirer.dart'; @@ -9,8 +10,10 @@ import '../utils/string_utils.dart'; class InitializeCommand { static const String fixedEntityOutput = ZfaConfig.fixedEntityOutput; - Future execute(List args) async { - final parser = ArgParser() + /// The parser used by [execute]. Exposed so tests exercise the real parser + /// instead of a duplicated copy. + static ArgParser buildParser() { + return ArgParser() ..addOption( 'entity', abbr: 'e', @@ -54,6 +57,10 @@ class InitializeCommand { negatable: false, ) ..addFlag('help', abbr: 'h', help: 'Show help', negatable: false); + } + + Future execute(List args) async { + final parser = buildParser(); final results = parser.parse(args); @@ -70,8 +77,10 @@ class InitializeCommand { final noDeps = results['no-deps'] as bool; if (depsOnly && noDeps) { - print('❌ --deps-only and --no-deps are mutually exclusive.'); - exit(1); + throw UsageException( + '--deps-only and --no-deps are mutually exclusive.', + parser.usage, + ); } // --- Dependency wiring (issue #275) ----------------------------------- @@ -82,11 +91,11 @@ class InitializeCommand { if (!noDeps) { final pubspecFile = File('pubspec.yaml'); if (!pubspecFile.existsSync()) { - print('❌ No pubspec.yaml found in current directory.'); - print( + throw UsageException( + 'No pubspec.yaml found in current directory.\n' ' Run `zfa setup ` to create a new app, or cd to a project root.', + parser.usage, ); - exit(1); } final pubspecContent = pubspecFile.readAsStringSync(); @@ -106,6 +115,8 @@ class InitializeCommand { '${wireResult.failed.join(', ')}', ); print(' Add them manually and re-run `zfa init`.'); + // Non-zero exit so CI can distinguish a partial wiring. + throw StateError('Some dependencies could not be wired automatically.'); } // Ensure build.yaml + domain directory structure exist. diff --git a/lib/src/commands/setup_command.dart b/lib/src/commands/setup_command.dart index 9456b529..19961ff2 100644 --- a/lib/src/commands/setup_command.dart +++ b/lib/src/commands/setup_command.dart @@ -46,7 +46,7 @@ class SetupCommand extends Command { argParser.addOption( 'org', valueHelp: 'com.example', - help: 'Organization name for `flutter create` (e.g. com.example).', + help: 'Organization name for `flutter create` (e.g. com.example; ignored with --dart).', ); argParser.addFlag( 'dry-run', @@ -57,7 +57,8 @@ class SetupCommand extends Command { 'force', abbr: 'f', negatable: false, - help: 'Overwrite the target directory if it already exists.', + help: 'Delete and recreate the target directory if it already exists ' + '(requires confirmation on a terminal).', ); argParser.addFlag( 'verbose', @@ -111,6 +112,7 @@ class SetupCommand extends Command { // 2. Wire the standard zuraffa dependency set (dart pub add + overrides). print('\n[2/5] Wiring zuraffa dependencies...'); + WireResult? wireResult; if (dryRun) { final missing = DependencyWirer.findMissing( _dryRunPubspec(appName, isFlutter), @@ -121,7 +123,7 @@ class SetupCommand extends Command { print(' • $spec'); } } else { - await DependencyWirer.wire( + wireResult = await DependencyWirer.wire( isFlutter: isFlutter, dryRun: false, projectRoot: appName, @@ -153,6 +155,15 @@ class SetupCommand extends Command { // 5. Summary. print('\n[5/5] Setup complete!'); + if (wireResult != null && !wireResult.isSuccess) { + print( + '\n⚠️ Some dependencies could not be wired automatically: ' + '${wireResult.failed.join(', ')}', + ); + print(' Add them manually to pubspec.yaml and re-run `zfa init`.'); + // Non-zero exit so CI can distinguish a partial bootstrap. + throw StateError('Some dependencies could not be wired automatically.'); + } // Next steps. print('\n── Next steps ──'); @@ -187,6 +198,17 @@ class SetupCommand extends Command { return false; } if (!dryRun) { + final absolutePath = targetDir.absolute.path; + final entryCount = _countEntries(targetDir); + print(' ⚠️ --force will DELETE: $absolutePath ($entryCount entries)'); + if (stdin.hasTerminal) { + stdout.write(' Type "yes" to confirm deletion: '); + final answer = stdin.readLineSync()?.trim().toLowerCase(); + if (answer != 'yes') { + print(' Aborted. Target directory was NOT deleted.'); + return false; + } + } await targetDir.delete(recursive: true); print(' Removed existing $appName (--force)'); } else { @@ -230,6 +252,11 @@ class SetupCommand extends Command { } // Pure Dart package. + if ((platforms != null && platforms.isNotEmpty) || + (org != null && org.isNotEmpty)) { + print(' ⚠️ --platforms/--org are ignored with --dart ' + '(dart create has no equivalent).'); + } final args = ['create', '-t', 'package', appName]; if (dryRun) { print('\n[1/5] Would run: dart ${args.join(" ")}'); @@ -250,6 +277,15 @@ class SetupCommand extends Command { return true; } + /// Counts all files and directories under [dir] (recursively). + static int _countEntries(Directory dir) { + var count = 0; + for (final _ in dir.listSync(recursive: true)) { + count++; + } + return count; + } + /// Minimal pubspec for dry-run preview (so findMissing has something to parse). String _dryRunPubspec(String name, bool isFlutter) { final flutterDep = isFlutter ? ''' diff --git a/lib/src/core/dependencies/dependency_wirer.dart b/lib/src/core/dependencies/dependency_wirer.dart index b37e57d4..c47e9ec3 100644 --- a/lib/src/core/dependencies/dependency_wirer.dart +++ b/lib/src/core/dependencies/dependency_wirer.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:path/path.dart' as path; import 'package:yaml/yaml.dart'; /// Kind of dependency entry in pubspec.yaml. @@ -210,10 +211,8 @@ class DependencyWirer { if (!result.endsWith('\n\n')) { result = '$result\n'; } - return '$result dependency_overrides:\n $key: $value\n'.replaceFirst( - ' dependency_overrides', - 'dependency_overrides', - ); + // Header is written at column zero; no leading-space workaround needed. + return '${result}dependency_overrides:\n $key: $value\n'; } // Walk the existing section looking for the key. @@ -247,7 +246,7 @@ class DependencyWirer { String? projectRoot, }) async { final root = projectRoot ?? Directory.current.path; - final pubspecFile = File('$root/pubspec.yaml'); + final pubspecFile = File(path.join(root, 'pubspec.yaml')); if (!pubspecFile.existsSync()) { print('❌ No pubspec.yaml found in $root'); @@ -259,10 +258,15 @@ class DependencyWirer { final content = pubspecFile.readAsStringSync(); final missing = findMissing(content, isFlutter: isFlutter); + // Names already present before wiring — reported as skipped so + // `WireResult.didNothing` is accurate when everything was already wired. + final skippedNames = standardSet( + isFlutter: isFlutter, + ).where((s) => !missing.contains(s)).map((s) => s.name).toList(); if (missing.isEmpty) { print('✅ All zuraffa dependencies are already present.'); - return const WireResult(); + return WireResult(skipped: skippedNames); } print('🔧 Wiring ${missing.length} missing dependenc${missing.length == 1 ? 'y' : 'ies'}:'); @@ -289,12 +293,15 @@ class DependencyWirer { .where((s) => s.kind == DependencyKind.override) .toList(); - // --- regular / dev deps via `dart pub add` --- + // --- regular / dev deps via `pub add` --- + // Flutter projects must use `flutter pub add`/`flutter pub get`: the + // standalone `dart` executable cannot resolve `sdk: flutter` deps. + final pubExecutable = isFlutter ? 'flutter' : 'dart'; for (final spec in pubAddSpecs) { final args = _buildPubAddArgs(spec); try { final result = await Process.run( - 'dart', + pubExecutable, ['pub', 'add', ...args], workingDirectory: root, ); @@ -322,31 +329,43 @@ class DependencyWirer { spec.name, spec.version ?? '', ); - added.add(spec.name); print(' ✅ Added override:${spec.name}=${spec.version}'); } - await pubspecFile.writeAsString(newContent); + try { + await pubspecFile.writeAsString(newContent); + // Record overrides as added only after the write succeeds. + added.addAll(overrideSpecs.map((s) => s.name)); + } catch (e) { + print( + ' ⚠️ Failed to write dependency_overrides to pubspec.yaml: $e', + ); + failed.addAll(overrideSpecs.map((s) => s.name)); + } // Re-resolve so the override takes effect. try { final getResult = await Process.run( - 'dart', + pubExecutable, ['pub', 'get'], workingDirectory: root, ); if (getResult.exitCode != 0) { final err = getResult.stderr.toString().trim(); if (err.isNotEmpty) { - print(' ⚠️ dart pub get reported issues after override edit:'); + print( + ' ⚠️ $pubExecutable pub get reported issues after override edit:', + ); print(' ${err.split('\n').take(3).join('\n ')}'); } } } catch (e) { // Non-fatal: overrides are written; user can resolve later. - print(' ⚠️ Could not run dart pub get after override edit: $e'); + print( + ' ⚠️ Could not run $pubExecutable pub get after override edit: $e', + ); } } - return WireResult(added: added, failed: failed); + return WireResult(added: added, skipped: skippedNames, failed: failed); } /// Builds the argument list for `dart pub add` from a [DependencySpec]. @@ -414,10 +433,10 @@ targets: final root = projectRoot ?? Directory.current.path; // build.yaml - final buildYaml = File('$root/build.yaml'); + final buildYaml = File(path.join(root, 'build.yaml')); if (!buildYaml.existsSync()) { if (dryRun) { - print(' Would create: $root/build.yaml'); + print(' Would create: ${path.join(root, 'build.yaml')}'); } else { await buildYaml.writeAsString(buildYamlContent); print(' Created: build.yaml'); @@ -426,7 +445,7 @@ targets: // Domain/data directories for (final dir in standardDirs) { - final full = '$root/$dir'; + final full = path.join(root, dir); if (!Directory(full).existsSync()) { if (dryRun) { print(' Would create: $full'); diff --git a/test/commands/setup_command_test.dart b/test/commands/setup_command_test.dart index aa76a06b..ef6c428a 100644 --- a/test/commands/setup_command_test.dart +++ b/test/commands/setup_command_test.dart @@ -1,6 +1,6 @@ import 'package:test/test.dart'; -import 'package:args/args.dart'; import 'package:args/command_runner.dart'; +import 'package:zuraffa/src/commands/initialize_command.dart'; import 'package:zuraffa/src/commands/setup_command.dart'; import 'package:zuraffa/src/core/dependencies/dependency_wirer.dart'; @@ -58,37 +58,37 @@ void main() { group('InitializeCommand flags', () { test('accepts --deps-only flag', () { - final parser = _buildInitializeParser(); + final parser = InitializeCommand.buildParser(); final result = parser.parse(['--deps-only']); expect(result['deps-only'], isTrue); }); test('accepts --no-deps flag', () { - final parser = _buildInitializeParser(); + final parser = InitializeCommand.buildParser(); final result = parser.parse(['--no-deps']); expect(result['no-deps'], isTrue); }); test('--deps-only defaults to false', () { - final parser = _buildInitializeParser(); + final parser = InitializeCommand.buildParser(); final result = parser.parse([]); expect(result['deps-only'], isFalse); }); test('--no-deps defaults to false', () { - final parser = _buildInitializeParser(); + final parser = InitializeCommand.buildParser(); final result = parser.parse([]); expect(result['no-deps'], isFalse); }); test('still accepts legacy --entity option', () { - final parser = _buildInitializeParser(); + final parser = InitializeCommand.buildParser(); final result = parser.parse(['--entity=User']); expect(result['entity'], 'User'); }); test('still accepts --force flag', () { - final parser = _buildInitializeParser(); + final parser = InitializeCommand.buildParser(); final result = parser.parse(['--force']); expect(result['force'], isTrue); }); @@ -111,6 +111,15 @@ void main() { expect(result['flutter'], isTrue); expect(result['dart'], isTrue); }); + + test('run() rejects both --flutter and --dart with UsageException', () async { + final runner = CommandRunner('zfa', 'test') + ..addCommand(SetupCommand()); + await expectLater( + runner.run(['setup', 'myapp', '--flutter', '--dart']), + throwsA(isA()), + ); + }); }); group('DependencyWirer', () { @@ -215,17 +224,4 @@ dependency_overrides: expect(dartNames, isNot(contains('flutter_lints'))); }); }); -} - -/// Builds the same ArgParser that InitializeCommand.execute() uses. -ArgParser _buildInitializeParser() { - return ArgParser() - ..addOption('entity', abbr: 'e', defaultsTo: 'Product') - ..addOption('output', abbr: 'o', defaultsTo: 'lib/src/domain/entities') - ..addFlag('force', abbr: 'f', negatable: false) - ..addFlag('dry-run', negatable: false) - ..addFlag('deps-only', negatable: false) - ..addFlag('no-deps', negatable: false) - ..addFlag('verbose', abbr: 'v', negatable: false) - ..addFlag('help', abbr: 'h', negatable: false); } \ No newline at end of file diff --git a/test/core/dependencies/dependency_wirer_test.dart b/test/core/dependencies/dependency_wirer_test.dart index dad3e1ed..7b6d8bb9 100644 --- a/test/core/dependencies/dependency_wirer_test.dart +++ b/test/core/dependencies/dependency_wirer_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:test/test.dart'; import 'package:zuraffa/src/core/dependencies/dependency_wirer.dart'; @@ -518,5 +520,57 @@ dev_dependencies: expect(DependencyWirer.standardDirs, contains('lib/src/data/repositories')); }); }); + + group('ensureProjectStructure', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('zfa_structure_'); + }); + + tearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + test('creates build.yaml and every standard directory', () async { + await DependencyWirer.ensureProjectStructure(projectRoot: tempDir.path); + + expect(File('${tempDir.path}/build.yaml').existsSync(), isTrue); + for (final dir in DependencyWirer.standardDirs) { + expect( + Directory('${tempDir.path}/$dir').existsSync(), + isTrue, + reason: 'expected $dir to be created', + ); + } + }); + + test('does not overwrite an existing build.yaml', () async { + final buildYaml = File('${tempDir.path}/build.yaml'); + await buildYaml.writeAsString('custom: true\n'); + + await DependencyWirer.ensureProjectStructure(projectRoot: tempDir.path); + + expect(buildYaml.readAsStringSync(), 'custom: true\n'); + }); + + test('dryRun creates nothing', () async { + await DependencyWirer.ensureProjectStructure( + projectRoot: tempDir.path, + dryRun: true, + ); + + expect(File('${tempDir.path}/build.yaml').existsSync(), isFalse); + for (final dir in DependencyWirer.standardDirs) { + expect( + Directory('${tempDir.path}/$dir').existsSync(), + isFalse, + reason: 'expected $dir NOT to be created in dry-run', + ); + } + }); + }); }); }