From 5be23266ad71c3b57de4ac1351168cef5d94dc30 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Wed, 17 Jun 2026 22:01:39 +0500 Subject: [PATCH 1/7] feat: introduce ensemble enable command for module activation --- CONTRIBUTING.md | 2 +- README.md | 37 +++- docs/ensemble-enable.md | 128 +++++++++++++ package.json | 4 +- src/commands/enable.ts | 153 ++++++++++++++++ src/core/dartToolchain.ts | 36 ++++ src/core/fs.ts | 10 + src/core/gitProjectChanges.ts | 63 +++++++ src/core/moduleParams.ts | 118 ++++++++++++ src/core/moduleRegistry.ts | 57 ++++++ src/core/moduleRunner.ts | 109 +++++++++++ src/core/modulesCache.ts | 173 ++++++++++++++++++ src/core/starterProject.ts | 57 ++++++ src/core/starterTypes.ts | 31 ++++ src/index.ts | 27 ++- tests/commands/enable.test.ts | 32 ++++ tests/core/gitProjectChanges.test.ts | 45 +++++ tests/core/moduleParams.test.ts | 67 +++++++ tests/core/moduleRegistry.test.ts | 36 ++++ tests/core/starterProject.test.ts | 54 ++++++ .../starter-cache/src/modules_scripts.ts | 14 ++ .../starter-cache/src/utility_scripts.ts | 7 + 22 files changed, 1254 insertions(+), 6 deletions(-) create mode 100644 docs/ensemble-enable.md create mode 100644 src/commands/enable.ts create mode 100644 src/core/dartToolchain.ts create mode 100644 src/core/fs.ts create mode 100644 src/core/gitProjectChanges.ts create mode 100644 src/core/moduleParams.ts create mode 100644 src/core/moduleRegistry.ts create mode 100644 src/core/moduleRunner.ts create mode 100644 src/core/modulesCache.ts create mode 100644 src/core/starterProject.ts create mode 100644 src/core/starterTypes.ts create mode 100644 tests/commands/enable.test.ts create mode 100644 tests/core/gitProjectChanges.test.ts create mode 100644 tests/core/moduleParams.test.ts create mode 100644 tests/core/moduleRegistry.test.ts create mode 100644 tests/core/starterProject.test.ts create mode 100644 tests/fixtures/starter-cache/src/modules_scripts.ts create mode 100644 tests/fixtures/starter-cache/src/utility_scripts.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea4bcc4..9e14697 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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) diff --git a/README.md b/README.md index e4f3799..5141704 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ ensemble push ensemble pull ensemble release ensemble add +ensemble enable ensemble update ``` @@ -34,7 +35,8 @@ ensemble update | `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 | +| `ensemble enable` | Enable starter modules (camera, location, google_maps, etc.) in a Flutter app | +| `ensemble update` | Update the CLI to the latest version | ### Options @@ -56,9 +58,40 @@ ensemble update - **release list** — `--limit ` — Maximum number of releases to show (default: 20) - **release list** — `--json` — Print releases as machine-readable JSON (for scripts) - **release use** — `--app ` — App alias (default: `default`) -- **release use** — `--hash ` — Non-interactive: use release by hash - **release use** — `--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//`, 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 googleMapsApiKey=YOUR_KEY ensemble_version=1.2.40 + ensemble enable camera --project ./my-starter-app + ``` + +- **Options** + - `--project ` — Starter project root (default: auto-detect from current directory) + - `--platform ` — `ios`, `android`, `web`, or comma-separated list + - `--verbose` — Print dart commands + - Module parameters use `key=value` (keys match `src/modules_scripts.ts` in cached tooling), 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. diff --git a/docs/ensemble-enable.md b/docs/ensemble-enable.md new file mode 100644 index 0000000..4b507ca --- /dev/null +++ b/docs/ensemble-enable.md @@ -0,0 +1,128 @@ +# `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 # starter root (default: auto-detect from cwd) + --platform # ios, android, web (comma-separated) + --verbose # print dart command lines +``` + +- **Interactive** (TTY): multiselect modules + prompts for missing params. +- **Direct**: `ensemble enable camera --platform ios cameraDescription=... ensemble_version=1.2.44` +- Does **not** require `ensemble login`. + +--- + +## Architecture + +``` +enable.ts + ├── starterProject.ts detect starter root (pubspec + ensemble.properties + ensemble_modules.dart) + ├── modulesCache.ts fetch/cache tooling from GitHub releases + ├── moduleRegistry.ts load modules_scripts.ts + utility_scripts.ts via jiti + ├── moduleParams.ts prompts + key=value args (mirrors starter utils) + └── moduleRunner.ts fvm dart run cwd=user project + └── gitProjectChanges.ts list Modified files (git snapshot diff) +``` + + +| Module | Role | +| ------------------- | --------------------------------------------------------------------------------------------------- | +| `modulesCache.ts` | Resolve latest **stable** GitHub release; cache under `~/.ensemble/cache/modules_dir//` | +| `moduleRegistry.ts` | Registry lookup, name aliases (`generate_keystore` → `generateKeystore`) | +| `moduleParams.ts` | `platform`, `ensemble_version`, per-module params; `googleMapsApiKey` fans out to per-platform keys | +| `moduleRunner.ts` | Runs scripts sequentially; partial success on batch failure | +| `dartToolchain.ts` | `fvm dart` when `.fvmrc` / `.fvm/fvm_config.json` exists, else `dart` | + + +--- + +## 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/modules_scripts.ts + scripts/modules/*.dart +``` + +**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 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 common params (`platform`, `ensemble_version`). + +--- + +## Modified files + +If the project root has a `.git` directory, the CLI snapshots tracked + untracked files (SHA-256) before/after each script and prints a deduped **Modified** list. + +- No `.git` in project root → Modified section omitted (known limitation for monorepo nested starters). +- `pubspec.yaml` in Modified → suggests `flutter pub get`. + +--- + +## 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 # unit tests including parseEnableTokens, cache paths, git diff, registry +npm run build +node dist/index.js enable camera --project ./my-app --platform ios ... +``` + +Fixtures: `tests/fixtures/starter-cache/` (minimal registry for `moduleRegistry` 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 but reports prior successes. +- 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) + diff --git a/package.json b/package.json index e2ea972..fe6c2f6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/enable.ts b/src/commands/enable.ts new file mode 100644 index 0000000..b0a229e --- /dev/null +++ b/src/commands/enable.ts @@ -0,0 +1,153 @@ +import prompts from 'prompts'; + +import { ENSEMBLE_MODULES_REPO, ensureModulesTooling } from '../core/modulesCache.js'; +import { + findStarterScript, + formatModuleLabel, + loadStarterRegistry, + normalizeModuleName, +} from '../core/moduleRegistry.js'; +import { ModuleBatchError, runStarterScriptsSequentially } from '../core/moduleRunner.js'; +import { resolveScriptArguments, type StarterArgMap } from '../core/moduleParams.js'; +import { resolveStarterProjectRoot } from '../core/starterProject.js'; +import { ui } from '../core/ui.js'; +import type { StarterScript } from '../core/starterTypes.js'; + +export interface EnableCommandOptions { + modules?: string[]; + project?: string; + platform?: string; + verbose?: boolean; +} + +const MODULE_NAME_RE = /^[a-z][a-z0-9_]*$/; +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); +} + +/** split commander [modules...] tokens into module names and key=value params */ +export function parseEnableTokens(tokens: string[]): { + moduleNames: string[]; + inlineArgs: StarterArgMap; +} { + const moduleNames: string[] = []; + const inlineArgs: StarterArgMap = {}; + for (const token of tokens) { + if (token.includes('=')) { + const eq = token.indexOf('='); + const key = token.slice(0, eq); + if (key) inlineArgs[key] = token.slice(eq + 1); + } else if (MODULE_NAME_RE.test(token)) { + moduleNames.push(token); + } + } + return { moduleNames, inlineArgs }; +} + +async function resolveScripts( + moduleNames: string[], + registry: Awaited>, + interactive: boolean +): Promise { + const names = moduleNames.map(normalizeModuleName).filter(Boolean); + if (names.length > 0) return names.map((name) => findStarterScript(name, registry)); + if (!interactive) throw new Error(NON_INTERACTIVE_HINT); + + const { selected } = await prompts({ + type: 'multiselect', + name: 'selected', + message: 'What do you want to enable?', + choices: registry.modules.map((module) => ({ + title: formatModuleLabel(module.name), + value: module.name, + })), + hint: '- Space to select. Return to submit.', + }); + + if (!selected?.length) { + ui.warn('Enable command cancelled.'); + process.exitCode = 130; + return []; + } + + return selected.map((name: string) => findStarterScript(name, registry)); +} + +export async function enableCommand(options: EnableCommandOptions = {}): Promise { + const interactive = isInteractiveTty(); + const { moduleNames, inlineArgs } = parseEnableTokens(options.modules ?? []); + const projectRoot = await resolveStarterProjectRoot(options.project); + const tooling = await ensureModulesTooling(); + + if (tooling.usedCacheFallback) { + ui.warn(`Could not fetch latest module tooling.\nUsing cached module tooling (${tooling.ref}).`); + } + + const registry = await loadStarterRegistry(tooling.cacheDir); + const scripts = await resolveScripts(moduleNames, registry, interactive); + if (scripts.length === 0) return; + + const argsArray = await resolveScriptArguments({ + scripts, + provided: { ...(options.platform ? { platform: options.platform } : {}), ...inlineArgs }, + interactive, + }); + + const runOptions = { + cacheDir: tooling.cacheDir, + projectRoot, + scripts, + argsArray, + verbose: options.verbose, + }; + + try { + printEnableSummary({ + scripts, + results: await runStarterScriptsSequentially(runOptions), + toolingRef: tooling.ref, + }); + } catch (err) { + if (err instanceof ModuleBatchError) { + printEnableSummary({ scripts, results: err.completed, toolingRef: tooling.ref }); + ui.error(`Stopped at ${formatModuleLabel(err.failedScript)}.`); + throw new Error(err.scriptOutput); + } + throw err; + } +} + +function printEnableSummary(options: { + scripts: StarterScript[]; + results: Array<{ scriptName: string; modifiedFiles: string[] }>; + toolingRef: string; +}): void { + const succeeded = new Set(options.results.map((result) => result.scriptName)); + const enabled = options.scripts + .filter((script) => succeeded.has(script.name)) + .map((script) => formatModuleLabel(script.name)); + const modified = [...new Set(options.results.flatMap((result) => result.modifiedFiles))].sort(); + + if (enabled.length === 0) return; + + ui.success('Enabled:'); + // eslint-disable-next-line no-console + console.log(enabled.map((name) => ` - ${name}`).join('\n')); + + ui.note('\nScripts:'); + ui.note(` Source: ${ENSEMBLE_MODULES_REPO}`); + ui.note(` Ref: ${options.toolingRef}`); + ui.note(' Registry: src/modules_scripts.ts'); + + if (modified.length > 0) { + ui.note('\nModified:'); + for (const file of modified) ui.note(` - ${file}`); + } + + if (modified.includes('pubspec.yaml')) { + ui.note('\nDependencies changed. Run:'); + ui.note(' flutter pub get'); + } +} diff --git a/src/core/dartToolchain.ts b/src/core/dartToolchain.ts new file mode 100644 index 0000000..86f9bed --- /dev/null +++ b/src/core/dartToolchain.ts @@ -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 { + 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 { + 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.` + ); + } +} diff --git a/src/core/fs.ts b/src/core/fs.ts new file mode 100644 index 0000000..65a8720 --- /dev/null +++ b/src/core/fs.ts @@ -0,0 +1,10 @@ +import fs from 'fs/promises'; + +export async function fileExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} diff --git a/src/core/gitProjectChanges.ts b/src/core/gitProjectChanges.ts new file mode 100644 index 0000000..22261c5 --- /dev/null +++ b/src/core/gitProjectChanges.ts @@ -0,0 +1,63 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { createHash } from 'node:crypto'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { fileExists } from './fs.js'; + +const execFileAsync = promisify(execFile); +const GIT_BUFFER = 10 * 1024 * 1024; + +async function hashFile(filePath: string): Promise { + return createHash('sha256').update(await fs.readFile(filePath)).digest('hex'); +} + +async function isGitRepository(projectRoot: string): Promise { + return fileExists(path.join(projectRoot, '.git')); +} + +async function listGitWorkspacePaths(projectRoot: string): Promise { + const [tracked, untracked] = await Promise.all([ + execFileAsync('git', ['-C', projectRoot, 'ls-files'], { maxBuffer: GIT_BUFFER }), + execFileAsync('git', ['-C', projectRoot, 'ls-files', '--others', '--exclude-standard'], { + maxBuffer: GIT_BUFFER, + }), + ]); + + return [...new Set(`${tracked.stdout}\n${untracked.stdout}`.split('\n').filter(Boolean))]; +} + +export async function snapshotGitWorkspace( + projectRoot: string +): Promise | null> { + if (!(await isGitRepository(projectRoot))) return null; + + const snapshot = new Map(); + await Promise.all( + (await listGitWorkspacePaths(projectRoot)).map(async (relativePath) => { + const absolute = path.join(projectRoot, relativePath); + if (!(await fileExists(absolute))) return; + if (!(await fs.stat(absolute)).isFile()) return; + snapshot.set(relativePath, await hashFile(absolute)); + }) + ); + return snapshot; +} + +function diffGitSnapshots(before: Map, after: Map): string[] { + const changed: string[] = []; + for (const relativePath of new Set([...before.keys(), ...after.keys()])) { + if (before.get(relativePath) !== after.get(relativePath)) changed.push(relativePath); + } + return changed.sort(); +} + +export async function collectGitWorkspaceChanges( + projectRoot: string, + before: Map | null +): Promise { + if (!before) return []; + const after = await snapshotGitWorkspace(projectRoot); + return after ? diffGitSnapshots(before, after) : []; +} diff --git a/src/core/moduleParams.ts b/src/core/moduleParams.ts new file mode 100644 index 0000000..2594a3a --- /dev/null +++ b/src/core/moduleParams.ts @@ -0,0 +1,118 @@ +import prompts from 'prompts'; + +import { + STARTER_COMMON_PARAMETERS, + type StarterParameter, + type StarterPlatform, + type StarterScript, +} from './starterTypes.js'; + +export type StarterArgMap = Record; + +const GOOGLE_MAPS_KEYS = [ + 'iOSGoogleMapsApiKey', + 'androidGoogleMapsApiKey', + 'webGoogleMapsApiKey', +] as const; + +function applyConvenienceFlags(args: StarterArgMap): StarterArgMap { + const next = { ...args }; + const sharedGoogleMapsKey = next.googleMapsApiKey; + if (!sharedGoogleMapsKey) return next; + + for (const key of GOOGLE_MAPS_KEYS) { + if (!next[key]) next[key] = sharedGoogleMapsKey; + } + delete next.googleMapsApiKey; + return next; +} + +function parsePlatformValue(raw: string | undefined): StarterPlatform | undefined { + const first = raw?.split(',')[0]?.trim().toLowerCase(); + if (first === 'ios' || first === 'android' || first === 'web') return first; + return undefined; +} + +function isStarterParameterRequired( + param: StarterParameter, + args: StarterArgMap, + providedKeys: Set +): boolean { + if (providedKeys.has(param.key) || args[param.key]) return false; + if (!args.platform) return true; + + const platform = parsePlatformValue(args.platform); + return platform !== undefined && param.platform.includes(platform); +} + +function normalizeAnswers(answers: Record): StarterArgMap { + const normalized: StarterArgMap = {}; + for (const [key, value] of Object.entries(answers)) { + if (value === 'yes') normalized[key] = 'true'; + else if (value === 'no') normalized[key] = 'false'; + else if (value !== undefined) normalized[key] = String(value); + } + return normalized; +} + +async function askForMissingParameters( + params: StarterParameter[], + args: StarterArgMap, + providedKeys: Set, + interactive: boolean +): Promise { + const questions: prompts.PromptObject[] = params + .filter((param) => isStarterParameterRequired(param, args, providedKeys)) + .map((param) => ({ + type: (param.type === 'toggle' ? 'select' : param.type) as prompts.PromptType, + name: param.key, + message: param.question, + choices: param.choices?.map((choice) => ({ title: choice, value: choice })), + validate: (value: string) => (value ? true : `Parameter "${param.key}" is required.`), + })); + + if (questions.length === 0) return {}; + if (!interactive) { + throw new Error( + `Missing required parameter(s): ${questions.map((q) => q.name).join(', ')}.\n\nPass them as key=value, for example:\n ensemble enable google_maps googleMapsApiKey=YOUR_KEY ensemble_version=1.2.40` + ); + } + + return normalizeAnswers((await prompts(questions)) as Record); +} + +function dedupeParameters(params: StarterParameter[]): StarterParameter[] { + const seen = new Set(); + return params.filter((param) => { + if (seen.has(param.key)) return false; + seen.add(param.key); + return true; + }); +} + +export async function resolveScriptArguments(options: { + scripts: StarterScript[]; + provided: StarterArgMap; + interactive: boolean; +}): Promise { + const args = applyConvenienceFlags(options.provided); + const providedKeys = new Set(Object.keys(args)); + + for (const params of [ + STARTER_COMMON_PARAMETERS, + dedupeParameters(options.scripts.flatMap((script) => script.parameters)), + ]) { + Object.assign(args, await askForMissingParameters(params, args, providedKeys, options.interactive)); + for (const key of Object.keys(args)) providedKeys.add(key); + } + + return Object.entries(args).map(([key, value]) => `${key}=${value}`); +} + +export function formatArgsForScript(script: StarterScript, argsArray: string[]): string[] { + const allowed = new Set([ + ...script.parameters.map((param) => param.key), + ...STARTER_COMMON_PARAMETERS.map((param) => param.key), + ]); + return argsArray.filter((arg) => allowed.has(arg.split('=')[0] ?? '')); +} diff --git a/src/core/moduleRegistry.ts b/src/core/moduleRegistry.ts new file mode 100644 index 0000000..aa02e3f --- /dev/null +++ b/src/core/moduleRegistry.ts @@ -0,0 +1,57 @@ +import path from 'path'; +import { createJiti } from 'jiti'; + +import type { StarterScript } from './starterTypes.js'; + +const MODULE_ALIASES: Record = { + generate_keystore: 'generateKeystore', + generatekeystore: 'generateKeystore', +}; + +export function normalizeModuleName(name: string): string { + const trimmed = name.trim(); + if (!trimmed) return trimmed; + return MODULE_ALIASES[trimmed.toLowerCase()] ?? trimmed.replace(/-/g, '_'); +} + +export function formatModuleLabel(name: string): string { + return name + .split('_') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +export async function loadStarterRegistry(cacheDir: string): Promise<{ + modules: StarterScript[]; + utilityScripts: StarterScript[]; +}> { + const jiti = createJiti(__filename, { interopDefault: true }); + const modulesExport = jiti(path.join(cacheDir, 'src', 'modules_scripts.ts')) as { + modules?: StarterScript[]; + }; + const utilityExport = jiti(path.join(cacheDir, 'src', 'utility_scripts.ts')) as { + scripts?: StarterScript[]; + }; + + return { + modules: modulesExport.modules ?? [], + utilityScripts: utilityExport.scripts ?? [], + }; +} + +export function findStarterScript( + name: string, + registry: { modules: StarterScript[]; utilityScripts: StarterScript[] } +): StarterScript { + const normalized = normalizeModuleName(name); + const script = [...registry.modules, ...registry.utilityScripts].find( + (entry) => entry.name === normalized || entry.name === name + ); + if (!script) { + throw new Error( + `Module "${name}" not found. Available modules: ${registry.modules.map((entry) => entry.name).join(', ')}` + ); + } + return script; +} diff --git a/src/core/moduleRunner.ts b/src/core/moduleRunner.ts new file mode 100644 index 0000000..5884dda --- /dev/null +++ b/src/core/moduleRunner.ts @@ -0,0 +1,109 @@ +import path from 'path'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +import { assertDartAvailable, resolveDartInvocation, type DartInvocation } from './dartToolchain.js'; +import { collectGitWorkspaceChanges, snapshotGitWorkspace } from './gitProjectChanges.js'; +import { formatArgsForScript } from './moduleParams.js'; +import type { StarterScript } from './starterTypes.js'; + +const execFileAsync = promisify(execFile); + +interface ModuleRunResult { + scriptName: string; + modifiedFiles: string[]; +} + +export class ModuleBatchError extends Error { + constructor( + message: string, + readonly completed: ModuleRunResult[], + readonly failedScript: string, + readonly scriptOutput: string + ) { + super(message); + this.name = 'ModuleBatchError'; + } +} + +function readExecOutput(err: unknown): string { + if (!err || typeof err !== 'object') return String(err); + const execErr = err as { stderr?: string; stdout?: string; message?: string }; + return [execErr.stderr, execErr.stdout, execErr.message].filter(Boolean).join('\n').trim(); +} + +function throwModuleError(scriptName: string, err: unknown): never { + const output = readExecOutput(err); + const detail = output || `Failed to run ${scriptName}`; + if (/Pattern not found/i.test(output)) { + throw new Error( + `${detail}\n\nThis starter project may not include placeholders for that module in lib/generated/ensemble_modules.dart. Try enabling modules individually, or update ensemble_modules.dart from the latest Ensemble starter.` + ); + } + throw new Error(detail); +} + +async function runStarterScript(options: { + cacheDir: string; + projectRoot: string; + script: StarterScript; + argsArray: string[]; + dart: DartInvocation; + verbose?: boolean; +}): Promise { + const before = await snapshotGitWorkspace(options.projectRoot); + const commandArgs = [ + ...options.dart.prefixArgs, + 'run', + path.join(options.cacheDir, options.script.path), + ...formatArgsForScript(options.script, options.argsArray), + ]; + + if (options.verbose) { + // eslint-disable-next-line no-console + console.log(`Executing: ${options.dart.command} ${commandArgs.join(' ')}`); + } + + try { + const { stdout, stderr } = await execFileAsync(options.dart.command, commandArgs, { + cwd: options.projectRoot, + maxBuffer: 10 * 1024 * 1024, + }); + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + } catch (err) { + throwModuleError(options.script.name, err); + } + + return { + scriptName: options.script.name, + modifiedFiles: await collectGitWorkspaceChanges(options.projectRoot, before), + }; +} + +export async function runStarterScriptsSequentially(options: { + cacheDir: string; + projectRoot: string; + scripts: StarterScript[]; + argsArray: string[]; + verbose?: boolean; +}): Promise { + const dart = await resolveDartInvocation(options.projectRoot); + await assertDartAvailable(dart); + + const results: ModuleRunResult[] = []; + for (const script of options.scripts) { + try { + results.push(await runStarterScript({ ...options, script, dart })); + } catch (err) { + const output = err instanceof Error ? err.message : String(err); + throw new ModuleBatchError( + `Failed to run ${script.name}: ${output}`, + results, + script.name, + output + ); + } + } + return results; +} diff --git a/src/core/modulesCache.ts b/src/core/modulesCache.ts new file mode 100644 index 0000000..add06e4 --- /dev/null +++ b/src/core/modulesCache.ts @@ -0,0 +1,173 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; +import { createWriteStream } from 'node:fs'; +import { pipeline } from 'node:stream/promises'; +import { Readable } from 'node:stream'; +import { createGunzip } from 'node:zlib'; +import * as tar from 'tar'; + +import { fileExists } from './fs.js'; + +export const ENSEMBLE_MODULES_REPO = 'EnsembleUI/ensemble'; + +const STARTER_PATHS = [ + 'starter/src/', + 'starter/scripts/', + 'starter/package.json', + 'starter/tsconfig.json', +]; +const FETCH_TIMEOUT_MS = 15_000; +const REGISTRY_REL = path.join('src', 'modules_scripts.ts'); + +function getModulesCacheRoot(): string { + return path.join(os.homedir(), '.ensemble', 'cache', 'modules_dir'); +} + +function getModulesReleaseCacheDir(ref: string): string { + return path.join(getModulesCacheRoot(), ref); +} + +function getModulesToolingDownloadUrl(ref: string): string { + return `https://codeload.github.com/EnsembleUI/ensemble/tar.gz/${encodeURIComponent(ref)}`; +} + +function getStableReleaseTag(release: { + tag_name: string; + prerelease: boolean; + draft: boolean; +}): string | null { + if (release.prerelease || release.draft) return null; + const tag = release.tag_name.trim(); + return tag || null; +} + +export interface ModulesToolingResult { + cacheDir: string; + ref: string; + usedCacheFallback: boolean; +} + +function toolingResult(ref: string, usedCacheFallback: boolean): ModulesToolingResult { + return { cacheDir: getModulesReleaseCacheDir(ref), ref, usedCacheFallback }; +} + +function unavailableError(detail: string): Error { + return new Error( + `Could not fetch module tooling and no cached version was found.\n\nPlease connect to the internet and retry:\n ensemble enable\n\n${detail}` + ); +} + +async function readCachedRef(): Promise { + try { + const ref = (await fs.readFile(path.join(getModulesCacheRoot(), '.ref'), 'utf8')).trim(); + return ref || null; + } catch { + return null; + } +} + +async function hasRegistry(ref: string): Promise { + return fileExists(path.join(getModulesReleaseCacheDir(ref), REGISTRY_REL)); +} + +async function cachedOrThrow(cachedRef: string | null, err: unknown): Promise { + if (cachedRef && (await hasRegistry(cachedRef))) return toolingResult(cachedRef, true); + throw unavailableError(err instanceof Error ? err.message : String(err)); +} + +async function fetchLatestRef(): Promise { + const response = await fetch( + 'https://api.github.com/repos/EnsembleUI/ensemble/releases/latest', + { + headers: { Accept: 'application/vnd.github+json' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + } + ); + if (!response.ok) { + throw new Error(`HTTP ${response.status} while fetching latest ensemble release`); + } + const tag = getStableReleaseTag((await response.json()) as Parameters[0]); + if (!tag) throw new Error('Latest GitHub release is not a stable release'); + return tag; +} + +async function downloadRelease(ref: string): Promise { + const root = getModulesCacheRoot(); + const dest = getModulesReleaseCacheDir(ref); + const tarball = path.join(root, '.download.tar'); + + try { + await fs.mkdir(root, { recursive: true }); + const response = await fetch(getModulesToolingDownloadUrl(ref), { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) throw new Error(`HTTP ${response.status} while downloading module tooling`); + if (!response.body) throw new Error('Empty response while downloading module tooling'); + + await pipeline( + Readable.fromWeb(response.body as import('stream/web').ReadableStream), + createGunzip(), + createWriteStream(tarball) + ); + + const tmp = path.join(dest, '.extract-tmp'); + await fs.mkdir(dest, { recursive: true }); + await fs.rm(tmp, { recursive: true, force: true }); + await fs.mkdir(tmp, { recursive: true }); + + await tar.extract({ + file: tarball, + cwd: tmp, + strip: 1, + filter: (entryPath) => { + const relative = entryPath.replace(/\\/g, '/').split('/').slice(1).join('/'); + return STARTER_PATHS.some( + (prefix) => relative === prefix.replace(/\/$/, '') || relative.startsWith(prefix) + ); + }, + }); + + const starter = path.join(tmp, 'starter'); + if (!(await fileExists(starter))) throw new Error('Downloaded archive did not contain starter/'); + + for (const entry of await fs.readdir(starter)) { + const target = path.join(dest, entry); + await fs.rm(target, { recursive: true, force: true }); + await fs.rename(path.join(starter, entry), target); + } + await fs.rm(tmp, { recursive: true, force: true }); + + if (!(await hasRegistry(ref))) { + throw new Error('Downloaded module tooling is missing src/modules_scripts.ts'); + } + } finally { + await fs.rm(tarball, { force: true }); + } +} + +export async function ensureModulesTooling(): Promise { + const cachedRef = await readCachedRef(); + + let latestRef: string; + try { + latestRef = await fetchLatestRef(); + } catch (err) { + return cachedOrThrow(cachedRef, err); + } + + if (cachedRef === latestRef && (await hasRegistry(latestRef))) { + return toolingResult(latestRef, false); + } + + try { + await downloadRelease(latestRef); + await fs.writeFile(path.join(getModulesCacheRoot(), '.ref'), `${latestRef}\n`, 'utf8'); + if (cachedRef && cachedRef !== latestRef) { + await fs.rm(getModulesReleaseCacheDir(cachedRef), { recursive: true, force: true }); + } + return toolingResult(latestRef, false); + } catch (err) { + return cachedOrThrow(cachedRef, err); + } +} diff --git a/src/core/starterProject.ts b/src/core/starterProject.ts new file mode 100644 index 0000000..83d536c --- /dev/null +++ b/src/core/starterProject.ts @@ -0,0 +1,57 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileExists } from './fs.js'; + +async function pubspecReferencesEnsemble(pubspecPath: string): Promise { + try { + return /\bensemble\b/.test(await fs.readFile(pubspecPath, 'utf8')); + } catch { + return false; + } +} + +async function isStarterProjectRoot(dir: string): Promise { + const root = path.resolve(dir); + const pubspecPath = path.join(root, 'pubspec.yaml'); + return ( + (await fileExists(pubspecPath)) && + (await pubspecReferencesEnsemble(pubspecPath)) && + (await fileExists(path.join(root, 'ensemble/ensemble.properties'))) && + (await fileExists(path.join(root, 'lib/generated/ensemble_modules.dart'))) + ); +} + +async function findStarterProjectRoot(startDir: string): Promise { + let current = path.resolve(startDir); + for (;;) { + if (await isStarterProjectRoot(current)) return current; + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } +} + +const NOT_FOUND_HINT = `Could not find an Ensemble starter project. + +Run this command from your starter project root, or pass: + + ensemble enable camera --project ./path-to-starter`; + +const INVALID_HINT = `This does not look like an Ensemble starter project. + +Expected: + - pubspec.yaml (with ensemble dependency) + - ensemble/ensemble.properties + - lib/generated/ensemble_modules.dart`; + +export async function resolveStarterProjectRoot(explicitPath?: string): Promise { + if (explicitPath) { + const resolved = path.resolve(explicitPath); + if (!(await isStarterProjectRoot(resolved))) throw new Error(INVALID_HINT); + return resolved; + } + + const root = await findStarterProjectRoot(process.cwd()); + if (!root) throw new Error(NOT_FOUND_HINT); + return root; +} diff --git a/src/core/starterTypes.ts b/src/core/starterTypes.ts new file mode 100644 index 0000000..c9296c4 --- /dev/null +++ b/src/core/starterTypes.ts @@ -0,0 +1,31 @@ +export type StarterPlatform = 'ios' | 'android' | 'web'; + +export interface StarterParameter { + key: string; + question: string; + type: string; + choices?: string[]; + platform: StarterPlatform[]; +} + +export interface StarterScript { + name: string; + path: string; + parameters: StarterParameter[]; +} + +export const STARTER_COMMON_PARAMETERS: StarterParameter[] = [ + { + key: 'platform', + question: 'Which platform(s) are you targeting?', + type: 'select', + choices: ['ios', 'android', 'web'], + platform: ['android', 'ios', 'web'], + }, + { + key: 'ensemble_version', + question: 'Which version of ensemble are you using?', + type: 'text', + platform: ['android', 'ios', 'web'], + }, +]; diff --git a/src/index.ts b/src/index.ts index 0428b82..1f351d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { addCommand } from './commands/add.js'; import { pullCommand } from './commands/pull.js'; import { releaseCreateCommand, releaseListCommand, releaseUseCommand } from './commands/release.js'; import { updateCommand } from './commands/update.js'; +import { enableCommand } from './commands/enable.js'; import { printCliError, resolveDebugFlag } from './core/cliError.js'; import { ui } from './core/ui.js'; @@ -214,6 +215,27 @@ program await updateCommand(); }); +program + .command('enable') + .description('Enable Ensemble starter modules (camera, location, google_maps, etc.).') + .argument('[modules...]', 'Module names to enable (e.g. camera location google_maps)') + .option('--project ', 'Starter project root (default: auto-detect from cwd)') + .option('--platform ', 'Target platform(s): ios, android, web (comma-separated)') + .option('--verbose', 'Print dart commands', false) + .action( + async ( + modules: string[], + options: { project?: string; platform?: string; verbose?: boolean } + ) => { + await enableCommand({ + modules, + project: options.project, + platform: options.platform, + verbose: options.verbose, + }); + } + ); + function checkForUpdates(): void { // Skip update checks in CI or when explicitly disabled. const ci = process.env.CI; @@ -222,11 +244,11 @@ function checkForUpdates(): void { return; } - // Use the user's existing npm + auth config to query GitHub Packages. // IMPORTANT: This command string must remain a static literal and MUST NOT // interpolate user-controlled input to avoid shell injection risks. - exec( + const child = exec( 'npm view @ensembleui/cli version --registry=https://registry.npmjs.org', + { timeout: 5_000 }, (error, stdout) => { if (error) { return; @@ -238,6 +260,7 @@ function checkForUpdates(): void { ui.note('Run "ensemble update" to upgrade.'); } ); + child.unref(); } checkForUpdates(); diff --git a/tests/commands/enable.test.ts b/tests/commands/enable.test.ts new file mode 100644 index 0000000..55f298d --- /dev/null +++ b/tests/commands/enable.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; + +import { parseEnableTokens } from '../../src/commands/enable.js'; + +describe('parseEnableTokens', () => { + it('splits module names from key=value parameters', () => { + const { moduleNames, inlineArgs } = parseEnableTokens([ + 'google_maps', + 'googleMapsApiKey=abc', + 'ensemble_version=1.2.40', + ]); + + expect(moduleNames).toEqual(['google_maps']); + expect(inlineArgs).toEqual({ + googleMapsApiKey: 'abc', + ensemble_version: '1.2.40', + }); + }); + + it('supports multiple modules and ignores non-module tokens', () => { + const { moduleNames, inlineArgs } = parseEnableTokens([ + 'camera', + 'location', + 'cameraDescription=Hello', + '__googleMapsApiKey', + 'not-a-module', + ]); + + expect(moduleNames).toEqual(['camera', 'location']); + expect(inlineArgs).toEqual({ cameraDescription: 'Hello' }); + }); +}); diff --git a/tests/core/gitProjectChanges.test.ts b/tests/core/gitProjectChanges.test.ts new file mode 100644 index 0000000..d65dc80 --- /dev/null +++ b/tests/core/gitProjectChanges.test.ts @@ -0,0 +1,45 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + collectGitWorkspaceChanges, + snapshotGitWorkspace, +} from '../../src/core/gitProjectChanges.js'; + +describe('gitProjectChanges', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ensemble-git-changes-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns null snapshot when project is not a git repo', async () => { + await fs.writeFile(path.join(tmpDir, 'pubspec.yaml'), 'name: demo\n'); + await expect(snapshotGitWorkspace(tmpDir)).resolves.toBeNull(); + await expect(collectGitWorkspaceChanges(tmpDir, null)).resolves.toEqual([]); + }); + + it('detects tracked file changes in a real git repo', async () => { + const pubspecPath = path.join(tmpDir, 'pubspec.yaml'); + await fs.writeFile(pubspecPath, 'name: demo\n'); + + const { execFileSync } = await import('node:child_process'); + execFileSync('git', ['init'], { cwd: tmpDir }); + execFileSync('git', ['add', 'pubspec.yaml'], { cwd: tmpDir }); + execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir }); + + const before = await snapshotGitWorkspace(tmpDir); + expect(before).not.toBeNull(); + + await fs.writeFile(pubspecPath, 'name: demo\ndependencies:\n ensemble: any\n'); + const modified = await collectGitWorkspaceChanges(tmpDir, before); + expect(modified).toContain('pubspec.yaml'); + }); +}); diff --git a/tests/core/moduleParams.test.ts b/tests/core/moduleParams.test.ts new file mode 100644 index 0000000..59e1047 --- /dev/null +++ b/tests/core/moduleParams.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { formatArgsForScript, resolveScriptArguments } from '../../src/core/moduleParams.js'; +import type { StarterScript } from '../../src/core/starterTypes.js'; + +const cameraScript: StarterScript = { + name: 'camera', + path: 'scripts/modules/enable_camera.dart', + parameters: [ + { + key: 'cameraDescription', + question: 'Camera description', + type: 'text', + platform: ['ios'], + }, + ], +}; + +describe('moduleParams', () => { + it('maps googleMapsApiKey into per-platform keys', async () => { + const args = await resolveScriptArguments({ + scripts: [ + { + name: 'google_maps', + path: 'scripts/modules/enable_google_maps.dart', + parameters: [], + }, + ], + provided: { + platform: 'web', + ensemble_version: '1.2.40', + googleMapsApiKey: 'abc123', + }, + interactive: false, + }); + + expect(args).toEqual( + expect.arrayContaining([ + 'platform=web', + 'ensemble_version=1.2.40', + 'webGoogleMapsApiKey=abc123', + ]) + ); + expect(args.some((arg) => arg.startsWith('googleMapsApiKey='))).toBe(false); + }); + + it('requires missing params in non-interactive mode', async () => { + await expect( + resolveScriptArguments({ + scripts: [cameraScript], + provided: { platform: 'ios' }, + interactive: false, + }) + ).rejects.toThrow(/Missing required parameter/i); + }); + + it('passes only args declared for the script', () => { + const args = formatArgsForScript(cameraScript, [ + 'platform=ios', + 'ensemble_version=1.2.40', + 'cameraDescription=hello', + 'webFirebaseApiKey=ignored', + ]); + + expect(args).toEqual(['platform=ios', 'ensemble_version=1.2.40', 'cameraDescription=hello']); + }); +}); diff --git a/tests/core/moduleRegistry.test.ts b/tests/core/moduleRegistry.test.ts new file mode 100644 index 0000000..f507fac --- /dev/null +++ b/tests/core/moduleRegistry.test.ts @@ -0,0 +1,36 @@ +import path from 'path'; + +import { describe, expect, it } from 'vitest'; + +import { + findStarterScript, + formatModuleLabel, + loadStarterRegistry, + normalizeModuleName, +} from '../../src/core/moduleRegistry.js'; + +const FIXTURE_STARTER = path.join(__dirname, '../fixtures/starter-cache'); + +describe('moduleRegistry', () => { + it('normalizes module aliases', () => { + expect(normalizeModuleName('generate_keystore')).toBe('generateKeystore'); + expect(normalizeModuleName('google-maps')).toBe('google_maps'); + }); + + it('formats labels for display', () => { + expect(formatModuleLabel('google_maps')).toBe('Google Maps'); + expect(formatModuleLabel('firebase_analytics')).toBe('Firebase Analytics'); + }); + + it('loads registry from cached starter fixture', async () => { + const registry = await loadStarterRegistry(FIXTURE_STARTER); + expect(registry.modules.some((module) => module.name === 'camera')).toBe(true); + expect(findStarterScript('camera', registry).path).toBe('scripts/modules/enable_camera.dart'); + expect(findStarterScript('generate_keystore', registry).name).toBe('generateKeystore'); + }); + + it('throws for unknown modules', async () => { + const registry = await loadStarterRegistry(FIXTURE_STARTER); + expect(() => findStarterScript('not_a_real_module', registry)).toThrow(/not found/i); + }); +}); diff --git a/tests/core/starterProject.test.ts b/tests/core/starterProject.test.ts new file mode 100644 index 0000000..2e75db5 --- /dev/null +++ b/tests/core/starterProject.test.ts @@ -0,0 +1,54 @@ +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { resolveStarterProjectRoot } from '../../src/core/starterProject.js'; + +describe('starterProject', () => { + let tmpDir: string; + let originalCwd: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ensemble-starter-project-')); + originalCwd = process.cwd(); + process.chdir(tmpDir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + async function writeStarterLayout(root: string): Promise { + await fs.mkdir(path.join(root, 'ensemble'), { recursive: true }); + await fs.mkdir(path.join(root, 'lib', 'generated'), { recursive: true }); + await fs.writeFile( + path.join(root, 'pubspec.yaml'), + 'name: demo\ndependencies:\n ensemble:\n git:\n url: https://github.com/EnsembleUI/ensemble.git\n' + ); + await fs.writeFile(path.join(root, 'ensemble', 'ensemble.properties'), 'appId=demo\n'); + await fs.writeFile(path.join(root, 'lib', 'generated', 'ensemble_modules.dart'), '// generated\n'); + } + + it('resolves starter root from cwd or parent directories', async () => { + await writeStarterLayout(tmpDir); + const nested = path.join(tmpDir, 'apps', 'mobile'); + await fs.mkdir(nested, { recursive: true }); + process.chdir(nested); + + const root = await resolveStarterProjectRoot(); + expect(await fs.realpath(root)).toBe(await fs.realpath(tmpDir)); + }); + + it('throws when starter markers are missing', async () => { + await expect(resolveStarterProjectRoot()).rejects.toThrow(/Could not find an Ensemble starter project/); + }); + + it('throws when explicit project path is invalid', async () => { + await expect(resolveStarterProjectRoot(tmpDir)).rejects.toThrow( + /This does not look like an Ensemble starter project/ + ); + }); +}); diff --git a/tests/fixtures/starter-cache/src/modules_scripts.ts b/tests/fixtures/starter-cache/src/modules_scripts.ts new file mode 100644 index 0000000..8c31ae7 --- /dev/null +++ b/tests/fixtures/starter-cache/src/modules_scripts.ts @@ -0,0 +1,14 @@ +export const modules = [ + { + name: 'camera', + path: 'scripts/modules/enable_camera.dart', + parameters: [ + { + key: 'cameraDescription', + question: 'Camera description', + platform: ['ios'], + type: 'text', + }, + ], + }, +]; diff --git a/tests/fixtures/starter-cache/src/utility_scripts.ts b/tests/fixtures/starter-cache/src/utility_scripts.ts new file mode 100644 index 0000000..14dd314 --- /dev/null +++ b/tests/fixtures/starter-cache/src/utility_scripts.ts @@ -0,0 +1,7 @@ +export const scripts = [ + { + name: 'generateKeystore', + path: 'scripts/generate_keystore.dart', + parameters: [], + }, +]; From 132040d8d62073200850e144210f993f44c49a24 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Wed, 17 Jun 2026 22:04:17 +0500 Subject: [PATCH 2/7] refactors --- README.md | 27 +- docs/ensemble-enable.md | 38 ++- src/commands/enable.ts | 91 +++--- src/core/enableRuntime.ts | 261 ++++++++++++++++++ src/core/gitProjectChanges.ts | 4 +- src/core/moduleParams.ts | 118 -------- src/core/moduleRegistry.ts | 57 ---- src/core/moduleRunner.ts | 26 +- src/core/modulesCache.ts | 30 +- src/core/starterTypes.ts | 31 --- src/index.ts | 26 +- tests/commands/enable.test.ts | 22 +- tests/core/enableRuntime.test.ts | 80 ++++++ tests/core/moduleParams.test.ts | 67 ----- tests/core/moduleRegistry.test.ts | 36 --- tests/core/starterProject.test.ts | 9 +- .../fixtures/starter-cache/src/interfaces.ts | 15 + .../starter-cache/src/utility_scripts.ts | 16 ++ 18 files changed, 496 insertions(+), 458 deletions(-) create mode 100644 src/core/enableRuntime.ts delete mode 100644 src/core/moduleParams.ts delete mode 100644 src/core/moduleRegistry.ts delete mode 100644 src/core/starterTypes.ts create mode 100644 tests/core/enableRuntime.test.ts delete mode 100644 tests/core/moduleParams.test.ts delete mode 100644 tests/core/moduleRegistry.test.ts create mode 100644 tests/fixtures/starter-cache/src/interfaces.ts diff --git a/README.md b/README.md index 5141704..2c58e12 100644 --- a/README.md +++ b/README.md @@ -25,18 +25,18 @@ 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 | +| 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 | +| `ensemble update` | Update the CLI to the latest version | ### Options @@ -75,15 +75,14 @@ ensemble update ```bash ensemble enable camera ensemble enable camera location - ensemble enable google_maps --platform web googleMapsApiKey=YOUR_KEY ensemble_version=1.2.40 + ensemble enable google_maps platform=web webGoogleMapsApiKey=YOUR_KEY ensemble_version=1.2.40 ensemble enable camera --project ./my-starter-app ``` - **Options** - `--project ` — Starter project root (default: auto-detect from current directory) - - `--platform ` — `ios`, `android`, `web`, or comma-separated list - `--verbose` — Print dart commands - - Module parameters use `key=value` (keys match `src/modules_scripts.ts` in cached tooling), or prompts in interactive mode + - 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` diff --git a/docs/ensemble-enable.md b/docs/ensemble-enable.md index 4b507ca..f8d9f7f 100644 --- a/docs/ensemble-enable.md +++ b/docs/ensemble-enable.md @@ -11,12 +11,11 @@ The CLI does **not** vendor module scripts. It downloads tooling from [EnsembleU ```bash ensemble enable [modules...] [key=value...] --project # starter root (default: auto-detect from cwd) - --platform # ios, android, web (comma-separated) --verbose # print dart command lines ``` -- **Interactive** (TTY): multiselect modules + prompts for missing params. -- **Direct**: `ensemble enable camera --platform ios cameraDescription=... ensemble_version=1.2.44` +- **Interactive** (TTY): cached runtime `selectModules` + `checkAndAskForMissingArgs`. +- **Direct**: `ensemble enable camera platform=ios cameraDescription=... ensemble_version=1.2.44` - Does **not** require `ensemble login`. --- @@ -27,21 +26,19 @@ ensemble enable [modules...] [key=value...] enable.ts ├── starterProject.ts detect starter root (pubspec + ensemble.properties + ensemble_modules.dart) ├── modulesCache.ts fetch/cache tooling from GitHub releases - ├── moduleRegistry.ts load modules_scripts.ts + utility_scripts.ts via jiti - ├── moduleParams.ts prompts + key=value args (mirrors starter utils) + ├── enableRuntime.ts jiti-load registry data; prompts use cached param definitions └── moduleRunner.ts fvm dart run cwd=user project └── gitProjectChanges.ts list Modified files (git snapshot diff) ``` +| Module | Role | +| ------------------ | -------------------------------------------------------------------------------------------------------- | +| `modulesCache.ts` | Resolve latest **stable** GitHub release; cache under `~/.ensemble/cache/modules_dir//` | +| `enableRuntime.ts` | jiti-load `modules_scripts.ts` + `utility_scripts.ts`; prompts via CLI `prompts` using cached param defs | +| `moduleRunner.ts` | Runs scripts sequentially; partial success on batch failure | +| `dartToolchain.ts` | `fvm dart` when `.fvmrc` / `.fvm/fvm_config.json` exists, else `dart` | -| Module | Role | -| ------------------- | --------------------------------------------------------------------------------------------------- | -| `modulesCache.ts` | Resolve latest **stable** GitHub release; cache under `~/.ensemble/cache/modules_dir//` | -| `moduleRegistry.ts` | Registry lookup, name aliases (`generate_keystore` → `generateKeystore`) | -| `moduleParams.ts` | `platform`, `ensemble_version`, per-module params; `googleMapsApiKey` fans out to per-platform keys | -| `moduleRunner.ts` | Runs scripts sequentially; partial success on batch failure | -| `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/`. --- @@ -53,8 +50,8 @@ enable.ts modules_dir/ .ref # last successfully cached release tag ensemble-v1.2.44/ # example tag — not hardcoded - src/modules_scripts.ts - scripts/modules/*.dart + src/* + scripts/* ``` **On each run:** @@ -75,7 +72,7 @@ fvm dart run 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 common params (`platform`, `ensemble_version`). +Args are `key=value` only (no `--flags`). Each script receives only keys declared in its registry entry plus `commonParameters` from cached `utility_scripts.ts`. --- @@ -90,7 +87,6 @@ If the project root has a `.git` directory, the CLI snapshots tracked + untracke ## Important distinctions - | Term | Meaning | | ------------------ | -------------------------------------------------------------------------------------------- | | `ensemble_version` | Flutter **package** git ref in pubspec (e.g. `1.2.44`) — prompted / passed by user | @@ -98,18 +94,17 @@ If the project root has a `.git` directory, the CLI snapshots tracked + untracke | Starter project | User’s Flutter app being modified | | Module tooling | Downloaded `starter/src` + `starter/scripts` from ensemble repo | - --- ## Testing ```bash -npm test # unit tests including parseEnableTokens, cache paths, git diff, registry +npm test npm run build -node dist/index.js enable camera --project ./my-app --platform ios ... +node dist/index.js enable camera --project ./my-app platform=ios ... ``` -Fixtures: `tests/fixtures/starter-cache/` (minimal registry for `moduleRegistry` tests). +Fixtures: `tests/fixtures/starter-cache/` (minimal cached `src/` tree for `enableRuntime` tests). --- @@ -125,4 +120,3 @@ Fixtures: `tests/fixtures/starter-cache/` (minimal registry for `moduleRegistry` ## Related - Issue: [ensemble-cli#3](https://github.com/EnsembleUI/ensemble-cli/issues/3) - diff --git a/src/commands/enable.ts b/src/commands/enable.ts index b0a229e..a31961d 100644 --- a/src/commands/enable.ts +++ b/src/commands/enable.ts @@ -1,105 +1,76 @@ -import prompts from 'prompts'; - import { ENSEMBLE_MODULES_REPO, ensureModulesTooling } from '../core/modulesCache.js'; import { - findStarterScript, + assertRequiredParamsPresent, formatModuleLabel, - loadStarterRegistry, - normalizeModuleName, -} from '../core/moduleRegistry.js'; + loadEnableRuntime, + parseEnableTokens, + resolveScript, + type EnableScript, +} from '../core/enableRuntime.js'; import { ModuleBatchError, runStarterScriptsSequentially } from '../core/moduleRunner.js'; -import { resolveScriptArguments, type StarterArgMap } from '../core/moduleParams.js'; import { resolveStarterProjectRoot } from '../core/starterProject.js'; import { ui } from '../core/ui.js'; -import type { StarterScript } from '../core/starterTypes.js'; + +export { parseEnableTokens } from '../core/enableRuntime.js'; export interface EnableCommandOptions { modules?: string[]; project?: string; - platform?: string; verbose?: boolean; } -const MODULE_NAME_RE = /^[a-z][a-z0-9_]*$/; -const NON_INTERACTIVE_HINT = 'Module name required for non-interactive use.\n\nExample:\n ensemble enable camera'; +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); } -/** split commander [modules...] tokens into module names and key=value params */ -export function parseEnableTokens(tokens: string[]): { - moduleNames: string[]; - inlineArgs: StarterArgMap; -} { - const moduleNames: string[] = []; - const inlineArgs: StarterArgMap = {}; - for (const token of tokens) { - if (token.includes('=')) { - const eq = token.indexOf('='); - const key = token.slice(0, eq); - if (key) inlineArgs[key] = token.slice(eq + 1); - } else if (MODULE_NAME_RE.test(token)) { - moduleNames.push(token); - } - } - return { moduleNames, inlineArgs }; -} - async function resolveScripts( - moduleNames: string[], - registry: Awaited>, + scriptNames: string[], + runtime: Awaited>, interactive: boolean -): Promise { - const names = moduleNames.map(normalizeModuleName).filter(Boolean); - if (names.length > 0) return names.map((name) => findStarterScript(name, registry)); +): Promise { + if (scriptNames.length > 0) { + return scriptNames.map((name) => resolveScript(name, runtime)); + } if (!interactive) throw new Error(NON_INTERACTIVE_HINT); - const { selected } = await prompts({ - type: 'multiselect', - name: 'selected', - message: 'What do you want to enable?', - choices: registry.modules.map((module) => ({ - title: formatModuleLabel(module.name), - value: module.name, - })), - hint: '- Space to select. Return to submit.', - }); - - if (!selected?.length) { + const selected = await runtime.selectModules(); + if (selected.length === 0) { ui.warn('Enable command cancelled.'); process.exitCode = 130; return []; } - - return selected.map((name: string) => findStarterScript(name, registry)); + return selected; } export async function enableCommand(options: EnableCommandOptions = {}): Promise { const interactive = isInteractiveTty(); - const { moduleNames, inlineArgs } = parseEnableTokens(options.modules ?? []); + const { scriptNames, argsArray: tokenArgs } = parseEnableTokens(options.modules ?? []); const projectRoot = await resolveStarterProjectRoot(options.project); const tooling = await ensureModulesTooling(); if (tooling.usedCacheFallback) { - ui.warn(`Could not fetch latest module tooling.\nUsing cached module tooling (${tooling.ref}).`); + ui.warn( + `Could not fetch latest module tooling.\nUsing cached module tooling (${tooling.ref}).` + ); } - const registry = await loadStarterRegistry(tooling.cacheDir); - const scripts = await resolveScripts(moduleNames, registry, interactive); + const runtime = await loadEnableRuntime(tooling.cacheDir); + const scripts = await resolveScripts(scriptNames, runtime, interactive); if (scripts.length === 0) return; - const argsArray = await resolveScriptArguments({ - scripts, - provided: { ...(options.platform ? { platform: options.platform } : {}), ...inlineArgs }, - interactive, - }); + const finalArgs = interactive + ? await runtime.checkAndAskForMissingArgs(scripts, tokenArgs) + : (assertRequiredParamsPresent(scripts, runtime.commonParameters, tokenArgs), tokenArgs); const runOptions = { cacheDir: tooling.cacheDir, projectRoot, scripts, - argsArray, + argsArray: finalArgs, + commonParameters: runtime.commonParameters, verbose: options.verbose, }; @@ -120,7 +91,7 @@ export async function enableCommand(options: EnableCommandOptions = {}): Promise } function printEnableSummary(options: { - scripts: StarterScript[]; + scripts: EnableScript[]; results: Array<{ scriptName: string; modifiedFiles: string[] }>; toolingRef: string; }): void { diff --git a/src/core/enableRuntime.ts b/src/core/enableRuntime.ts new file mode 100644 index 0000000..8993a77 --- /dev/null +++ b/src/core/enableRuntime.ts @@ -0,0 +1,261 @@ +import path from 'path'; +import { createJiti } from 'jiti'; +import prompts from 'prompts'; + +export interface EnableParameter { + key: string; + question: string; + type: string; + choices?: string[]; + platform: string[]; +} + +export interface EnableScript { + name: string; + path: string; + parameters: EnableParameter[]; +} + +export interface LoadedEnableRuntime { + modules: EnableScript[]; + utilityScripts: EnableScript[]; + commonParameters: EnableParameter[]; + selectModules: () => Promise; + checkAndAskForMissingArgs: (selected: EnableScript[], argsArray: string[]) => Promise; +} + +/** `=` → param, else → script/module name (matches cached dart_runner parseArguments) */ +export function parseEnableTokens(tokens: string[]): { + scriptNames: string[]; + argsArray: string[]; +} { + const scriptNames: string[] = []; + const argsArray: string[] = []; + for (const token of tokens) { + if (token.includes('=')) { + argsArray.push(token); + } else { + scriptNames.push(token); + } + } + return { scriptNames, argsArray }; +} + +function canonicalName(name: string): string { + return name.toLowerCase().replace(/[-_]/g, ''); +} + +export function formatModuleLabel(name: string): string { + return name + .split('_') + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +export function resolveScript(name: string, runtime: LoadedEnableRuntime): EnableScript { + const all = [...runtime.modules, ...runtime.utilityScripts]; + const found = + all.find((script) => script.name === name) ?? + all.find((script) => canonicalName(script.name) === canonicalName(name)); + if (!found) { + throw new Error( + `Module "${name}" not found. Available modules: ${runtime.modules.map((module) => module.name).join(', ')}` + ); + } + return found; +} + +function parseArgsMap(argsArray: string[]): Record { + const args: Record = {}; + for (const arg of argsArray) { + const eq = arg.indexOf('='); + if (eq < 0) continue; + const key = arg.slice(0, eq); + let value = arg.slice(eq + 1); + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1).replace(/\\"/g, '"'); + } + args[key] = value; + } + return args; +} + +function parsePlatformValue(args: Record): string | undefined { + const first = args.platform?.split(',')[0]?.trim().toLowerCase(); + if (first === 'ios' || first === 'android' || first === 'web') return first; + return undefined; +} + +function isParameterRequired( + param: EnableParameter, + args: Record, + providedKeys: Set +): boolean { + if (providedKeys.has(param.key) || args[param.key]) return false; + if (!args.platform) return true; + + const platform = parsePlatformValue(args); + return platform !== undefined && param.platform.includes(platform); +} + +function dedupeParameters(params: EnableParameter[]): EnableParameter[] { + const seen = new Set(); + return params.filter((param) => { + if (seen.has(param.key)) return false; + seen.add(param.key); + return true; + }); +} + +export function assertRequiredParamsPresent( + scripts: EnableScript[], + commonParameters: EnableParameter[], + argsArray: string[] +): void { + const args = parseArgsMap(argsArray); + const providedKeys = new Set(Object.keys(args)); + const params = dedupeParameters([ + ...commonParameters, + ...scripts.flatMap((script) => script.parameters), + ]); + const missing = params + .filter((param) => isParameterRequired(param, args, providedKeys)) + .map((param) => param.key); + + if (missing.length === 0) return; + + throw new Error( + `Missing required parameter(s): ${missing.join(', ')}.\n\nPass them as key=value, for example:\n ensemble enable camera platform=ios ensemble_version=1.2.40` + ); +} + +export function argsForScript( + script: EnableScript, + argsArray: string[], + commonParameters: EnableParameter[] +): string[] { + const allowed = new Set([ + ...script.parameters.map((param) => param.key), + ...commonParameters.map((param) => param.key), + ]); + return argsArray.filter((arg) => allowed.has(arg.split('=')[0] ?? '')); +} + +function normalizePromptAnswers( + answers: Record +): Record { + const normalized: Record = {}; + for (const [key, value] of Object.entries(answers)) { + if (value === 'yes') normalized[key] = 'true'; + else if (value === 'no') normalized[key] = 'false'; + else if (value !== undefined) normalized[key] = String(value); + } + return normalized; +} + +async function askForMissingParameters( + params: EnableParameter[], + args: Record, + providedKeys: Set +): Promise> { + const questions: prompts.PromptObject[] = params + .filter((param) => isParameterRequired(param, args, providedKeys)) + .map((param) => ({ + type: (param.type === 'toggle' ? 'select' : param.type) as prompts.PromptType, + name: param.key, + message: param.question, + choices: param.choices?.map((choice) => ({ title: choice, value: choice })), + validate: (value: string) => (value ? true : `Parameter "${param.key}" is required.`), + })); + + if (questions.length === 0) return {}; + return normalizePromptAnswers((await prompts(questions)) as Record); +} + +async function selectModulesFromRegistry(modules: EnableScript[]): Promise { + const { selectedModules } = await prompts({ + type: 'multiselect', + name: 'selectedModules', + message: 'Please select the modules you want to enable:', + choices: modules.map((module) => ({ + title: formatModuleLabel(module.name), + value: module.name, + })), + hint: '- Space to select. Return to submit.', + }); + + if (!selectedModules?.length) return []; + return selectedModules.map( + (name: string) => modules.find((module) => module.name === name) as EnableScript + ); +} + +async function checkAndAskForMissingArgs( + scripts: EnableScript[], + argsArray: string[], + commonParameters: EnableParameter[] +): Promise { + const args = parseArgsMap(argsArray); + const providedKeys = new Set(Object.keys(args)); + + const commonAnswers = await askForMissingParameters(commonParameters, args, providedKeys); + Object.assign(args, commonAnswers); + for (const key of Object.keys(commonAnswers)) providedKeys.add(key); + + const moduleAnswers = await askForMissingParameters( + dedupeParameters(scripts.flatMap((script) => script.parameters)), + args, + providedKeys + ); + + return argsArray.concat( + ...Object.entries({ ...commonAnswers, ...moduleAnswers }).map( + ([key, value]) => `${key}=${value}` + ) + ); +} + +/** Dart getArgumentValue splits on '=' and does not strip shell quotes — execFile args must be unquoted. */ +export function normalizeArgsForDart(argsArray: string[]): string[] { + return argsArray.map((arg) => { + const eq = arg.indexOf('='); + if (eq < 0) return arg; + const key = arg.slice(0, eq); + let value = arg.slice(eq + 1); + if (value.startsWith('"') && value.endsWith('"')) { + value = value.slice(1, -1).replace(/\\"/g, '"'); + } + return `${key}=${value}`; + }); +} + +function createCacheJiti(): ReturnType { + return createJiti(__filename, { interopDefault: true }); +} + +export async function loadEnableRuntime(cacheDir: string): Promise { + const jiti = createCacheJiti(); + const srcDir = path.join(cacheDir, 'src'); + + const modulesExport = jiti(path.join(srcDir, 'modules_scripts.ts')) as { + modules?: EnableScript[]; + }; + const utilityExport = jiti(path.join(srcDir, 'utility_scripts.ts')) as { + scripts?: EnableScript[]; + commonParameters?: EnableParameter[]; + }; + + const modules = modulesExport.modules ?? []; + const utilityScripts = utilityExport.scripts ?? []; + const commonParameters = utilityExport.commonParameters ?? []; + + return { + modules, + utilityScripts, + commonParameters, + selectModules: () => selectModulesFromRegistry(modules), + checkAndAskForMissingArgs: (selected, argsArray) => + checkAndAskForMissingArgs(selected, argsArray, commonParameters), + }; +} diff --git a/src/core/gitProjectChanges.ts b/src/core/gitProjectChanges.ts index 22261c5..2ba228d 100644 --- a/src/core/gitProjectChanges.ts +++ b/src/core/gitProjectChanges.ts @@ -10,7 +10,9 @@ const execFileAsync = promisify(execFile); const GIT_BUFFER = 10 * 1024 * 1024; async function hashFile(filePath: string): Promise { - return createHash('sha256').update(await fs.readFile(filePath)).digest('hex'); + return createHash('sha256') + .update(await fs.readFile(filePath)) + .digest('hex'); } async function isGitRepository(projectRoot: string): Promise { diff --git a/src/core/moduleParams.ts b/src/core/moduleParams.ts deleted file mode 100644 index 2594a3a..0000000 --- a/src/core/moduleParams.ts +++ /dev/null @@ -1,118 +0,0 @@ -import prompts from 'prompts'; - -import { - STARTER_COMMON_PARAMETERS, - type StarterParameter, - type StarterPlatform, - type StarterScript, -} from './starterTypes.js'; - -export type StarterArgMap = Record; - -const GOOGLE_MAPS_KEYS = [ - 'iOSGoogleMapsApiKey', - 'androidGoogleMapsApiKey', - 'webGoogleMapsApiKey', -] as const; - -function applyConvenienceFlags(args: StarterArgMap): StarterArgMap { - const next = { ...args }; - const sharedGoogleMapsKey = next.googleMapsApiKey; - if (!sharedGoogleMapsKey) return next; - - for (const key of GOOGLE_MAPS_KEYS) { - if (!next[key]) next[key] = sharedGoogleMapsKey; - } - delete next.googleMapsApiKey; - return next; -} - -function parsePlatformValue(raw: string | undefined): StarterPlatform | undefined { - const first = raw?.split(',')[0]?.trim().toLowerCase(); - if (first === 'ios' || first === 'android' || first === 'web') return first; - return undefined; -} - -function isStarterParameterRequired( - param: StarterParameter, - args: StarterArgMap, - providedKeys: Set -): boolean { - if (providedKeys.has(param.key) || args[param.key]) return false; - if (!args.platform) return true; - - const platform = parsePlatformValue(args.platform); - return platform !== undefined && param.platform.includes(platform); -} - -function normalizeAnswers(answers: Record): StarterArgMap { - const normalized: StarterArgMap = {}; - for (const [key, value] of Object.entries(answers)) { - if (value === 'yes') normalized[key] = 'true'; - else if (value === 'no') normalized[key] = 'false'; - else if (value !== undefined) normalized[key] = String(value); - } - return normalized; -} - -async function askForMissingParameters( - params: StarterParameter[], - args: StarterArgMap, - providedKeys: Set, - interactive: boolean -): Promise { - const questions: prompts.PromptObject[] = params - .filter((param) => isStarterParameterRequired(param, args, providedKeys)) - .map((param) => ({ - type: (param.type === 'toggle' ? 'select' : param.type) as prompts.PromptType, - name: param.key, - message: param.question, - choices: param.choices?.map((choice) => ({ title: choice, value: choice })), - validate: (value: string) => (value ? true : `Parameter "${param.key}" is required.`), - })); - - if (questions.length === 0) return {}; - if (!interactive) { - throw new Error( - `Missing required parameter(s): ${questions.map((q) => q.name).join(', ')}.\n\nPass them as key=value, for example:\n ensemble enable google_maps googleMapsApiKey=YOUR_KEY ensemble_version=1.2.40` - ); - } - - return normalizeAnswers((await prompts(questions)) as Record); -} - -function dedupeParameters(params: StarterParameter[]): StarterParameter[] { - const seen = new Set(); - return params.filter((param) => { - if (seen.has(param.key)) return false; - seen.add(param.key); - return true; - }); -} - -export async function resolveScriptArguments(options: { - scripts: StarterScript[]; - provided: StarterArgMap; - interactive: boolean; -}): Promise { - const args = applyConvenienceFlags(options.provided); - const providedKeys = new Set(Object.keys(args)); - - for (const params of [ - STARTER_COMMON_PARAMETERS, - dedupeParameters(options.scripts.flatMap((script) => script.parameters)), - ]) { - Object.assign(args, await askForMissingParameters(params, args, providedKeys, options.interactive)); - for (const key of Object.keys(args)) providedKeys.add(key); - } - - return Object.entries(args).map(([key, value]) => `${key}=${value}`); -} - -export function formatArgsForScript(script: StarterScript, argsArray: string[]): string[] { - const allowed = new Set([ - ...script.parameters.map((param) => param.key), - ...STARTER_COMMON_PARAMETERS.map((param) => param.key), - ]); - return argsArray.filter((arg) => allowed.has(arg.split('=')[0] ?? '')); -} diff --git a/src/core/moduleRegistry.ts b/src/core/moduleRegistry.ts deleted file mode 100644 index aa02e3f..0000000 --- a/src/core/moduleRegistry.ts +++ /dev/null @@ -1,57 +0,0 @@ -import path from 'path'; -import { createJiti } from 'jiti'; - -import type { StarterScript } from './starterTypes.js'; - -const MODULE_ALIASES: Record = { - generate_keystore: 'generateKeystore', - generatekeystore: 'generateKeystore', -}; - -export function normalizeModuleName(name: string): string { - const trimmed = name.trim(); - if (!trimmed) return trimmed; - return MODULE_ALIASES[trimmed.toLowerCase()] ?? trimmed.replace(/-/g, '_'); -} - -export function formatModuleLabel(name: string): string { - return name - .split('_') - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(' '); -} - -export async function loadStarterRegistry(cacheDir: string): Promise<{ - modules: StarterScript[]; - utilityScripts: StarterScript[]; -}> { - const jiti = createJiti(__filename, { interopDefault: true }); - const modulesExport = jiti(path.join(cacheDir, 'src', 'modules_scripts.ts')) as { - modules?: StarterScript[]; - }; - const utilityExport = jiti(path.join(cacheDir, 'src', 'utility_scripts.ts')) as { - scripts?: StarterScript[]; - }; - - return { - modules: modulesExport.modules ?? [], - utilityScripts: utilityExport.scripts ?? [], - }; -} - -export function findStarterScript( - name: string, - registry: { modules: StarterScript[]; utilityScripts: StarterScript[] } -): StarterScript { - const normalized = normalizeModuleName(name); - const script = [...registry.modules, ...registry.utilityScripts].find( - (entry) => entry.name === normalized || entry.name === name - ); - if (!script) { - throw new Error( - `Module "${name}" not found. Available modules: ${registry.modules.map((entry) => entry.name).join(', ')}` - ); - } - return script; -} diff --git a/src/core/moduleRunner.ts b/src/core/moduleRunner.ts index 5884dda..a915b8d 100644 --- a/src/core/moduleRunner.ts +++ b/src/core/moduleRunner.ts @@ -2,10 +2,18 @@ import path from 'path'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; -import { assertDartAvailable, resolveDartInvocation, type DartInvocation } from './dartToolchain.js'; +import { + argsForScript, + normalizeArgsForDart, + type EnableParameter, + type EnableScript, +} from './enableRuntime.js'; +import { + assertDartAvailable, + resolveDartInvocation, + type DartInvocation, +} from './dartToolchain.js'; import { collectGitWorkspaceChanges, snapshotGitWorkspace } from './gitProjectChanges.js'; -import { formatArgsForScript } from './moduleParams.js'; -import type { StarterScript } from './starterTypes.js'; const execFileAsync = promisify(execFile); @@ -46,8 +54,9 @@ function throwModuleError(scriptName: string, err: unknown): never { async function runStarterScript(options: { cacheDir: string; projectRoot: string; - script: StarterScript; + script: EnableScript; argsArray: string[]; + commonParameters: EnableParameter[]; dart: DartInvocation; verbose?: boolean; }): Promise { @@ -56,7 +65,11 @@ async function runStarterScript(options: { ...options.dart.prefixArgs, 'run', path.join(options.cacheDir, options.script.path), - ...formatArgsForScript(options.script, options.argsArray), + ...argsForScript( + options.script, + normalizeArgsForDart(options.argsArray), + options.commonParameters + ), ]; if (options.verbose) { @@ -84,8 +97,9 @@ async function runStarterScript(options: { export async function runStarterScriptsSequentially(options: { cacheDir: string; projectRoot: string; - scripts: StarterScript[]; + scripts: EnableScript[]; argsArray: string[]; + commonParameters: EnableParameter[]; verbose?: boolean; }): Promise { const dart = await resolveDartInvocation(options.projectRoot); diff --git a/src/core/modulesCache.ts b/src/core/modulesCache.ts index add06e4..08ef980 100644 --- a/src/core/modulesCache.ts +++ b/src/core/modulesCache.ts @@ -11,12 +11,7 @@ import { fileExists } from './fs.js'; export const ENSEMBLE_MODULES_REPO = 'EnsembleUI/ensemble'; -const STARTER_PATHS = [ - 'starter/src/', - 'starter/scripts/', - 'starter/package.json', - 'starter/tsconfig.json', -]; +const STARTER_PATHS = ['starter/src/', 'starter/scripts/']; const FETCH_TIMEOUT_MS = 15_000; const REGISTRY_REL = path.join('src', 'modules_scripts.ts'); @@ -71,23 +66,25 @@ async function hasRegistry(ref: string): Promise { return fileExists(path.join(getModulesReleaseCacheDir(ref), REGISTRY_REL)); } -async function cachedOrThrow(cachedRef: string | null, err: unknown): Promise { +async function cachedOrThrow( + cachedRef: string | null, + err: unknown +): Promise { if (cachedRef && (await hasRegistry(cachedRef))) return toolingResult(cachedRef, true); throw unavailableError(err instanceof Error ? err.message : String(err)); } async function fetchLatestRef(): Promise { - const response = await fetch( - 'https://api.github.com/repos/EnsembleUI/ensemble/releases/latest', - { - headers: { Accept: 'application/vnd.github+json' }, - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), - } - ); + const response = await fetch('https://api.github.com/repos/EnsembleUI/ensemble/releases/latest', { + headers: { Accept: 'application/vnd.github+json' }, + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); if (!response.ok) { throw new Error(`HTTP ${response.status} while fetching latest ensemble release`); } - const tag = getStableReleaseTag((await response.json()) as Parameters[0]); + const tag = getStableReleaseTag( + (await response.json()) as Parameters[0] + ); if (!tag) throw new Error('Latest GitHub release is not a stable release'); return tag; } @@ -129,7 +126,8 @@ async function downloadRelease(ref: string): Promise { }); const starter = path.join(tmp, 'starter'); - if (!(await fileExists(starter))) throw new Error('Downloaded archive did not contain starter/'); + if (!(await fileExists(starter))) + throw new Error('Downloaded archive did not contain starter/'); for (const entry of await fs.readdir(starter)) { const target = path.join(dest, entry); diff --git a/src/core/starterTypes.ts b/src/core/starterTypes.ts deleted file mode 100644 index c9296c4..0000000 --- a/src/core/starterTypes.ts +++ /dev/null @@ -1,31 +0,0 @@ -export type StarterPlatform = 'ios' | 'android' | 'web'; - -export interface StarterParameter { - key: string; - question: string; - type: string; - choices?: string[]; - platform: StarterPlatform[]; -} - -export interface StarterScript { - name: string; - path: string; - parameters: StarterParameter[]; -} - -export const STARTER_COMMON_PARAMETERS: StarterParameter[] = [ - { - key: 'platform', - question: 'Which platform(s) are you targeting?', - type: 'select', - choices: ['ios', 'android', 'web'], - platform: ['android', 'ios', 'web'], - }, - { - key: 'ensemble_version', - question: 'Which version of ensemble are you using?', - type: 'text', - platform: ['android', 'ios', 'web'], - }, -]; diff --git a/src/index.ts b/src/index.ts index 1f351d0..bb1fbce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -218,23 +218,19 @@ program program .command('enable') .description('Enable Ensemble starter modules (camera, location, google_maps, etc.).') - .argument('[modules...]', 'Module names to enable (e.g. camera location google_maps)') + .argument( + '[modules...]', + 'Module names and key=value params (e.g. camera platform=ios ensemble_version=1.2.40)' + ) .option('--project ', 'Starter project root (default: auto-detect from cwd)') - .option('--platform ', 'Target platform(s): ios, android, web (comma-separated)') .option('--verbose', 'Print dart commands', false) - .action( - async ( - modules: string[], - options: { project?: string; platform?: string; verbose?: boolean } - ) => { - await enableCommand({ - modules, - project: options.project, - platform: options.platform, - verbose: options.verbose, - }); - } - ); + .action(async (modules: string[], options: { project?: string; verbose?: boolean }) => { + await enableCommand({ + modules, + project: options.project, + verbose: options.verbose, + }); + }); function checkForUpdates(): void { // Skip update checks in CI or when explicitly disabled. diff --git a/tests/commands/enable.test.ts b/tests/commands/enable.test.ts index 55f298d..1307ba7 100644 --- a/tests/commands/enable.test.ts +++ b/tests/commands/enable.test.ts @@ -3,30 +3,26 @@ import { describe, expect, it } from 'vitest'; import { parseEnableTokens } from '../../src/commands/enable.js'; describe('parseEnableTokens', () => { - it('splits module names from key=value parameters', () => { - const { moduleNames, inlineArgs } = parseEnableTokens([ + it('splits script names from key=value parameters', () => { + const { scriptNames, argsArray } = parseEnableTokens([ 'google_maps', - 'googleMapsApiKey=abc', + 'webGoogleMapsApiKey=abc', 'ensemble_version=1.2.40', ]); - expect(moduleNames).toEqual(['google_maps']); - expect(inlineArgs).toEqual({ - googleMapsApiKey: 'abc', - ensemble_version: '1.2.40', - }); + expect(scriptNames).toEqual(['google_maps']); + expect(argsArray).toEqual(['webGoogleMapsApiKey=abc', 'ensemble_version=1.2.40']); }); - it('supports multiple modules and ignores non-module tokens', () => { - const { moduleNames, inlineArgs } = parseEnableTokens([ + it('treats every non key=value token as a script name', () => { + const { scriptNames, argsArray } = parseEnableTokens([ 'camera', 'location', 'cameraDescription=Hello', - '__googleMapsApiKey', 'not-a-module', ]); - expect(moduleNames).toEqual(['camera', 'location']); - expect(inlineArgs).toEqual({ cameraDescription: 'Hello' }); + expect(scriptNames).toEqual(['camera', 'location', 'not-a-module']); + expect(argsArray).toEqual(['cameraDescription=Hello']); }); }); diff --git a/tests/core/enableRuntime.test.ts b/tests/core/enableRuntime.test.ts new file mode 100644 index 0000000..077874b --- /dev/null +++ b/tests/core/enableRuntime.test.ts @@ -0,0 +1,80 @@ +import path from 'path'; + +import { describe, expect, it } from 'vitest'; + +import { + argsForScript, + assertRequiredParamsPresent, + argsForScript, + formatModuleLabel, + loadEnableRuntime, + normalizeArgsForDart, + parseEnableTokens, + resolveScript, +} from '../../src/core/enableRuntime.js'; + +const FIXTURE_CACHE = path.join(__dirname, '../fixtures/starter-cache'); + +describe('enableRuntime', () => { + it('parses tokens like cached dart_runner (equals = param, else script name)', () => { + expect(parseEnableTokens(['camera', 'platform=ios', 'ensemble_version=1.2.40'])).toEqual({ + scriptNames: ['camera'], + argsArray: ['platform=ios', 'ensemble_version=1.2.40'], + }); + }); + + it('formats labels for display', () => { + expect(formatModuleLabel('google_maps')).toBe('Google Maps'); + }); + + it('loads registry and runtime helpers from cached fixture', async () => { + const runtime = await loadEnableRuntime(FIXTURE_CACHE); + expect(runtime.modules.some((module) => module.name === 'camera')).toBe(true); + expect(runtime.commonParameters.map((param) => param.key)).toEqual([ + 'platform', + 'ensemble_version', + ]); + expect(resolveScript('camera', runtime).path).toBe('scripts/modules/enable_camera.dart'); + expect(resolveScript('generate_keystore', runtime).name).toBe('generateKeystore'); + }); + + it('throws for unknown scripts', async () => { + const runtime = await loadEnableRuntime(FIXTURE_CACHE); + expect(() => resolveScript('not_a_real_module', runtime)).toThrow(/not found/i); + }); + + it('filters args using commonParameters from cache', async () => { + const runtime = await loadEnableRuntime(FIXTURE_CACHE); + const camera = resolveScript('camera', runtime); + const filtered = argsForScript( + camera, + [ + 'platform=ios', + 'ensemble_version=1.2.40', + 'cameraDescription=hello', + 'webFirebaseApiKey=ignored', + ], + runtime.commonParameters + ); + expect(filtered).toEqual([ + 'platform=ios', + 'ensemble_version=1.2.40', + 'cameraDescription=hello', + ]); + }); + + it('requires missing params in non-interactive mode using cached definitions', async () => { + const runtime = await loadEnableRuntime(FIXTURE_CACHE); + const camera = resolveScript('camera', runtime); + expect(() => + assertRequiredParamsPresent([camera], runtime.commonParameters, ['platform=ios']) + ).toThrow(/Missing required parameter/i); + }); + + it('strips quotes from args before dart (platform="ios" breaks getPlatforms)', () => { + expect(normalizeArgsForDart(['platform="ios"', 'cameraDescription=hello'])).toEqual([ + 'platform=ios', + 'cameraDescription=hello', + ]); + }); +}); diff --git a/tests/core/moduleParams.test.ts b/tests/core/moduleParams.test.ts deleted file mode 100644 index 59e1047..0000000 --- a/tests/core/moduleParams.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { formatArgsForScript, resolveScriptArguments } from '../../src/core/moduleParams.js'; -import type { StarterScript } from '../../src/core/starterTypes.js'; - -const cameraScript: StarterScript = { - name: 'camera', - path: 'scripts/modules/enable_camera.dart', - parameters: [ - { - key: 'cameraDescription', - question: 'Camera description', - type: 'text', - platform: ['ios'], - }, - ], -}; - -describe('moduleParams', () => { - it('maps googleMapsApiKey into per-platform keys', async () => { - const args = await resolveScriptArguments({ - scripts: [ - { - name: 'google_maps', - path: 'scripts/modules/enable_google_maps.dart', - parameters: [], - }, - ], - provided: { - platform: 'web', - ensemble_version: '1.2.40', - googleMapsApiKey: 'abc123', - }, - interactive: false, - }); - - expect(args).toEqual( - expect.arrayContaining([ - 'platform=web', - 'ensemble_version=1.2.40', - 'webGoogleMapsApiKey=abc123', - ]) - ); - expect(args.some((arg) => arg.startsWith('googleMapsApiKey='))).toBe(false); - }); - - it('requires missing params in non-interactive mode', async () => { - await expect( - resolveScriptArguments({ - scripts: [cameraScript], - provided: { platform: 'ios' }, - interactive: false, - }) - ).rejects.toThrow(/Missing required parameter/i); - }); - - it('passes only args declared for the script', () => { - const args = formatArgsForScript(cameraScript, [ - 'platform=ios', - 'ensemble_version=1.2.40', - 'cameraDescription=hello', - 'webFirebaseApiKey=ignored', - ]); - - expect(args).toEqual(['platform=ios', 'ensemble_version=1.2.40', 'cameraDescription=hello']); - }); -}); diff --git a/tests/core/moduleRegistry.test.ts b/tests/core/moduleRegistry.test.ts deleted file mode 100644 index f507fac..0000000 --- a/tests/core/moduleRegistry.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import path from 'path'; - -import { describe, expect, it } from 'vitest'; - -import { - findStarterScript, - formatModuleLabel, - loadStarterRegistry, - normalizeModuleName, -} from '../../src/core/moduleRegistry.js'; - -const FIXTURE_STARTER = path.join(__dirname, '../fixtures/starter-cache'); - -describe('moduleRegistry', () => { - it('normalizes module aliases', () => { - expect(normalizeModuleName('generate_keystore')).toBe('generateKeystore'); - expect(normalizeModuleName('google-maps')).toBe('google_maps'); - }); - - it('formats labels for display', () => { - expect(formatModuleLabel('google_maps')).toBe('Google Maps'); - expect(formatModuleLabel('firebase_analytics')).toBe('Firebase Analytics'); - }); - - it('loads registry from cached starter fixture', async () => { - const registry = await loadStarterRegistry(FIXTURE_STARTER); - expect(registry.modules.some((module) => module.name === 'camera')).toBe(true); - expect(findStarterScript('camera', registry).path).toBe('scripts/modules/enable_camera.dart'); - expect(findStarterScript('generate_keystore', registry).name).toBe('generateKeystore'); - }); - - it('throws for unknown modules', async () => { - const registry = await loadStarterRegistry(FIXTURE_STARTER); - expect(() => findStarterScript('not_a_real_module', registry)).toThrow(/not found/i); - }); -}); diff --git a/tests/core/starterProject.test.ts b/tests/core/starterProject.test.ts index 2e75db5..298eb41 100644 --- a/tests/core/starterProject.test.ts +++ b/tests/core/starterProject.test.ts @@ -29,7 +29,10 @@ describe('starterProject', () => { 'name: demo\ndependencies:\n ensemble:\n git:\n url: https://github.com/EnsembleUI/ensemble.git\n' ); await fs.writeFile(path.join(root, 'ensemble', 'ensemble.properties'), 'appId=demo\n'); - await fs.writeFile(path.join(root, 'lib', 'generated', 'ensemble_modules.dart'), '// generated\n'); + await fs.writeFile( + path.join(root, 'lib', 'generated', 'ensemble_modules.dart'), + '// generated\n' + ); } it('resolves starter root from cwd or parent directories', async () => { @@ -43,7 +46,9 @@ describe('starterProject', () => { }); it('throws when starter markers are missing', async () => { - await expect(resolveStarterProjectRoot()).rejects.toThrow(/Could not find an Ensemble starter project/); + await expect(resolveStarterProjectRoot()).rejects.toThrow( + /Could not find an Ensemble starter project/ + ); }); it('throws when explicit project path is invalid', async () => { diff --git a/tests/fixtures/starter-cache/src/interfaces.ts b/tests/fixtures/starter-cache/src/interfaces.ts new file mode 100644 index 0000000..9e2a5b3 --- /dev/null +++ b/tests/fixtures/starter-cache/src/interfaces.ts @@ -0,0 +1,15 @@ +export type Platform = 'ios' | 'android' | 'web'; + +export interface Script { + name: string; + path: string; + parameters: Parameter[]; +} + +export interface Parameter { + key: string; + question: string; + type: string; + choices?: string[]; + platform: Platform[]; +} diff --git a/tests/fixtures/starter-cache/src/utility_scripts.ts b/tests/fixtures/starter-cache/src/utility_scripts.ts index 14dd314..eb88138 100644 --- a/tests/fixtures/starter-cache/src/utility_scripts.ts +++ b/tests/fixtures/starter-cache/src/utility_scripts.ts @@ -1,3 +1,19 @@ +export const commonParameters = [ + { + key: 'platform', + question: 'Which platform(s) are you targeting?', + type: 'select', + choices: ['ios', 'android', 'web'], + platform: ['android', 'ios', 'web'], + }, + { + key: 'ensemble_version', + question: 'Which version of ensemble are you using?', + type: 'text', + platform: ['android', 'ios', 'web'], + }, +]; + export const scripts = [ { name: 'generateKeystore', From 2877fcd24e846ba091b0636d4ec28e897f50e91f Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Wed, 17 Jun 2026 22:09:28 +0500 Subject: [PATCH 3/7] fix test for CI --- tests/core/gitProjectChanges.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/core/gitProjectChanges.test.ts b/tests/core/gitProjectChanges.test.ts index d65dc80..baf60e1 100644 --- a/tests/core/gitProjectChanges.test.ts +++ b/tests/core/gitProjectChanges.test.ts @@ -1,6 +1,7 @@ import fs from 'fs/promises'; import os from 'os'; import path from 'path'; +import { execFileSync } from 'node:child_process'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -9,6 +10,12 @@ import { snapshotGitWorkspace, } from '../../src/core/gitProjectChanges.js'; +function initGitRepo(repoDir: string): void { + execFileSync('git', ['init'], { cwd: repoDir }); + execFileSync('git', ['config', 'user.email', 'ensemble-cli-test@example.com'], { cwd: repoDir }); + execFileSync('git', ['config', 'user.name', 'Ensemble CLI Test'], { cwd: repoDir }); +} + describe('gitProjectChanges', () => { let tmpDir: string; @@ -30,8 +37,7 @@ describe('gitProjectChanges', () => { const pubspecPath = path.join(tmpDir, 'pubspec.yaml'); await fs.writeFile(pubspecPath, 'name: demo\n'); - const { execFileSync } = await import('node:child_process'); - execFileSync('git', ['init'], { cwd: tmpDir }); + initGitRepo(tmpDir); execFileSync('git', ['add', 'pubspec.yaml'], { cwd: tmpDir }); execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir }); From 2003618c1bf7775b3b31b9529ae6bdc2bb1c8477 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Thu, 18 Jun 2026 00:51:26 +0500 Subject: [PATCH 4/7] refactor: improve starter project root detection logic --- docs/ensemble-enable.md | 2 +- src/core/starterProject.ts | 51 ++++++++----------------------- tests/core/starterProject.test.ts | 31 +++++++++++++------ 3 files changed, 35 insertions(+), 49 deletions(-) diff --git a/docs/ensemble-enable.md b/docs/ensemble-enable.md index f8d9f7f..75602df 100644 --- a/docs/ensemble-enable.md +++ b/docs/ensemble-enable.md @@ -24,7 +24,7 @@ ensemble enable [modules...] [key=value...] ``` enable.ts - ├── starterProject.ts detect starter root (pubspec + ensemble.properties + ensemble_modules.dart) + ├── 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 cwd=user project diff --git a/src/core/starterProject.ts b/src/core/starterProject.ts index 83d536c..64f850c 100644 --- a/src/core/starterProject.ts +++ b/src/core/starterProject.ts @@ -2,56 +2,31 @@ import fs from 'fs/promises'; import path from 'path'; import { fileExists } from './fs.js'; -async function pubspecReferencesEnsemble(pubspecPath: string): Promise { +async function isStarterProjectRoot(dir: string): Promise { + const root = path.resolve(dir); + const pubspecPath = path.join(root, 'pubspec.yaml'); + if (!(await fileExists(pubspecPath))) return false; try { - return /\bensemble\b/.test(await fs.readFile(pubspecPath, 'utf8')); + if (!/\bensemble\b/.test(await fs.readFile(pubspecPath, 'utf8'))) return false; } catch { return false; } -} - -async function isStarterProjectRoot(dir: string): Promise { - const root = path.resolve(dir); - const pubspecPath = path.join(root, 'pubspec.yaml'); return ( - (await fileExists(pubspecPath)) && - (await pubspecReferencesEnsemble(pubspecPath)) && (await fileExists(path.join(root, 'ensemble/ensemble.properties'))) && (await fileExists(path.join(root, 'lib/generated/ensemble_modules.dart'))) ); } -async function findStarterProjectRoot(startDir: string): Promise { - let current = path.resolve(startDir); - for (;;) { - if (await isStarterProjectRoot(current)) return current; - const parent = path.dirname(current); - if (parent === current) return null; - current = parent; - } -} - -const NOT_FOUND_HINT = `Could not find an Ensemble starter project. - -Run this command from your starter project root, or pass: - - ensemble enable camera --project ./path-to-starter`; - -const INVALID_HINT = `This does not look like an Ensemble starter project. - -Expected: - - pubspec.yaml (with ensemble dependency) - - ensemble/ensemble.properties - - lib/generated/ensemble_modules.dart`; - export async function resolveStarterProjectRoot(explicitPath?: string): Promise { - if (explicitPath) { - const resolved = path.resolve(explicitPath); - if (!(await isStarterProjectRoot(resolved))) throw new Error(INVALID_HINT); - return resolved; + const root = path.resolve(explicitPath ?? process.cwd()); + + if (!(await isStarterProjectRoot(root))) { + throw new Error( + explicitPath + ? 'Not a starter project. Expected pubspec.yaml (ensemble), ensemble/ensemble.properties, lib/generated/ensemble_modules.dart' + : 'Not at starter project root. cd to the Flutter starter root or pass --project .' + ); } - const root = await findStarterProjectRoot(process.cwd()); - if (!root) throw new Error(NOT_FOUND_HINT); return root; } diff --git a/tests/core/starterProject.test.ts b/tests/core/starterProject.test.ts index 298eb41..685dc19 100644 --- a/tests/core/starterProject.test.ts +++ b/tests/core/starterProject.test.ts @@ -35,25 +35,36 @@ describe('starterProject', () => { ); } - it('resolves starter root from cwd or parent directories', async () => { + it('accepts cwd when it is the starter root', async () => { await writeStarterLayout(tmpDir); - const nested = path.join(tmpDir, 'apps', 'mobile'); + const root = await resolveStarterProjectRoot(); + expect(await fs.realpath(root)).toBe(await fs.realpath(tmpDir)); + }); + + it('rejects cwd when not the starter root', async () => { + await writeStarterLayout(tmpDir); + const nested = path.join(tmpDir, 'ensemble', 'apps', 'kpnApp'); await fs.mkdir(nested, { recursive: true }); process.chdir(nested); - const root = await resolveStarterProjectRoot(); - expect(await fs.realpath(root)).toBe(await fs.realpath(tmpDir)); + await expect(resolveStarterProjectRoot()).rejects.toThrow(/Not at starter project root/i); }); it('throws when starter markers are missing', async () => { - await expect(resolveStarterProjectRoot()).rejects.toThrow( - /Could not find an Ensemble starter project/ - ); + await expect(resolveStarterProjectRoot()).rejects.toThrow(/Not at starter project root/i); }); it('throws when explicit project path is invalid', async () => { - await expect(resolveStarterProjectRoot(tmpDir)).rejects.toThrow( - /This does not look like an Ensemble starter project/ - ); + await expect(resolveStarterProjectRoot(tmpDir)).rejects.toThrow(/Not a starter project/i); + }); + + it('accepts an explicit starter root via --project', async () => { + await writeStarterLayout(tmpDir); + const nested = path.join(tmpDir, 'ensemble', 'apps', 'kpnApp'); + await fs.mkdir(nested, { recursive: true }); + process.chdir(nested); + + const root = await resolveStarterProjectRoot(tmpDir); + expect(await fs.realpath(root)).toBe(await fs.realpath(tmpDir)); }); }); From 6df5d542df5fe78b29808865339eae8436883c36 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Thu, 18 Jun 2026 18:52:27 +0500 Subject: [PATCH 5/7] refactor: remove git project changes functionality --- docs/ensemble-enable.md | 10 ----- src/commands/enable.ts | 57 ++---------------------- src/core/gitProjectChanges.ts | 65 ---------------------------- src/core/moduleRunner.ts | 42 ++---------------- tests/core/gitProjectChanges.test.ts | 51 ---------------------- 5 files changed, 7 insertions(+), 218 deletions(-) delete mode 100644 src/core/gitProjectChanges.ts delete mode 100644 tests/core/gitProjectChanges.test.ts diff --git a/docs/ensemble-enable.md b/docs/ensemble-enable.md index 75602df..f0c1124 100644 --- a/docs/ensemble-enable.md +++ b/docs/ensemble-enable.md @@ -28,7 +28,6 @@ enable.ts ├── modulesCache.ts fetch/cache tooling from GitHub releases ├── enableRuntime.ts jiti-load registry data; prompts use cached param definitions └── moduleRunner.ts fvm dart run cwd=user project - └── gitProjectChanges.ts list Modified files (git snapshot diff) ``` | Module | Role | @@ -76,15 +75,6 @@ Args are `key=value` only (no `--flags`). Each script receives only keys declare --- -## Modified files - -If the project root has a `.git` directory, the CLI snapshots tracked + untracked files (SHA-256) before/after each script and prints a deduped **Modified** list. - -- No `.git` in project root → Modified section omitted (known limitation for monorepo nested starters). -- `pubspec.yaml` in Modified → suggests `flutter pub get`. - ---- - ## Important distinctions | Term | Meaning | diff --git a/src/commands/enable.ts b/src/commands/enable.ts index a31961d..6c14362 100644 --- a/src/commands/enable.ts +++ b/src/commands/enable.ts @@ -1,13 +1,12 @@ -import { ENSEMBLE_MODULES_REPO, ensureModulesTooling } from '../core/modulesCache.js'; +import { ensureModulesTooling } from '../core/modulesCache.js'; import { assertRequiredParamsPresent, - formatModuleLabel, loadEnableRuntime, parseEnableTokens, resolveScript, type EnableScript, } from '../core/enableRuntime.js'; -import { ModuleBatchError, runStarterScriptsSequentially } from '../core/moduleRunner.js'; +import { runStarterScriptsSequentially } from '../core/moduleRunner.js'; import { resolveStarterProjectRoot } from '../core/starterProject.js'; import { ui } from '../core/ui.js'; @@ -65,60 +64,12 @@ export async function enableCommand(options: EnableCommandOptions = {}): Promise ? await runtime.checkAndAskForMissingArgs(scripts, tokenArgs) : (assertRequiredParamsPresent(scripts, runtime.commonParameters, tokenArgs), tokenArgs); - const runOptions = { + await runStarterScriptsSequentially({ cacheDir: tooling.cacheDir, projectRoot, scripts, argsArray: finalArgs, commonParameters: runtime.commonParameters, verbose: options.verbose, - }; - - try { - printEnableSummary({ - scripts, - results: await runStarterScriptsSequentially(runOptions), - toolingRef: tooling.ref, - }); - } catch (err) { - if (err instanceof ModuleBatchError) { - printEnableSummary({ scripts, results: err.completed, toolingRef: tooling.ref }); - ui.error(`Stopped at ${formatModuleLabel(err.failedScript)}.`); - throw new Error(err.scriptOutput); - } - throw err; - } -} - -function printEnableSummary(options: { - scripts: EnableScript[]; - results: Array<{ scriptName: string; modifiedFiles: string[] }>; - toolingRef: string; -}): void { - const succeeded = new Set(options.results.map((result) => result.scriptName)); - const enabled = options.scripts - .filter((script) => succeeded.has(script.name)) - .map((script) => formatModuleLabel(script.name)); - const modified = [...new Set(options.results.flatMap((result) => result.modifiedFiles))].sort(); - - if (enabled.length === 0) return; - - ui.success('Enabled:'); - // eslint-disable-next-line no-console - console.log(enabled.map((name) => ` - ${name}`).join('\n')); - - ui.note('\nScripts:'); - ui.note(` Source: ${ENSEMBLE_MODULES_REPO}`); - ui.note(` Ref: ${options.toolingRef}`); - ui.note(' Registry: src/modules_scripts.ts'); - - if (modified.length > 0) { - ui.note('\nModified:'); - for (const file of modified) ui.note(` - ${file}`); - } - - if (modified.includes('pubspec.yaml')) { - ui.note('\nDependencies changed. Run:'); - ui.note(' flutter pub get'); - } + }); } diff --git a/src/core/gitProjectChanges.ts b/src/core/gitProjectChanges.ts deleted file mode 100644 index 2ba228d..0000000 --- a/src/core/gitProjectChanges.ts +++ /dev/null @@ -1,65 +0,0 @@ -import fs from 'fs/promises'; -import path from 'path'; -import { createHash } from 'node:crypto'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; - -import { fileExists } from './fs.js'; - -const execFileAsync = promisify(execFile); -const GIT_BUFFER = 10 * 1024 * 1024; - -async function hashFile(filePath: string): Promise { - return createHash('sha256') - .update(await fs.readFile(filePath)) - .digest('hex'); -} - -async function isGitRepository(projectRoot: string): Promise { - return fileExists(path.join(projectRoot, '.git')); -} - -async function listGitWorkspacePaths(projectRoot: string): Promise { - const [tracked, untracked] = await Promise.all([ - execFileAsync('git', ['-C', projectRoot, 'ls-files'], { maxBuffer: GIT_BUFFER }), - execFileAsync('git', ['-C', projectRoot, 'ls-files', '--others', '--exclude-standard'], { - maxBuffer: GIT_BUFFER, - }), - ]); - - return [...new Set(`${tracked.stdout}\n${untracked.stdout}`.split('\n').filter(Boolean))]; -} - -export async function snapshotGitWorkspace( - projectRoot: string -): Promise | null> { - if (!(await isGitRepository(projectRoot))) return null; - - const snapshot = new Map(); - await Promise.all( - (await listGitWorkspacePaths(projectRoot)).map(async (relativePath) => { - const absolute = path.join(projectRoot, relativePath); - if (!(await fileExists(absolute))) return; - if (!(await fs.stat(absolute)).isFile()) return; - snapshot.set(relativePath, await hashFile(absolute)); - }) - ); - return snapshot; -} - -function diffGitSnapshots(before: Map, after: Map): string[] { - const changed: string[] = []; - for (const relativePath of new Set([...before.keys(), ...after.keys()])) { - if (before.get(relativePath) !== after.get(relativePath)) changed.push(relativePath); - } - return changed.sort(); -} - -export async function collectGitWorkspaceChanges( - projectRoot: string, - before: Map | null -): Promise { - if (!before) return []; - const after = await snapshotGitWorkspace(projectRoot); - return after ? diffGitSnapshots(before, after) : []; -} diff --git a/src/core/moduleRunner.ts b/src/core/moduleRunner.ts index a915b8d..f908b8e 100644 --- a/src/core/moduleRunner.ts +++ b/src/core/moduleRunner.ts @@ -13,27 +13,9 @@ import { resolveDartInvocation, type DartInvocation, } from './dartToolchain.js'; -import { collectGitWorkspaceChanges, snapshotGitWorkspace } from './gitProjectChanges.js'; const execFileAsync = promisify(execFile); -interface ModuleRunResult { - scriptName: string; - modifiedFiles: string[]; -} - -export class ModuleBatchError extends Error { - constructor( - message: string, - readonly completed: ModuleRunResult[], - readonly failedScript: string, - readonly scriptOutput: string - ) { - super(message); - this.name = 'ModuleBatchError'; - } -} - function readExecOutput(err: unknown): string { if (!err || typeof err !== 'object') return String(err); const execErr = err as { stderr?: string; stdout?: string; message?: string }; @@ -59,8 +41,7 @@ async function runStarterScript(options: { commonParameters: EnableParameter[]; dart: DartInvocation; verbose?: boolean; -}): Promise { - const before = await snapshotGitWorkspace(options.projectRoot); +}): Promise { const commandArgs = [ ...options.dart.prefixArgs, 'run', @@ -87,11 +68,6 @@ async function runStarterScript(options: { } catch (err) { throwModuleError(options.script.name, err); } - - return { - scriptName: options.script.name, - modifiedFiles: await collectGitWorkspaceChanges(options.projectRoot, before), - }; } export async function runStarterScriptsSequentially(options: { @@ -101,23 +77,11 @@ export async function runStarterScriptsSequentially(options: { argsArray: string[]; commonParameters: EnableParameter[]; verbose?: boolean; -}): Promise { +}): Promise { const dart = await resolveDartInvocation(options.projectRoot); await assertDartAvailable(dart); - const results: ModuleRunResult[] = []; for (const script of options.scripts) { - try { - results.push(await runStarterScript({ ...options, script, dart })); - } catch (err) { - const output = err instanceof Error ? err.message : String(err); - throw new ModuleBatchError( - `Failed to run ${script.name}: ${output}`, - results, - script.name, - output - ); - } + await runStarterScript({ ...options, script, dart }); } - return results; } diff --git a/tests/core/gitProjectChanges.test.ts b/tests/core/gitProjectChanges.test.ts deleted file mode 100644 index baf60e1..0000000 --- a/tests/core/gitProjectChanges.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import fs from 'fs/promises'; -import os from 'os'; -import path from 'path'; -import { execFileSync } from 'node:child_process'; - -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { - collectGitWorkspaceChanges, - snapshotGitWorkspace, -} from '../../src/core/gitProjectChanges.js'; - -function initGitRepo(repoDir: string): void { - execFileSync('git', ['init'], { cwd: repoDir }); - execFileSync('git', ['config', 'user.email', 'ensemble-cli-test@example.com'], { cwd: repoDir }); - execFileSync('git', ['config', 'user.name', 'Ensemble CLI Test'], { cwd: repoDir }); -} - -describe('gitProjectChanges', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ensemble-git-changes-')); - }); - - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - it('returns null snapshot when project is not a git repo', async () => { - await fs.writeFile(path.join(tmpDir, 'pubspec.yaml'), 'name: demo\n'); - await expect(snapshotGitWorkspace(tmpDir)).resolves.toBeNull(); - await expect(collectGitWorkspaceChanges(tmpDir, null)).resolves.toEqual([]); - }); - - it('detects tracked file changes in a real git repo', async () => { - const pubspecPath = path.join(tmpDir, 'pubspec.yaml'); - await fs.writeFile(pubspecPath, 'name: demo\n'); - - initGitRepo(tmpDir); - execFileSync('git', ['add', 'pubspec.yaml'], { cwd: tmpDir }); - execFileSync('git', ['commit', '-m', 'init'], { cwd: tmpDir }); - - const before = await snapshotGitWorkspace(tmpDir); - expect(before).not.toBeNull(); - - await fs.writeFile(pubspecPath, 'name: demo\ndependencies:\n ensemble: any\n'); - const modified = await collectGitWorkspaceChanges(tmpDir, before); - expect(modified).toContain('pubspec.yaml'); - }); -}); From 8231a1d3d002b7093d9c4e008f0f7477c92d76f5 Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Thu, 18 Jun 2026 19:08:21 +0500 Subject: [PATCH 6/7] simplify script exec --- docs/ensemble-enable.md | 4 +- src/core/moduleRunner.ts | 86 +++++++++---------------------- src/core/starterProject.ts | 4 +- tests/core/starterProject.test.ts | 2 +- 4 files changed, 28 insertions(+), 68 deletions(-) diff --git a/docs/ensemble-enable.md b/docs/ensemble-enable.md index f0c1124..5829f64 100644 --- a/docs/ensemble-enable.md +++ b/docs/ensemble-enable.md @@ -34,7 +34,7 @@ enable.ts | ------------------ | -------------------------------------------------------------------------------------------------------- | | `modulesCache.ts` | Resolve latest **stable** GitHub release; cache under `~/.ensemble/cache/modules_dir//` | | `enableRuntime.ts` | jiti-load `modules_scripts.ts` + `utility_scripts.ts`; prompts via CLI `prompts` using cached param defs | -| `moduleRunner.ts` | Runs scripts sequentially; partial success on batch failure | +| `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/`. @@ -102,7 +102,7 @@ Fixtures: `tests/fixtures/starter-cache/` (minimal cached `src/` tree for `enabl - 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 but reports prior successes. +- Batch enable stops at first failure. - Global `checkForUpdates()` runs on every CLI invocation; use `ENSEMBLE_NO_UPDATE_CHECK=1` to skip. --- diff --git a/src/core/moduleRunner.ts b/src/core/moduleRunner.ts index f908b8e..30ef80e 100644 --- a/src/core/moduleRunner.ts +++ b/src/core/moduleRunner.ts @@ -1,6 +1,5 @@ import path from 'path'; -import { execFile } from 'node:child_process'; -import { promisify } from 'node:util'; +import { spawn } from 'node:child_process'; import { argsForScript, @@ -8,66 +7,17 @@ import { type EnableParameter, type EnableScript, } from './enableRuntime.js'; -import { - assertDartAvailable, - resolveDartInvocation, - type DartInvocation, -} from './dartToolchain.js'; - -const execFileAsync = promisify(execFile); - -function readExecOutput(err: unknown): string { - if (!err || typeof err !== 'object') return String(err); - const execErr = err as { stderr?: string; stdout?: string; message?: string }; - return [execErr.stderr, execErr.stdout, execErr.message].filter(Boolean).join('\n').trim(); -} - -function throwModuleError(scriptName: string, err: unknown): never { - const output = readExecOutput(err); - const detail = output || `Failed to run ${scriptName}`; - if (/Pattern not found/i.test(output)) { - throw new Error( - `${detail}\n\nThis starter project may not include placeholders for that module in lib/generated/ensemble_modules.dart. Try enabling modules individually, or update ensemble_modules.dart from the latest Ensemble starter.` - ); - } - throw new Error(detail); -} - -async function runStarterScript(options: { - cacheDir: string; - projectRoot: string; - script: EnableScript; - argsArray: string[]; - commonParameters: EnableParameter[]; - dart: DartInvocation; - verbose?: boolean; -}): Promise { - const commandArgs = [ - ...options.dart.prefixArgs, - 'run', - path.join(options.cacheDir, options.script.path), - ...argsForScript( - options.script, - normalizeArgsForDart(options.argsArray), - options.commonParameters - ), - ]; - - if (options.verbose) { - // eslint-disable-next-line no-console - console.log(`Executing: ${options.dart.command} ${commandArgs.join(' ')}`); - } - - try { - const { stdout, stderr } = await execFileAsync(options.dart.command, commandArgs, { - cwd: options.projectRoot, - maxBuffer: 10 * 1024 * 1024, +import { assertDartAvailable, resolveDartInvocation } from './dartToolchain.js'; + +function runProcess(command: string, args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, stdio: 'inherit' }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) resolve(); + else process.exit(1); }); - if (stdout) process.stdout.write(stdout); - if (stderr) process.stderr.write(stderr); - } catch (err) { - throwModuleError(options.script.name, err); - } + }); } export async function runStarterScriptsSequentially(options: { @@ -82,6 +32,18 @@ export async function runStarterScriptsSequentially(options: { await assertDartAvailable(dart); for (const script of options.scripts) { - await runStarterScript({ ...options, script, dart }); + const commandArgs = [ + ...dart.prefixArgs, + 'run', + path.join(options.cacheDir, script.path), + ...argsForScript(script, normalizeArgsForDart(options.argsArray), options.commonParameters), + ]; + + if (options.verbose) { + // eslint-disable-next-line no-console + console.log(`Executing: ${dart.command} ${commandArgs.join(' ')}`); + } + + await runProcess(dart.command, commandArgs, options.projectRoot); } } diff --git a/src/core/starterProject.ts b/src/core/starterProject.ts index 64f850c..7fc1909 100644 --- a/src/core/starterProject.ts +++ b/src/core/starterProject.ts @@ -22,9 +22,7 @@ export async function resolveStarterProjectRoot(explicitPath?: string): Promise< if (!(await isStarterProjectRoot(root))) { throw new Error( - explicitPath - ? 'Not a starter project. Expected pubspec.yaml (ensemble), ensemble/ensemble.properties, lib/generated/ensemble_modules.dart' - : 'Not at starter project root. cd to the Flutter starter root or pass --project .' + 'Not at starter project root. cd to the Flutter starter root or pass --project .' ); } diff --git a/tests/core/starterProject.test.ts b/tests/core/starterProject.test.ts index 685dc19..a5c5129 100644 --- a/tests/core/starterProject.test.ts +++ b/tests/core/starterProject.test.ts @@ -55,7 +55,7 @@ describe('starterProject', () => { }); it('throws when explicit project path is invalid', async () => { - await expect(resolveStarterProjectRoot(tmpDir)).rejects.toThrow(/Not a starter project/i); + await expect(resolveStarterProjectRoot(tmpDir)).rejects.toThrow(/Not at starter project root/i); }); it('accepts an explicit starter root via --project', async () => { From 6627c567de141a18479066e6aedc98311602858e Mon Sep 17 00:00:00 2001 From: anserwaseem Date: Thu, 18 Jun 2026 20:27:11 +0500 Subject: [PATCH 7/7] feat: add spinner for module tooling preparation in enable command --- src/commands/enable.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/enable.ts b/src/commands/enable.ts index 6c14362..6cbd069 100644 --- a/src/commands/enable.ts +++ b/src/commands/enable.ts @@ -9,6 +9,7 @@ import { 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'; @@ -48,7 +49,7 @@ export async function enableCommand(options: EnableCommandOptions = {}): Promise const interactive = isInteractiveTty(); const { scriptNames, argsArray: tokenArgs } = parseEnableTokens(options.modules ?? []); const projectRoot = await resolveStarterProjectRoot(options.project); - const tooling = await ensureModulesTooling(); + const tooling = await withSpinner('Preparing module tooling...', () => ensureModulesTooling()); if (tooling.usedCacheFallback) { ui.warn(