PluginInstaller Fluent DSL
The PluginInstaller class is the procedural escape hatch for plugin install logic that the
declarative install.yaml manifest cannot express.
Table of Contents
- When to Use
- Two Phases: IMMEDIATE vs DEFERRED
- DSL Method Reference
- Atomic Commit Semantics
- Example
- Related
When to Use
Prefer install.yaml for straightforward installs. Use the procedural DSL when:
- Complex conditional logic: the install plan branches on runtime values (platform detection, user input, env vars) that YAML conditions cannot evaluate at parse time.
- Branching prompts: the set of operations depends on answers collected during the install interaction (e.g. "Firebase or Amplitude?" selects different dependency blocks).
- Programmatic file generation: installed files are assembled from captured prompt answers rather than fixed stub templates.
When none of these apply, install.yaml + ManifestInstaller is simpler and preferred.
Two Phases: IMMEDIATE vs DEFERRED
Every chain method falls into one of two categories. This split determines whether a method's
effect is visible during the chain or only after commit().
IMMEDIATE
IMMEDIATE methods run synchronously the moment they appear in the chain. They do not enqueue an
InstallOperation; they produce a side effect right now.
| Method | Effect |
|---|---|
ask(...) |
Drives InstallContext.prompt.ask(...); stores the answer in _vars |
confirm(...) |
Drives InstallContext.prompt.confirm(...); stores 'true' or 'false' |
choice(...) |
Drives InstallContext.prompt.choice(...); stores the selected option |
startWith(hook) |
Registers a pre-commit callback (fires before op dispatch, every outcome) |
endWith(hook) |
Registers a post-Success callback (skipped on DryRun/Conflict/Error) |
Captured answers are readable via installer.vars['key'] immediately, allowing the chain to
branch on user input before enqueuing ops.
DEFERRED
DEFERRED methods append an InstallOperation to the internal queue. Nothing touches the
filesystem until commit(). Ops execute in enqueue order.
All add*, inject*, write*, delete*, copy*, publish*, merge*, wrap*, and
runShell methods are DEFERRED.
Hybrid: askToRunShell drives its prompt IMMEDIATELY but enqueues RunShell only when the
user confirms, so the shell command still executes deferredly during commit.
DSL Method Reference
All methods return this (chainable) unless noted otherwise.
Prompts
| Method | Description |
|---|---|
ask(varName:, question:, [defaultValue:, validator:]) |
Free-text prompt; stores answer in vars[varName] |
confirm(varName:, question:, [defaultValue:]) |
Yes/no prompt; stores 'true' or 'false' in vars[varName] |
choice(varName:, question:, options:, [defaultValue:]) |
Pick-one-of prompt; stores selected option string in vars[varName] |
Pubspec Operations
| Method | Operation | Description |
|---|---|---|
addDependency(name, version) |
AddDependency |
Adds a runtime dependency to pubspec.yaml |
addDevDependency(name, version) |
AddDependency(isDev: true) |
Adds a dev dependency to pubspec.yaml |
addPathDependency(name, path) |
AddPathDependency |
Adds a relative-path dependency to pubspec.yaml |
removeDependency(name) |
RemoveDependency |
Removes a dependency from pubspec.yaml (idempotent) |
addPubspecAsset(assetPath) |
AddPubspecAsset |
Appends an asset path to flutter.assets in pubspec.yaml (idempotent) |
File Operations
| Method | Operation | Description |
|---|---|---|
publishConfig(stubName:, targetPath:, [replacements:]) |
PublishFile |
Loads a stub, applies token replacements, writes to targetPath |
writeFile(targetPath:, content:) |
WriteFile |
Writes raw programmatic content to targetPath |
deleteFile(targetPath) |
DeleteFile |
Deletes targetPath if it exists (idempotent) |
copyFile(sourcePath:, targetPath:) |
CopyFile |
Copies sourcePath to targetPath |
mergeJson(targetPath:, sourceData:, [additive:]) |
MergeJson |
Deep-merges sourceData into the JSON file at targetPath |
mergeJson defaults to additive mode (existing keys are preserved). Pass additive: false to
allow source values to overwrite conflicting target keys.
Injection Operations
| Method | Operation | Description |
|---|---|---|
injectImport(targetFile:, importStatement:) |
InjectImport |
Appends an import line to any Dart file (idempotent) |
injectBefore(targetFile:, pattern:, code:, [fallbackPattern:]) |
InjectBeforePattern |
Inserts code before the first match of pattern in targetFile |
injectAfter(targetFile:, pattern:, code:, [fallbackPattern:]) |
InjectAfterPattern |
Inserts code after the first match of pattern in targetFile |
injectMainDartImport(importStatement) |
InjectMainDartImport |
Appends an import to lib/main.dart specifically (grouped in dry-run output) |
injectBeforeMagicInit(code) |
InjectIntoMainDart(beforeInit) |
Inserts code before Magic.init(...) in lib/main.dart |
injectAfterMagicInit(code) |
InjectIntoMainDart(afterInit) |
Inserts code after Magic.init(...) in lib/main.dart |
wrapRunApp(wrapperName) |
InjectIntoMainDart(wrapRunApp) |
Wraps the runApp(...) argument with the named widget constructor |
injectProvider(providerClassName, [package:]) |
composite | Adds import + appends (app) => X(app), to lib/config/app.dart providers list |
injectConfigFactory(factoryName, [package:]) |
composite | Adds import + appends () => XConfig, to lib/main.dart configFactories list |
injectRoute(registerFunctionName) |
InjectRouteRegistration |
Calls registerFunctionName() in RouteServiceProvider.boot() |
injectProvider and injectConfigFactory each enqueue two operations (one InjectImport + one
InjectAfterPattern). The regex starts at the list's own key ('providers': [,
configFactories: [) and scans forward to the last entry before ], with ] excluded from the
gap so the scan cannot leave the list it opened.
That anchor is load-bearing. () => \w+, before a ] describes any zero-argument closure that is
last in any list, and firstMatch scans by position, so without the key in front of it an earlier
list anywhere in lib/main.dart won and the factory was appended to that list instead. An empty
configFactories: [] was the same defect wearing a second face: the primary matched a later list's
last entry, so the fallback that exists for the empty case never ran.
The lookahead skips a // comment between the last entry and the ]: one trailing the entry on the
same line ((app) => AppServiceProvider(app), // core), and comment-only or blank lines below it,
which is where a scaffold placeholder lives (// add plugin providers here). A /* block */ comment
there is a miss and falls to the fallback. It cannot skip a real entry, since every line it consumes
must be whitespace or a // comment through to the newline.
The providers key takes either quote (['"]providers['"]). Anchoring on the key is what made its
quoting matter at all, and prefer_single_quotes makes the double-quoted spelling uncommon rather
than impossible.
injectProvider accepts both spellings of the providers list entry, because the type annotation is
optional in Dart and both are in use: (app) => XServiceProvider(app), and
(MagicApp app) => XServiceProvider(app),.
A pattern injection that matches nothing fails the install, before anything is written. The
transaction checks every InjectBeforePattern and InjectAfterPattern against its target file ahead
of the stage loop and returns Error naming every offending op. Before this they wrote nothing and
the install reported Success, so a plugin whose pattern did not fit the host's file registered nothing
and said nothing about it.
The check is a preflight rather than an in-loop failure because helper-backed ops write through
dart:io during staging, outside the .tmp rollback, and the install record plugin:uninstall reads
is written later still. Failing mid-loop would leave an orphan import with nothing recorded to reverse
it.
An idempotent skip, where the code is already present, still counts as resolvable, so a re-run does not fail on work it has already done.
fallbackPattern keeps an empty list installable. Both pattern ops take an optional second
pattern, tried only when the primary matches nothing. The append-to-a-list shape anchors on the last
entry before the closing bracket and so cannot match an empty list; the fallback anchors on the
opening bracket instead. A single regex with an alternation cannot do this: firstMatch scans by
position, the opening bracket always comes first, and every injection would land at the top of a
populated list.
It anchors the insertion rather than narrowing it to the empty case. A populated list whose last entry the primary does not describe falls through to the fallback too, and the code then lands at the top of that list instead of the end: correct Dart, wrong position. When you meet that, widen the primary rather than accept the fallback, which is what the trailing-comment tolerance above is.
install.yaml has no pattern-injection op at all (see the manifest schema), so fallbackPattern is
a property of the Dart DSL only. The install record persists pattern through toString() and
neither pattern op has a reverse in V1, so plugin:uninstall logs [skipped] for both and reads
neither field.
Android Operations
| Method | Operation | Description |
|---|---|---|
injectAndroidPermission(permission) |
InjectAndroidPermission |
Adds to AndroidManifest.xml; silently skipped on non-Android consumers |
injectAndroidMetaData(name:, value:) |
InjectAndroidMetaData |
Adds inside in AndroidManifest.xml |
injectAndroidActivity(name:, exported:, [taskAffinity:], [intentFilters:]) |
InjectAndroidActivity |
Adds an with its elements inside in AndroidManifest.xml; idempotent by content (see below) |
injectGradlePlugin(pluginId:, [version:]) |
InjectGradlePlugin |
Adds a plugin entry to the plugins { } block in build.gradle.kts |
injectGradleDependency(scope:, notation:) |
InjectGradleDependency |
Adds a dependency under scope in android/app/build.gradle.kts |
iOS and macOS Operations
| Method | Operation | Description |
|---|---|---|
injectInfoPlistKey(key:, value:, [platform:]) |
InjectInfoPlistKey |
Sets a key in ios/Runner/Info.plist or macos/Runner/Info.plist; value may be String, bool, or List |
injectInfoPlistUrlScheme(scheme:, [platform:]) |
InjectInfoPlistUrlScheme |
Registers a custom URL scheme under CFBundleURLTypes in ios/Runner/Info.plist or macos/Runner/Info.plist; scheme is written without the :// suffix |
injectEntitlement(platform:, key:, value:) |
InjectEntitlement |
Sets a key in every entitlements file the application target signs with; when the project names none, writes AND CODE_SIGN_ENTITLEMENTS into ; platform is 'ios' or 'macos'; value may be String, bool or List |
injectPodfileLine([platform:], line:) |
InjectPodfileLine |
Appends a CocoaPods pod declaration to the target 'Runner' Podfile block |
Platform-scoped ops are silently skipped when the target platform directory is absent.
injectAndroidActivity compares content
injectAndroidActivity takes the activity's name and exported, an optional taskAffinity (''
renders an empty affinity, null omits the attribute) and a list of AndroidIntentFilter, each with
autoVerify, actions, categories and a list of AndroidIntentData (scheme, host, path,
pathPrefix). AndroidIntentFilter and AndroidIntentData come from
package:fluttersdk_artisan/artisan.dart.
installer.injectAndroidActivity(
name: 'com.linusu.flutter_web_auth_2.CallbackActivity',
exported: true,
taskAffinity: '',
intentFilters: [
AndroidIntentFilter(
autoVerify: true,
actions: ['android.intent.action.VIEW'],
categories: [
'android.intent.category.DEFAULT',
'android.intent.category.BROWSABLE',
],
data: [
AndroidIntentData(
scheme: 'https',
host: 'auth.example.com',
path: '/social/callback',
),
],
),
],
);
The manifest is parsed to decide, and the element is spliced in as text just before the real
(a closing tag spelled inside an XML comment is ignored), so every other byte of
the file keeps its bytes. The write is helper-backed, so the transaction's .tmp rollback does not
cover it.
| Manifest state | Result |
|---|---|
No with this android:name under |
The element is inserted |
One exists with an equal set of intent filters (same autoVerify, actions, categories and attributes, in any order) |
No-op |
| One exists with a different set | Left alone, never rewritten; the install finishes and warns with the block it expected so you can reconcile it by hand |
exported and taskAffinity are not part of the comparison. A carrying an attribute other
than scheme, host, path or pathPrefix compares as different.
injectInfoPlistUrlScheme
Appends one CFBundleURLTypes dict (CFBundleTypeRole = Editor, one CFBundleURLSchemes entry)
beside the existing ones, creating the array when the plist has none. When ANY existing dict already
lists the scheme the call is a no-op and the file is not rewritten. A CFBundleURLTypes value that is
not an fails the install.
injectEntitlement writes to the files Xcode signs with
Xcode reads an entitlements plist only when the application target's CODE_SIGN_ENTITLEMENTS build
setting names it. The op therefore asks the project which files the application target (the target
whose productType is com.apple.product-type.application, never the test bundle, never the
project-level defaults) already signs with, one per distinct CODE_SIGN_ENTITLEMENTS value across its
build configurations, and sets the key in each of them. The build settings stay untouched.
- A split project, a Debug and a Release configuration signing against different files, gets the key
in both. A macOS Flutter project (
Runner/DebugProfile.entitlementsplusRunner/Release.entitlements) is this case. - A project whose configurations name no entitlements file at all gets
written AND the application target pointed at it through/Runner/Runner.entitlements CODE_SIGN_ENTITLEMENTS. That second write edits./Runner.xcodeproj/project.pbxproj - A
Listvalue is merged into the array the file already carries, entry by entry, so entries another plugin or the app put there survive. A value of any other type fails the install with anError.
All of these writes are helper-backed, so none is covered by the transaction's .tmp rollback. Plan
for that when you stage this op.
Only the last case, pointing the target at a new file, can leave the build setting alone. It prints a warning naming what to set by hand and lets the install finish instead of aborting on top of the writes that already landed:
- The project has no
at all, so there is nothing to point at./Runner.xcodeproj - A configuration holds a non-string
CODE_SIGN_ENTITLEMENTS, which the reader cannot resolve to a file and the editor will not overwrite. - The reader read the project and declined to edit it. Two shapes reach this. The re-emission does
not match byte for byte, so it refuses to write a file it cannot reproduce exactly: Xcode escapes
non-ASCII in that file as
\Uxxxx, and an accentedPRODUCT_NAMEis enough. Or the project is readable but not addressable: no application target at all, two applications and neither namedRunner, or a target carrying no build configurations. Both shapes answer to the same remedy, which is why they warn alike: the file is one Xcode can open, so setting the build setting there by hand works.
A project.pbxproj that is not a valid project file at all still fails the install with an Error,
and the project file is left untouched in every one of these cases.
Web Operations
| Method | Operation | Description |
|---|---|---|
injectIntoWebHead(content) |
InjectIntoWebHead |
Inserts raw HTML before in web/index.html; skipped on non-web consumers |
addWebMetaTag(attributes) |
AddWebMetaTag |
Adds a element with the given attribute map to web/index.html |
Environment Operations
| Method | Operation | Description |
|---|---|---|
injectEnvVar(key:, value:, [comment:]) |
InjectEnvVar |
Writes KEY=value to .env; creates the file when absent; an optional comment is written as # above the key line |
Shell Operations
| Method | Phase | Description |
|---|---|---|
runShell(command:, [args:, workingDir:]) |
DEFERRED | Enqueues a RunShell op that executes after all file mutations have landed |
askToRunShell(prompt:, command:, [args:]) |
HYBRID | Prompts immediately; enqueues RunShell only when the user confirms |
Shell ops execute after all file mutations have landed. Non-zero exit surfaces as Error; the
install record from the preceding phase stays on disk so plugin:uninstall can reverse file
mutations independently.
Lifecycle Hooks
| Method | Phase | Description |
|---|---|---|
startWith(hook) |
IMMEDIATE (registration); fires pre-commit | void Function(InstallContext) invoked before op dispatch, on every outcome |
endWith(hook) |
IMMEDIATE (registration); fires post-Success only | Invoked after Success; never fires on DryRun, Conflict, or Error |
Atomic Commit Semantics
commit() delegates to InstallTransaction.commit() (see
lib/src/installer/install_transaction.dart:132-234), which executes in seven phases:
- Dry-run short-circuit (line 144):
dryRun: truerenders staged ops and returnsDryRunwithout touching disk. - Conflict pre-flight (line 152): detects user-modified target files.
force: truebypasses. - In-memory staging (line 162): ops are reduced into
Mapwherenullmarks a delete. No disk writes yet. - Atomic
.tmpwrites (line 173): each non-null entry is written to. If any write throws, all successful temps are deleted and the method returns.tmp Error(rolledBack: true). - Rename into place (line 193):
.tmpfiles are renamed over their targets; deletes are applied. POSIXrename(2)is atomic, so readers never see partial state. Failures past this point surface asError(rolledBack: false). - Install record (line 213):
.artisan/installed/is written BEFORE shell ops so the install is always reversible even when a shell step fails later..json - Shell ops (line 228):
RunShellops execute last. A non-zero exit returnsError; the record from phase 6 stays intact.
One-shot guard
PluginInstaller._committed flips to true at the start of commit(). A second call throws
StateError regardless of the first call's outcome. Construct a fresh PluginInstaller per
install pass.
V1 reversibility
plugin:uninstall reverses WriteFile, DeleteFile, and CopyFile (hash-verified). Injection
ops and helper-backed ops (pubspec, native, web, env) log [skipped] in V1. V1.1 will introduce
anchor-bracket markers for reversible injections.
Example
Pattern from assets/stubs/make_plugin/magic/install_command.dart.stub, with a conditional
backend branch illustrating IMMEDIATE prompt + DEFERRED ops:
import 'package:fluttersdk_artisan/artisan.dart';
class AnalyticsInstallCommand extends ArtisanInstallCommand {
@override
String get signature => 'analytics:install $baseFlags';
@override
String get description => 'Install the Analytics plugin into the host project.';
@override
String pluginName(ArtisanContext ctx) => 'analytics';
@override
Future handle(ArtisanContext ctx) async {
final installer = PluginInstaller(buildContext(ctx), pluginName: pluginName(ctx));
// 1. IMMEDIATE prompt: answer is readable in vars before any op is enqueued.
installer.choice(
varName: 'backend',
question: 'Which analytics backend?',
options: ['firebase', 'amplitude'],
defaultValue: 'firebase',
);
// 2. Common DEFERRED ops.
installer
.publishConfig(
stubName: 'install/analytics_config.dart',
targetPath: '${buildContext(ctx).projectRoot}/lib/config/analytics.dart',
replacements: {'BACKEND': installer.vars['backend']!},
)
.injectProvider('AnalyticsServiceProvider')
.injectEnvVar(key: 'ANALYTICS_KEY', value: '', comment: 'Analytics write key.');
// 3. Backend-specific ops (DEFERRED, conditional on captured answer).
if (installer.vars['backend'] == 'firebase') {
installer
.addDependency('firebase_core', '^3.0.0')
.addDependency('firebase_analytics', '^11.0.0')
.injectAndroidPermission('android.permission.INTERNET')
.mergeJson(targetPath: 'assets/lang/en.json',
sourceData: {'analytics': {'title': 'Analytics'}});
} else {
installer.addDependency('amplitude_flutter', '^4.0.0');
}
// 4. Hybrid: prompt fires now; RunShell enqueued only when user confirms.
installer.askToRunShell(prompt: 'Run "flutter pub get" now?',
command: 'flutter', args: ['pub', 'get']);
final result = await installer.commit(dryRun: isDryRun(ctx), force: isForce(ctx));
return switch (result) { Success() => 0, DryRun() => 0, Conflict() => 1, Error() => 2 };
}
}
Related
- install-yaml: declarative manifest schema; preferred for straightforward installs.
- authoring: end-to-end plugin authoring guide (scaffold, provider registration, publish checklist).