Skip to content

feat: [#959] add WithCommandsFilter builder to scope registered Artisan commands#1500

Merged
hwbrzzl merged 4 commits into
masterfrom
feat/optional-artisan-bundle
Jun 20, 2026
Merged

feat: [#959] add WithCommandsFilter builder to scope registered Artisan commands#1500
hwbrzzl merged 4 commits into
masterfrom
feat/optional-artisan-bundle

Conversation

@goravel-coder

Copy link
Copy Markdown
Contributor

Why

Closes goravel/goravel#959. The framework currently registers every Artisan dev command (about, env:, package:, vendor:publish, make:*, etc.) on every binary, including production server-only builds. There was no supported way to scope this down — ./app --help in production revealed dev commands, and the surface was always the same regardless of environment.

What

Introduces a new WithCommandsFilter(func() []string) builder method on ApplicationBuilder. The callback runs once at Build() time and returns the positive list of command signatures to keep. The list is captured on the Application and applied uniformly to both the framework's defaultCommands batch (foundation/application.go:defaultCommands) and the console/console/* batch registered by console.ServiceProvider.Boot.

Matching rules (signature-only — category is never consulted):

  • exact match (no wildcard) — checked against command.Signature()
  • glob match (entry contains *) — checked against command.Signature() via path.Match (* matches any sequence of non-/ characters, ? is a literal character)

Semantics:

Callback returns Result
method not called keep every command (default — no migration)
nil keep every command (no filter applied)
[]string{} drop every command
entries keep only commands whose signature matches

The filter also trims the user-added commands registered via WithCommands(...), so the user cannot bypass the filter by adding commands. The implementation of add first, then filter lives in two places — foundation/application.go:configureCommands filters user-added commands, and console.ServiceProvider.Boot filters its own batch.

User-facing API

return foundation.Setup().
    WithCommandsFilter(func() []string {
        if facades.Config().GetString("app.env") == "production" {
            return []string{
                "up", "down", "key:generate", "about",
                "env:*",     // glob
                "make*",     // glob (matches make, make:controller, make:test, ...)
                "package:*", // glob
                "vendor:publish",
            }
        }
        return nil // dev/staging: keep everything
    }).
    // ...

The user can also drive the list from build tags or ldflags instead of facades.Config() — the framework doesn't care, it just consumes the returned slice.

Files

  • console/application.go — new FilterCommandsByAllowlist helper (signature-only, *-only glob)
  • console/service_provider.go — apply filter to the console/console/* batch via the new Application.ConsoleCommandsFilter() getter
  • foundation/application.go — new consoleCommandsFilter field, Build() capture, defaultCommands and configureCommands apply the filter
  • foundation/application_builder.go — new WithCommandsFilter method
  • contracts/foundation/application.go + application_builder.go — interface extensions
  • mocks/foundation/Application.go + ApplicationBuilder.go — regenerated via go tool mockery
  • Tests added in console/application_test.go, console/service_provider_test.go (new), foundation/application_test.go, foundation/application_builder_test.go

Breaking changes

None. Default behavior is unchanged for every existing app — WithCommandsFilter is opt-in.

Test plan

  • go build ./... — clean
  • go test ./... — all pass (foundation, console, console/console, plus every downstream package that uses mocks/foundation)
  • go vet ./... — clean
  • New tests cover: nil/empty/exact/glob/mixed/question-mark-literal/whitespace-only/category-not-matched/up-down-by-signature, the "add first, then filter" composition between WithCommands and WithCommandsFilter, and the nil/[]string{}/entries distinction on both registration paths.

@goravel-coder
goravel-coder requested a review from a team as a code owner June 20, 2026 03:24
@codecov

codecov Bot commented Jun 20, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.61538% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.57%. Comparing base (d44f2c8) to head (58911c1).

Files with missing lines Patch % Lines
foundation/application.go 33.33% 4 Missing ⚠️
console/service_provider.go 50.00% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1500      +/-   ##
==========================================
+ Coverage   69.43%   69.57%   +0.13%     
==========================================
  Files         378      378              
  Lines       29782    29813      +31     
==========================================
+ Hits        20680    20743      +63     
+ Misses       8147     8114      -33     
- Partials      955      956       +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…an commands

Introduces a parameter-less WithCommandsFilter callback on
ApplicationBuilder that returns the positive list of command
signatures to keep when the framework registers its own Artisan
commands. The list is captured once at Build() time and applied to
both the framework's defaultCommands batch and the
console/console/* batch registered by console.ServiceProvider.

Matching rules (signature-only, no category matching):
  - exact match (no wildcard)  -> command.Signature()
  - glob match (entry contains '*') -> command.Signature() via
    path.Match ('?' is a literal character)

Semantics:
  - method not called           -> keep every command (default)
  - callback returns nil        -> keep every command (no filter)
  - callback returns []string{} -> drop every command
  - callback returns entries    -> keep only matching commands

The filter also trims the user-added commands registered via
WithCommands, so the user cannot bypass the filter by adding
commands.

Closes goravel/goravel#959
@goravel-coder
goravel-coder force-pushed the feat/optional-artisan-bundle branch from 6161684 to ede0de0 Compare June 20, 2026 03:29
Comment thread contracts/foundation/application.go Outdated
Commands([]console.Command)
// ConsoleCommandsFilter returns the captured positive-list of command
// signatures to keep (set via WithCommandsFilter). nil means no filter.
ConsoleCommandsFilter() []string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ConsoleCommandsFilter() []string
CommandsFilter() []string

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to CommandsFilter() throughout the codebase.

Comment thread console/service_provider_test.go Outdated
Comment on lines +66 to +67
app.EXPECT().MakeConfig().Return(configFacade).Maybe()
app.EXPECT().MakeProcess().Return(processFacade).Maybe()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use Maybe? It should be avoided. The same as below.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced .Maybe() with explicit .Once() in all three Boot test functions.

Comment on lines +25 to +45
// WithCommandsFilter registers a callback that returns the positive list of
// command signatures to keep when the framework registers its own Artisan
// commands. The callback runs once at Build() time.
//
// Each entry in the returned slice is matched in one of two ways:
// - Exact match (no wildcard) — checked against command.Signature().
// - Glob match (the entry contains '*') — checked against
// command.Signature() using stdpath.Match. '*' matches any sequence
// of non-'/' characters. '?' is not a wildcard.
//
// Category is never consulted. The filter is signature-only.
//
// Semantics:
// - Method not called → keep every command (default).
// - Callback returns nil → keep every command (no filter).
// - Callback returns []string{} → drop every command (filter, no matches).
// - Callback returns entries → keep only commands whose signature
// matches an entry (exact or glob).
//
// The filter applies to user-added commands from WithCommands as well,
// so the user cannot bypass the filter by adding commands.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplify the annotation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simplified the docstring. Also clarified that only * triggers glob matching and ? is matched literally as an exact match.

Comment thread foundation/application.go Outdated
publishGroups map[string]map[string]string
json foundation.Json
bootedRunners []string
consoleCommandsFilter []string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
consoleCommandsFilter []string
commandsFilter []string

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to commandsFilter.

Comment thread foundation/application_builder.go Outdated
Comment on lines +71 to +98
// WithCommandsFilter registers a callback that returns the positive list of
// command signatures to keep when the framework registers its own Artisan
// commands. The callback runs once at Build() time; the user can call
// facades.Config() inside it to read app.env or any other setting without
// needing a parameter.
//
// Each entry in the returned slice is matched in one of two ways:
//
// - Exact match (no wildcard) — checked against command.Signature().
// - Glob match (the entry contains '*') — checked against
// command.Signature() using stdpath.Match. '*' matches any sequence
// of non-'/' characters. '?' is not a wildcard.
//
// Category is never consulted. The filter is signature-only.
//
// Semantics:
//
// - Method not called → keep every command (default).
// - Callback returns nil → keep every command (no filter).
// - Callback returns []string{} → drop every command (filter, no matches).
// - Callback returns entries → keep only commands whose signature
// matches an entry (exact or glob).
//
// Composition with WithCommands:
//
// WithCommands adds extra commands to the framework's set; WithCommandsFilter
// then trims the combined set. The filter applies to user-added commands
// too, so the user cannot bypass the filter by adding commands.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — simplified in the implementation as well.

Comment thread foundation/application_test.go Outdated
Comment on lines +256 to +267
func (s *ApplicationTestSuite) TestDefaultCommandsUpDownBySignature() {
s.app.SetJson(foundationjson.New())
s.app.Instance(binding.Route, mocksroute.NewRoute(s.T()))
s.app.consoleCommandsFilter = []string{"up", "down"}

commands := s.app.defaultCommands()
got := commandSignatures(commands)

s.Contains(got, "up")
s.Contains(got, "down")
s.NotContains(got, "about")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case is unnecessary, duplicated.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the duplicate test.

Comment thread console/application.go

@goravel-coder goravel-coder left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #1500

Diff: https://critique.work/v/49ae9fcc92287dcc8b95ca92b00f9215

What it does

Adds WithCommandsFilter() to the ApplicationBuilder, allowing users to scope which built-in Artisan commands get registered -- particularly useful for production-only binaries that don't need make:*, package:*, vendor:publish, etc.

What's good

  • Comprehensive tests -- table-driven tests cover nil/empty/whitespace/glob/exact/literal-? cases across both unit (FilterCommandsByAllowlist) and integration-level (defaultCommands, configureCommands, service provider) tests. All pass green.
  • Go vet clean -- no issues.
  • Defensive nil check -- registerCommands now checks artisanFacade == nil before proceeding.
  • Clean API -- WithCommandsFilter(func() []string) is a typical builder pattern, callback delayed to Build() so it can read config/env.
  • Consistent semantics -- nil allowlist = keep all, []string{} = drop all, entries = keep only matches. Documented in both contract and implementation.
  • Filter applies everywhere -- console SP commands, defaultCommands(), and user WithCommands() commands all go through the same gate.

Issues

Medium: Docstring misleads about path.Match semantics

The docstring claims '?' is not a wildcard, which is technically true for this implementation (only * triggers the glob path), but the docstring also says matching uses stdpath.Match -- which does treat ? as a wildcard. A user reading the docs might think ? behaves like path.Match's ?, but it doesn't because the code only routes patterns containing * to path.Match. Patterns with ? but no * go to exact match.

Suggestion: Clarify in the docstring that the glob path is only triggered by * presence, and patterns without * are exact-matched regardless of ? content.

Low: Double Boot of service providers

Boot() (line 92) and Build() (line 106) both call r.providerRepository.Boot(r). This means if a goravel app calls both Boot() and then Build(), console SP's registerCommands runs twice (once without filter, once with filter). This is pre-existing and not introduced by this PR, but worth noting since the filter now adds a behavioral difference between the two calls.

Low: TestServiceProviderBootNilAllowlistRegistersAll uses .Maybe() for config/process

The test at console/service_provider_test.go:57 asserts the console SP registers exactly 4 commands when filter is nil. This is correct, but the test doesn't verify that MakeConfig() and MakeProcess() are actually called -- they use .Maybe() which can match 0 times. This is fine for testing filtering but could mask regressions if the underlying command constructors change their dependencies.

Verdict

Request changes -- The docstring clarification about ? behavior is the main ask. The code and tests themselves are solid.

- Rename ConsoleCommandsFilter() -> CommandsFilter() (contract, impl, tests, mocks)
- Rename consoleCommandsFilter -> commandsFilter (field name)
- Simplify WithCommandsFilter docstrings and clarify ? behavior
- Replace .Maybe() with explicit .Once() in service provider tests
- Remove duplicate TestDefaultCommandsUpDownBySignature test
- Add comment on console.Application.Register clarifying filtering is caller's responsibility
Comment thread foundation/application_builder.go Outdated
commands func() []console.Command
config func()
configuredServiceProviders func() []foundation.ServiceProvider
commandsFilter func() []string

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lint is incorrect.

hwbrzzl added 2 commits June 20, 2026 12:13
Move FilterCommandsByAllowlist into registerCommands() so ALL callers
(defaultCommands, configureCommands, Commands()) go through the same
gate. Remove redundant filtering from defaultCommands() and simplify
configureCommands() to use registerCommands(). Update tests to verify
filtering through the Commands() path.
@hwbrzzl
hwbrzzl merged commit 2059fd0 into master Jun 20, 2026
17 of 19 checks passed
@hwbrzzl
hwbrzzl deleted the feat/optional-artisan-bundle branch June 20, 2026 05:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

make sure the artisan can be opt in bundle output binary.

2 participants