Skip to content
Open
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
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ src/
├── cloud/ # Firestore API client
├── commands/ # CLI commands
├── config/ # Global & project config
├── core/ # Domain logic (app collection, DTOs, diff)
├── core/ # Domain logic (app collection, DTOs, diff, starter enable)
└── lib/ # Shared utilities
tests/
├── auth/ # Auth unit tests (token, session)
Expand Down
56 changes: 44 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,24 @@ ensemble push
ensemble pull
ensemble release
ensemble add
ensemble enable
ensemble update
```

## Commands

| Command | Description |
| ------------------ | ------------------------------------------------------------------------- |
| `ensemble login` | Log in to Ensemble (opens browser) |
| `ensemble logout` | Log out and clear local auth session |
| `ensemble token` | Print token for CI (set as `ENSEMBLE_TOKEN`); run `ensemble login` first |
| `ensemble init` | Initialize or update `ensemble.config.json` in the project |
| `ensemble push` | Scan the app directory and push changes to the cloud |
| `ensemble pull` | Pull artifacts from the cloud and overwrite local files |
| `ensemble release` | Manage releases (snapshots) of your app (interactive menu or subcommands) |
| `ensemble add` | Add a new screen, widget, script, action, translation, or asset |
| `ensemble update` | Update the CLI to the latest version |
| Command | Description |
| ------------------ | ----------------------------------------------------------------------------- |
| `ensemble login` | Log in to Ensemble (opens browser) |
| `ensemble logout` | Log out and clear local auth session |
| `ensemble token` | Print token for CI (set as `ENSEMBLE_TOKEN`); run `ensemble login` first |
| `ensemble init` | Initialize or update `ensemble.config.json` in the project |
| `ensemble push` | Scan the app directory and push changes to the cloud |
| `ensemble pull` | Pull artifacts from the cloud and overwrite local files |
| `ensemble release` | Manage releases (snapshots) of your app (interactive menu or subcommands) |
| `ensemble add` | Add a new screen, widget, script, action, translation, or asset |
| `ensemble enable` | Enable starter modules (camera, location, google_maps, etc.) in a Flutter app |
| `ensemble update` | Update the CLI to the latest version |

### Options

Expand All @@ -56,9 +58,39 @@ ensemble update
- **release list** — `--limit <n>` — Maximum number of releases to show (default: 20)
- **release list** — `--json` — Print releases as machine-readable JSON (for scripts)
- **release use** — `--app <alias>` — App alias (default: `default`)
- **release use** — `--hash <hash>` — Non-interactive: use release by hash
- **release use** — `--hash <hash>` — Non-interactive: use release by hash (printed by `release list`)

### `ensemble enable`

`ensemble enable` fetches the latest stable module tooling from [EnsembleUI/ensemble](https://github.com/EnsembleUI/ensemble) (latest GitHub release), caches it under `~/.ensemble/cache/modules_dir/<release-tag>/`, and runs module scripts against your starter project.

- **Interactive**

```bash
ensemble enable
```

- **Direct**

```bash
ensemble enable camera
ensemble enable camera location
ensemble enable google_maps platform=web webGoogleMapsApiKey=YOUR_KEY ensemble_version=1.2.40
ensemble enable camera --project ./my-starter-app
```

- **Options**
- `--project <path>` — Starter project root (default: auto-detect from current directory)
- `--verbose` — Print dart commands
- Module parameters use `key=value` (keys match cached `src/modules_scripts.ts` and `src/utility_scripts.ts`), or prompts in interactive mode

- **Notes**
- Does not require `ensemble login`
- Uses `fvm dart` when the project has `.fvmrc`
- Checks GitHub for the latest release on each run; re-downloads only when the cached release tag differs (or cache is missing). Offline runs use the cached release.
- After `pubspec.yaml` changes, run `flutter pub get`
- Team architecture notes: [docs/ensemble-enable.md](docs/ensemble-enable.md)

### `ensemble add`

`ensemble add` scaffolds common app artifacts in your project and updates `.manifest.json` when needed.
Expand Down
112 changes: 112 additions & 0 deletions docs/ensemble-enable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# `ensemble enable`

Enable optional capabilities (camera, maps, notifications, etc.) in an Ensemble Flutter starter project.

The CLI does **not** vendor module scripts. It downloads tooling from [EnsembleUI/ensemble](https://github.com/EnsembleUI/ensemble) at runtime, caches it locally, and runs Dart scripts against the user’s project.

---

## Command surface

```bash
ensemble enable [modules...] [key=value...]
--project <path> # starter root (default: auto-detect from cwd)
--verbose # print dart command lines
```

- **Interactive** (TTY): cached runtime `selectModules` + `checkAndAskForMissingArgs`.
- **Direct**: `ensemble enable camera platform=ios cameraDescription=... ensemble_version=1.2.44`
- Does **not** require `ensemble login`.

---

## Architecture

```
enable.ts
├── starterProject.ts cwd or --project must be starter root (no walk-up)
├── modulesCache.ts fetch/cache tooling from GitHub releases
├── enableRuntime.ts jiti-load registry data; prompts use cached param definitions
└── moduleRunner.ts fvm dart run <cached-script> cwd=user project
```

| Module | Role |
| ------------------ | -------------------------------------------------------------------------------------------------------- |
| `modulesCache.ts` | Resolve latest **stable** GitHub release; cache under `~/.ensemble/cache/modules_dir/<tag>/` |
| `enableRuntime.ts` | jiti-load `modules_scripts.ts` + `utility_scripts.ts`; prompts via CLI `prompts` using cached param defs |
| `moduleRunner.ts` | Runs cached Dart scripts sequentially (`stdio: inherit`; script owns output) |
| `dartToolchain.ts` | `fvm dart` when `.fvmrc` / `.fvm/fvm_config.json` exists, else `dart` |

**Not duplicated in CLI:** module list, parameter keys, prompt text, `commonParameters` — all from cached Ensemble `src/`.

---

## Module tooling cache

**Path:** `~/.ensemble/cache/modules_dir/`

```
modules_dir/
.ref # last successfully cached release tag
ensemble-v1.2.44/ # example tag — not hardcoded
src/*
scripts/*
```

**On each run:**

1. `GET /repos/EnsembleUI/ensemble/releases/latest` (15s timeout)
2. If cached tag matches latest and registry exists → **no download**
3. If tag differs or cache missing → download tarball, extract `starter/` subset, update `.ref`, delete previous tag dir
4. Offline / fetch failure → use cached release if present (warn user)

**Not cached in CLI repo** — only downloaded at runtime.

---

## Script execution

```bash
fvm dart run <abs-path-to-cached-script> key=value key=value ...
# cwd: user starter project root
```

Args are `key=value` only (no `--flags`). Each script receives only keys declared in its registry entry plus `commonParameters` from cached `utility_scripts.ts`.

---

## Important distinctions

| Term | Meaning |
| ------------------ | -------------------------------------------------------------------------------------------- |
| `ensemble_version` | Flutter **package** git ref in pubspec (e.g. `1.2.44`) — prompted / passed by user |
| Cache release tag | GitHub **release** tag for module tooling (e.g. `ensemble-v1.2.44`) — resolved automatically |
| Starter project | User’s Flutter app being modified |
| Module tooling | Downloaded `starter/src` + `starter/scripts` from ensemble repo |

---

## Testing

```bash
npm test
npm run build
node dist/index.js enable camera --project ./my-app platform=ios ...
```

Fixtures: `tests/fixtures/starter-cache/` (minimal cached `src/` tree for `enableRuntime` tests).

---

## Known limitations

- Older starters may lack placeholders in `lib/generated/ensemble_modules.dart` → `Pattern not found` from Dart scripts.
- Re-enabling an already-enabled module often fails (expected).
- Batch enable stops at first failure.
- Global `checkForUpdates()` runs on every CLI invocation; use `ENSEMBLE_NO_UPDATE_CHECK=1` to skip.

---

## Related

- Issue: [ensemble-cli#3](https://github.com/EnsembleUI/ensemble-cli/issues/3)
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,10 @@
"type": "commonjs",
"dependencies": {
"commander": "^12.1.0",
"jiti": "^2.4.2",
"picocolors": "^1.1.0",
"prompts": "^2.4.2"
"prompts": "^2.4.2",
"tar": "^7.4.3"
},
"devDependencies": {
"@types/node": "^22.10.1",
Expand Down
76 changes: 76 additions & 0 deletions src/commands/enable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ensureModulesTooling } from '../core/modulesCache.js';
import {
assertRequiredParamsPresent,
loadEnableRuntime,
parseEnableTokens,
resolveScript,
type EnableScript,
} from '../core/enableRuntime.js';
import { runStarterScriptsSequentially } from '../core/moduleRunner.js';
import { resolveStarterProjectRoot } from '../core/starterProject.js';
import { ui } from '../core/ui.js';
import { withSpinner } from '../lib/spinner.js';

export { parseEnableTokens } from '../core/enableRuntime.js';

export interface EnableCommandOptions {
modules?: string[];
project?: string;
verbose?: boolean;
}

const NON_INTERACTIVE_HINT =
'Module name required for non-interactive use.\n\nExample:\n ensemble enable camera';

function isInteractiveTty(): boolean {
return Boolean(process.stdout.isTTY && process.stdin.isTTY);
}

async function resolveScripts(
scriptNames: string[],
runtime: Awaited<ReturnType<typeof loadEnableRuntime>>,
interactive: boolean
): Promise<EnableScript[]> {
if (scriptNames.length > 0) {
return scriptNames.map((name) => resolveScript(name, runtime));
}
if (!interactive) throw new Error(NON_INTERACTIVE_HINT);

const selected = await runtime.selectModules();
if (selected.length === 0) {
ui.warn('Enable command cancelled.');
process.exitCode = 130;
return [];
}
return selected;
}

export async function enableCommand(options: EnableCommandOptions = {}): Promise<void> {
const interactive = isInteractiveTty();
const { scriptNames, argsArray: tokenArgs } = parseEnableTokens(options.modules ?? []);
const projectRoot = await resolveStarterProjectRoot(options.project);
const tooling = await withSpinner('Preparing module tooling...', () => ensureModulesTooling());

if (tooling.usedCacheFallback) {
ui.warn(
`Could not fetch latest module tooling.\nUsing cached module tooling (${tooling.ref}).`
);
}

const runtime = await loadEnableRuntime(tooling.cacheDir);
const scripts = await resolveScripts(scriptNames, runtime, interactive);
if (scripts.length === 0) return;

const finalArgs = interactive
? await runtime.checkAndAskForMissingArgs(scripts, tokenArgs)
: (assertRequiredParamsPresent(scripts, runtime.commonParameters, tokenArgs), tokenArgs);

await runStarterScriptsSequentially({
cacheDir: tooling.cacheDir,
projectRoot,
scripts,
argsArray: finalArgs,
commonParameters: runtime.commonParameters,
verbose: options.verbose,
});
}
36 changes: 36 additions & 0 deletions src/core/dartToolchain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import path from 'path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

import { fileExists } from './fs.js';

const execFileAsync = promisify(execFile);

export interface DartInvocation {
command: string;
prefixArgs: string[];
}

export async function resolveDartInvocation(projectRoot: string): Promise<DartInvocation> {
const hasFvm =
(await fileExists(path.join(projectRoot, '.fvmrc'))) ||
(await fileExists(path.join(projectRoot, '.fvm', 'fvm_config.json')));

return hasFvm ? { command: 'fvm', prefixArgs: ['dart'] } : { command: 'dart', prefixArgs: [] };
}

export async function assertDartAvailable(invocation: DartInvocation): Promise<void> {
try {
await execFileAsync(invocation.command, [...invocation.prefixArgs, '--version'], {
timeout: 15_000,
});
} catch {
const via =
invocation.prefixArgs.length > 0
? `${invocation.command} ${invocation.prefixArgs.join(' ')}`
: 'dart';
throw new Error(
`Could not run ${via}. Install Dart/Flutter or configure FVM for this project.`
);
}
}
Loading
Loading