# PluginInstaller Fluent DSL

The `PluginInstaller` class is the procedural escape hatch for plugin install logic that the
declarative `install.yaml` manifest cannot express.

- [When to Use](#when-to-use)
- [Two Phases: IMMEDIATE vs DEFERRED](#two-phases-immediate-vs-deferred)
- [DSL Method Reference](#dsl-method-reference)
  - [Prompts](#prompts)
  - [Pubspec Operations](#pubspec-operations)
  - [File Operations](#file-operations)
  - [Injection Operations](#injection-operations)
  - [Android Operations](#android-operations)
  - [iOS and macOS Operations](#ios-and-macos-operations)
  - [Web Operations](#web-operations)
  - [Environment Operations](#environment-operations)
  - [Shell Operations](#shell-operations)
  - [Lifecycle Hooks](#lifecycle-hooks)
- [Atomic Commit Semantics](#atomic-commit-semantics)
- [Example](#example)
- [Related Documentation](#related-documentation)

---

## 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 `<uses-permission>` to `AndroidManifest.xml`; silently skipped on non-Android consumers |
| `injectAndroidMetaData(name:, value:)` | `InjectAndroidMetaData` | Adds `<meta-data>` inside `<application>` in `AndroidManifest.xml` |
| `injectAndroidActivity(name:, exported:, [taskAffinity:], [intentFilters:])` | `InjectAndroidActivity` | Adds an `<activity>` with its `<intent-filter>` elements inside `<application>` 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<String>` |
| `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 `<platform>/Runner/Runner.entitlements` AND `CODE_SIGN_ENTITLEMENTS` into `<platform>/Runner.xcodeproj/project.pbxproj`; `platform` is `'ios'` or `'macos'`; value may be `String`, `bool` or `List<String>` |
| `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:dowel/dowel.dart`.

```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
`</application>` (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 `<activity>` with this `android:name` under `<application>` | The element is inserted |
| One exists with an equal set of intent filters (same `autoVerify`, actions, categories and `<data>` 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 `<data>` 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 `<array>` 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 `<platform>/Runner/Runner.entitlements`
  written AND the application target pointed at it through `CODE_SIGN_ENTITLEMENTS`. That second write
  edits `<platform>/Runner.xcodeproj/project.pbxproj`.
- A `List<String>` 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 `<platform>/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 `</head>` in `web/index.html`; skipped on non-web consumers |
| `addWebMetaTag(attributes)` | `AddWebMetaTag` | Adds a `<meta>` 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 `# <comment>` 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<String, String?>` where `null`
   marks a delete. No disk writes yet.
4. **Atomic `.tmp` writes** (line 173): each non-null entry is written to `<absPath>.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): `.dowel/installed/<plugin>.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:

```dart
import 'package:dowel/dowel.dart';

class AnalyticsInstallCommand extends DowelInstallCommand {
  @override
  String get signature => 'analytics:install $baseFlags';

  @override
  String get description => 'Install the Analytics plugin into the host project.';

  @override
  String pluginName(DowelContext ctx) => 'analytics';

  @override
  Future<int> handle(DowelContext 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 Documentation

- [install-yaml.md](https://anilcancakir.com/dowel/docs/plugins/install-yaml.md): declarative manifest schema; preferred for straightforward installs.
- [authoring.md](https://anilcancakir.com/dowel/docs/plugins/authoring.md): end-to-end plugin authoring guide (scaffold, provider registration, publish checklist).
