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..2c58e12 100644 --- a/README.md +++ b/README.md @@ -19,22 +19,24 @@ ensemble push ensemble pull ensemble release ensemble add +ensemble enable ensemble update ``` ## Commands -| Command | Description | -| ------------------ | ------------------------------------------------------------------------- | -| `ensemble login` | Log in to Ensemble (opens browser) | -| `ensemble logout` | Log out and clear local auth session | -| `ensemble token` | Print token for CI (set as `ENSEMBLE_TOKEN`); run `ensemble login` first | -| `ensemble init` | Initialize or update `ensemble.config.json` in the project | -| `ensemble push` | Scan the app directory and push changes to the cloud | -| `ensemble pull` | Pull artifacts from the cloud and overwrite local files | -| `ensemble release` | Manage releases (snapshots) of your app (interactive menu or subcommands) | -| `ensemble add` | Add a new screen, widget, script, action, translation, or asset | -| `ensemble update` | Update the CLI to the latest version | +| Command | Description | +| ------------------ | ----------------------------------------------------------------------------- | +| `ensemble login` | Log in to Ensemble (opens browser) | +| `ensemble logout` | Log out and clear local auth session | +| `ensemble token` | Print token for CI (set as `ENSEMBLE_TOKEN`); run `ensemble login` first | +| `ensemble init` | Initialize or update `ensemble.config.json` in the project | +| `ensemble push` | Scan the app directory and push changes to the cloud | +| `ensemble pull` | Pull artifacts from the cloud and overwrite local files | +| `ensemble release` | Manage releases (snapshots) of your app (interactive menu or subcommands) | +| `ensemble add` | Add a new screen, widget, script, action, translation, or asset | +| `ensemble enable` | Enable starter modules (camera, location, google_maps, etc.) in a Flutter app | +| `ensemble update` | Update the CLI to the latest version | ### Options @@ -56,9 +58,39 @@ 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 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) + - `--verbose` — Print dart commands + - Module parameters use `key=value` (keys match cached `src/modules_scripts.ts` and `src/utility_scripts.ts`), or prompts in interactive mode + +- **Notes** + - Does not require `ensemble login` + - Uses `fvm dart` when the project has `.fvmrc` + - Checks GitHub for the latest release on each run; re-downloads only when the cached release tag differs (or cache is missing). Offline runs use the cached release. + - After `pubspec.yaml` changes, run `flutter pub get` + - Team architecture notes: [docs/ensemble-enable.md](docs/ensemble-enable.md) + ### `ensemble add` `ensemble add` scaffolds common app artifacts in your project and updates `.manifest.json` when needed. diff --git a/docs/ensemble-enable.md b/docs/ensemble-enable.md new file mode 100644 index 0000000..5829f64 --- /dev/null +++ b/docs/ensemble-enable.md @@ -0,0 +1,112 @@ +# `ensemble enable` + +Enable optional capabilities (camera, maps, notifications, etc.) in an Ensemble Flutter starter project. + +The CLI does **not** vendor module scripts. It downloads tooling from [EnsembleUI/ensemble](https://github.com/EnsembleUI/ensemble) at runtime, caches it locally, and runs Dart scripts against the user’s project. + +--- + +## Command surface + +```bash +ensemble enable [modules...] [key=value...] + --project # starter root (default: auto-detect from cwd) + --verbose # print dart command lines +``` + +- **Interactive** (TTY): cached runtime `selectModules` + `checkAndAskForMissingArgs`. +- **Direct**: `ensemble enable camera platform=ios cameraDescription=... ensemble_version=1.2.44` +- Does **not** require `ensemble login`. + +--- + +## Architecture + +``` +enable.ts + ├── starterProject.ts cwd or --project must be starter root (no walk-up) + ├── modulesCache.ts fetch/cache tooling from GitHub releases + ├── enableRuntime.ts jiti-load registry data; prompts use cached param definitions + └── moduleRunner.ts fvm dart run cwd=user project +``` + +| 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 cached Dart scripts sequentially (`stdio: inherit`; script owns output) | +| `dartToolchain.ts` | `fvm dart` when `.fvmrc` / `.fvm/fvm_config.json` exists, else `dart` | + +**Not duplicated in CLI:** module list, parameter keys, prompt text, `commonParameters` — all from cached Ensemble `src/`. + +--- + +## Module tooling cache + +**Path:** `~/.ensemble/cache/modules_dir/` + +``` +modules_dir/ + .ref # last successfully cached release tag + ensemble-v1.2.44/ # example tag — not hardcoded + src/* + scripts/* +``` + +**On each run:** + +1. `GET /repos/EnsembleUI/ensemble/releases/latest` (15s timeout) +2. If cached tag matches latest and registry exists → **no download** +3. If tag differs or cache missing → download tarball, extract `starter/` subset, update `.ref`, delete previous tag dir +4. Offline / fetch failure → use cached release if present (warn user) + +**Not cached in CLI repo** — only downloaded at runtime. + +--- + +## Script execution + +```bash +fvm dart run key=value key=value ... +# cwd: user starter project root +``` + +Args are `key=value` only (no `--flags`). Each script receives only keys declared in its registry entry plus `commonParameters` from cached `utility_scripts.ts`. + +--- + +## Important distinctions + +| Term | Meaning | +| ------------------ | -------------------------------------------------------------------------------------------- | +| `ensemble_version` | Flutter **package** git ref in pubspec (e.g. `1.2.44`) — prompted / passed by user | +| Cache release tag | GitHub **release** tag for module tooling (e.g. `ensemble-v1.2.44`) — resolved automatically | +| Starter project | User’s Flutter app being modified | +| Module tooling | Downloaded `starter/src` + `starter/scripts` from ensemble repo | + +--- + +## Testing + +```bash +npm test +npm run build +node dist/index.js enable camera --project ./my-app platform=ios ... +``` + +Fixtures: `tests/fixtures/starter-cache/` (minimal cached `src/` tree for `enableRuntime` tests). + +--- + +## Known limitations + +- Older starters may lack placeholders in `lib/generated/ensemble_modules.dart` → `Pattern not found` from Dart scripts. +- Re-enabling an already-enabled module often fails (expected). +- Batch enable stops at first failure. +- Global `checkForUpdates()` runs on every CLI invocation; use `ENSEMBLE_NO_UPDATE_CHECK=1` to skip. + +--- + +## Related + +- Issue: [ensemble-cli#3](https://github.com/EnsembleUI/ensemble-cli/issues/3) 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..6cbd069 --- /dev/null +++ b/src/commands/enable.ts @@ -0,0 +1,76 @@ +import { ensureModulesTooling } from '../core/modulesCache.js'; +import { + assertRequiredParamsPresent, + loadEnableRuntime, + parseEnableTokens, + resolveScript, + type EnableScript, +} from '../core/enableRuntime.js'; +import { runStarterScriptsSequentially } from '../core/moduleRunner.js'; +import { resolveStarterProjectRoot } from '../core/starterProject.js'; +import { ui } from '../core/ui.js'; +import { withSpinner } from '../lib/spinner.js'; + +export { parseEnableTokens } from '../core/enableRuntime.js'; + +export interface EnableCommandOptions { + modules?: string[]; + project?: string; + verbose?: boolean; +} + +const NON_INTERACTIVE_HINT = + 'Module name required for non-interactive use.\n\nExample:\n ensemble enable camera'; + +function isInteractiveTty(): boolean { + return Boolean(process.stdout.isTTY && process.stdin.isTTY); +} + +async function resolveScripts( + scriptNames: string[], + runtime: Awaited>, + interactive: boolean +): Promise { + if (scriptNames.length > 0) { + return scriptNames.map((name) => resolveScript(name, runtime)); + } + if (!interactive) throw new Error(NON_INTERACTIVE_HINT); + + const selected = await runtime.selectModules(); + if (selected.length === 0) { + ui.warn('Enable command cancelled.'); + process.exitCode = 130; + return []; + } + return selected; +} + +export async function enableCommand(options: EnableCommandOptions = {}): Promise { + const interactive = isInteractiveTty(); + const { scriptNames, argsArray: tokenArgs } = parseEnableTokens(options.modules ?? []); + const projectRoot = await resolveStarterProjectRoot(options.project); + const tooling = await withSpinner('Preparing module tooling...', () => ensureModulesTooling()); + + if (tooling.usedCacheFallback) { + ui.warn( + `Could not fetch latest module tooling.\nUsing cached module tooling (${tooling.ref}).` + ); + } + + const runtime = await loadEnableRuntime(tooling.cacheDir); + const scripts = await resolveScripts(scriptNames, runtime, interactive); + if (scripts.length === 0) return; + + const finalArgs = interactive + ? await runtime.checkAndAskForMissingArgs(scripts, tokenArgs) + : (assertRequiredParamsPresent(scripts, runtime.commonParameters, tokenArgs), tokenArgs); + + await runStarterScriptsSequentially({ + cacheDir: tooling.cacheDir, + projectRoot, + scripts, + argsArray: finalArgs, + commonParameters: runtime.commonParameters, + verbose: options.verbose, + }); +} 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/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/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/moduleRunner.ts b/src/core/moduleRunner.ts new file mode 100644 index 0000000..30ef80e --- /dev/null +++ b/src/core/moduleRunner.ts @@ -0,0 +1,49 @@ +import path from 'path'; +import { spawn } from 'node:child_process'; + +import { + argsForScript, + normalizeArgsForDart, + type EnableParameter, + type EnableScript, +} from './enableRuntime.js'; +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); + }); + }); +} + +export async function runStarterScriptsSequentially(options: { + cacheDir: string; + projectRoot: string; + scripts: EnableScript[]; + argsArray: string[]; + commonParameters: EnableParameter[]; + verbose?: boolean; +}): Promise { + const dart = await resolveDartInvocation(options.projectRoot); + await assertDartAvailable(dart); + + for (const script of options.scripts) { + 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/modulesCache.ts b/src/core/modulesCache.ts new file mode 100644 index 0000000..08ef980 --- /dev/null +++ b/src/core/modulesCache.ts @@ -0,0 +1,171 @@ +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/']; +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..7fc1909 --- /dev/null +++ b/src/core/starterProject.ts @@ -0,0 +1,30 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileExists } from './fs.js'; + +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 { + if (!/\bensemble\b/.test(await fs.readFile(pubspecPath, 'utf8'))) return false; + } catch { + return false; + } + return ( + (await fileExists(path.join(root, 'ensemble/ensemble.properties'))) && + (await fileExists(path.join(root, 'lib/generated/ensemble_modules.dart'))) + ); +} + +export async function resolveStarterProjectRoot(explicitPath?: string): Promise { + const root = path.resolve(explicitPath ?? process.cwd()); + + if (!(await isStarterProjectRoot(root))) { + throw new Error( + 'Not at starter project root. cd to the Flutter starter root or pass --project .' + ); + } + + return root; +} diff --git a/src/index.ts b/src/index.ts index 0428b82..bb1fbce 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,23 @@ program await updateCommand(); }); +program + .command('enable') + .description('Enable Ensemble starter modules (camera, location, google_maps, etc.).') + .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('--verbose', 'Print dart commands', false) + .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. const ci = process.env.CI; @@ -222,11 +240,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 +256,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..1307ba7 --- /dev/null +++ b/tests/commands/enable.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { parseEnableTokens } from '../../src/commands/enable.js'; + +describe('parseEnableTokens', () => { + it('splits script names from key=value parameters', () => { + const { scriptNames, argsArray } = parseEnableTokens([ + 'google_maps', + 'webGoogleMapsApiKey=abc', + 'ensemble_version=1.2.40', + ]); + + expect(scriptNames).toEqual(['google_maps']); + expect(argsArray).toEqual(['webGoogleMapsApiKey=abc', 'ensemble_version=1.2.40']); + }); + + it('treats every non key=value token as a script name', () => { + const { scriptNames, argsArray } = parseEnableTokens([ + 'camera', + 'location', + 'cameraDescription=Hello', + 'not-a-module', + ]); + + 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/starterProject.test.ts b/tests/core/starterProject.test.ts new file mode 100644 index 0000000..a5c5129 --- /dev/null +++ b/tests/core/starterProject.test.ts @@ -0,0 +1,70 @@ +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('accepts cwd when it is the starter root', async () => { + await writeStarterLayout(tmpDir); + 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); + + await expect(resolveStarterProjectRoot()).rejects.toThrow(/Not at starter project root/i); + }); + + it('throws when starter markers are missing', async () => { + 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(/Not at starter project root/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)); + }); +}); 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/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..eb88138 --- /dev/null +++ b/tests/fixtures/starter-cache/src/utility_scripts.ts @@ -0,0 +1,23 @@ +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', + path: 'scripts/generate_keystore.dart', + parameters: [], + }, +];