Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Standard Flutter commands (`flutter test`, `dart analyze`, `dart format .`, `flu
| `flutter test test/<module>` | Single module |
| `dart pub publish --dry-run` | Pre-release validation |
| `dart run <app>:artisan magic:install` | One-shot consumer bootstrap (hybrid installer) |
| `dart run <app>:artisan make:model User -mcf` | Generators: 14 `make:*` + `key:generate` |
| `dart run <app>:artisan make:model User -mcf` | Generators: 20 `make:*` + `key:generate` |

Magic's CLI is an `fluttersdk_artisan` plugin (`MagicArtisanProvider`); a consumer runs it through its own artisan dispatcher (`dart run <app>:artisan <cmd>`), not as a global activate. There is no standalone magic executable beyond the artisan plugin surface.

Expand All @@ -67,7 +67,7 @@ lib/
├── http/ # MagicController, middleware pipeline, Kernel
├── concerns/ # ValidatesRequests mixin (import from here, NOT http/)
├── localization/ logging/ routing/ support/ validation/ ui/
└── cli/ # magic:install + 14 make:* generators on fluttersdk_artisan
└── cli/ # magic:install + 20 make:* generators on fluttersdk_artisan
```

## Testing rule that catches everyone
Expand Down
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,22 @@ jobs:
with:
files: coverage/lcov.info
fail_ci_if_error: false

generated-code-analyzes:
name: Generated code analyzes
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v7

- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
channel: stable
cache: true

- name: Install dependencies
run: flutter pub get

- name: Run every make:* generator and analyze its output
run: flutter test --tags integration --run-skipped
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,32 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### BREAKING

- **`make:controller`'s plain and `--resource` stubs, and `make:view`'s stateful stub, changed shape.** The resource controller stub dropped its own hand-rolled CRUD/state scaffolding in favour of the `RepositoryQuery`-backed read state `MakeControllerCommand` now assembles per flag (`--actions`, `--broadcasts`, `--timers`, `--validates`, plus a `resetForSession()` implementing `SessionScoped`); the plain controller stub gained the same `SessionScoped` shape with an empty `resetForSession()`. `make:view --stateful` now writes a `MagicStatefulView<<Name>Controller>` instead of a plain `StatefulWidget`, so the derived `<Name>Controller` has to exist (or be named with `--controller=<Name>`) for the view to compile; the new `--list` adds `RefetchesOnMount` over a `--resource` controller, and `--form=<FormObject>` adds a State-owned form object disposed in `onClose`. A controller or stateful view generated before this change keeps its old shape until regenerated; regenerating with `--force` overwrites any hand-edited body, so review the diff first. (`lib/src/cli/commands/make_controller_command.dart`, `lib/src/cli/commands/make_view_command.dart`, `assets/stubs/controller.stub`, `assets/stubs/controller.resource.stub`, `assets/stubs/view.stateful.stub`)
- **`make:component` no longer always scaffolds the preview file and chains `previews:refresh`.** It now writes `<name>.preview.dart` only when the target project already maintains a preview catalogue (any `*.preview.dart` file or a `_previews.g.dart` index under `lib/`); pass `--preview` or `--no-preview` to force the direction explicitly. A project with no preview catalogue yet used to get one scaffolded regardless. (`lib/src/cli/commands/make_component_command.dart`)
- **Exporting `ActionRequestFailed` from `lib/magic.dart` conflicts with an app class of the same name until the app imports magic's.** Dart resolves a bare `ActionRequestFailed` reference in a file that neither declares nor imports one of its own to the export from `package:magic/magic.dart`; an app that already declares its own `ActionRequestFailed` sees an ambiguous-import error the moment both land in scope, and must rename its own class or import magic's explicitly instead. (`lib/magic.dart`, `lib/src/actions/action_request_failed.dart`)

### Added

- **`references/plugin-sentry.md`, the skill's page for `magic_sentry` 0.0.1.** The ecosystem plugin table, the reference index and the skill's trigger line now name the package, and the page covers the boot order (`MagicSentry.run` before `Magic.init`), what `SentryServiceProvider.boot()` wires, how `SentryNetworkInterceptor` sorts an HTTP failure into an event or a breadcrumb, the scope user, `ReportsBreadcrumb` events, and the published `.env` config. (`skills/magic-framework/`)
- **`make:resource`, the artisan generator composing a full CRUD vertical for a model.** Magic's analogue of Laravel's `make:model --all`: model and factory, repository, the create/update/delete actions, the Store and Update requests, the resource form object, a `--resource --actions` controller, and the list and form views, plus the tests for the actions, the form and the controller. Every file comes from its own owning generator through `RunChild`; the run preflights every target path first, so a clash anywhere (other than a kept model or factory) fails the whole run with nothing written unless `--force` is passed. `--no-model` leaves the model and factory out (an existing model and factory are kept either way); `--no-views` stops after the data and write layers. The index and create route lines print for `RouteServiceProvider.boot()`, never write into it. A new integration test (tagged `integration`, run in CI as its own job) generates every `make:*` output into a scratch Flutter project and requires `flutter analyze` to report zero issues. (`lib/src/cli/commands/make_resource_command.dart`, `test/cli/integration/generated_code_analyzes_test.dart`, `dart_test.yaml`, `.github/workflows/ci.yml`, `skills/magic-framework/`)
- **`make:test --kind=<kind>`, the artisan generator for a test skeleton mirroring another `make:*` generator's own output.** Covers `controller`, `action`, `form`, `repository`, `request`, `view` and `unit`, resolving the target project's package name from its own `pubspec.yaml` so the generated test imports app code as `package:<name>/...` rather than a relative `../lib/` path (which trips `avoid_relative_lib_imports`). `make:controller`, `make:view`, `make:action`, `make:form`, `make:repository` and `make:request` now accept `--test`, chaining `make:test` onto a successful write so the matching test lands in the same run; `--force` on the host forwards to the chained test too, and a refused test write fails the host with exit 1. `make:component` now also writes a widget test for the component, importing its barrel with a prefix so a component named like a Material widget (`Badge`, `Card`) stays unambiguous; a test already at that path fails the run before anything is written unless `--force` is passed. (`lib/src/cli/commands/make_test_command.dart`, `lib/src/cli/commands/make_component_command.dart`, `lib/src/cli/helpers/creates_matching_test.dart`, `lib/src/cli/helpers/run_child.dart`, `skills/magic-framework/`)
- **`make:action --kind=create|update|delete --model=<Model>`, the write variants of the plain `make:action` generator.** `create` fills and saves a new model, `update` saves an edit and writes it into `<Model>Repository`, `delete` deletes and evicts it; a refused save throws `ActionRequestFailed`. `--kind` requires `--model`. (`lib/src/cli/commands/make_action_command.dart`, `assets/stubs/action.create.stub`, `assets/stubs/action.update.stub`, `assets/stubs/action.delete.stub`, `skills/magic-framework/`)
- **`make:form --resource=<Model>`, the full create/edit variant of the plain `make:form` generator.** Scaffolds an `editing` field, `initial` seeded from `editing?.toArray()`, a `request` choosing between the model's Store/Update requests, and a `persist` running the matching Create/Update action (a create then reloads the `--resource` `<Model>Controller`, so it expects the controller `make:resource` writes), instead of the plain skeleton's TODO placeholders. (`lib/src/cli/commands/make_form_command.dart`, `assets/stubs/form.resource.stub`, `skills/magic-framework/`)
- **`ActionRequestFailed`, the exception a refused `MagicAction` write throws.** `ActionRequestFailed.refusalOf(action, errors, [response])` answers a `ValidationException` (first message per field) when `errors` carries any, otherwise an `ActionRequestFailed` carrying the refusing response; `retryAfterSeconds` reads a 429 body's `retry_after_seconds`, falling back to 1. (`lib/src/actions/action_request_failed.dart`, `doc/basics/actions.md`, `skills/magic-framework/`)
- **`make:model`'s generated stub now includes a static `fromMap(Map<String, dynamic> map)` factory.** Hydrates the model directly via `setRawAttributes`, bypassing the `fillable` mass-assignment guard (unlike `fill`), and sets `exists` from whether the map carries an `id` key, matching how a factory or a repository already builds a model from raw API data. (`assets/stubs/model.stub`, `skills/magic-framework/`)
- **`make:enum --wire`, a wire-backed variant of the plain `make:enum` generator.** Adds an `unknown` fallback case, a `fromWire()` factory that never throws on an unrecognised value, and a `trans()`-backed `label` getter, for an enum mirroring a backend string value. (`lib/src/cli/commands/make_enum_command.dart`, `assets/stubs/enum.wire.stub`, `skills/magic-framework/`)
- **`make:lang --from=<locale>`, copying an existing language file's key tree into a new one.** Defaults to `en`; when `assets/lang/<from>.json` exists, the new locale's file carries the same keys with each leaf copied verbatim (a complete catalogue ready for a human translator) instead of an empty `{}`. A source that is not valid JSON fails with exit 1 and writes nothing. (`lib/src/cli/commands/make_lang_command.dart`, `skills/magic-framework/`)

### Changed

- **`make:request` now writes a `FormRequest` subclass with a `const` constructor and a `rules()` override, and accepts `--test`.** The old stub was a plain class wrapping `Validator.make`, which neither `MagicFormObject.request` nor `ValidatesRequests.validateRequest` accepts. (`lib/src/cli/commands/make_request_command.dart`, `assets/stubs/request.stub`, `skills/magic-framework/`)
- **`make:model --all` also writes the model's repository, and passes `--model=<Model>` to the resource controller it chains**, since that controller now reads through `<Model>Repository`. (`lib/src/cli/commands/make_model_command.dart`)

### Fixed

- **`make:model` now exits 1 when the model file already exists without `--force`, instead of logging the clash and generating the requested companions (migration, factory, seeder, policy, controller) against it anyway.** (`lib/src/cli/commands/make_model_command.dart`)

## [0.0.22] - 2026-09-27

Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Standard Flutter commands (`flutter test`, `dart analyze`, `dart format .`, `flu
| `flutter test test/<module>` | Single module |
| `dart pub publish --dry-run` | Pre-release validation |
| `dart run magic:artisan magic:install` | One-shot consumer bootstrap (hybrid installer) |
| `dart run magic:artisan make:model User -mcf` | Generators: 14 `make:*` + `key:generate` |
| `dart run magic:artisan make:model User -mcf` | Generators: 20 `make:*` + `key:generate` |

Magic ships an `artisan` executable (`pubspec.yaml` `executables: { artisan: }`, backed by `bin/artisan.dart`), so a consumer runs any command with `dart run magic:artisan <cmd>` once `magic` is a dependency: no global activate, no app-specific package name. `MagicArtisanProvider` supplies the commands; it also plugs into a consumer app's own aggregated artisan dispatcher when one exists.

Expand All @@ -71,7 +71,7 @@ lib/
├── http/ # MagicController, middleware pipeline, Kernel
├── concerns/ # ValidatesRequests mixin (import from here, NOT http/)
├── localization/ logging/ routing/ support/ validation/ ui/
└── cli/ # magic:install + 14 make:* generators on fluttersdk_artisan
└── cli/ # magic:install + 20 make:* generators on fluttersdk_artisan
```

## Testing rule that catches everyone
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ The same Facades, the same Eloquent syntax, the same Service Provider lifecycle
| 📡 | **Broadcasting** | Laravel Echo equivalent: real-time WebSocket channels via the `Echo` facade with presence support and `Echo.fake()`. |
| 🔄 | **Offline Sync** | `SyncFeed` runs a push-then-pull loop over any REST resource, bookmarked by `SyncLedger`'s `sync_cursors` table (`CreateSyncCursorsTable`). |
| 🧪 | **Testing** | First-class fakes: `Http.fake()`, `Auth.fake()`, `Cache.fake()`, `Vault.fake()`, `Log.fake()`, `Echo.fake()`. No mockito needed. |
| 🧰 | **Magic CLI** | Artisan-style scaffolding via `dart run magic:artisan make:model`, `make:controller`, 15 generators, plus `make:component` for design-first component workflows and `design:sync` / `design:lint` to drive the Wind theme from a `DESIGN.md`. |
| 🧰 | **Magic CLI** | Artisan-style scaffolding via `dart run magic:artisan make:model`, `make:controller`, 20 `make:*` generators (including `make:resource`, which composes a full CRUD vertical: model, repository, actions, requests, form, controller, views and tests), plus `make:component` for design-first component workflows and `design:sync` / `design:lint` to drive the Wind theme from a `DESIGN.md`. |

## A taste of Magic

Expand Down
24 changes: 24 additions & 0 deletions assets/stubs/action.create.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import 'package:magic/magic.dart';

{{ modelImport }}

/// {{ className }}
///
/// Creates a [{{ modelName }}] from an already-validated field map (see
/// [MagicAction]). A refused save throws [ActionRequestFailed] carrying the
/// field errors from `validationErrors`.
class {{ className }} extends MagicAction<Map<String, dynamic>, {{ modelName }}> {
const {{ className }}();

@override
Future<{{ modelName }}> handle(Map<String, dynamic> fields) async {
final {{ modelName }} {{ modelVariable }} = {{ modelName }}()
..fill(fields, strict: true);
if (await {{ modelVariable }}.save()) return {{ modelVariable }};

throw ActionRequestFailed.refusalOf(
'create',
{{ modelVariable }}.validationErrors,
);
}
}
22 changes: 22 additions & 0 deletions assets/stubs/action.delete.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import 'package:magic/magic.dart';

{{ modelImport }}
{{ repositoryImport }}

/// {{ className }}
///
/// Deletes a [{{ modelName }}] through the ORM and evicts it from
/// [{{ repositoryName }}], so every screen reading the inventory loses the
/// row at once.
class {{ className }} extends MagicAction<{{ modelName }}, void> {
const {{ className }}();

@override
Future<void> handle({{ modelName }} {{ modelVariable }}) async {
final bool deleted = await {{ modelVariable }}.delete();
if (!deleted) throw ActionRequestFailed('delete {{ modelIdInterpolation }}');

// The repository keys rows by the id's string form, whatever its type.
{{ repositoryName }}.instance.evict('{{ modelIdInterpolation }}');
}
}
36 changes: 36 additions & 0 deletions assets/stubs/action.update.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import 'package:magic/magic.dart';

{{ modelImport }}
{{ repositoryImport }}

/// {{ className }}
///
/// Saves an edit to a [{{ modelName }}] from an already-validated field map
/// and writes it into [{{ repositoryName }}]. Answers the saved
/// {{ modelName }}, or null when it no longer resolves (no field to flag, so
/// nothing is written). A refused save throws
/// [ActionRequestFailed] carrying the field errors from `validationErrors`.
class {{ className }}
extends MagicAction<({String id, Map<String, dynamic> fields}), {{ modelName }}?> {
const {{ className }}();

@override
Future<{{ modelName }}?> handle(
({String id, Map<String, dynamic> fields}) input,
) async {
final {{ modelName }}? {{ modelVariable }} = await {{ modelName }}.find(input.id);
if ({{ modelVariable }} == null) return null;

{{ modelVariable }}.fill(input.fields, strict: true);
if (await {{ modelVariable }}.save()) {
// Every screen reading the cached row sees the edit without a refetch.
{{ repositoryName }}.instance.upsertFromShow({{ modelVariable }});
return {{ modelVariable }};
}

throw ActionRequestFailed.refusalOf(
'update ${input.id}',
{{ modelVariable }}.validationErrors,
);
}
}
41 changes: 41 additions & 0 deletions assets/stubs/component_test.stub
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:magic/magic.dart';
// Prefixed: a component may share its name with a Material widget (Badge,
// Card, Chip), and an unprefixed import would make every use ambiguous.
import 'package:{{ packageName }}/ui/components/{{ snakeName }}/index.dart'
as component;

/// Widget test for [component.{{ className }}], scaffolded by `make:component`.
void main() {
/// Wraps [widget] in a [MaterialApp] with a default [WindTheme] so
/// W-widgets can resolve Wind styles without a running Magic app.
Widget wrap(Widget widget) {
return MaterialApp(
home: WindTheme(data: WindThemeData(), child: Scaffold(body: widget)),
);
}

testWidgets('{{ className }} renders its child', (tester) async {
await tester.pumpWidget(
wrap(component.{{ className }}(child: const Text('{{ className }}'))),
);

expect(find.text('{{ className }}'), findsOneWidget);
});

testWidgets("{{ className }} applies its recipe's base className", (
tester,
) async {
await tester.pumpWidget(
wrap(component.{{ className }}(child: const Text('{{ className }}'))),
);

final divs = tester.widgetList<WDiv>(find.byType(WDiv));
expect(
divs.any((w) => w.className?.contains('flex') ?? false),
isTrue,
reason: "no WDiv carries {{ className }}Recipe()'s base className",
);
});
}
Loading
Loading