search ESC

Searching…

No results for "".

Type at least 2 characters to search.

Docs

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

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 /Runner/Runner.entitlements AND CODE_SIGN_ENTITLEMENTS into /Runner.xcodeproj/project.pbxproj; 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.entitlements plus Runner/Release.entitlements) is this case.
  • A project whose configurations name no entitlements file at all gets /Runner/Runner.entitlements written AND the application target pointed at it through CODE_SIGN_ENTITLEMENTS. That second write edits /Runner.xcodeproj/project.pbxproj.
  • A List value 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 an Error.

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 /Runner.xcodeproj at all, so there is nothing to point at.
  • 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 accented PRODUCT_NAME is enough. Or the project is readable but not addressable: no application target at all, two applications and neither named Runner, 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:

  1. Dry-run short-circuit (line 144): dryRun: true renders staged ops and returns DryRun without touching disk.
  2. Conflict pre-flight (line 152): detects user-modified target files. force: true bypasses.
  3. In-memory staging (line 162): ops are reduced into Map where null marks a delete. No disk writes yet.
  4. Atomic .tmp writes (line 173): each non-null entry is written to .tmp. If any write throws, all successful temps are deleted and the method returns Error(rolledBack: true).
  5. Rename into place (line 193): .tmp files are renamed over their targets; deletes are applied. POSIX rename(2) is atomic, so readers never see partial state. Failures past this point surface as Error(rolledBack: false).
  6. Install record (line 213): .artisan/installed/.json is written BEFORE shell ops so the install is always reversible even when a shell step fails later.
  7. Shell ops (line 228): RunShell ops execute last. A non-zero exit returns Error; 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 };
  }
}
  • install-yaml: declarative manifest schema; preferred for straightforward installs.
  • authoring: end-to-end plugin authoring guide (scaffold, provider registration, publish checklist).