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: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ This project follows [Semantic Versioning 2.0.0](https://semver.org/spec/v2.0.0.
- `start --timeout=<n>` option (default `90`): configures the maximum seconds the VM Service URI scrape loop waits for `flutter run` to print the debug URI in the log file. Previously the deadline was hardcoded to 90 s; cold starts on slow CI machines or after a fresh Flutter SDK install can exceed this limit. Setting `--timeout=120` (or higher) prevents false-timeout failures. The error message now reports the configured value rather than a literal "90s". Applies to the `--cdp-port` branch only; the non-CDP branch retains its own hardcoded deadline.
- `start --cdp-port=<n>` now probes the CDP port for availability BEFORE launching Chrome. When the port is already in use, the command exits 1 immediately with a clear message: "CDP port N is already in use; pass --cdp-port <free-port> or free it before running start." This replaces the previous misleading "Is Chrome installed?" catch-all that fired when Chrome failed to open the debug port it was asked to bind.

### Changed

- `plugin:install`'s `install.yaml` `bootstrap_command` now AUTO-RUNS after a successful manifest install instead of only printing a hint. Once the plugin is registered (`plugins.json` + `lib/app/_plugins.g.dart` regenerated), the declared command is spawned as a fresh dispatcher subprocess (`./bin/fsa <cmd> --non-interactive` when `bin/fsa` exists, else `dart run <consumer>:artisan <cmd> --non-interactive`), so the just-registered plugin command actually executes. `--non-interactive` is always forwarded so an interactive bootstrap (e.g. `starter:install`) cannot hang. `--bootstrap-command=<name>` overrides the manifest value; `--no-bootstrap` skips the auto-run and falls back to the hint. When no dispatcher resolves (no `bin/fsa`, no consumer pubspec name), the one-line `Bootstrap with: artisan <cmd>` hint is printed as before.

### Fixed

- `start --timeout=<n>` now rejects zero and negative values immediately with an actionable error ("--timeout must be a positive integer"), instead of silently passing a non-positive deadline to the VM Service scrape loop and producing a confusing "Timed out after 0s" failure.
Expand Down
17 changes: 16 additions & 1 deletion doc/plugins/install-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,22 @@ post_install:

## bootstrap_command

Optional plugin-specific bootstrap command name run after a Success result. Translates to a single `dart run <consumer>:artisan <name>` invocation deferred to the very end of the manifest flow.
Optional plugin-specific bootstrap command name AUTO-RUN after a Success result. On a non-dry-run install, once the plugin is registered (`plugins.json` + `lib/app/_plugins.g.dart` regenerated), `plugin:install` spawns the declared command as a fresh dispatcher subprocess so the just-registered plugin command actually executes. The chained command is a plugin command that was only just written to `_plugins.g.dart`, so it is not loaded in the current process; a subprocess is the only way to reach it.

Dispatcher resolution:

1. `./bin/fsa <name> --non-interactive` when a `bin/fsa` wrapper exists at the consumer project root (the AOT fast-CLI path).
2. Otherwise `dart run <consumer>:artisan <name> --non-interactive`, deriving the consumer package name from the project's `pubspec.yaml` `name:` field.
3. When neither resolves, `plugin:install` prints the one-line bootstrap hint (`Bootstrap with: artisan <name>`) instead, so the operator can run it manually.

`--non-interactive` is ALWAYS forwarded to the chained command so an interactive bootstrap (e.g. `starter:install`) cannot hang waiting on stdin.

Override and opt-out:

- `--bootstrap-command=<name>` on the `plugin:install` invocation overrides the manifest value (the override command runs instead of the declared one).
- `--no-bootstrap` skips the auto-run entirely; the command falls back to printing the hint.

This was hint-only before `0.0.9`: prior versions only printed `Bootstrap with: artisan <name>` and never executed the command.

```yaml
bootstrap_command: example:install
Expand Down
173 changes: 173 additions & 0 deletions lib/src/commands/helpers/bootstrap_command_runner.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import 'dart:io';

import 'package:path/path.dart' as p;

/// Signature for the [Process.run] seam, injectable in tests.
///
/// Mirrors the subset of [Process.run] parameters that
/// [BootstrapCommandRunner] uses: executable, arguments, and an optional
/// workingDirectory. Tests pass a recording fake so no real subprocess spawns.
typedef ProcessRunner = Future<ProcessResult> Function(
String executable,
List<String> arguments, {
String? workingDirectory,
});

/// Default [ProcessRunner] that delegates to [Process.run].
Future<ProcessResult> _defaultRunner(
String executable,
List<String> arguments, {
String? workingDirectory,
}) {
return Process.run(
executable,
arguments,
workingDirectory: workingDirectory,
);
}

/// Outcome of a [BootstrapCommandRunner.run] attempt.
///
/// - [invoked]: a dispatcher was resolved and the bootstrap command was run
/// as a subprocess (regardless of the chained command's own exit code).
/// - [notResolvable]: no dispatcher could be resolved (no `bin/fsa` wrapper
/// and no consumer package name in `pubspec.yaml`), so nothing was spawned
/// and the caller should fall back to the one-line bootstrap hint.
enum BootstrapRunOutcome {
invoked,
notResolvable,
}

/// Runs a plugin's declared `bootstrap_command` as a fresh dispatcher
/// subprocess after `plugin:install` registers the plugin.
///
/// The bootstrap command is a PLUGIN command that was only just written to
/// `lib/app/_plugins.g.dart` during this same install run, so it is NOT
/// loaded in the current process. A fresh dispatcher invocation is the only
/// way to reach it, hence a subprocess rather than an in-process `handle()`.
///
/// ## Dispatcher resolution
///
/// 1. Prefer `./bin/fsa <command> --non-interactive` when a `bin/fsa` wrapper
/// exists at the consumer project root (the AOT fast-CLI path).
/// 2. Otherwise `dart run <pkg>:artisan <command> --non-interactive`, deriving
/// `<pkg>` (the consumer package name) from the project's `pubspec.yaml`
/// `name:` field.
/// 3. When neither resolves, return [BootstrapRunOutcome.notResolvable] so the
/// caller can fall back to printing the bootstrap hint.
///
/// `--non-interactive` is ALWAYS forwarded so an interactive chained install
/// (e.g. `starter:install`) cannot hang waiting on stdin.
///
/// ## Constructor injection seam
///
/// ```dart
/// // Production: real subprocess
/// final runner = BootstrapCommandRunner();
///
/// // Test: recording fake
/// final runner = BootstrapCommandRunner(processRunner: myFakeRunner);
/// ```
class BootstrapCommandRunner {
/// Creates a runner with an optional [ProcessRunner] override.
///
/// [processRunner] defaults to [Process.run] from `dart:io`. Inject a fake
/// in tests to assert the resolved command without spawning a process.
BootstrapCommandRunner({ProcessRunner? processRunner})
: _runner = processRunner ?? _defaultRunner;

final ProcessRunner _runner;

/// Resolves a dispatcher and runs `<bootstrapCommand> --non-interactive`
/// against [projectRoot].
///
/// @param bootstrapCommand The plugin command to chain (e.g.
/// `starter:install`).
/// @param projectRoot Absolute path to the consumer project root.
/// @return [BootstrapRunOutcome.invoked] when a dispatcher was resolved and
/// the subprocess ran; [BootstrapRunOutcome.notResolvable] when no
/// dispatcher could be resolved.
Future<BootstrapRunOutcome> run({
required String bootstrapCommand,
required String projectRoot,
}) async {
// 1. Build the dispatcher invocation: bin/fsa fast-CLI when present,
// else `dart run <consumer>:artisan`. Return early when neither is
// resolvable so the caller can fall back to the hint.
final invocation = _resolveDispatcher(projectRoot, bootstrapCommand);
if (invocation == null) return BootstrapRunOutcome.notResolvable;

// 2. Run the chained command as a subprocess scoped to the consumer root.
// The chained command's own exit code does not change this outcome:
// "invoked" reports that the auto-run fired, not that it succeeded.
await _runner(
invocation.executable,
invocation.arguments,
workingDirectory: projectRoot,
);
return BootstrapRunOutcome.invoked;
}

/// Resolves the dispatcher executable + argument list, or `null` when no
/// dispatcher is available.
_DispatcherInvocation? _resolveDispatcher(
String projectRoot,
String bootstrapCommand,
) {
const nonInteractive = '--non-interactive';

// bin/fsa fast-CLI wrapper takes precedence when present.
if (File(p.join(projectRoot, 'bin', 'fsa')).existsSync()) {
return _DispatcherInvocation(
executable: './bin/fsa',
arguments: <String>[bootstrapCommand, nonInteractive],
);
}

// Fall back to `dart run <consumer>:artisan` using the pubspec name.
final consumerName = _readConsumerName(projectRoot);
if (consumerName != null) {
return _DispatcherInvocation(
executable: 'dart',
arguments: <String>[
'run',
'$consumerName:artisan',
bootstrapCommand,
nonInteractive,
],
);
}

return null;
}

/// Reads the consumer package name from `<root>/pubspec.yaml` `name:` field.
/// Returns `null` when the pubspec is missing, unreadable, or has no `name:`
/// entry. An unreadable pubspec (permissions, mid-write) is treated as "not
/// resolvable" rather than crashing the post-install bootstrap step.
static String? _readConsumerName(String root) {
final pubspec = File(p.join(root, 'pubspec.yaml'));
if (!pubspec.existsSync()) return null;
final String content;
try {
content = pubspec.readAsStringSync();
} on FileSystemException {
return null;
}
final match = RegExp(r'^name:\s*(\S+)', multiLine: true).firstMatch(
content,
);
return match?.group(1);
}
}

/// Resolved dispatcher executable + argument list.
class _DispatcherInvocation {
const _DispatcherInvocation({
required this.executable,
required this.arguments,
});

final String executable;
final List<String> arguments;
}
84 changes: 74 additions & 10 deletions lib/src/commands/plugin_install_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import '../installer/manifest_installer.dart';
import '../installer/manifest_parser.dart';
import '../installer/plugins_registry_file.dart';
import '../installer/virtual_fs.dart';
import 'helpers/bootstrap_command_runner.dart';
import 'plugins_refresh_command.dart';

/// `plugin:install <name>`, register a third-party artisan plugin into the
Expand Down Expand Up @@ -263,13 +264,70 @@ class PluginInstallCommand extends ArtisanInstallCommand {
nonInteractive: isNonInteractive(ctx),
);

final exit = _renderResultAndExit(ctx, result, manifest: manifest);
final exit = _renderResultAndExit(ctx, result);
if (result is Success && !isDryRun(ctx)) {
await _registerArtisanProvider(ctx, name: manifest.pluginName);
await _runBootstrapCommand(ctx, manifest: manifest);
}
return exit;
}

/// Auto-runs the plugin's declared `bootstrap_command` (or the
/// `--bootstrap-command` override) as a fresh dispatcher subprocess after a
/// successful manifest install.
///
/// The bootstrap command is a PLUGIN command that was only just written to
/// `lib/app/_plugins.g.dart` during this same install run, so it is NOT
/// loaded in the current process. [BootstrapCommandRunner] reaches it by
/// spawning a fresh `./bin/fsa` (or `dart run <consumer>:artisan`)
/// invocation, always forwarding `--non-interactive` so an interactive
/// chained install cannot hang.
///
/// Falls back to the one-line bootstrap hint when the operator passed
/// `--no-bootstrap`, when no command is declared, or when no dispatcher is
/// resolvable (e.g. neither `bin/fsa` nor a consumer pubspec name exists).
Future<void> _runBootstrapCommand(
ArtisanContext ctx, {
required InstallManifest manifest,
}) async {
// 1. The --bootstrap-command override wins over the manifest declaration.
final override = ctx.input.option('bootstrap-command') as String?;
final bootstrap = override ?? manifest.bootstrapCommand;

// 2. Nothing to do when no command is declared or the operator opted out.
if (bootstrap == null) return;
if (isSkipBootstrap(ctx)) {
_emitBootstrapHint(ctx, bootstrap: bootstrap, skipped: true);
return;
Comment thread
anilcancakir marked this conversation as resolved.
}

// 3. Spawn the chained command. This is a best-effort convenience step that
// runs AFTER the install + registration already succeeded, so a failure
// here (missing `dart` on PATH, non-executable `bin/fsa`, working-dir
// issues) must not crash `plugin:install`: catch it, warn, and fall back
// to the manual hint so the operator can still bootstrap by hand.
try {
final outcome = await buildBootstrapRunner().run(
bootstrapCommand: bootstrap,
projectRoot: getProjectRoot(),
);
if (outcome == BootstrapRunOutcome.notResolvable) {
_emitBootstrapHint(ctx, bootstrap: bootstrap, skipped: false);
}
} catch (e) {
ctx.output.warning('Could not auto-run the bootstrap command: $e');
_emitBootstrapHint(ctx, bootstrap: bootstrap, skipped: false);
}
}

/// Builds the [BootstrapCommandRunner] used to spawn the chained bootstrap
/// command. Test subclasses override to inject a recording fake so no real
/// subprocess spawns during unit tests.
///
/// @return A production [BootstrapCommandRunner] wired to [Process.run].
@visibleForTesting
BootstrapCommandRunner buildBootstrapRunner() => BootstrapCommandRunner();

/// Convention-based registration of the plugin's [ArtisanServiceProvider] in
/// `.artisan/plugins.json`, then a synchronous regeneration of
/// `lib/app/_plugins.g.dart` via [PluginsRefreshCommand] so the host's
Expand Down Expand Up @@ -308,13 +366,11 @@ class PluginInstallCommand extends ArtisanInstallCommand {
/// process exit code.
int _renderResultAndExit(
ArtisanContext ctx,
TransactionResult result, {
required InstallManifest manifest,
}) {
TransactionResult result,
) {
switch (result) {
case Success():
ctx.output.success(result.describe());
_emitBootstrapHint(ctx, manifest: manifest);
return 0;
case DryRun():
ctx.output.info(result.describe());
Expand All @@ -328,14 +384,22 @@ class PluginInstallCommand extends ArtisanInstallCommand {
}
}

/// Emits the one-line bootstrap hint when the manifest declares a
/// `bootstrap_command` and the operator did not pass `--no-bootstrap`.
/// Emits the one-line bootstrap hint as the FALLBACK path for the manifest
/// flow: when the operator passed `--no-bootstrap` ([skipped] true), or when
/// auto-run could not resolve a dispatcher ([skipped] false). The happy path
/// auto-runs the command instead via [_runBootstrapCommand].
///
/// @param ctx The active context.
/// @param bootstrap The resolved bootstrap command name.
/// @param skipped `true` when emitted because `--no-bootstrap` was set.
void _emitBootstrapHint(
ArtisanContext ctx, {
required InstallManifest manifest,
required String bootstrap,
required bool skipped,
}) {
final bootstrap = manifest.bootstrapCommand;
if (bootstrap == null || isSkipBootstrap(ctx)) return;
if (skipped) {
ctx.output.info('Skipping plugin bootstrap command (--no-bootstrap).');
}
ctx.output.info(
'Re-run `artisan list` to see new commands. '
'Bootstrap with: artisan $bootstrap',
Expand Down
2 changes: 1 addition & 1 deletion lib/src/installer/artisan_install_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ abstract class ArtisanInstallCommand extends ArtisanCommand {
String get baseFlags => '{--force : Bypass conflict detection} '
'{--dry-run : Print staged ops without writing} '
'{--non-interactive : Skip prompts, use defaults} '
'{--no-bootstrap : Skip post-install hint message} ';
'{--no-bootstrap : Skip auto-running the plugin bootstrap command} ';

/// Install commands run before the Flutter app boots: no VM Service, no
/// connected isolate. The dispatcher constructs a [ArtisanContext.bare].
Expand Down
9 changes: 7 additions & 2 deletions skills/fluttersdk-artisan/references/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ against the freshly-regenerated barrel.

## plugin:install

- **Signature**: `plugin:install <name> [--dry-run] [--force] [--use-yaml-only] [--provider=<C>] [--bootstrap-command=<cmd>]`
- **Signature**: `plugin:install <name> [--dry-run] [--force] [--non-interactive] [--no-bootstrap] [--use-yaml-only] [--provider=<C>] [--bootstrap-command=<cmd>]`
- **Group**: plugin management
- **Boot**: `none`
- **Allowlisted**: no (interactive prompts for destructive ops)
Expand All @@ -247,7 +247,12 @@ Install a plugin. Dispatches in three routing modes
1. **Manifest flow** (preferred): plugin ships `install.yaml` at package
root. Parse, walk `ManifestInstaller`, commit atomically, record at
`.artisan/installed/<plugin>.json`, register in `.artisan/plugins.json`,
regen `lib/app/_plugins.g.dart`.
regen `lib/app/_plugins.g.dart`. When the manifest declares a
`bootstrap_command`, it AUTO-RUNS after registration as a fresh dispatcher
subprocess (`./bin/fsa <cmd> --non-interactive`, or `dart run
<consumer>:artisan <cmd> --non-interactive` when no `bin/fsa`). `--no-bootstrap`
skips the auto-run (falls back to a hint); `--bootstrap-command=<cmd>`
overrides the manifest value.
2. **Magic-free canonical fast path**: no manifest, but
`lib/app/_plugins.g.dart` exists (canonical scaffold from `install`).
Skip legacy injection; write directly to `plugins.json` + refresh.
Expand Down
Loading