diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cb1293b..f1c52f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,31 +1,31 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - lint-and-test: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Install dependencies - run: npm install - - - name: Lint - run: npm run lint - - - name: Format check - run: npm run format:check - - - name: Test - run: npm test +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: npm install + + - name: Lint + run: npm run lint + + - name: Format check + run: npm run format:check + + - name: Test + run: npm test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1c48593..d297163 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,82 +1,82 @@ -name: Release (bump version, tag, publish) - -on: - workflow_dispatch: - inputs: - bump: - description: Version bump (patch/minor/major) - required: true - default: patch - type: choice - options: - - patch - - minor - - major - -jobs: - release: - runs-on: ubuntu-latest - - permissions: - contents: write - packages: write - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: 24 - registry-url: https://npm.pkg.github.com - - - name: Install dependencies - run: npm install - - - name: Run tests - run: npm test - - - name: Configure git identity - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Bump version and create tag - run: | - npm version "${{ inputs.bump }}" -m "chore(release): %s" - - - name: Read new tag - id: tag - run: | - VERSION=$(node -p "require('./package.json').version") - echo "name=v$VERSION" >> "$GITHUB_OUTPUT" - - - name: Push commit and tags - run: git push --follow-tags - - - name: Build CLI (create dist/) - run: npm run build - env: - ENSEMBLE_FIREBASE_API_KEY: ${{ secrets.ENSEMBLE_FIREBASE_API_KEY }} - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.tag.outputs.name }} - generate_release_notes: true - - - name: Publish to GitHub Packages - run: npm publish --registry=https://npm.pkg.github.com - env: - NODE_AUTH_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN != '' && secrets.GH_PACKAGES_TOKEN || github.token }} - - - name: Publish to npm (trusted publishing / OIDC) - run: | - node --version - npm --version - npm config set @ensembleui:registry https://registry.npmjs.org - npm publish --registry=https://registry.npmjs.org --provenance +name: Release (bump version, tag, publish) + +on: + workflow_dispatch: + inputs: + bump: + description: Version bump (patch/minor/major) + required: true + default: patch + type: choice + options: + - patch + - minor + - major + +jobs: + release: + runs-on: ubuntu-latest + + permissions: + contents: write + packages: write + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 24 + registry-url: https://npm.pkg.github.com + + - name: Install dependencies + run: npm install + + - name: Run tests + run: npm test + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Bump version and create tag + run: | + npm version "${{ inputs.bump }}" -m "chore(release): %s" + + - name: Read new tag + id: tag + run: | + VERSION=$(node -p "require('./package.json').version") + echo "name=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Push commit and tags + run: git push --follow-tags + + - name: Build CLI (create dist/) + run: npm run build + env: + ENSEMBLE_FIREBASE_API_KEY: ${{ secrets.ENSEMBLE_FIREBASE_API_KEY }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.name }} + generate_release_notes: true + + - name: Publish to GitHub Packages + run: npm publish --registry=https://npm.pkg.github.com + env: + NODE_AUTH_TOKEN: ${{ secrets.GH_PACKAGES_TOKEN != '' && secrets.GH_PACKAGES_TOKEN || github.token }} + + - name: Publish to npm (trusted publishing / OIDC) + run: | + node --version + npm --version + npm config set @ensembleui:registry https://registry.npmjs.org + npm publish --registry=https://registry.npmjs.org --provenance diff --git a/.gitignore b/.gitignore index 85dd763..ad0870f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,16 @@ -node_modules/ -dist/ -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -.DS_Store +node_modules/ +dist/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store +.idea/ +.eslintcache +package-lock.json + .env + .env.local -.idea/ + .vscode/ -.eslintcache -package-lock.json \ No newline at end of file diff --git a/README.md b/README.md index 9db852f..196a852 100644 --- a/README.md +++ b/README.md @@ -1,237 +1,247 @@ -# Ensemble CLI - -CLI for logging in, initializing, and pushing app definitions to the Ensemble cloud. - -## Installation - -```bash -npm install -g @ensembleui/cli -``` - -### Use the CLI - -```bash -ensemble login -ensemble logout -ensemble token -ensemble init -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 enable` | Enable starter modules (camera, location, google_maps, etc.) in a Flutter app | -| `ensemble update` | Update the CLI to the latest version | - -### Options - -- **global** — `--debug` — Print full debug information and stack traces. Can also be enabled with `DEBUG=1`. -- **login** — `--verbose` — Print auth config path -- **push** — `--app ` — App alias / environment key from `ensemble.config.json` (default: `default`) -- **push** — `--verbose` — Write collected data, diff, bundle, and payload JSON files for debugging -- **push** — `--dry-run` — Show what would be pushed without sending anything to the cloud -- **push** — `-y, --yes` — Skip confirmation prompt (useful for CI) -- **pull** — `--app ` — App alias / environment key from `ensemble.config.json` (default: `default`) -- **pull** — `--verbose` — Write fetched cloud JSON to disk -- **pull** — `--dry-run` — Show what would change without modifying local files -- **pull** — `-y, --yes` — Skip confirmation prompt (overwrite without asking) -- **release create** — `--app ` — App alias (default: `default`) -- **release create** — `-m, --message ` — Release message (skips prompt) -- **release create** — `-y, --yes` — Skip message prompt (use empty message) -- **release create** — `--verbose` — Show full Firestore/Storage error response text (debugging) -- **release list** — `--app ` — App alias (default: `default`) -- **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 (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. - -- **Supported kinds** - - `screen` - - `widget` - - `script` - - `action` - - `translation` - - `asset` - -- **Usage** - - Interactive (prompts for kind and name): - - ```bash - ensemble add - ``` - - - Non-interactive: - - ```bash - ensemble add screen Home - ensemble add widget MyWidget - ensemble add script myUtility - ensemble add action ShowToast - ensemble add translation en_US - ensemble add asset ./logo.png - ``` - -- **Naming rules** - - Artifact names are normalized (trimmed, repeated whitespace collapsed). - - Names **cannot contain spaces** in the final file name. If you pass a name with spaces, the CLI will suggest a version without spaces (for example, `\"My Screen\"` → `MyScreen`) and let you confirm in interactive mode. - -- **Files created** - - Screens: `screens/.yaml` - - Widgets: `widgets/.yaml` - - Actions: `actions/.yaml` - - Scripts: `scripts/.js` - - Translations: `translations/.yaml` - - Assets: `assets/` - -- **Asset upload behavior** - - `asset` expects a file path (not a generated scaffold name). - - The file is copied to `assets/`, uploaded to Ensemble cloud, and `.env.config` is upserted: - - `assets=` is added only if missing. - - The cloud-provided env variable key/value is added (or updated). - - The CLI prints the returned usage key so you can paste it directly in app definitions. - -- **Manifest behavior** - - For `widget`, `script`, `action`, and `translation`, `.manifest.json` is updated to include the new artifact. - - Screens do not change `.manifest.json`. - -## Usage - -1. Log in: `ensemble login` -2. From your project root, run `ensemble init` and link an existing app -3. Run `ensemble push` to sync your local app (screens, widgets, scripts, etc.) with the cloud -4. Optionally run `ensemble pull` to refresh local artifacts from the cloud when other collaborators change them - -### Environment files - -Config and secrets sync on `push`, `pull`, and `release use`: - -- **Base**: `.env.config`, `.env.secrets` (shared defaults) -- **Per-alias** (non-default alias, or when both scoped files exist): `.env.config.`, `.env.secrets.` -- Default alias with only base files uses the base pair; other aliases never overwrite base on pull -- **Missing** local file → that side skipped on push (no cloud wipe) -- **Empty** local file → wipe cloud keys on that side (with confirmation) - -Details: [docs/Env-config-aliases.md](docs/Env-config-aliases.md). - -### Versions / releases (snapshots) - -You can save and use snapshots of your app state in the cloud: - -- **Create a release from local state:** After you have local changes you want to “tag”, run **`ensemble release create`** to save a snapshot (release) of the **current local app state** with an optional message. -- **List releases:** Run **`ensemble release list`** to see recent releases. -- **Use a release locally:** Run **`ensemble release use`** to choose a release and update **local files only** to that snapshot. Then run **`ensemble push`** to apply that state to the cloud. - -When you run `ensemble release` **without a subcommand** in an interactive terminal, the CLI opens an interactive menu that lets you choose between **create**, **list**, and **use**. In non-interactive environments (e.g. CI), you must call an explicit subcommand such as `ensemble release list` or `ensemble release use --hash `. - -### Exit codes - -- `0` — Command completed successfully (including “Up to date. Nothing to push/pull.”). -- `1` — Error (e.g., not logged in, app not found, or no access). -- `130` — User cancelled an interactive confirmation (push/pull prompt). - -### CI/CD - -**Auth:** Set `ENSEMBLE_TOKEN` in your CI environment. To get the token: - -1. On your machine, run `ensemble login` (browser) once. -2. Run `ensemble token` — it prints the token for CI. -3. Add that value as a secret in your CI (e.g. GitHub Actions → Settings → Secrets → `ENSEMBLE_TOKEN`). - -If `ENSEMBLE_TOKEN` is not set, the CLI uses the global config from `ensemble login` (e.g. on your laptop). - -**Non-interactive:** Use `-y` so push and pull do not prompt: - -- `ensemble push -y` — Push without confirmation. -- `ensemble pull -y` — Pull without confirmation. - -Without `-y`, both commands refuse to run when not attached to a TTY and exit with code 1. Use `--dry-run` in a validation job to inspect changes without applying them. The project must already have `ensemble.config.json`. - -> **Tip:** In CI, prefer `ensemble push --dry-run` / `ensemble pull --dry-run` in a validation job, and use `-y` only when you are ready to apply changes. - -## Environment variables - -For everyday use you do not need to set anything beyond what is described in [CI/CD](#cicd) (`ENSEMBLE_TOKEN` in automation). - -**Optional:** - -| Variable | Purpose | -| --------------------------------- | ------------------------------------------------------------------------------------------------------------ | -| `ENSEMBLE_VERBOSE` / `VERBOSE` | Truthy values (`1`, `true`, `yes`, `on`) enable verbose mode where supported. | -| `DEBUG` | Same idea as global `--debug` (truthy values as above). | -| `CI` / `ENSEMBLE_NO_UPDATE_CHECK` | Truthy values disable the startup “new version available” check (useful in CI or when you want a quiet run). | - -Firebase project, auth URL, and API key are fixed for the published CLI (the API key is injected when the package is built). You only need to think about those when [developing the CLI](CONTRIBUTING.md#advanced-firebase-and-backend-configuration). - -## Security considerations - -- **Secrets and tokens** - - `ENSEMBLE_TOKEN` (CI token) is a long-lived Firebase refresh token. Store it only in CI secret stores (e.g. GitHub Actions secrets), never in source control or logs. - - The local auth file at `~/.ensemble/cli-config.json` contains ID tokens and refresh tokens for your user account. Anyone who can read this file can act as you in the CLI. - - The CLI now writes `~/.ensemble/cli-config.json` with user-only permissions on POSIX systems (`0700` directory, `0600` file), but you should still treat it as sensitive. -- **Auth and authorization model** - - Authentication is handled via browser sign-in to Ensemble (backed by Firebase). The CLI stores tokens locally and refreshes them via Firebase’s secure token API. - - Authorization is enforced server-side using Firestore security rules and app-level roles (`write`/`owner`). The CLI passes your Firebase ID token as a Bearer token and does not make its own trust decisions beyond handling HTTP responses. -- **Local login callback** - - The `ensemble login` flow uses a loopback HTTP callback on `127.0.0.1` with a short timeout and a random `state` value to bind the browser flow to the CLI. - - Only complete login flows in a browser you trust on the same machine; untrusted local processes with full user access may still interfere, as with most loopback-based OAuth flows. -- **Shell and network usage** - - All shell commands used by the CLI (`npm view`, `npm install -g`, `open`/`start`/`xdg-open`) are static string literals and must remain so to avoid shell injection. - - Firestore/network debug hooks intentionally avoid logging Authorization headers or raw tokens; custom debug handlers must preserve this invariant. - -## Contributing - -See [CONTRIBUTING.md](CONTRIBUTING.md) for local development, tests, project layout, and the release workflow. - -## License - -MIT +# Ensemble CLI + +CLI for logging in, initializing, and pushing app definitions to the Ensemble cloud. + +## Installation + +```bash +npm install -g @ensembleui/cli +``` + +### Use the CLI + +```bash +ensemble login +ensemble logout +ensemble token +ensemble init +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 enable` | Enable starter modules (camera, location, google_maps, etc.) in a Flutter app | +| `ensemble update` | Update the CLI to the latest version | + +### Options + +- **global** — `--debug` — Print full debug information and stack traces. Can also be enabled with `DEBUG=1`. +- **login** — `--verbose` — Print auth config path +- **push** — `--app ` — App alias / environment key from `ensemble.config.json` (default: `default`) +- **push** — `--verbose` — Write collected data, diff, bundle, and payload JSON files for debugging +- **push** — `--dry-run` — Show what would be pushed without sending anything to the cloud +- **push** — `-y, --yes` — Skip confirmation prompt (useful for CI) +- **pull** — `--app ` — App alias / environment key from `ensemble.config.json` (default: `default`) +- **pull** — `--verbose` — Write fetched cloud JSON to disk +- **pull** — `--dry-run` — Show what would change without modifying local files +- **pull** — `-y, --yes` — Skip confirmation prompt (overwrite without asking) +- **release create** — `--app ` — App alias (default: `default`) +- **release create** — `-m, --message ` — Release message (skips prompt) +- **release create** — `-y, --yes` — Skip message prompt (use empty message) +- **release create** — `--verbose` — Show full Firestore/Storage error response text (debugging) +- **release list** — `--app ` — App alias (default: `default`) +- **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 (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. + +- **Supported kinds** + - `screen` + - `widget` + - `script` + - `action` + - `translation` + - `asset` + +- **Usage** + - Interactive (prompts for kind and name): + + ```bash + ensemble add + ``` + + - Non-interactive: + + ```bash + ensemble add screen Home + ensemble add widget MyWidget + ensemble add script myUtility + ensemble add action ShowToast + ensemble add translation en_US + ensemble add asset ./logo.png + ``` + +- **Naming rules** + - Artifact names are normalized (trimmed, repeated whitespace collapsed). + - Names **cannot contain spaces** in the final file name. If you pass a name with spaces, the CLI will suggest a version without spaces (for example, `\"My Screen\"` → `MyScreen`) and let you confirm in interactive mode. + +- **Files created** + - Screens: `screens/.yaml` + - Widgets: `widgets/.yaml` + - Actions: `actions/.yaml` + - Scripts: `scripts/.js` + - Translations: `translations/.yaml` + - Assets: `assets/` + +- **Asset upload behavior** + - `asset` expects a file path (not a generated scaffold name). + - The file is copied to `assets/`, uploaded to Ensemble cloud, and `.env.config` is upserted: + - `assets=` is added only if missing. + - The cloud-provided env variable key/value is added (or updated). + - The CLI prints the returned usage key so you can paste it directly in app definitions. + +- **Manifest behavior** + - For `widget`, `script`, `action`, and `translation`, `.manifest.json` is updated to include the new artifact. + - Screens do not change `.manifest.json`. + +## Usage + +1. Log in: `ensemble login` +2. From your project root, run `ensemble init` and link an existing app +3. Run `ensemble push` to sync your local app (screens, widgets, scripts, etc.) with the cloud +4. Optionally run `ensemble pull` to refresh local artifacts from the cloud when other collaborators change them + +### Environment files + +Config and secrets sync on `push`, `pull`, and `release use`: + +- **Base**: `.env.config`, `.env.secrets` (shared defaults) +- **Per-alias** (non-default alias, or when both scoped files exist): `.env.config.`, `.env.secrets.` +- Default alias with only base files uses the base pair; other aliases never overwrite base on pull +- **Missing** local file → that side skipped on push (no cloud wipe) +- **Empty** local file → wipe cloud keys on that side (with confirmation) + +Details: [docs/Env-config-aliases.md](docs/Env-config-aliases.md). + +### Versions / releases (snapshots) + +You can save and use snapshots of your app state in the cloud. Releases are **always encrypted** and require `ENSEMBLE_ENCRYPTION_KEY` in your alias secrets file: + +```bash +openssl rand -hex 32 # add to .env.secrets or .env.secrets. +``` + +`release create` and `release use` read the same alias-scoped secrets file as `push` / `pull`. + +- **Create:** `ensemble release create` — encrypts snapshot (AES-256-GCM) to `.enc.json` in Storage. +- **List:** `ensemble release list` +- **Use:** `ensemble release use` — downloads from Storage (Firebase auth), decrypts locally, restores files + secrets. + +Legacy plain `.json` releases are not supported. Re-create after adding the encryption key. + +**Firebase Storage rules (prod):** Restrict `releases/{appId}/*` to app collaborators via Firestore `get()` (see team lead / ops). Encryption protects snapshot contents; rules control who can download ciphertext. + +When you run `ensemble release` **without a subcommand** in an interactive terminal, the CLI opens an interactive menu that lets you choose between **create**, **list**, and **use**. In non-interactive environments (e.g. CI), you must call an explicit subcommand such as `ensemble release list` or `ensemble release use --hash `. + +### Exit codes + +- `0` — Command completed successfully (including “Up to date. Nothing to push/pull.”). +- `1` — Error (e.g., not logged in, app not found, or no access). +- `130` — User cancelled an interactive confirmation (push/pull prompt). + +### CI/CD + +**Auth:** Set `ENSEMBLE_TOKEN` in your CI environment. To get the token: + +1. On your machine, run `ensemble login` (browser) once. +2. Run `ensemble token` — it prints the token for CI. +3. Add that value as a secret in your CI (e.g. GitHub Actions → Settings → Secrets → `ENSEMBLE_TOKEN`). + +If `ENSEMBLE_TOKEN` is not set, the CLI uses the global config from `ensemble login` (e.g. on your laptop). + +**Non-interactive:** Use `-y` so push and pull do not prompt: + +- `ensemble push -y` — Push without confirmation. +- `ensemble pull -y` — Pull without confirmation. + +Without `-y`, both commands refuse to run when not attached to a TTY and exit with code 1. Use `--dry-run` in a validation job to inspect changes without applying them. The project must already have `ensemble.config.json`. + +> **Tip:** In CI, prefer `ensemble push --dry-run` / `ensemble pull --dry-run` in a validation job, and use `-y` only when you are ready to apply changes. + +## Environment variables + +For everyday use you do not need to set anything beyond what is described in [CI/CD](#cicd) (`ENSEMBLE_TOKEN` in automation). + +**Optional:** + +| Variable | Purpose | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| `ENSEMBLE_VERBOSE` / `VERBOSE` | Truthy values (`1`, `true`, `yes`, `on`) enable verbose mode where supported. | +| `DEBUG` | Same idea as global `--debug` (truthy values as above). | +| `CI` / `ENSEMBLE_NO_UPDATE_CHECK` | Truthy values disable the startup “new version available” check (useful in CI or when you want a quiet run). | + +Firebase project, auth URL, and API key are fixed for the published CLI (the API key is injected when the package is built). You only need to think about those when [developing the CLI](CONTRIBUTING.md#advanced-firebase-and-backend-configuration). + +## Security considerations + +- **Secrets and tokens** + - `ENSEMBLE_TOKEN` (CI token) is a long-lived Firebase refresh token. Store it only in CI secret stores (e.g. GitHub Actions secrets), never in source control or logs. + - The local auth file at `~/.ensemble/cli-config.json` contains ID tokens and refresh tokens for your user account. Anyone who can read this file can act as you in the CLI. + - The CLI now writes `~/.ensemble/cli-config.json` with user-only permissions on POSIX systems (`0700` directory, `0600` file), but you should still treat it as sensitive. +- **Auth and authorization model** + - Authentication is handled via browser sign-in to Ensemble (backed by Firebase). The CLI stores tokens locally and refreshes them via Firebase’s secure token API. + - Authorization is enforced server-side using Firestore security rules and app-level roles (`write`/`owner`). The CLI passes your Firebase ID token as a Bearer token and does not make its own trust decisions beyond handling HTTP responses. +- **Local login callback** + - The `ensemble login` flow uses a loopback HTTP callback on `127.0.0.1` with a short timeout and a random `state` value to bind the browser flow to the CLI. + - Only complete login flows in a browser you trust on the same machine; untrusted local processes with full user access may still interfere, as with most loopback-based OAuth flows. +- **Shell and network usage** + - All shell commands used by the CLI (`npm view`, `npm install -g`, `open`/`start`/`xdg-open`) are static string literals and must remain so to avoid shell injection. + - Firestore/network debug hooks intentionally avoid logging Authorization headers or raw tokens; custom debug handlers must preserve this invariant. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for local development, tests, project layout, and the release workflow. + +## License + +MIT diff --git a/docs/Env-config-aliases.md b/docs/Env-config-aliases.md index 642928c..02e9c90 100644 --- a/docs/Env-config-aliases.md +++ b/docs/Env-config-aliases.md @@ -1,215 +1,225 @@ -# Environment config + secrets files (`.env.config` / `.env.secrets` + per-alias overrides) - -This document describes the environment-variable architecture used by the Ensemble CLI for **multiple app environments** (or “targets”). - -The chosen approach is: - -- **Config base file**: `.env.config` (shared defaults) -- **Config scoped override file**: `.env.config.` (per app/environment alias) -- **Secrets base file**: `.env.secrets` (shared defaults) -- **Secrets scoped override file**: `.env.secrets.` (per app/environment alias) - -Where `` matches the `--app ` value (an app key in `ensemble.config.json`). The README already describes `--app ` as the “App alias / environment key”. - ---- - -## Goals - -- **Prevent accidental cross-environment breakage** (e.g. pushing dev values to prod). -- **Support per-environment differences** (e.g. `api_url` differs between dev and prod). -- **Keep local configuration readable** and easy to reason about. -- **Play nicely with existing CLI behavior** that already maintains `.env.config` for assets. -- **Keep secrets out of source control by default** with a predictable local structure. - -## Non-goals - -- Replacing runtime secrets management (e.g. Vault/KMS). This design is about **how the CLI stores and syncs config**, not the ultimate secret storage strategy. -- Introducing a complex file format. Files remain simple `KEY=value` pairs. - ---- - -## Terminology - -- **Alias**: The value passed to `--app ` (e.g. `default`, `dev`, `prod`), corresponding to an app entry in `ensemble.config.json`. -- **Base config**: `.env.config` -- **Alias config**: `.env.config.` (example: `.env.config.prod`) -- **Effective config**: The merged view used by commands (base + alias overrides). -- **Base secrets**: `.env.secrets` -- **Alias secrets**: `.env.secrets.` (example: `.env.secrets.prod`) -- **Effective secrets**: The merged view used by commands (base + alias overrides). - ---- - -## File format - -Both files use the same syntax: - -- One entry per line: `KEY=value` -- Empty lines allowed -- Lines starting with `#` are comments -- The first `=` separates key from value -- Whitespace around keys is trimmed - -Example: - -```ini -# shared defaults -api_timeout_ms=30000 -api_url=https://dev.ensemble.com -``` - -And an alias override: - -```ini -# prod overrides -api_url=https://prod.ensemble.com -``` - ---- - -## Resolution rules - -For the active alias (`--app` or `ensemble.config.json` → `default`): - -| Situation | Files used | -| ---------------------------------------------- | --------------------------------------------------------------------------- | -| Alias is **default** and only base files exist | `.env.config` + `.env.secrets` | -| Alias is **not default** | `.env.config.` + `.env.secrets.` (created on pull if missing) | -| Alias has **both** scoped files (any alias) | scoped pair wins over base | - -No mixing across tiers. Config and secrets always come from the same tier. - -Pulling a non-default alias (e.g. `ensemble pull --app uat`) writes cloud env into `.env.config.uat` / `.env.secrets.uat` and leaves base files untouched. - ---- - -## CLI behavior - -### Reading env files - -Commands use the resolved pair for the selected `--app` alias (see resolution rules above). - -`--app` is optional and defaults to `ensemble.config.json` → `default`. - -### Missing vs empty (push) - -| Local state | Push behavior | -| ----------------------- | ----------------------------------------------------------------- | -| File **missing** | Ignored — no env push for that side, no cloud wipe | -| File **present, empty** | Wipe — warn + `[y/N]` before deleting all cloud keys on that side | - -### Pushing env variables - -- `ensemble push --app ` pushes the **effective** env for that alias. -- Config and secrets are pushed independently (missing file → that side skipped). - -### Pulling env variables - -- `ensemble pull --app ` writes cloud env into the scoped target file when in scoped mode (`.env.config.` / `.env.secrets.`), leaving the base file untouched. -- In legacy mode, pull continues to write `.env.config` / `.env.secrets`. - -### Release use - -- `ensemble release use` restores snapshot config into the same write target as pull (scoped or base). - ---- - -## Asset-generated keys and `.env.config` - -The CLI upserts `.env.config` for asset-related keys after: - -- `ensemble add asset` -- `ensemble push` (asset upload) - -Pull writes asset env keys (`assets=`, per-asset keys) into the resolved config file for the active alias (base or scoped). `ensemble add asset` still upserts the base `.env.config`. - ---- - -## Safety - -- **Never default to destructive deletes** except when a local env file exists but is empty (explicit wipe semantics above). -- **`--delete-missing`** is not implemented; local-only keys are not auto-deleted from cloud on push. - ---- - -## Git and secrets guidance - -Different teams will choose different policies. Recommended defaults: - -- Commit `.env.config` only if it contains **non-secret** shared defaults. -- Do **not** commit `.env.secrets` or `.env.secrets.` (treat as sensitive). -- Prefer `.env.config.example` / `.env.secrets.example` for documentation when needed. - -At minimum, consider adding these to `.gitignore`: - -```gitignore -.env.config.* -!.env.config.example -.env.secrets -.env.secrets.* -!.env.secrets.example -``` - -If you _do_ want to commit alias files for non-secret config, use a more selective ignore pattern or separate “public” vs “secret” configs. - ---- - -## Examples - -### Dev + prod API URL - -`.env.config`: - -```ini -api_timeout_ms=30000 -api_url=https://dev.ensemble.com -``` - -`.env.config.prod`: - -```ini -api_url=https://prod.ensemble.com -``` - -- `ensemble push --app default` uses dev URL -- `ensemble push --app prod` uses prod URL - -### Shared defaults + per-alias assets - -`.env.config`: - -```ini -cdn_region=us-east-1 -``` - -`.env.config.dev`: - -```ini -assets=https://assets.dev.ensemble.com/ -``` - -`.env.config.prod`: - -```ini -assets=https://assets.prod.ensemble.com/ -``` - ---- - -## Migration plan - -1. Single-app projects: no change — keep using `.env.config` / `.env.secrets`. -2. Multi-app projects: add `.env.config.` / `.env.secrets.` for per-target overrides; shared defaults stay in the base files. -3. Existing single-app repos can opt in early by creating a scoped file (e.g. `.env.config.dev`). - ---- - -## Open questions (for follow-up) - -- Should `.env.config` be treated as **shared defaults** only, or also as the “default alias” file? - - This doc treats it as shared defaults that apply to all aliases unless overridden. -- Should asset keys always be alias-scoped, or can some be global? - - Recommended: alias-scoped, since assets are tied to a specific app target. - -- Do we want separate commands for secrets vs config (recommended), or a unified env push/pull that handles both? - - Recommendation: separate surfaces (e.g. `env` vs `secrets`) to make “high risk” operations explicit. +# Environment config + secrets files (`.env.config` / `.env.secrets` + per-alias overrides) + +This document describes the environment-variable architecture used by the Ensemble CLI for **multiple app environments** (or “targets”). + +The chosen approach is: + +- **Config base file**: `.env.config` (shared defaults) +- **Config scoped override file**: `.env.config.` (per app/environment alias) +- **Secrets base file**: `.env.secrets` (shared defaults) +- **Secrets scoped override file**: `.env.secrets.` (per app/environment alias) + +Where `` matches the `--app ` value (an app key in `ensemble.config.json`). The README already describes `--app ` as the “App alias / environment key”. + +--- + +## Goals + +- **Prevent accidental cross-environment breakage** (e.g. pushing dev values to prod). +- **Support per-environment differences** (e.g. `api_url` differs between dev and prod). +- **Keep local configuration readable** and easy to reason about. +- **Play nicely with existing CLI behavior** that already maintains `.env.config` for assets. +- **Keep secrets out of source control by default** with a predictable local structure. + +## Non-goals + +- Replacing runtime secrets management (e.g. Vault/KMS). This design is about **how the CLI stores and syncs config**, not the ultimate secret storage strategy. +- Introducing a complex file format. Files remain simple `KEY=value` pairs. + +--- + +## Terminology + +- **Alias**: The value passed to `--app ` (e.g. `default`, `dev`, `prod`), corresponding to an app entry in `ensemble.config.json`. +- **Base config**: `.env.config` +- **Alias config**: `.env.config.` (example: `.env.config.prod`) +- **Effective config**: The merged view used by commands (base + alias overrides). +- **Base secrets**: `.env.secrets` +- **Alias secrets**: `.env.secrets.` (example: `.env.secrets.prod`) +- **Effective secrets**: The merged view used by commands (base + alias overrides). + +--- + +## File format + +Both files use the same syntax: + +- One entry per line: `KEY=value` +- Empty lines allowed +- Lines starting with `#` are comments +- The first `=` separates key from value +- Whitespace around keys is trimmed + +Example: + +```ini +# shared defaults +api_timeout_ms=30000 +api_url=https://dev.ensemble.com +``` + +And an alias override: + +```ini +# prod overrides +api_url=https://prod.ensemble.com +``` + +--- + +## Resolution rules + +For the active alias (`--app` or `ensemble.config.json` → `default`): + +| Situation | Files used | +| ---------------------------------------------- | --------------------------------------------------------------------------- | +| Alias is **default** and only base files exist | `.env.config` + `.env.secrets` | +| Alias is **not default** | `.env.config.` + `.env.secrets.` (created on pull if missing) | +| Alias has **both** scoped files (any alias) | scoped pair wins over base | + +No mixing across tiers. Config and secrets always come from the same tier. + +Pulling a non-default alias (e.g. `ensemble pull --app uat`) writes cloud env into `.env.config.uat` / `.env.secrets.uat` and leaves base files untouched. + +--- + +## CLI behavior + +### Reading env files + +Commands use the resolved pair for the selected `--app` alias (see resolution rules above). + +`--app` is optional and defaults to `ensemble.config.json` → `default`. + +### Missing vs empty (push) + +| Local state | Push behavior | +| ----------------------- | ----------------------------------------------------------------- | +| File **missing** | Ignored — no env push for that side, no cloud wipe | +| File **present, empty** | Wipe — warn + `[y/N]` before deleting all cloud keys on that side | + +### Pushing env variables + +- `ensemble push --app ` pushes the **effective** env for that alias. +- Config and secrets are pushed independently (missing file → that side skipped). + +### Pulling env variables + +- `ensemble pull --app ` writes cloud env into the scoped target file when in scoped mode (`.env.config.` / `.env.secrets.`), leaving the base file untouched. +- In legacy mode, pull continues to write `.env.config` / `.env.secrets`. + +### Release use + +- `ensemble release use` restores snapshot config and secrets into the same write targets as pull (scoped or base). +- `release create` and `release use` require `ENSEMBLE_ENCRYPTION_KEY` in the alias secrets file (`.env.secrets` or `.env.secrets.`). Generate with `openssl rand -hex 32`. +- Snapshots are stored encrypted as `.enc.json` in Firebase Storage. Download uses normal Firebase Storage auth; access is enforced by Storage rules (app collaborators). + +### Release encryption (CDN vs CLI) + +| Key | CDN publish | CLI releases | +| ------------------------- | ---------------------------------------- | ------------------------------------------------ | +| `ENSEMBLE_ENCRYPTION_KEY` | Encrypts `encrypted-manifest.json` on R2 | Encrypts release `.enc.json` on Firebase Storage | + +CDN may also use `ENSEMBLE_MANIFEST_KEY` for Cloudflare WAF. CLI releases do not use a manifest key. + +--- + +## Asset-generated keys and `.env.config` + +The CLI upserts `.env.config` for asset-related keys after: + +- `ensemble add asset` +- `ensemble push` (asset upload) + +Pull writes asset env keys (`assets=`, per-asset keys) into the resolved config file for the active alias (base or scoped). `ensemble add asset` still upserts the base `.env.config`. + +--- + +## Safety + +- **Never default to destructive deletes** except when a local env file exists but is empty (explicit wipe semantics above). +- **`--delete-missing`** is not implemented; local-only keys are not auto-deleted from cloud on push. + +--- + +## Git and secrets guidance + +Different teams will choose different policies. Recommended defaults: + +- Commit `.env.config` only if it contains **non-secret** shared defaults. +- Do **not** commit `.env.secrets` or `.env.secrets.` (treat as sensitive). +- Prefer `.env.config.example` / `.env.secrets.example` for documentation when needed. + +At minimum, consider adding these to `.gitignore`: + +```gitignore +.env.config.* +!.env.config.example +.env.secrets +.env.secrets.* +!.env.secrets.example +``` + +If you _do_ want to commit alias files for non-secret config, use a more selective ignore pattern or separate “public” vs “secret” configs. + +--- + +## Examples + +### Dev + prod API URL + +`.env.config`: + +```ini +api_timeout_ms=30000 +api_url=https://dev.ensemble.com +``` + +`.env.config.prod`: + +```ini +api_url=https://prod.ensemble.com +``` + +- `ensemble push --app default` uses dev URL +- `ensemble push --app prod` uses prod URL + +### Shared defaults + per-alias assets + +`.env.config`: + +```ini +cdn_region=us-east-1 +``` + +`.env.config.dev`: + +```ini +assets=https://assets.dev.ensemble.com/ +``` + +`.env.config.prod`: + +```ini +assets=https://assets.prod.ensemble.com/ +``` + +--- + +## Migration plan + +1. Single-app projects: no change — keep using `.env.config` / `.env.secrets`. +2. Multi-app projects: add `.env.config.` / `.env.secrets.` for per-target overrides; shared defaults stay in the base files. +3. Existing single-app repos can opt in early by creating a scoped file (e.g. `.env.config.dev`). + +--- + +## Open questions (for follow-up) + +- Should `.env.config` be treated as **shared defaults** only, or also as the “default alias” file? + - This doc treats it as shared defaults that apply to all aliases unless overridden. +- Should asset keys always be alias-scoped, or can some be global? + - Recommended: alias-scoped, since assets are tied to a specific app target. + +- Do we want separate commands for secrets vs config (recommended), or a unified env push/pull that handles both? + - Recommendation: separate surfaces (e.g. `env` vs `secrets`) to make “high risk” operations explicit. diff --git a/package.json b/package.json index fe22621..43ae738 100644 --- a/package.json +++ b/package.json @@ -1,59 +1,59 @@ -{ - "name": "@ensembleui/cli", - "version": "0.2.0", - "description": "Ensemble CLI for logging in, initializing, and pushing/pulling app definitions to the cloud.", - "bin": { - "ensemble": "dist/index.js" - }, - "scripts": { - "build": "tsc && node scripts/inject-env.mjs && chmod +x dist/index.js", - "inject-env": "node scripts/inject-env.mjs", - "dev": "ts-node src/index.ts", - "start": "node dist/index.js", - "lint": "eslint src tests --ext .ts", - "lint:fix": "eslint src tests --ext .ts --fix", - "format": "prettier --write .", - "format:check": "prettier --check .", - "test": "vitest run", - "test:watch": "vitest", - "prepare": "husky install" - }, - "keywords": [ - "ensemble", - "cli" - ], - "files": [ - "dist", - "README.md" - ], - "publishConfig": { - "access": "public" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/EnsembleUI/ensemble-cli.git" - }, - "license": "MIT", - "type": "commonjs", - "dependencies": { - "commander": "^12.1.0", - "jiti": "^2.4.2", - "picocolors": "^1.1.0", - "prompts": "^2.4.2", - "tar": "^7.4.3" - }, - "devDependencies": { - "@types/node": "^22.10.1", - "@types/prompts": "^2.4.9", - "@typescript-eslint/eslint-plugin": "^8.15.0", - "@typescript-eslint/parser": "^8.15.0", - "dotenv": "^17.3.1", - "eslint": "^8.57.0", - "eslint-config-prettier": "^9.1.0", - "husky": "^9.0.0", - "prettier": "^3.3.3", - "ts-node": "^10.9.2", - "typescript": "^5.6.3", - "vitest": "^3.2.4" - } -} +{ + "name": "@ensembleui/cli", + "version": "0.2.0", + "description": "Ensemble CLI for logging in, initializing, and pushing/pulling app definitions to the cloud.", + "bin": { + "ensemble": "dist/index.js" + }, + "scripts": { + "build": "tsc && node scripts/inject-env.mjs && chmod +x dist/index.js", + "inject-env": "node scripts/inject-env.mjs", + "dev": "ts-node src/index.ts", + "start": "node dist/index.js", + "lint": "eslint src tests --ext .ts", + "lint:fix": "eslint src tests --ext .ts --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "test": "vitest run", + "test:watch": "vitest", + "prepare": "husky install" + }, + "keywords": [ + "ensemble", + "cli" + ], + "files": [ + "dist", + "README.md" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/EnsembleUI/ensemble-cli.git" + }, + "license": "MIT", + "type": "commonjs", + "dependencies": { + "commander": "^12.1.0", + "jiti": "^2.4.2", + "picocolors": "^1.1.0", + "prompts": "^2.4.2", + "tar": "^7.4.3" + }, + "devDependencies": { + "@types/node": "^22.10.1", + "@types/prompts": "^2.4.9", + "@typescript-eslint/eslint-plugin": "^8.15.0", + "@typescript-eslint/parser": "^8.15.0", + "dotenv": "^17.3.1", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "husky": "^9.0.0", + "prettier": "^3.3.3", + "ts-node": "^10.9.2", + "typescript": "^5.6.3", + "vitest": "^3.2.4" + } +} diff --git a/src/auth/session.ts b/src/auth/session.ts index 995d562..b8fbfc5 100644 --- a/src/auth/session.ts +++ b/src/auth/session.ts @@ -1,169 +1,169 @@ -import { - readGlobalConfig, - writeGlobalConfig, - type EnsembleUserConfig, -} from '../config/globalConfig.js'; -import { decodeIdTokenClaims, isTokenExpired } from './token.js'; -import { getEnsembleFirebaseApiKey } from '../config/env.js'; - -const DEFAULT_REFRESH_API_BASE = 'https://securetoken.googleapis.com/v1/token'; - -interface RefreshTokenResponse { - id_token?: string; - refresh_token?: string; - user_id?: string; - expires_in?: string; - error?: { - message?: string; - }; -} - -export type AuthSessionResult = - | { - ok: true; - idToken: string; - userId: string; - name?: string; - email?: string; - refreshed: boolean; - } - | { - ok: false; - reason: 'not_logged_in' | 'expired'; - message: string; - }; - -async function refreshIdToken(refreshToken: string): Promise<{ - idToken: string; - refreshToken: string; - userId?: string; -}> { - const apiKey = getEnsembleFirebaseApiKey(); - if (!apiKey) { - throw new Error('Missing Firebase API key for token refresh. Set ENSEMBLE_FIREBASE_API_KEY.'); - } - - const refreshUrl = `${DEFAULT_REFRESH_API_BASE}?key=${encodeURIComponent(apiKey)}`; - const body = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - }); - - const res = await fetch(refreshUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body, - }); - - const data = (await res.json()) as RefreshTokenResponse; - if (!res.ok || !data.id_token) { - const reason = data?.error?.message ?? `HTTP ${res.status}`; - throw new Error(`Token refresh failed: ${reason}`); - } - - return { - idToken: data.id_token, - refreshToken: data.refresh_token ?? refreshToken, - userId: data.user_id, - }; -} - -const ENSEMBLE_TOKEN_ENV = 'ENSEMBLE_TOKEN'; - -/** - * Use refresh token from environment (e.g. CI). Refreshes to get an id token; - * does not read or write global config. - */ -async function sessionFromEnvToken(): Promise { - const refreshToken = process.env[ENSEMBLE_TOKEN_ENV]?.trim(); - if (!refreshToken) return { ok: false, reason: 'not_logged_in', message: '' }; - - try { - const refreshed = await refreshIdToken(refreshToken); - const claims = decodeIdTokenClaims(refreshed.idToken); - return { - ok: true, - idToken: refreshed.idToken, - userId: claims.uid ?? refreshed.userId ?? 'cli-user', - name: claims.name ?? undefined, - email: claims.email ?? undefined, - refreshed: true, - }; - } catch (err) { - const message = err instanceof Error ? err.message : 'Token refresh failed.'; - return { - ok: false, - reason: 'expired', - message: `${message} Check that ENSEMBLE_TOKEN is a valid refresh token (from \`ensemble token\`) and ENSEMBLE_FIREBASE_API_KEY is set.`, - }; - } -} - -export async function getValidAuthSession(): Promise { - const fromEnv = await sessionFromEnvToken(); - if (fromEnv.ok) return fromEnv; - if (fromEnv.reason === 'expired') return fromEnv; - // Not set or empty: fall back to global config - - const config: EnsembleUserConfig = (await readGlobalConfig()) ?? {}; - const user = config.user; - - if (!user?.idToken || !user.uid) { - return { - ok: false, - reason: 'not_logged_in', - message: 'You must be logged in. Run `ensemble login` first.', - }; - } - - if (!isTokenExpired(user.idToken)) { - return { - ok: true, - idToken: user.idToken, - userId: user.uid, - name: user.name, - email: user.email, - refreshed: false, - }; - } - - if (!user.refreshToken) { - return { - ok: false, - reason: 'expired', - message: 'Session expired and no refresh token was found. Run `ensemble login` again.', - }; - } - - try { - const refreshed = await refreshIdToken(user.refreshToken); - const claims = decodeIdTokenClaims(refreshed.idToken); - const updatedUser: NonNullable = { - uid: claims.uid ?? refreshed.userId ?? user.uid, - name: claims.name ?? user.name, - email: claims.email ?? user.email, - idToken: refreshed.idToken, - refreshToken: refreshed.refreshToken, - }; - const updatedConfig: EnsembleUserConfig = { - ...config, - user: updatedUser, - }; - await writeGlobalConfig(updatedConfig); - - return { - ok: true, - idToken: refreshed.idToken, - userId: updatedUser.uid, - name: updatedUser.name, - email: updatedUser.email, - refreshed: true, - }; - } catch { - return { - ok: false, - reason: 'expired', - message: `Session expired and automatic refresh failed. Run \`ensemble login\` again.`, - }; - } -} +import { + readGlobalConfig, + writeGlobalConfig, + type EnsembleUserConfig, +} from '../config/globalConfig.js'; +import { decodeIdTokenClaims, isTokenExpired } from './token.js'; +import { getEnsembleFirebaseApiKey } from '../config/env.js'; + +const DEFAULT_REFRESH_API_BASE = 'https://securetoken.googleapis.com/v1/token'; + +interface RefreshTokenResponse { + id_token?: string; + refresh_token?: string; + user_id?: string; + expires_in?: string; + error?: { + message?: string; + }; +} + +export type AuthSessionResult = + | { + ok: true; + idToken: string; + userId: string; + name?: string; + email?: string; + refreshed: boolean; + } + | { + ok: false; + reason: 'not_logged_in' | 'expired'; + message: string; + }; + +async function refreshIdToken(refreshToken: string): Promise<{ + idToken: string; + refreshToken: string; + userId?: string; +}> { + const apiKey = getEnsembleFirebaseApiKey(); + if (!apiKey) { + throw new Error('Missing Firebase API key for token refresh. Set ENSEMBLE_FIREBASE_API_KEY.'); + } + + const refreshUrl = `${DEFAULT_REFRESH_API_BASE}?key=${encodeURIComponent(apiKey)}`; + const body = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + }); + + const res = await fetch(refreshUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body, + }); + + const data = (await res.json()) as RefreshTokenResponse; + if (!res.ok || !data.id_token) { + const reason = data?.error?.message ?? `HTTP ${res.status}`; + throw new Error(`Token refresh failed: ${reason}`); + } + + return { + idToken: data.id_token, + refreshToken: data.refresh_token ?? refreshToken, + userId: data.user_id, + }; +} + +const ENSEMBLE_TOKEN_ENV = 'ENSEMBLE_TOKEN'; + +/** + * Use refresh token from environment (e.g. CI). Refreshes to get an id token; + * does not read or write global config. + */ +async function sessionFromEnvToken(): Promise { + const refreshToken = process.env[ENSEMBLE_TOKEN_ENV]?.trim(); + if (!refreshToken) return { ok: false, reason: 'not_logged_in', message: '' }; + + try { + const refreshed = await refreshIdToken(refreshToken); + const claims = decodeIdTokenClaims(refreshed.idToken); + return { + ok: true, + idToken: refreshed.idToken, + userId: claims.uid ?? refreshed.userId ?? 'cli-user', + name: claims.name ?? undefined, + email: claims.email ?? undefined, + refreshed: true, + }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Token refresh failed.'; + return { + ok: false, + reason: 'expired', + message: `${message} Check that ENSEMBLE_TOKEN is a valid refresh token (from \`ensemble token\`) and ENSEMBLE_FIREBASE_API_KEY is set.`, + }; + } +} + +export async function getValidAuthSession(): Promise { + const fromEnv = await sessionFromEnvToken(); + if (fromEnv.ok) return fromEnv; + if (fromEnv.reason === 'expired') return fromEnv; + // Not set or empty: fall back to global config + + const config: EnsembleUserConfig = (await readGlobalConfig()) ?? {}; + const user = config.user; + + if (!user?.idToken || !user.uid) { + return { + ok: false, + reason: 'not_logged_in', + message: 'You must be logged in. Run `ensemble login` first.', + }; + } + + if (!isTokenExpired(user.idToken)) { + return { + ok: true, + idToken: user.idToken, + userId: user.uid, + name: user.name, + email: user.email, + refreshed: false, + }; + } + + if (!user.refreshToken) { + return { + ok: false, + reason: 'expired', + message: 'Session expired and no refresh token was found. Run `ensemble login` again.', + }; + } + + try { + const refreshed = await refreshIdToken(user.refreshToken); + const claims = decodeIdTokenClaims(refreshed.idToken); + const updatedUser: NonNullable = { + uid: claims.uid ?? refreshed.userId ?? user.uid, + name: claims.name ?? user.name, + email: claims.email ?? user.email, + idToken: refreshed.idToken, + refreshToken: refreshed.refreshToken, + }; + const updatedConfig: EnsembleUserConfig = { + ...config, + user: updatedUser, + }; + await writeGlobalConfig(updatedConfig); + + return { + ok: true, + idToken: refreshed.idToken, + userId: updatedUser.uid, + name: updatedUser.name, + email: updatedUser.email, + refreshed: true, + }; + } catch { + return { + ok: false, + reason: 'expired', + message: `Session expired and automatic refresh failed. Run \`ensemble login\` again.`, + }; + } +} diff --git a/src/auth/token.ts b/src/auth/token.ts index 5e10746..73e89d0 100644 --- a/src/auth/token.ts +++ b/src/auth/token.ts @@ -1,47 +1,47 @@ -export interface DecodedIdTokenClaims { - uid: string | null; - name: string | null; - email: string | null; - exp?: number; -} - -/** Decode JWT payload without verification. Uses "userId" (Ensemble) or "sub" (Firebase) for uid, and "email". */ -export function decodeIdTokenClaims(idToken: string): DecodedIdTokenClaims { - try { - const parts = idToken.split('.'); - if (parts.length !== 3) return { uid: null, name: null, email: null }; - const payload = Buffer.from(parts[1], 'base64url').toString('utf8'); - const decoded = JSON.parse(payload) as { - userId?: string; - sub?: string; - name?: string; - email?: string; - exp?: number; - }; - const uid = - typeof decoded.userId === 'string' - ? decoded.userId - : typeof decoded.sub === 'string' - ? decoded.sub - : null; - return { - uid, - name: typeof decoded.name === 'string' ? decoded.name : null, - email: typeof decoded.email === 'string' ? decoded.email : null, - exp: typeof decoded.exp === 'number' ? decoded.exp : undefined, - }; - } catch { - return { uid: null, name: null, email: null }; - } -} - -export function getIdTokenExpiryMs(idToken: string): number | undefined { - const { exp } = decodeIdTokenClaims(idToken); - return typeof exp === 'number' ? exp * 1000 : undefined; -} - -export function isTokenExpired(idToken: string, bufferSeconds = 60): boolean { - const expiry = getIdTokenExpiryMs(idToken); - if (expiry === undefined) return true; - return expiry <= Date.now() + bufferSeconds * 1000; -} +export interface DecodedIdTokenClaims { + uid: string | null; + name: string | null; + email: string | null; + exp?: number; +} + +/** Decode JWT payload without verification. Uses "userId" (Ensemble) or "sub" (Firebase) for uid, and "email". */ +export function decodeIdTokenClaims(idToken: string): DecodedIdTokenClaims { + try { + const parts = idToken.split('.'); + if (parts.length !== 3) return { uid: null, name: null, email: null }; + const payload = Buffer.from(parts[1], 'base64url').toString('utf8'); + const decoded = JSON.parse(payload) as { + userId?: string; + sub?: string; + name?: string; + email?: string; + exp?: number; + }; + const uid = + typeof decoded.userId === 'string' + ? decoded.userId + : typeof decoded.sub === 'string' + ? decoded.sub + : null; + return { + uid, + name: typeof decoded.name === 'string' ? decoded.name : null, + email: typeof decoded.email === 'string' ? decoded.email : null, + exp: typeof decoded.exp === 'number' ? decoded.exp : undefined, + }; + } catch { + return { uid: null, name: null, email: null }; + } +} + +export function getIdTokenExpiryMs(idToken: string): number | undefined { + const { exp } = decodeIdTokenClaims(idToken); + return typeof exp === 'number' ? exp * 1000 : undefined; +} + +export function isTokenExpired(idToken: string, bufferSeconds = 60): boolean { + const expiry = getIdTokenExpiryMs(idToken); + if (expiry === undefined) return true; + return expiry <= Date.now() + bufferSeconds * 1000; +} diff --git a/src/cloud/firestoreClient.ts b/src/cloud/firestoreClient.ts index 1c5f047..51046ff 100644 --- a/src/cloud/firestoreClient.ts +++ b/src/cloud/firestoreClient.ts @@ -1,1618 +1,1618 @@ -/** - * Firestore client for validating app existence and user access. - * Uses the Firestore REST API with the user's Firebase ID token. - */ - -import type { - ApplicationDTO, - AssetDTO, - WidgetDTO, - ScriptDTO, - ActionDTO, - ScreenDTO, - ThemeDTO, - TranslationDTO, - ConfigDTO, - SecretDTO, -} from '../core/dto.js'; -import { EnsembleDocumentType } from '../core/dto.js'; -import { configDtoToEnvEntries, secretsDtoToEnvEntries } from '../core/envSync.js'; -import { getArtifactConfig, type ArtifactProp } from '../core/artifacts.js'; -import { processWithConcurrency } from '../core/concurrency.js'; -import { uploadProjectAssetsForPush } from '../core/pushAssets.js'; -import { getEnsembleFirebaseProject } from '../config/env.js'; - -const DEFAULT_FIRESTORE_CONCURRENCY = 15; - -export type FirestoreErrorCode = - | 'AUTH_EXPIRED' - | 'PERMISSION_DENIED' - | 'NOT_FOUND' - | 'NETWORK_UNAVAILABLE' - | 'QUOTA_EXCEEDED' - | 'UNKNOWN'; - -export class FirestoreClientError extends Error { - code: FirestoreErrorCode; - - status?: number; - - hint?: string; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - cause?: any; - - constructor(params: { - code: FirestoreErrorCode; - message: string; - status?: number; - hint?: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - cause?: any; - }) { - super(params.message); - this.name = 'FirestoreClientError'; - this.code = params.code; - this.status = params.status; - this.hint = params.hint; - this.cause = params.cause; - } -} - -export type FirestoreDebugEvent = - | { - kind: 'request'; - method: string; - url: string; - context: string; - } - | { - kind: 'response'; - method: string; - url: string; - status: number; - context: string; - } - | { - kind: 'list_documents'; - collection: string; - parentPath: string; - count: number; - } - | { - kind: 'push_operation'; - appId: string; - operation: 'create' | 'update'; - artifactKind: ArtifactProp; - documentId: string; - }; - -export interface FirestoreClientOptions { - /** - * Optional debug hook for Firestore requests. - * IMPORTANT: Implementations must NEVER log Authorization headers, ID tokens, - * refresh tokens, or other sensitive values. - */ - debug?: (event: FirestoreDebugEvent) => void; -} - -function logDebug(options: FirestoreClientOptions | undefined, event: FirestoreDebugEvent): void { - if (!options?.debug) return; - try { - options.debug(event); - } catch { - // Debug logging must never break core behavior. - } -} - -function mapStatusToErrorCode(status: number): FirestoreErrorCode { - if (status === 401) return 'AUTH_EXPIRED'; - if (status === 403) return 'PERMISSION_DENIED'; - if (status === 404) return 'NOT_FOUND'; - if (status === 429 || status === 503) return 'QUOTA_EXCEEDED'; - if (status === 0) return 'NETWORK_UNAVAILABLE'; - return 'UNKNOWN'; -} - -function defaultHintForCode(code: FirestoreErrorCode): string | undefined { - if (code === 'AUTH_EXPIRED') { - return 'Session expired or invalid. Run `ensemble login` and try again.'; - } - if (code === 'PERMISSION_DENIED') { - return 'You do not have permission to access this app. Check your account or app sharing settings.'; - } - if (code === 'NETWORK_UNAVAILABLE') { - return 'Check your internet connection or proxy settings, then try again.'; - } - if (code === 'QUOTA_EXCEEDED') { - return 'You have hit a Firestore quota limit. Try again later or adjust your Firebase project quotas.'; - } - return undefined; -} - -async function toFirestoreError( - context: string, - res: Response, - options?: FirestoreClientOptions -): Promise { - const text = await res.text(); - const code = mapStatusToErrorCode(res.status); - const hint = defaultHintForCode(code); - - logDebug(options, { - kind: 'response', - method: 'UNKNOWN', - url: res.url ?? '', - status: res.status, - context, - }); - - return new FirestoreClientError({ - code, - status: res.status, - message: `Firestore ${context} failed (${res.status})`, - hint, - cause: text.slice(0, 200), - }); -} - -function networkError(context: string, err: unknown): FirestoreClientError { - const message = - err instanceof Error && typeof err.message === 'string' - ? err.message - : 'Network request failed.'; - return new FirestoreClientError({ - code: 'NETWORK_UNAVAILABLE', - message: `Firestore ${context} failed: ${message}`, - cause: err, - }); -} - -/** Raw Firestore document from list/get API. */ -export interface FirestoreDocument { - name: string; - fields?: Record; -} - -type FirestoreValue = - | { stringValue: string } - | { booleanValue: boolean } - | { timestampValue: string } - | { integerValue: string } - | { mapValue: { fields: Record } } - | { referenceValue: string }; - -type FirestoreWriteFields = Record; - -/** Cloud app in ApplicationDTO shape, aligned with local app structure. */ -export type CloudApp = Pick< - ApplicationDTO, - | 'id' - | 'name' - | 'createdAt' - | 'updatedAt' - | 'widgets' - | 'scripts' - | 'actions' - | 'screens' - | 'theme' - | 'translations' - | 'assets' - | 'config' - | 'secrets' ->; - -/** Metadata for a saved version (commit); snapshot stored in same doc. */ -export interface VersionMetadata { - id: string; - message: string; - createdAt: string; - createdBy: { name: string; email?: string; id: string }; - expiresAt: string; - snapshotPath: string; -} - -/** Version doc metadata (snapshot stored in Storage). */ -export type VersionDoc = VersionMetadata; - -export interface CreateVersionParams { - /** Firestore version document id; must match the storage object name. */ - id: string; - message: string; - createdAt: string; - createdBy: { name: string; email?: string; id: string }; - /** Must be Firestore Timestamp (e.g. 30 days from now). Use ISO string for timestampValue. */ - expiresAt: string; - snapshotPath: string; -} - -const ALLOWED_ROLES = new Set(['write', 'owner']); - -export interface AppInfo { - name?: string; - description?: string; -} - -export type AppAccessResult = - | { ok: true; app: AppInfo } - | { - ok: false; - reason: 'not_found' | 'no_access' | 'not_logged_in' | 'network_error'; - message: string; - code?: FirestoreErrorCode; - hint?: string; - status?: number; - }; - -type YamlArtifactPushOperation = - | { - operation: 'create'; - document: { - id: string; - name: string; - content: string; - type: string; - isRoot?: boolean; - isArchived?: boolean; - defaultLocale?: boolean; - createdAt?: string; - updatedAt?: string; - updatedBy?: { name: string; email?: string; id: string }; - createdBy?: { name: string; email?: string; id: string }; - description?: string; - }; - } - | { - operation: 'update'; - id: string; - history: { - content: string; - name: string; - type: string; - isRoot?: boolean; - isArchived?: boolean; - defaultLocale?: boolean; - updatedAt?: string; - updatedBy?: { name: string; email?: string; id: string }; - }; - updates: { - content?: string; - name?: string; - isRoot?: boolean; - isArchived?: boolean; - defaultLocale?: boolean; - updatedAt?: string; - updatedBy?: { name: string; email?: string; id: string }; - }; - }; - -interface PushPayloadShape { - id: string; - name?: string; - updatedAt: string; - screens?: YamlArtifactPushOperation[]; - widgets?: YamlArtifactPushOperation[]; - scripts?: YamlArtifactPushOperation[]; - actions?: YamlArtifactPushOperation[]; - translations?: YamlArtifactPushOperation[]; - theme?: YamlArtifactPushOperation; - assets?: YamlArtifactPushOperation[]; -} - -type CreateYamlOp = Extract; -type UpdateYamlOp = Extract; -type UpdateHistory = UpdateYamlOp['history']; -type UpdateUpdates = UpdateYamlOp['updates']; - -function assertValidPushPayload(payload: unknown): asserts payload is PushPayloadShape { - if (!payload || typeof payload !== 'object') { - throw new Error('Invalid push payload: expected an object.'); - } - const p = payload as { id?: unknown; updatedAt?: unknown }; - if (typeof p.id !== 'string' || typeof p.updatedAt !== 'string') { - throw new Error('Invalid push payload: missing or invalid "id" or "updatedAt".'); - } -} - -function getFirestoreConcurrency(): number { - const raw = process.env.ENSEMBLE_FIRESTORE_CONCURRENCY; - if (raw === undefined) return DEFAULT_FIRESTORE_CONCURRENCY; - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) { - return DEFAULT_FIRESTORE_CONCURRENCY; - } - return Math.floor(parsed); -} - -async function applyAssetArchiveOperations( - appId: string, - idToken: string, - project: string, - ops: YamlArtifactPushOperation[] | undefined, - options?: FirestoreClientOptions -): Promise { - if (!ops || ops.length === 0) return; - - const baseCollectionUrl = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}/artifacts`; - const concurrency = getFirestoreConcurrency(); - - await processWithConcurrency( - ops, - async (op) => { - if (op.operation !== 'update') return; - - const docId = op.id; - const docUrl = `${baseCollectionUrl}/${encodeURIComponent(docId)}`; - const historyFields = encodeHistoryFields(op.history); - const historyUrl = `${docUrl}/history`; - - logDebug(options, { - kind: 'push_operation', - appId, - operation: 'update', - artifactKind: 'screens', - documentId: docId, - }); - logDebug(options, { - kind: 'request', - method: 'POST', - url: historyUrl, - context: 'submitCliPush/writeAssetHistory', - }); - const historyRes = await fetch(historyUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${idToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ fields: historyFields }), - }); - if (!historyRes.ok) { - throw await toFirestoreError( - `write history for asset "${op.history.name}"`, - historyRes, - options - ); - } - - const { fields: updateFields, fieldPaths } = encodeUpdateFields('screens', op.updates); - if (fieldPaths.length === 0) return; - - const params = fieldPaths - .map((path) => `updateMask.fieldPaths=${encodeURIComponent(path)}`) - .join('&'); - const patchUrl = `${docUrl}?${params}`; - logDebug(options, { - kind: 'request', - method: 'PATCH', - url: patchUrl, - context: 'submitCliPush/patchAsset', - }); - const patchRes = await fetch(patchUrl, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${idToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ fields: updateFields }), - }); - if (!patchRes.ok) { - throw await toFirestoreError(`archive asset "${op.history.name}"`, patchRes, options); - } - }, - concurrency - ); -} - -async function applyYamlOperationsForKind( - kind: 'screens' | 'widgets' | 'scripts' | 'actions' | 'translations' | 'theme', - appId: string, - idToken: string, - project: string, - ops: YamlArtifactPushOperation[] | undefined, - options?: FirestoreClientOptions -): Promise { - if (!ops || ops.length === 0) return; - const { collection } = artifactCollectionAndType(kind); - const baseCollectionUrl = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}/${collection}`; - - const concurrency = getFirestoreConcurrency(); - - await processWithConcurrency( - ops, - async (op) => { - if (op.operation === 'create') { - const doc = op.document; - const fields = encodeYamlDocumentFields(kind, doc); - const createUrl = `${baseCollectionUrl}?documentId=${encodeURIComponent(doc.id)}`; - logDebug(options, { - kind: 'push_operation', - appId, - operation: 'create', - artifactKind: kind, - documentId: doc.id, - }); - logDebug(options, { - kind: 'request', - method: 'POST', - url: createUrl, - context: 'submitCliPush/create', - }); - const res = await fetch(createUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${idToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ fields }), - }); - if (!res.ok) { - throw await toFirestoreError(`create ${kind.slice(0, -1)} "${doc.name}"`, res, options); - } - } else if (op.operation === 'update') { - const docId = op.id; - const docUrl = `${baseCollectionUrl}/${encodeURIComponent(docId)}`; - - // 1) Write history entry - const historyFields = encodeHistoryFields(op.history); - const historyUrl = `${docUrl}/history`; - logDebug(options, { - kind: 'push_operation', - appId, - operation: 'update', - artifactKind: kind, - documentId: docId, - }); - logDebug(options, { - kind: 'request', - method: 'POST', - url: historyUrl, - context: 'submitCliPush/writeHistory', - }); - const historyRes = await fetch(historyUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${idToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ fields: historyFields }), - }); - if (!historyRes.ok) { - throw await toFirestoreError( - `write history for ${kind.slice(0, -1)} "${op.history.name}"`, - historyRes, - options - ); - } - - // 2) Patch main document with partial updates - const { fields: updateFields, fieldPaths } = encodeUpdateFields(kind, op.updates); - if (fieldPaths.length === 0) { - return; - } - const params = fieldPaths - .map((p) => `updateMask.fieldPaths=${encodeURIComponent(p)}`) - .join('&'); - const patchUrl = `${docUrl}?${params}`; - logDebug(options, { - kind: 'request', - method: 'PATCH', - url: patchUrl, - context: 'submitCliPush/patchDocument', - }); - const patchRes = await fetch(patchUrl, { - method: 'PATCH', - headers: { - Authorization: `Bearer ${idToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ fields: updateFields }), - }); - if (!patchRes.ok) { - throw await toFirestoreError( - `update ${kind.slice(0, -1)} "${op.history.name}"`, - patchRes, - options - ); - } - } - }, - concurrency - ); -} - -/** Optional local asset uploads after Firestore YAML apply (studio cloud function + .env.config). */ -export interface CliPushExtras { - projectRoot: string; - assetFileNames?: string[]; -} - -export interface CliPushResult { - assetsUploaded: number; -} - -/** - * Apply a push payload directly to Firestore, updating YAML artifacts in-place. - * Optionally uploads new assets (studio-uploadAsset + .env.config) in the same operation. - */ -export async function submitCliPush( - appId: string, - idToken: string, - payload: unknown, - options?: FirestoreClientOptions, - extras?: CliPushExtras -): Promise { - const project = getEnsembleFirebaseProject(); - assertValidPushPayload(payload); - const p = payload as PushPayloadShape; - - await applyYamlOperationsForKind('screens', appId, idToken, project, p.screens, options); - await applyYamlOperationsForKind('widgets', appId, idToken, project, p.widgets, options); - await applyYamlOperationsForKind('scripts', appId, idToken, project, p.scripts, options); - await applyYamlOperationsForKind('actions', appId, idToken, project, p.actions, options); - await applyYamlOperationsForKind( - 'translations', - appId, - idToken, - project, - p.translations, - options - ); - if (p.theme) { - await applyYamlOperationsForKind('theme', appId, idToken, project, [p.theme], options); - } - await applyAssetArchiveOperations(appId, idToken, project, p.assets, options); - - const names = extras?.assetFileNames?.filter((n) => n.trim() !== '') ?? []; - if (names.length === 0 || !extras?.projectRoot) { - return { assetsUploaded: 0 }; - } - const assetsUploaded = await uploadProjectAssetsForPush( - appId, - idToken, - extras.projectRoot, - names - ); - return { assetsUploaded }; -} - -function parseFirestoreString(field: { stringValue?: string } | undefined): string | undefined { - return typeof field?.stringValue === 'string' ? field.stringValue : undefined; -} - -function parseFirestoreTimestamp( - field: { timestampValue?: string } | undefined -): string | undefined { - const v = field?.timestampValue; - return typeof v === 'string' ? v : undefined; -} - -function parseFirestoreBoolean(field: { booleanValue?: boolean } | undefined): boolean | undefined { - return typeof field?.booleanValue === 'boolean' ? field.booleanValue : undefined; -} - -function parseUpdatedBy( - field: { referenceValue?: string } | undefined -): { name: string; email?: string; id: string } | undefined { - const ref = field?.referenceValue; - if (typeof ref === 'string') { - const id = ref.split('/').pop(); - return id ? { name: id, id } : undefined; - } - return undefined; -} - -function encodeUpdatedBy( - updatedBy: { name: string; email?: string; id: string } | undefined -): FirestoreValue | undefined { - if (!updatedBy) return undefined; - const project = getEnsembleFirebaseProject(); - return { - referenceValue: `projects/${project}/databases/(default)/documents/users/${updatedBy.id}`, - }; -} - -function getDocId(docName: string): string { - return docName.split('/').pop() ?? docName; -} - -function artifactCollectionAndType(kind: ArtifactProp): { - collection: 'artifacts' | 'internal_artifacts'; - typeValue: string | null; -} { - const cfg = getArtifactConfig(kind); - return { - collection: cfg.firestoreCollection, - typeValue: cfg.firestoreType, - }; -} - -function encodeYamlDocumentFields( - kind: ArtifactProp, - doc: CreateYamlOp['document'] -): FirestoreWriteFields { - const { typeValue } = artifactCollectionAndType(kind); - const fields: FirestoreWriteFields = { - name: { stringValue: doc.name }, - content: { stringValue: doc.content }, - }; - if (typeValue) { - fields.type = { stringValue: typeValue }; - } - if (typeof doc.description === 'string') { - fields.description = { stringValue: doc.description }; - } - if (typeof doc.isRoot === 'boolean') { - fields.isRoot = { booleanValue: doc.isRoot }; - } - if (typeof doc.isArchived === 'boolean') { - fields.isArchived = { booleanValue: doc.isArchived }; - } - if (typeof doc.defaultLocale === 'boolean') { - fields.defaultLocale = { booleanValue: doc.defaultLocale }; - } - if (doc.createdAt) { - fields.createdAt = { timestampValue: doc.createdAt }; - } - if (doc.updatedAt) { - fields.updatedAt = { timestampValue: doc.updatedAt }; - } - const updatedByVal = encodeUpdatedBy(doc.updatedBy); - if (updatedByVal) { - fields.updatedBy = updatedByVal; - } - const createdByVal = encodeUpdatedBy( - (doc as { createdBy?: { name: string; email?: string; id: string } }).createdBy - ); - if (createdByVal) { - fields.createdBy = createdByVal; - } - return fields; -} - -function encodeHistoryFields(history: UpdateHistory): FirestoreWriteFields { - const fields: FirestoreWriteFields = { - name: { stringValue: history.name }, - content: { stringValue: history.content }, - type: { stringValue: history.type }, - }; - if (typeof history.isRoot === 'boolean') { - fields.isRoot = { booleanValue: history.isRoot }; - } - if (typeof history.isArchived === 'boolean') { - fields.isArchived = { booleanValue: history.isArchived }; - } - const defaultLocale = (history as { defaultLocale?: boolean }).defaultLocale; - if (typeof defaultLocale === 'boolean') { - fields.defaultLocale = { booleanValue: defaultLocale }; - } - if (history.updatedAt) { - fields.updatedAt = { timestampValue: history.updatedAt }; - } - const updatedByVal = encodeUpdatedBy(history.updatedBy); - if (updatedByVal) { - fields.updatedBy = updatedByVal; - } - return fields; -} - -function encodeUpdateFields( - kind: ArtifactProp, - updates: UpdateUpdates -): { fields: FirestoreWriteFields; fieldPaths: string[] } { - const { typeValue } = artifactCollectionAndType(kind); - const fields: FirestoreWriteFields = {}; - const fieldPaths: string[] = []; - if (typeof updates.name === 'string') { - fields.name = { stringValue: updates.name }; - fieldPaths.push('name'); - } - if (typeof updates.content === 'string') { - fields.content = { stringValue: updates.content }; - fieldPaths.push('content'); - } - if (typeof updates.isRoot === 'boolean') { - fields.isRoot = { booleanValue: updates.isRoot }; - fieldPaths.push('isRoot'); - } - if (typeof updates.isArchived === 'boolean') { - fields.isArchived = { booleanValue: updates.isArchived }; - fieldPaths.push('isArchived'); - } - if (typeof updates.defaultLocale === 'boolean') { - fields.defaultLocale = { booleanValue: updates.defaultLocale }; - fieldPaths.push('defaultLocale'); - } - if (updates.updatedAt) { - fields.updatedAt = { timestampValue: updates.updatedAt }; - fieldPaths.push('updatedAt'); - } - if (updates.updatedBy) { - const updatedByVal = encodeUpdatedBy(updates.updatedBy); - if (updatedByVal) { - fields.updatedBy = updatedByVal; - fieldPaths.push('updatedBy'); - } - } - // Ensure type is set on update if needed (theme / artifacts should already have type, so we skip here). - if (typeValue && !fieldPaths.includes('type')) { - // no-op: rely on existing type field - } - return { fields, fieldPaths }; -} - -type FirestoreFields = Record< - string, - | { stringValue?: string } - | { timestampValue?: string } - | { booleanValue?: boolean } - | { mapValue?: { fields?: Record } } - | { referenceValue?: string } ->; - -function firestoreDocToEnsembleBase(doc: FirestoreDocument): { - id: string; - name: string; - content: string; - description?: string; - isRoot?: boolean; - isDraft?: boolean; - isArchived?: boolean; - createdAt?: string; - updatedAt?: string; - createdBy?: { name: string; email?: string; id: string }; - updatedBy?: { name: string; email?: string; id: string }; -} { - const fields = (doc.fields ?? {}) as FirestoreFields; - const id = getDocId(doc.name); - const base: ReturnType = { - id, - name: parseFirestoreString(fields.name as { stringValue?: string }) ?? id, - content: parseFirestoreString(fields.content as { stringValue?: string }) ?? '', - createdAt: parseFirestoreTimestamp(fields.createdAt as { timestampValue?: string }), - updatedAt: parseFirestoreTimestamp(fields.updatedAt as { timestampValue?: string }), - }; - const description = parseFirestoreString(fields.description as { stringValue?: string }); - if (description !== undefined) base.description = description; - const isArchived = parseFirestoreBoolean(fields.isArchived as { booleanValue?: boolean }); - if (isArchived !== undefined) base.isArchived = isArchived; - const isRoot = parseFirestoreBoolean(fields.isRoot as { booleanValue?: boolean }); - if (isRoot !== undefined) base.isRoot = isRoot; - const isDraft = parseFirestoreBoolean(fields.isDraft as { booleanValue?: boolean }); - if (isDraft !== undefined) base.isDraft = isDraft; - const updatedBy = parseUpdatedBy( - fields.updatedBy as { - mapValue?: { fields?: Record }; - referenceValue?: string; - } - ); - if (updatedBy) base.updatedBy = updatedBy; - const createdBy = parseUpdatedBy( - fields.createdBy as { - mapValue?: { fields?: Record }; - referenceValue?: string; - } - ); - if (createdBy) base.createdBy = createdBy; - return base; -} - -function toWidgetDTO(doc: FirestoreDocument): WidgetDTO { - const base = firestoreDocToEnsembleBase(doc); - return { - ...base, - type: EnsembleDocumentType.Widget, - }; -} - -function toScriptDTO(doc: FirestoreDocument): ScriptDTO { - const base = firestoreDocToEnsembleBase(doc); - return { - ...base, - type: EnsembleDocumentType.Script, - }; -} - -function toActionDTO(doc: FirestoreDocument): ActionDTO { - const base = firestoreDocToEnsembleBase(doc); - return { - ...base, - type: EnsembleDocumentType.Action, - }; -} - -function toScreenDTO(doc: FirestoreDocument): ScreenDTO { - const base = firestoreDocToEnsembleBase(doc); - return { - ...base, - type: EnsembleDocumentType.Screen, - }; -} - -function toThemeDTO(doc: FirestoreDocument): ThemeDTO { - const base = firestoreDocToEnsembleBase(doc); - return { - ...base, - type: EnsembleDocumentType.Theme, - }; -} - -function toTranslationDTO(doc: FirestoreDocument, defaultLocale: boolean): TranslationDTO { - const base = firestoreDocToEnsembleBase(doc); - return { - ...base, - type: EnsembleDocumentType.I18n, - defaultLocale, - }; -} - -function toAssetDTO(doc: FirestoreDocument): AssetDTO { - const base = firestoreDocToEnsembleBase(doc); - const fields = (doc.fields ?? {}) as FirestoreFields; - const fileName = parseFirestoreString(fields.fileName as { stringValue?: string }) ?? base.name; - const publicUrl = parseFirestoreString(fields.publicUrl as { stringValue?: string }); - const copyText = parseFirestoreString(fields.copyText as { stringValue?: string }); - return { - ...base, - name: fileName, - fileName, - type: EnsembleDocumentType.Asset, - ...(publicUrl !== undefined && { publicUrl }), - ...(copyText !== undefined && { copyText }), - }; -} - -function parseFirestoreMapField( - field: { mapValue?: { fields?: Record } } | undefined -): Record | undefined { - const mapFields = field?.mapValue?.fields; - if (!mapFields || typeof mapFields !== 'object') return undefined; - const result: Record = {}; - for (const [key, value] of Object.entries(mapFields)) { - if (typeof (value as { stringValue?: string }).stringValue === 'string') { - result[key] = (value as { stringValue: string }).stringValue; - continue; - } - if (typeof (value as { booleanValue?: boolean }).booleanValue === 'boolean') { - result[key] = (value as { booleanValue: boolean }).booleanValue; - continue; - } - if ((value as { integerValue?: string }).integerValue !== undefined) { - result[key] = Number((value as { integerValue: string }).integerValue); - } - } - return Object.keys(result).length > 0 ? result : undefined; -} - -function encodeFirestoreStringMap(values: Record): { - mapValue: { fields: Record }; -} { - const fields: Record = {}; - for (const [key, value] of Object.entries(values)) { - fields[key] = { stringValue: value }; - } - return { mapValue: { fields } }; -} - -function dtoToStringMap(entries: Array<{ key: string; value: string }>): Record { - return Object.fromEntries(entries.map((entry) => [entry.key, entry.value])); -} - -function parseJsonObjectField(content: string | undefined): T | undefined { - if (!content) return undefined; - try { - const parsed = JSON.parse(content) as T; - return parsed && typeof parsed === 'object' ? parsed : undefined; - } catch { - return undefined; - } -} - -function toConfigDTO(doc: FirestoreDocument): ConfigDTO | undefined { - const fields = (doc.fields ?? {}) as FirestoreFields; - const fromContent = parseJsonObjectField( - parseFirestoreString(fields.content as { stringValue?: string }) - ); - const envVariablesFromMap = parseFirestoreMapField( - fields.envVariables as { mapValue?: { fields?: Record } } - ); - const baseUrl = parseFirestoreString(fields.baseUrl as { stringValue?: string }); - const useBrowserUrl = parseFirestoreBoolean(fields.useBrowserUrl as { booleanValue?: boolean }); - const envVariables = - envVariablesFromMap ?? (fromContent?.envVariables as Record | undefined); - if (!envVariables && baseUrl === undefined && useBrowserUrl === undefined) { - return undefined; - } - return { - ...(envVariables && { envVariables }), - ...(baseUrl !== undefined && { baseUrl }), - ...(useBrowserUrl !== undefined && { useBrowserUrl }), - }; -} - -function toSecretDTO(doc: FirestoreDocument): SecretDTO | undefined { - const fields = (doc.fields ?? {}) as FirestoreFields; - const fromContent = parseJsonObjectField( - parseFirestoreString(fields.content as { stringValue?: string }) - ); - const secretsFromMap = parseFirestoreMapField( - fields.secrets as { mapValue?: { fields?: Record } } - ); - if (secretsFromMap) { - return { secrets: secretsFromMap as Record }; - } - if (fromContent) return fromContent; - - const flat = parseFirestoreMapField( - fields as { mapValue?: { fields?: Record } } - ); - return flat ? (flat as SecretDTO) : undefined; -} - -async function upsertEnvArtifactDocument( - appId: string, - idToken: string, - project: string, - documentId: string, - typeValue: string, - contentJson: string, - mapFieldName: 'envVariables' | 'secrets', - mapValues: Record, - options?: FirestoreClientOptions -): Promise { - const collectionUrl = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}/artifacts`; - const docUrl = `${collectionUrl}/${encodeURIComponent(documentId)}`; - const updatedAt = new Date().toISOString(); - const patchFields: FirestoreWriteFields = { - content: { stringValue: contentJson }, - [mapFieldName]: encodeFirestoreStringMap(mapValues), - updatedAt: { timestampValue: updatedAt }, - }; - const fieldPaths = ['content', mapFieldName, 'updatedAt']; - - logDebug(options, { - kind: 'request', - method: 'PATCH', - url: `${docUrl}?${fieldPaths.map((p) => `updateMask.fieldPaths=${encodeURIComponent(p)}`).join('&')}`, - context: 'submitEnvDocumentsPush/patch', - }); - const patchRes = await fetch( - `${docUrl}?${fieldPaths.map((p) => `updateMask.fieldPaths=${encodeURIComponent(p)}`).join('&')}`, - { - method: 'PATCH', - headers: { - Authorization: `Bearer ${idToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ fields: patchFields }), - } - ); - - if (patchRes.ok) return; - - if (patchRes.status !== 404) { - throw await toFirestoreError(`update ${documentId}`, patchRes, options); - } - - const createUrl = `${collectionUrl}?documentId=${encodeURIComponent(documentId)}`; - const createFields: FirestoreWriteFields = { - name: { stringValue: documentId }, - type: { stringValue: typeValue }, - ...patchFields, - }; - logDebug(options, { - kind: 'request', - method: 'POST', - url: createUrl, - context: 'submitEnvDocumentsPush/create', - }); - const createRes = await fetch(createUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${idToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ fields: createFields }), - }); - if (!createRes.ok) { - throw await toFirestoreError(`create ${documentId}`, createRes, options); - } -} - -export async function submitEnvDocumentsPush( - appId: string, - idToken: string, - payload: { config?: ConfigDTO; secrets?: SecretDTO }, - options?: FirestoreClientOptions -): Promise { - const project = getEnsembleFirebaseProject(); - if (payload.config) { - await upsertEnvArtifactDocument( - appId, - idToken, - project, - 'appConfig', - EnsembleDocumentType.Environment, - JSON.stringify(payload.config), - 'envVariables', - dtoToStringMap(configDtoToEnvEntries(payload.config)), - options - ); - } - if (payload.secrets) { - await upsertEnvArtifactDocument( - appId, - idToken, - project, - 'secrets', - EnsembleDocumentType.Secrets, - JSON.stringify(payload.secrets), - 'secrets', - dtoToStringMap(secretsDtoToEnvEntries(payload.secrets)), - options - ); - } -} - -function getCollaboratorRole( - collaboratorsField: - | { mapValue?: { fields?: Record } } - | undefined, - userKey: string -): string | undefined { - const mapFields = collaboratorsField?.mapValue?.fields; - if (!mapFields || typeof mapFields !== 'object') return undefined; - return parseFirestoreString(mapFields[userKey]); -} - -/** - * Check if an app exists in Firestore and the current user has write or owner access. - * Verifies the user is in collaborators as users_{uid} with "write" or "owner". - */ -export async function checkAppAccess( - appId: string, - idToken: string, - userId: string, - options?: FirestoreClientOptions -): Promise { - const project = getEnsembleFirebaseProject(); - const url = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}`; - - try { - logDebug(options, { - kind: 'request', - method: 'GET', - url, - context: 'checkAppAccess', - }); - const res = await fetch(url, { - method: 'GET', - headers: { - Authorization: `Bearer ${idToken}`, - }, - }); - - if (res.ok) { - logDebug(options, { - kind: 'response', - method: 'GET', - url, - status: res.status, - context: 'checkAppAccess', - }); - const doc = (await res.json()) as { - fields?: Record< - string, - { stringValue?: string; mapValue?: { fields?: Record } } - >; - }; - const fields = doc?.fields ?? {}; - - const userKey = `users_${userId}`; - const role = getCollaboratorRole(fields.collaborators, userKey); - if (!role || !ALLOWED_ROLES.has(role)) { - return { - ok: false, - reason: 'no_access', - message: `You do not have write or owner access to app "${appId}".`, - code: 'PERMISSION_DENIED', - hint: defaultHintForCode('PERMISSION_DENIED'), - }; - } - - const app: AppInfo = { - name: parseFirestoreString(fields.name as { stringValue?: string }), - description: parseFirestoreString(fields.description as { stringValue?: string }), - }; - return { ok: true, app }; - } - - if (res.status === 404) { - return { - ok: false, - reason: 'not_found', - message: `App "${appId}" does not exist.`, - code: mapStatusToErrorCode(res.status), - status: res.status, - }; - } - - if (res.status === 403) { - return { - ok: false, - reason: 'no_access', - message: `You do not have access to app "${appId}".`, - code: mapStatusToErrorCode(res.status), - status: res.status, - hint: defaultHintForCode('PERMISSION_DENIED'), - }; - } - - if (res.status === 401) { - return { - ok: false, - reason: 'not_logged_in', - message: 'Session expired or invalid. Run `ensemble login` to sign in again.', - code: mapStatusToErrorCode(res.status), - status: res.status, - hint: defaultHintForCode('AUTH_EXPIRED'), - }; - } - - const text = await res.text(); - return { - ok: false, - reason: 'network_error', - message: `Firestore request failed (${res.status}): ${text.slice(0, 200)}`, - code: mapStatusToErrorCode(res.status), - status: res.status, - }; - } catch (err) { - const mapped = networkError('checkAppAccess', err); - return { - ok: false, - reason: 'network_error', - message: mapped.message, - code: mapped.code, - hint: mapped.hint, - }; - } -} - -async function listCollectionDocuments( - project: string, - parentPath: string, - collectionId: string, - idToken: string, - filter?: (doc: FirestoreDocument) => boolean, - options?: FirestoreClientOptions -): Promise { - const baseUrl = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/${parentPath}/${collectionId}`; - const docs: FirestoreDocument[] = []; - let pageToken: string | undefined; - - do { - const url = new URL(baseUrl); - if (pageToken) url.searchParams.set('pageToken', pageToken); - - logDebug(options, { - kind: 'request', - method: 'GET', - url: url.toString(), - context: 'listCollectionDocuments', - }); - const res = await fetch(url.toString(), { - headers: { Authorization: `Bearer ${idToken}` }, - }); - - if (!res.ok) { - throw await toFirestoreError('list collection documents', res, options); - } - - const body = (await res.json()) as { - documents?: FirestoreDocument[]; - nextPageToken?: string; - }; - - for (const doc of body.documents ?? []) { - if (!filter || filter(doc)) { - docs.push(doc); - } - } - logDebug(options, { - kind: 'list_documents', - collection: collectionId, - parentPath, - count: body.documents?.length ?? 0, - }); - pageToken = body.nextPageToken; - } while (pageToken); - - return docs; -} - -async function fetchAppDocument( - project: string, - appId: string, - idToken: string, - options?: FirestoreClientOptions -): Promise<{ id: string; name?: string; createdAt?: string; updatedAt?: string }> { - const url = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}`; - logDebug(options, { - kind: 'request', - method: 'GET', - url, - context: 'fetchAppDocument', - }); - const res = await fetch(url, { headers: { Authorization: `Bearer ${idToken}` } }); - if (!res.ok) { - throw await toFirestoreError('fetch app document', res, options); - } - const doc = (await res.json()) as { - name?: string; - createTime?: string; - updateTime?: string; - fields?: FirestoreFields; - }; - const fields = doc?.fields ?? {}; - return { - id: appId, - name: parseFirestoreString(fields.name as { stringValue?: string }), - createdAt: - doc.createTime ?? parseFirestoreTimestamp(fields.createdAt as { timestampValue?: string }), - updatedAt: - doc.updateTime ?? parseFirestoreTimestamp(fields.updatedAt as { timestampValue?: string }), - }; -} - -/** - * Fetch the cloud app and transform to ApplicationDTO shape. - * - App-level: id, name, createdAt, updatedAt (from app document) - * - internal_artifacts: scripts, widgets → widgets[], scripts[] - * - artifacts: screens, appConfig, secrets, theme → screens[], config, secrets, theme - */ -export async function fetchCloudApp( - appId: string, - idToken: string, - options?: FirestoreClientOptions -): Promise { - const project = getEnsembleFirebaseProject(); - const parentPath = `apps/${appId}`; - - const [appDoc, internalArtifacts, artifacts] = await Promise.all([ - fetchAppDocument(project, appId, idToken, options), - listCollectionDocuments(project, parentPath, 'internal_artifacts', idToken, undefined, options), - listCollectionDocuments( - project, - parentPath, - 'artifacts', - idToken, - (doc) => { - const docId = getDocId(doc.name); - return docId !== 'resources'; - }, - options - ), - ]); - - const widgets: WidgetDTO[] = []; - const scripts: ScriptDTO[] = []; - const actions: ActionDTO[] = []; - for (const doc of internalArtifacts) { - const type = parseFirestoreString((doc.fields?.type as { stringValue?: string }) ?? undefined); - if (type === 'internal_widget') widgets.push(toWidgetDTO(doc)); - else if (type === 'internal_script') scripts.push(toScriptDTO(doc)); - else if (type === 'internal_action') actions.push(toActionDTO(doc)); - } - - const screens: ScreenDTO[] = []; - const translations: TranslationDTO[] = []; - const assets: AssetDTO[] = []; - let theme: ThemeDTO | undefined; - let config: ConfigDTO | undefined; - let secrets: SecretDTO | undefined; - const i18nDocs: FirestoreDocument[] = []; - for (const doc of artifacts) { - const docId = getDocId(doc.name); - const type = parseFirestoreString((doc.fields?.type as { stringValue?: string }) ?? undefined); - if (type === 'screen') screens.push(toScreenDTO(doc)); - else if (type === 'i18n') i18nDocs.push(doc); - else if (type === 'asset') assets.push(toAssetDTO(doc)); - else if (type === 'config' || docId === 'appConfig') config = toConfigDTO(doc) ?? config; - else if (type === 'secrets' || docId === 'secrets') secrets = toSecretDTO(doc) ?? secrets; - else if (type === 'theme') { - if (!theme || docId === 'theme') { - theme = toThemeDTO(doc); - } - } - } - const defaultLocaleField = (doc: FirestoreDocument) => - parseFirestoreBoolean((doc.fields?.defaultLocale as { booleanValue?: boolean }) ?? undefined); - for (let i = 0; i < i18nDocs.length; i++) { - const doc = i18nDocs[i]; - const isDefault = defaultLocaleField(doc); - translations.push( - // Only pass true/false when explicitly stored; otherwise leave undefined - toTranslationDTO(doc, isDefault === true) - ); - } - - return { - id: appDoc.id, - name: appDoc.name ?? appDoc.id, - ...(appDoc.createdAt !== undefined && { createdAt: appDoc.createdAt }), - ...(appDoc.updatedAt !== undefined && { updatedAt: appDoc.updatedAt }), - widgets, - scripts, - // Actions are stored as internal_artifacts with type=internal_action. - ...(actions.length > 0 && { actions }), - screens, - ...(theme && { theme }), - ...(translations.length > 0 && { translations }), - ...(assets.length > 0 && { assets }), - ...(config && { config }), - ...(secrets && { secrets }), - }; -} - -/** - * Fetch only the name of the screen with isRoot: true. - * Uses a minimal Firestore runQuery (type=screen, isRoot=true, limit 1). - * Parent must be in the URL path for subcollection queries. - */ -export async function fetchRootScreenName( - appId: string, - idToken: string, - options?: FirestoreClientOptions -): Promise { - const project = getEnsembleFirebaseProject(); - const parent = `projects/${project}/databases/(default)/documents/apps/${appId}`; - const url = `https://firestore.googleapis.com/v1/${parent}:runQuery`; - - const structuredQuery = { - from: [{ collectionId: 'artifacts' }], - where: { - compositeFilter: { - op: 'AND' as const, - filters: [ - { - fieldFilter: { - field: { fieldPath: 'type' }, - op: 'EQUAL' as const, - value: { stringValue: 'screen' }, - }, - }, - { - fieldFilter: { - field: { fieldPath: 'isRoot' }, - op: 'EQUAL' as const, - value: { booleanValue: true }, - }, - }, - ], - }, - }, - limit: 1, - }; - - logDebug(options, { - kind: 'request', - method: 'POST', - url, - context: 'fetchRootScreenName', - }); - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${idToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ structuredQuery }), - }); - - if (!res.ok) return undefined; - - const body = await res.json(); - const results = Array.isArray(body) ? body : [body]; - const doc = results[0]?.document; - if (!doc?.fields) return undefined; - - return parseFirestoreString(doc.fields.name as { stringValue?: string }); -} - -/** - * Create a version (snapshot) document under apps/{appId}/versions. - * expiresAt must be stored as Firestore Timestamp for TTL policy to work. - */ -export async function createVersion( - appId: string, - idToken: string, - params: CreateVersionParams, - options?: FirestoreClientOptions -): Promise<{ id: string }> { - const project = getEnsembleFirebaseProject(); - const versionId = params.id; - const parent = `projects/${project}/databases/(default)/documents/apps/${appId}`; - const url = `https://firestore.googleapis.com/v1/${parent}/versions?documentId=${encodeURIComponent(versionId)}`; - - const createdByVal = encodeUpdatedBy(params.createdBy); - const fields: Record = { - message: { stringValue: params.message }, - createdAt: { timestampValue: params.createdAt }, - expiresAt: { timestampValue: params.expiresAt }, - ...(createdByVal && { createdBy: createdByVal }), - snapshotPath: { stringValue: params.snapshotPath }, - }; - - logDebug(options, { - kind: 'request', - method: 'POST', - url, - context: 'createVersion', - }); - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${idToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ fields }), - }); - - if (!res.ok) { - throw await toFirestoreError('create version', res, options); - } - - logDebug(options, { - kind: 'response', - method: 'POST', - url, - status: res.status, - context: 'createVersion', - }); - return { id: versionId }; -} - -function parseVersionDoc(doc: FirestoreDocument): VersionDoc | null { - if (!doc.name || !doc.fields) return null; - const fields = doc.fields as Record; - const id = getDocId(doc.name); - const message = parseFirestoreString(fields.message as { stringValue?: string }) ?? ''; - const createdAt = parseFirestoreTimestamp(fields.createdAt as { timestampValue?: string }); - const expiresAt = parseFirestoreTimestamp(fields.expiresAt as { timestampValue?: string }); - const createdBy = parseUpdatedBy(fields.createdBy as { referenceValue?: string }) ?? { - name: 'Unknown', - id: '', - }; - if (createdAt === undefined || expiresAt === undefined) return null; - const snapshotPath = parseFirestoreString(fields.snapshotPath as { stringValue?: string }); - if (!snapshotPath) return null; - - return { - id, - message, - createdAt, - createdBy, - expiresAt, - snapshotPath, - }; -} - -export interface ListVersionsResult { - versions: VersionDoc[]; - /** Pass as startAfter on next call to fetch the next page. Omitted when there are no more. */ - nextStartAfter?: string; -} - -/** - * List a page of version documents (newest first), 5 per page for efficient Firebase queries. - * Uses runQuery with orderBy createdAt desc. Requires a composite index on versions (createdAt desc). - */ -export async function listVersions( - appId: string, - idToken: string, - opts: { limit: number; startAfter?: string }, - options?: FirestoreClientOptions -): Promise { - const project = getEnsembleFirebaseProject(); - const parent = `projects/${project}/databases/(default)/documents/apps/${appId}`; - const url = `https://firestore.googleapis.com/v1/${parent}:runQuery`; - - const structuredQuery: { - from: { collectionId: string }[]; - orderBy: { field: { fieldPath: string }; direction: string }[]; - limit: number; - startAt?: { values: { timestampValue: string }[]; before: boolean }; - } = { - from: [{ collectionId: 'versions' }], - orderBy: [{ field: { fieldPath: 'createdAt' }, direction: 'DESCENDING' }], - limit: opts.limit, - }; - if (opts.startAfter !== undefined && opts.startAfter !== '') { - structuredQuery.startAt = { - values: [{ timestampValue: opts.startAfter }], - before: false, - }; - } - - logDebug(options, { - kind: 'request', - method: 'POST', - url, - context: 'listVersions', - }); - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${idToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ structuredQuery }), - }); - - if (!res.ok) { - throw await toFirestoreError('list versions', res, options); - } - - const body = (await res.json()) as { document?: FirestoreDocument }[]; - const items = Array.isArray(body) ? body : [body]; - const versions: VersionDoc[] = []; - let lastCreatedAt: string | undefined; - for (const item of items) { - const doc = item.document; - if (!doc) continue; - const parsed = parseVersionDoc(doc); - if (parsed) { - versions.push(parsed); - lastCreatedAt = parsed.createdAt; - } - } - const nextStartAfter = - versions.length === opts.limit && lastCreatedAt !== undefined ? lastCreatedAt : undefined; - return { versions, nextStartAfter }; -} - -/** - * Fetch a single version document including snapshot. - */ -export async function getVersion( - appId: string, - idToken: string, - versionId: string, - options?: FirestoreClientOptions -): Promise { - const project = getEnsembleFirebaseProject(); - const url = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}/versions/${versionId}`; - - logDebug(options, { - kind: 'request', - method: 'GET', - url, - context: 'getVersion', - }); - const res = await fetch(url, { - headers: { Authorization: `Bearer ${idToken}` }, - }); - - if (!res.ok) { - if (res.status === 404) { - throw new FirestoreClientError({ - code: 'NOT_FOUND', - message: `Version "${versionId}" not found.`, - status: 404, - hint: 'It may have expired (versions are deleted after 30 days).', - }); - } - throw await toFirestoreError('get version', res, options); - } - - const doc = (await res.json()) as FirestoreDocument; - const parsed = parseVersionDoc(doc); - if (!parsed) { - throw new FirestoreClientError({ - code: 'UNKNOWN', - message: 'Version metadata is invalid.', - }); - } - return parsed; -} +/** + * Firestore client for validating app existence and user access. + * Uses the Firestore REST API with the user's Firebase ID token. + */ + +import type { + ApplicationDTO, + AssetDTO, + WidgetDTO, + ScriptDTO, + ActionDTO, + ScreenDTO, + ThemeDTO, + TranslationDTO, + ConfigDTO, + SecretDTO, +} from '../core/dto.js'; +import { EnsembleDocumentType } from '../core/dto.js'; +import { configDtoToEnvEntries, secretsDtoToEnvEntries } from '../core/envSync.js'; +import { getArtifactConfig, type ArtifactProp } from '../core/artifacts.js'; +import { processWithConcurrency } from '../core/concurrency.js'; +import { uploadProjectAssetsForPush } from '../core/pushAssets.js'; +import { getEnsembleFirebaseProject } from '../config/env.js'; + +const DEFAULT_FIRESTORE_CONCURRENCY = 15; + +export type FirestoreErrorCode = + | 'AUTH_EXPIRED' + | 'PERMISSION_DENIED' + | 'NOT_FOUND' + | 'NETWORK_UNAVAILABLE' + | 'QUOTA_EXCEEDED' + | 'UNKNOWN'; + +export class FirestoreClientError extends Error { + code: FirestoreErrorCode; + + status?: number; + + hint?: string; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cause?: any; + + constructor(params: { + code: FirestoreErrorCode; + message: string; + status?: number; + hint?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cause?: any; + }) { + super(params.message); + this.name = 'FirestoreClientError'; + this.code = params.code; + this.status = params.status; + this.hint = params.hint; + this.cause = params.cause; + } +} + +export type FirestoreDebugEvent = + | { + kind: 'request'; + method: string; + url: string; + context: string; + } + | { + kind: 'response'; + method: string; + url: string; + status: number; + context: string; + } + | { + kind: 'list_documents'; + collection: string; + parentPath: string; + count: number; + } + | { + kind: 'push_operation'; + appId: string; + operation: 'create' | 'update'; + artifactKind: ArtifactProp; + documentId: string; + }; + +export interface FirestoreClientOptions { + /** + * Optional debug hook for Firestore requests. + * IMPORTANT: Implementations must NEVER log Authorization headers, ID tokens, + * refresh tokens, or other sensitive values. + */ + debug?: (event: FirestoreDebugEvent) => void; +} + +function logDebug(options: FirestoreClientOptions | undefined, event: FirestoreDebugEvent): void { + if (!options?.debug) return; + try { + options.debug(event); + } catch { + // Debug logging must never break core behavior. + } +} + +function mapStatusToErrorCode(status: number): FirestoreErrorCode { + if (status === 401) return 'AUTH_EXPIRED'; + if (status === 403) return 'PERMISSION_DENIED'; + if (status === 404) return 'NOT_FOUND'; + if (status === 429 || status === 503) return 'QUOTA_EXCEEDED'; + if (status === 0) return 'NETWORK_UNAVAILABLE'; + return 'UNKNOWN'; +} + +function defaultHintForCode(code: FirestoreErrorCode): string | undefined { + if (code === 'AUTH_EXPIRED') { + return 'Session expired or invalid. Run `ensemble login` and try again.'; + } + if (code === 'PERMISSION_DENIED') { + return 'You do not have permission to access this app. Check your account or app sharing settings.'; + } + if (code === 'NETWORK_UNAVAILABLE') { + return 'Check your internet connection or proxy settings, then try again.'; + } + if (code === 'QUOTA_EXCEEDED') { + return 'You have hit a Firestore quota limit. Try again later or adjust your Firebase project quotas.'; + } + return undefined; +} + +async function toFirestoreError( + context: string, + res: Response, + options?: FirestoreClientOptions +): Promise { + const text = await res.text(); + const code = mapStatusToErrorCode(res.status); + const hint = defaultHintForCode(code); + + logDebug(options, { + kind: 'response', + method: 'UNKNOWN', + url: res.url ?? '', + status: res.status, + context, + }); + + return new FirestoreClientError({ + code, + status: res.status, + message: `Firestore ${context} failed (${res.status})`, + hint, + cause: text.slice(0, 200), + }); +} + +function networkError(context: string, err: unknown): FirestoreClientError { + const message = + err instanceof Error && typeof err.message === 'string' + ? err.message + : 'Network request failed.'; + return new FirestoreClientError({ + code: 'NETWORK_UNAVAILABLE', + message: `Firestore ${context} failed: ${message}`, + cause: err, + }); +} + +/** Raw Firestore document from list/get API. */ +export interface FirestoreDocument { + name: string; + fields?: Record; +} + +type FirestoreValue = + | { stringValue: string } + | { booleanValue: boolean } + | { timestampValue: string } + | { integerValue: string } + | { mapValue: { fields: Record } } + | { referenceValue: string }; + +type FirestoreWriteFields = Record; + +/** Cloud app in ApplicationDTO shape, aligned with local app structure. */ +export type CloudApp = Pick< + ApplicationDTO, + | 'id' + | 'name' + | 'createdAt' + | 'updatedAt' + | 'widgets' + | 'scripts' + | 'actions' + | 'screens' + | 'theme' + | 'translations' + | 'assets' + | 'config' + | 'secrets' +>; + +/** Metadata for a saved version (commit); snapshot stored in same doc. */ +export interface VersionMetadata { + id: string; + message: string; + createdAt: string; + createdBy: { name: string; email?: string; id: string }; + expiresAt: string; + snapshotPath: string; +} + +/** Version doc metadata (snapshot stored in Storage). */ +export type VersionDoc = VersionMetadata; + +export interface CreateVersionParams { + /** Firestore version document id; must match the storage object name. */ + id: string; + message: string; + createdAt: string; + createdBy: { name: string; email?: string; id: string }; + /** Must be Firestore Timestamp (e.g. 30 days from now). Use ISO string for timestampValue. */ + expiresAt: string; + snapshotPath: string; +} + +const ALLOWED_ROLES = new Set(['write', 'owner']); + +export interface AppInfo { + name?: string; + description?: string; +} + +export type AppAccessResult = + | { ok: true; app: AppInfo } + | { + ok: false; + reason: 'not_found' | 'no_access' | 'not_logged_in' | 'network_error'; + message: string; + code?: FirestoreErrorCode; + hint?: string; + status?: number; + }; + +type YamlArtifactPushOperation = + | { + operation: 'create'; + document: { + id: string; + name: string; + content: string; + type: string; + isRoot?: boolean; + isArchived?: boolean; + defaultLocale?: boolean; + createdAt?: string; + updatedAt?: string; + updatedBy?: { name: string; email?: string; id: string }; + createdBy?: { name: string; email?: string; id: string }; + description?: string; + }; + } + | { + operation: 'update'; + id: string; + history: { + content: string; + name: string; + type: string; + isRoot?: boolean; + isArchived?: boolean; + defaultLocale?: boolean; + updatedAt?: string; + updatedBy?: { name: string; email?: string; id: string }; + }; + updates: { + content?: string; + name?: string; + isRoot?: boolean; + isArchived?: boolean; + defaultLocale?: boolean; + updatedAt?: string; + updatedBy?: { name: string; email?: string; id: string }; + }; + }; + +interface PushPayloadShape { + id: string; + name?: string; + updatedAt: string; + screens?: YamlArtifactPushOperation[]; + widgets?: YamlArtifactPushOperation[]; + scripts?: YamlArtifactPushOperation[]; + actions?: YamlArtifactPushOperation[]; + translations?: YamlArtifactPushOperation[]; + theme?: YamlArtifactPushOperation; + assets?: YamlArtifactPushOperation[]; +} + +type CreateYamlOp = Extract; +type UpdateYamlOp = Extract; +type UpdateHistory = UpdateYamlOp['history']; +type UpdateUpdates = UpdateYamlOp['updates']; + +function assertValidPushPayload(payload: unknown): asserts payload is PushPayloadShape { + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid push payload: expected an object.'); + } + const p = payload as { id?: unknown; updatedAt?: unknown }; + if (typeof p.id !== 'string' || typeof p.updatedAt !== 'string') { + throw new Error('Invalid push payload: missing or invalid "id" or "updatedAt".'); + } +} + +function getFirestoreConcurrency(): number { + const raw = process.env.ENSEMBLE_FIRESTORE_CONCURRENCY; + if (raw === undefined) return DEFAULT_FIRESTORE_CONCURRENCY; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_FIRESTORE_CONCURRENCY; + } + return Math.floor(parsed); +} + +async function applyAssetArchiveOperations( + appId: string, + idToken: string, + project: string, + ops: YamlArtifactPushOperation[] | undefined, + options?: FirestoreClientOptions +): Promise { + if (!ops || ops.length === 0) return; + + const baseCollectionUrl = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}/artifacts`; + const concurrency = getFirestoreConcurrency(); + + await processWithConcurrency( + ops, + async (op) => { + if (op.operation !== 'update') return; + + const docId = op.id; + const docUrl = `${baseCollectionUrl}/${encodeURIComponent(docId)}`; + const historyFields = encodeHistoryFields(op.history); + const historyUrl = `${docUrl}/history`; + + logDebug(options, { + kind: 'push_operation', + appId, + operation: 'update', + artifactKind: 'screens', + documentId: docId, + }); + logDebug(options, { + kind: 'request', + method: 'POST', + url: historyUrl, + context: 'submitCliPush/writeAssetHistory', + }); + const historyRes = await fetch(historyUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields: historyFields }), + }); + if (!historyRes.ok) { + throw await toFirestoreError( + `write history for asset "${op.history.name}"`, + historyRes, + options + ); + } + + const { fields: updateFields, fieldPaths } = encodeUpdateFields('screens', op.updates); + if (fieldPaths.length === 0) return; + + const params = fieldPaths + .map((path) => `updateMask.fieldPaths=${encodeURIComponent(path)}`) + .join('&'); + const patchUrl = `${docUrl}?${params}`; + logDebug(options, { + kind: 'request', + method: 'PATCH', + url: patchUrl, + context: 'submitCliPush/patchAsset', + }); + const patchRes = await fetch(patchUrl, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields: updateFields }), + }); + if (!patchRes.ok) { + throw await toFirestoreError(`archive asset "${op.history.name}"`, patchRes, options); + } + }, + concurrency + ); +} + +async function applyYamlOperationsForKind( + kind: 'screens' | 'widgets' | 'scripts' | 'actions' | 'translations' | 'theme', + appId: string, + idToken: string, + project: string, + ops: YamlArtifactPushOperation[] | undefined, + options?: FirestoreClientOptions +): Promise { + if (!ops || ops.length === 0) return; + const { collection } = artifactCollectionAndType(kind); + const baseCollectionUrl = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}/${collection}`; + + const concurrency = getFirestoreConcurrency(); + + await processWithConcurrency( + ops, + async (op) => { + if (op.operation === 'create') { + const doc = op.document; + const fields = encodeYamlDocumentFields(kind, doc); + const createUrl = `${baseCollectionUrl}?documentId=${encodeURIComponent(doc.id)}`; + logDebug(options, { + kind: 'push_operation', + appId, + operation: 'create', + artifactKind: kind, + documentId: doc.id, + }); + logDebug(options, { + kind: 'request', + method: 'POST', + url: createUrl, + context: 'submitCliPush/create', + }); + const res = await fetch(createUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields }), + }); + if (!res.ok) { + throw await toFirestoreError(`create ${kind.slice(0, -1)} "${doc.name}"`, res, options); + } + } else if (op.operation === 'update') { + const docId = op.id; + const docUrl = `${baseCollectionUrl}/${encodeURIComponent(docId)}`; + + // 1) Write history entry + const historyFields = encodeHistoryFields(op.history); + const historyUrl = `${docUrl}/history`; + logDebug(options, { + kind: 'push_operation', + appId, + operation: 'update', + artifactKind: kind, + documentId: docId, + }); + logDebug(options, { + kind: 'request', + method: 'POST', + url: historyUrl, + context: 'submitCliPush/writeHistory', + }); + const historyRes = await fetch(historyUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields: historyFields }), + }); + if (!historyRes.ok) { + throw await toFirestoreError( + `write history for ${kind.slice(0, -1)} "${op.history.name}"`, + historyRes, + options + ); + } + + // 2) Patch main document with partial updates + const { fields: updateFields, fieldPaths } = encodeUpdateFields(kind, op.updates); + if (fieldPaths.length === 0) { + return; + } + const params = fieldPaths + .map((p) => `updateMask.fieldPaths=${encodeURIComponent(p)}`) + .join('&'); + const patchUrl = `${docUrl}?${params}`; + logDebug(options, { + kind: 'request', + method: 'PATCH', + url: patchUrl, + context: 'submitCliPush/patchDocument', + }); + const patchRes = await fetch(patchUrl, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields: updateFields }), + }); + if (!patchRes.ok) { + throw await toFirestoreError( + `update ${kind.slice(0, -1)} "${op.history.name}"`, + patchRes, + options + ); + } + } + }, + concurrency + ); +} + +/** Optional local asset uploads after Firestore YAML apply (studio cloud function + .env.config). */ +export interface CliPushExtras { + projectRoot: string; + assetFileNames?: string[]; +} + +export interface CliPushResult { + assetsUploaded: number; +} + +/** + * Apply a push payload directly to Firestore, updating YAML artifacts in-place. + * Optionally uploads new assets (studio-uploadAsset + .env.config) in the same operation. + */ +export async function submitCliPush( + appId: string, + idToken: string, + payload: unknown, + options?: FirestoreClientOptions, + extras?: CliPushExtras +): Promise { + const project = getEnsembleFirebaseProject(); + assertValidPushPayload(payload); + const p = payload as PushPayloadShape; + + await applyYamlOperationsForKind('screens', appId, idToken, project, p.screens, options); + await applyYamlOperationsForKind('widgets', appId, idToken, project, p.widgets, options); + await applyYamlOperationsForKind('scripts', appId, idToken, project, p.scripts, options); + await applyYamlOperationsForKind('actions', appId, idToken, project, p.actions, options); + await applyYamlOperationsForKind( + 'translations', + appId, + idToken, + project, + p.translations, + options + ); + if (p.theme) { + await applyYamlOperationsForKind('theme', appId, idToken, project, [p.theme], options); + } + await applyAssetArchiveOperations(appId, idToken, project, p.assets, options); + + const names = extras?.assetFileNames?.filter((n) => n.trim() !== '') ?? []; + if (names.length === 0 || !extras?.projectRoot) { + return { assetsUploaded: 0 }; + } + const assetsUploaded = await uploadProjectAssetsForPush( + appId, + idToken, + extras.projectRoot, + names + ); + return { assetsUploaded }; +} + +function parseFirestoreString(field: { stringValue?: string } | undefined): string | undefined { + return typeof field?.stringValue === 'string' ? field.stringValue : undefined; +} + +function parseFirestoreTimestamp( + field: { timestampValue?: string } | undefined +): string | undefined { + const v = field?.timestampValue; + return typeof v === 'string' ? v : undefined; +} + +function parseFirestoreBoolean(field: { booleanValue?: boolean } | undefined): boolean | undefined { + return typeof field?.booleanValue === 'boolean' ? field.booleanValue : undefined; +} + +function parseUpdatedBy( + field: { referenceValue?: string } | undefined +): { name: string; email?: string; id: string } | undefined { + const ref = field?.referenceValue; + if (typeof ref === 'string') { + const id = ref.split('/').pop(); + return id ? { name: id, id } : undefined; + } + return undefined; +} + +function encodeUpdatedBy( + updatedBy: { name: string; email?: string; id: string } | undefined +): FirestoreValue | undefined { + if (!updatedBy) return undefined; + const project = getEnsembleFirebaseProject(); + return { + referenceValue: `projects/${project}/databases/(default)/documents/users/${updatedBy.id}`, + }; +} + +function getDocId(docName: string): string { + return docName.split('/').pop() ?? docName; +} + +function artifactCollectionAndType(kind: ArtifactProp): { + collection: 'artifacts' | 'internal_artifacts'; + typeValue: string | null; +} { + const cfg = getArtifactConfig(kind); + return { + collection: cfg.firestoreCollection, + typeValue: cfg.firestoreType, + }; +} + +function encodeYamlDocumentFields( + kind: ArtifactProp, + doc: CreateYamlOp['document'] +): FirestoreWriteFields { + const { typeValue } = artifactCollectionAndType(kind); + const fields: FirestoreWriteFields = { + name: { stringValue: doc.name }, + content: { stringValue: doc.content }, + }; + if (typeValue) { + fields.type = { stringValue: typeValue }; + } + if (typeof doc.description === 'string') { + fields.description = { stringValue: doc.description }; + } + if (typeof doc.isRoot === 'boolean') { + fields.isRoot = { booleanValue: doc.isRoot }; + } + if (typeof doc.isArchived === 'boolean') { + fields.isArchived = { booleanValue: doc.isArchived }; + } + if (typeof doc.defaultLocale === 'boolean') { + fields.defaultLocale = { booleanValue: doc.defaultLocale }; + } + if (doc.createdAt) { + fields.createdAt = { timestampValue: doc.createdAt }; + } + if (doc.updatedAt) { + fields.updatedAt = { timestampValue: doc.updatedAt }; + } + const updatedByVal = encodeUpdatedBy(doc.updatedBy); + if (updatedByVal) { + fields.updatedBy = updatedByVal; + } + const createdByVal = encodeUpdatedBy( + (doc as { createdBy?: { name: string; email?: string; id: string } }).createdBy + ); + if (createdByVal) { + fields.createdBy = createdByVal; + } + return fields; +} + +function encodeHistoryFields(history: UpdateHistory): FirestoreWriteFields { + const fields: FirestoreWriteFields = { + name: { stringValue: history.name }, + content: { stringValue: history.content }, + type: { stringValue: history.type }, + }; + if (typeof history.isRoot === 'boolean') { + fields.isRoot = { booleanValue: history.isRoot }; + } + if (typeof history.isArchived === 'boolean') { + fields.isArchived = { booleanValue: history.isArchived }; + } + const defaultLocale = (history as { defaultLocale?: boolean }).defaultLocale; + if (typeof defaultLocale === 'boolean') { + fields.defaultLocale = { booleanValue: defaultLocale }; + } + if (history.updatedAt) { + fields.updatedAt = { timestampValue: history.updatedAt }; + } + const updatedByVal = encodeUpdatedBy(history.updatedBy); + if (updatedByVal) { + fields.updatedBy = updatedByVal; + } + return fields; +} + +function encodeUpdateFields( + kind: ArtifactProp, + updates: UpdateUpdates +): { fields: FirestoreWriteFields; fieldPaths: string[] } { + const { typeValue } = artifactCollectionAndType(kind); + const fields: FirestoreWriteFields = {}; + const fieldPaths: string[] = []; + if (typeof updates.name === 'string') { + fields.name = { stringValue: updates.name }; + fieldPaths.push('name'); + } + if (typeof updates.content === 'string') { + fields.content = { stringValue: updates.content }; + fieldPaths.push('content'); + } + if (typeof updates.isRoot === 'boolean') { + fields.isRoot = { booleanValue: updates.isRoot }; + fieldPaths.push('isRoot'); + } + if (typeof updates.isArchived === 'boolean') { + fields.isArchived = { booleanValue: updates.isArchived }; + fieldPaths.push('isArchived'); + } + if (typeof updates.defaultLocale === 'boolean') { + fields.defaultLocale = { booleanValue: updates.defaultLocale }; + fieldPaths.push('defaultLocale'); + } + if (updates.updatedAt) { + fields.updatedAt = { timestampValue: updates.updatedAt }; + fieldPaths.push('updatedAt'); + } + if (updates.updatedBy) { + const updatedByVal = encodeUpdatedBy(updates.updatedBy); + if (updatedByVal) { + fields.updatedBy = updatedByVal; + fieldPaths.push('updatedBy'); + } + } + // Ensure type is set on update if needed (theme / artifacts should already have type, so we skip here). + if (typeValue && !fieldPaths.includes('type')) { + // no-op: rely on existing type field + } + return { fields, fieldPaths }; +} + +type FirestoreFields = Record< + string, + | { stringValue?: string } + | { timestampValue?: string } + | { booleanValue?: boolean } + | { mapValue?: { fields?: Record } } + | { referenceValue?: string } +>; + +function firestoreDocToEnsembleBase(doc: FirestoreDocument): { + id: string; + name: string; + content: string; + description?: string; + isRoot?: boolean; + isDraft?: boolean; + isArchived?: boolean; + createdAt?: string; + updatedAt?: string; + createdBy?: { name: string; email?: string; id: string }; + updatedBy?: { name: string; email?: string; id: string }; +} { + const fields = (doc.fields ?? {}) as FirestoreFields; + const id = getDocId(doc.name); + const base: ReturnType = { + id, + name: parseFirestoreString(fields.name as { stringValue?: string }) ?? id, + content: parseFirestoreString(fields.content as { stringValue?: string }) ?? '', + createdAt: parseFirestoreTimestamp(fields.createdAt as { timestampValue?: string }), + updatedAt: parseFirestoreTimestamp(fields.updatedAt as { timestampValue?: string }), + }; + const description = parseFirestoreString(fields.description as { stringValue?: string }); + if (description !== undefined) base.description = description; + const isArchived = parseFirestoreBoolean(fields.isArchived as { booleanValue?: boolean }); + if (isArchived !== undefined) base.isArchived = isArchived; + const isRoot = parseFirestoreBoolean(fields.isRoot as { booleanValue?: boolean }); + if (isRoot !== undefined) base.isRoot = isRoot; + const isDraft = parseFirestoreBoolean(fields.isDraft as { booleanValue?: boolean }); + if (isDraft !== undefined) base.isDraft = isDraft; + const updatedBy = parseUpdatedBy( + fields.updatedBy as { + mapValue?: { fields?: Record }; + referenceValue?: string; + } + ); + if (updatedBy) base.updatedBy = updatedBy; + const createdBy = parseUpdatedBy( + fields.createdBy as { + mapValue?: { fields?: Record }; + referenceValue?: string; + } + ); + if (createdBy) base.createdBy = createdBy; + return base; +} + +function toWidgetDTO(doc: FirestoreDocument): WidgetDTO { + const base = firestoreDocToEnsembleBase(doc); + return { + ...base, + type: EnsembleDocumentType.Widget, + }; +} + +function toScriptDTO(doc: FirestoreDocument): ScriptDTO { + const base = firestoreDocToEnsembleBase(doc); + return { + ...base, + type: EnsembleDocumentType.Script, + }; +} + +function toActionDTO(doc: FirestoreDocument): ActionDTO { + const base = firestoreDocToEnsembleBase(doc); + return { + ...base, + type: EnsembleDocumentType.Action, + }; +} + +function toScreenDTO(doc: FirestoreDocument): ScreenDTO { + const base = firestoreDocToEnsembleBase(doc); + return { + ...base, + type: EnsembleDocumentType.Screen, + }; +} + +function toThemeDTO(doc: FirestoreDocument): ThemeDTO { + const base = firestoreDocToEnsembleBase(doc); + return { + ...base, + type: EnsembleDocumentType.Theme, + }; +} + +function toTranslationDTO(doc: FirestoreDocument, defaultLocale: boolean): TranslationDTO { + const base = firestoreDocToEnsembleBase(doc); + return { + ...base, + type: EnsembleDocumentType.I18n, + defaultLocale, + }; +} + +function toAssetDTO(doc: FirestoreDocument): AssetDTO { + const base = firestoreDocToEnsembleBase(doc); + const fields = (doc.fields ?? {}) as FirestoreFields; + const fileName = parseFirestoreString(fields.fileName as { stringValue?: string }) ?? base.name; + const publicUrl = parseFirestoreString(fields.publicUrl as { stringValue?: string }); + const copyText = parseFirestoreString(fields.copyText as { stringValue?: string }); + return { + ...base, + name: fileName, + fileName, + type: EnsembleDocumentType.Asset, + ...(publicUrl !== undefined && { publicUrl }), + ...(copyText !== undefined && { copyText }), + }; +} + +function parseFirestoreMapField( + field: { mapValue?: { fields?: Record } } | undefined +): Record | undefined { + const mapFields = field?.mapValue?.fields; + if (!mapFields || typeof mapFields !== 'object') return undefined; + const result: Record = {}; + for (const [key, value] of Object.entries(mapFields)) { + if (typeof (value as { stringValue?: string }).stringValue === 'string') { + result[key] = (value as { stringValue: string }).stringValue; + continue; + } + if (typeof (value as { booleanValue?: boolean }).booleanValue === 'boolean') { + result[key] = (value as { booleanValue: boolean }).booleanValue; + continue; + } + if ((value as { integerValue?: string }).integerValue !== undefined) { + result[key] = Number((value as { integerValue: string }).integerValue); + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + +function encodeFirestoreStringMap(values: Record): { + mapValue: { fields: Record }; +} { + const fields: Record = {}; + for (const [key, value] of Object.entries(values)) { + fields[key] = { stringValue: value }; + } + return { mapValue: { fields } }; +} + +function dtoToStringMap(entries: Array<{ key: string; value: string }>): Record { + return Object.fromEntries(entries.map((entry) => [entry.key, entry.value])); +} + +function parseJsonObjectField(content: string | undefined): T | undefined { + if (!content) return undefined; + try { + const parsed = JSON.parse(content) as T; + return parsed && typeof parsed === 'object' ? parsed : undefined; + } catch { + return undefined; + } +} + +function toConfigDTO(doc: FirestoreDocument): ConfigDTO | undefined { + const fields = (doc.fields ?? {}) as FirestoreFields; + const fromContent = parseJsonObjectField( + parseFirestoreString(fields.content as { stringValue?: string }) + ); + const envVariablesFromMap = parseFirestoreMapField( + fields.envVariables as { mapValue?: { fields?: Record } } + ); + const baseUrl = parseFirestoreString(fields.baseUrl as { stringValue?: string }); + const useBrowserUrl = parseFirestoreBoolean(fields.useBrowserUrl as { booleanValue?: boolean }); + const envVariables = + envVariablesFromMap ?? (fromContent?.envVariables as Record | undefined); + if (!envVariables && baseUrl === undefined && useBrowserUrl === undefined) { + return undefined; + } + return { + ...(envVariables && { envVariables }), + ...(baseUrl !== undefined && { baseUrl }), + ...(useBrowserUrl !== undefined && { useBrowserUrl }), + }; +} + +function toSecretDTO(doc: FirestoreDocument): SecretDTO | undefined { + const fields = (doc.fields ?? {}) as FirestoreFields; + const fromContent = parseJsonObjectField( + parseFirestoreString(fields.content as { stringValue?: string }) + ); + const secretsFromMap = parseFirestoreMapField( + fields.secrets as { mapValue?: { fields?: Record } } + ); + if (secretsFromMap) { + return { secrets: secretsFromMap as Record }; + } + if (fromContent) return fromContent; + + const flat = parseFirestoreMapField( + fields as { mapValue?: { fields?: Record } } + ); + return flat ? (flat as SecretDTO) : undefined; +} + +async function upsertEnvArtifactDocument( + appId: string, + idToken: string, + project: string, + documentId: string, + typeValue: string, + contentJson: string, + mapFieldName: 'envVariables' | 'secrets', + mapValues: Record, + options?: FirestoreClientOptions +): Promise { + const collectionUrl = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}/artifacts`; + const docUrl = `${collectionUrl}/${encodeURIComponent(documentId)}`; + const updatedAt = new Date().toISOString(); + const patchFields: FirestoreWriteFields = { + content: { stringValue: contentJson }, + [mapFieldName]: encodeFirestoreStringMap(mapValues), + updatedAt: { timestampValue: updatedAt }, + }; + const fieldPaths = ['content', mapFieldName, 'updatedAt']; + + logDebug(options, { + kind: 'request', + method: 'PATCH', + url: `${docUrl}?${fieldPaths.map((p) => `updateMask.fieldPaths=${encodeURIComponent(p)}`).join('&')}`, + context: 'submitEnvDocumentsPush/patch', + }); + const patchRes = await fetch( + `${docUrl}?${fieldPaths.map((p) => `updateMask.fieldPaths=${encodeURIComponent(p)}`).join('&')}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields: patchFields }), + } + ); + + if (patchRes.ok) return; + + if (patchRes.status !== 404) { + throw await toFirestoreError(`update ${documentId}`, patchRes, options); + } + + const createUrl = `${collectionUrl}?documentId=${encodeURIComponent(documentId)}`; + const createFields: FirestoreWriteFields = { + name: { stringValue: documentId }, + type: { stringValue: typeValue }, + ...patchFields, + }; + logDebug(options, { + kind: 'request', + method: 'POST', + url: createUrl, + context: 'submitEnvDocumentsPush/create', + }); + const createRes = await fetch(createUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields: createFields }), + }); + if (!createRes.ok) { + throw await toFirestoreError(`create ${documentId}`, createRes, options); + } +} + +export async function submitEnvDocumentsPush( + appId: string, + idToken: string, + payload: { config?: ConfigDTO; secrets?: SecretDTO }, + options?: FirestoreClientOptions +): Promise { + const project = getEnsembleFirebaseProject(); + if (payload.config) { + await upsertEnvArtifactDocument( + appId, + idToken, + project, + 'appConfig', + EnsembleDocumentType.Environment, + JSON.stringify(payload.config), + 'envVariables', + dtoToStringMap(configDtoToEnvEntries(payload.config)), + options + ); + } + if (payload.secrets) { + await upsertEnvArtifactDocument( + appId, + idToken, + project, + 'secrets', + EnsembleDocumentType.Secrets, + JSON.stringify(payload.secrets), + 'secrets', + dtoToStringMap(secretsDtoToEnvEntries(payload.secrets)), + options + ); + } +} + +function getCollaboratorRole( + collaboratorsField: + | { mapValue?: { fields?: Record } } + | undefined, + userKey: string +): string | undefined { + const mapFields = collaboratorsField?.mapValue?.fields; + if (!mapFields || typeof mapFields !== 'object') return undefined; + return parseFirestoreString(mapFields[userKey]); +} + +/** + * Check if an app exists in Firestore and the current user has write or owner access. + * Verifies the user is in collaborators as users_{uid} with "write" or "owner". + */ +export async function checkAppAccess( + appId: string, + idToken: string, + userId: string, + options?: FirestoreClientOptions +): Promise { + const project = getEnsembleFirebaseProject(); + const url = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}`; + + try { + logDebug(options, { + kind: 'request', + method: 'GET', + url, + context: 'checkAppAccess', + }); + const res = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${idToken}`, + }, + }); + + if (res.ok) { + logDebug(options, { + kind: 'response', + method: 'GET', + url, + status: res.status, + context: 'checkAppAccess', + }); + const doc = (await res.json()) as { + fields?: Record< + string, + { stringValue?: string; mapValue?: { fields?: Record } } + >; + }; + const fields = doc?.fields ?? {}; + + const userKey = `users_${userId}`; + const role = getCollaboratorRole(fields.collaborators, userKey); + if (!role || !ALLOWED_ROLES.has(role)) { + return { + ok: false, + reason: 'no_access', + message: `You do not have write or owner access to app "${appId}".`, + code: 'PERMISSION_DENIED', + hint: defaultHintForCode('PERMISSION_DENIED'), + }; + } + + const app: AppInfo = { + name: parseFirestoreString(fields.name as { stringValue?: string }), + description: parseFirestoreString(fields.description as { stringValue?: string }), + }; + return { ok: true, app }; + } + + if (res.status === 404) { + return { + ok: false, + reason: 'not_found', + message: `App "${appId}" does not exist.`, + code: mapStatusToErrorCode(res.status), + status: res.status, + }; + } + + if (res.status === 403) { + return { + ok: false, + reason: 'no_access', + message: `You do not have access to app "${appId}".`, + code: mapStatusToErrorCode(res.status), + status: res.status, + hint: defaultHintForCode('PERMISSION_DENIED'), + }; + } + + if (res.status === 401) { + return { + ok: false, + reason: 'not_logged_in', + message: 'Session expired or invalid. Run `ensemble login` to sign in again.', + code: mapStatusToErrorCode(res.status), + status: res.status, + hint: defaultHintForCode('AUTH_EXPIRED'), + }; + } + + const text = await res.text(); + return { + ok: false, + reason: 'network_error', + message: `Firestore request failed (${res.status}): ${text.slice(0, 200)}`, + code: mapStatusToErrorCode(res.status), + status: res.status, + }; + } catch (err) { + const mapped = networkError('checkAppAccess', err); + return { + ok: false, + reason: 'network_error', + message: mapped.message, + code: mapped.code, + hint: mapped.hint, + }; + } +} + +async function listCollectionDocuments( + project: string, + parentPath: string, + collectionId: string, + idToken: string, + filter?: (doc: FirestoreDocument) => boolean, + options?: FirestoreClientOptions +): Promise { + const baseUrl = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/${parentPath}/${collectionId}`; + const docs: FirestoreDocument[] = []; + let pageToken: string | undefined; + + do { + const url = new URL(baseUrl); + if (pageToken) url.searchParams.set('pageToken', pageToken); + + logDebug(options, { + kind: 'request', + method: 'GET', + url: url.toString(), + context: 'listCollectionDocuments', + }); + const res = await fetch(url.toString(), { + headers: { Authorization: `Bearer ${idToken}` }, + }); + + if (!res.ok) { + throw await toFirestoreError('list collection documents', res, options); + } + + const body = (await res.json()) as { + documents?: FirestoreDocument[]; + nextPageToken?: string; + }; + + for (const doc of body.documents ?? []) { + if (!filter || filter(doc)) { + docs.push(doc); + } + } + logDebug(options, { + kind: 'list_documents', + collection: collectionId, + parentPath, + count: body.documents?.length ?? 0, + }); + pageToken = body.nextPageToken; + } while (pageToken); + + return docs; +} + +async function fetchAppDocument( + project: string, + appId: string, + idToken: string, + options?: FirestoreClientOptions +): Promise<{ id: string; name?: string; createdAt?: string; updatedAt?: string }> { + const url = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}`; + logDebug(options, { + kind: 'request', + method: 'GET', + url, + context: 'fetchAppDocument', + }); + const res = await fetch(url, { headers: { Authorization: `Bearer ${idToken}` } }); + if (!res.ok) { + throw await toFirestoreError('fetch app document', res, options); + } + const doc = (await res.json()) as { + name?: string; + createTime?: string; + updateTime?: string; + fields?: FirestoreFields; + }; + const fields = doc?.fields ?? {}; + return { + id: appId, + name: parseFirestoreString(fields.name as { stringValue?: string }), + createdAt: + doc.createTime ?? parseFirestoreTimestamp(fields.createdAt as { timestampValue?: string }), + updatedAt: + doc.updateTime ?? parseFirestoreTimestamp(fields.updatedAt as { timestampValue?: string }), + }; +} + +/** + * Fetch the cloud app and transform to ApplicationDTO shape. + * - App-level: id, name, createdAt, updatedAt (from app document) + * - internal_artifacts: scripts, widgets → widgets[], scripts[] + * - artifacts: screens, appConfig, secrets, theme → screens[], config, secrets, theme + */ +export async function fetchCloudApp( + appId: string, + idToken: string, + options?: FirestoreClientOptions +): Promise { + const project = getEnsembleFirebaseProject(); + const parentPath = `apps/${appId}`; + + const [appDoc, internalArtifacts, artifacts] = await Promise.all([ + fetchAppDocument(project, appId, idToken, options), + listCollectionDocuments(project, parentPath, 'internal_artifacts', idToken, undefined, options), + listCollectionDocuments( + project, + parentPath, + 'artifacts', + idToken, + (doc) => { + const docId = getDocId(doc.name); + return docId !== 'resources'; + }, + options + ), + ]); + + const widgets: WidgetDTO[] = []; + const scripts: ScriptDTO[] = []; + const actions: ActionDTO[] = []; + for (const doc of internalArtifacts) { + const type = parseFirestoreString((doc.fields?.type as { stringValue?: string }) ?? undefined); + if (type === 'internal_widget') widgets.push(toWidgetDTO(doc)); + else if (type === 'internal_script') scripts.push(toScriptDTO(doc)); + else if (type === 'internal_action') actions.push(toActionDTO(doc)); + } + + const screens: ScreenDTO[] = []; + const translations: TranslationDTO[] = []; + const assets: AssetDTO[] = []; + let theme: ThemeDTO | undefined; + let config: ConfigDTO | undefined; + let secrets: SecretDTO | undefined; + const i18nDocs: FirestoreDocument[] = []; + for (const doc of artifacts) { + const docId = getDocId(doc.name); + const type = parseFirestoreString((doc.fields?.type as { stringValue?: string }) ?? undefined); + if (type === 'screen') screens.push(toScreenDTO(doc)); + else if (type === 'i18n') i18nDocs.push(doc); + else if (type === 'asset') assets.push(toAssetDTO(doc)); + else if (type === 'config' || docId === 'appConfig') config = toConfigDTO(doc) ?? config; + else if (type === 'secrets' || docId === 'secrets') secrets = toSecretDTO(doc) ?? secrets; + else if (type === 'theme') { + if (!theme || docId === 'theme') { + theme = toThemeDTO(doc); + } + } + } + const defaultLocaleField = (doc: FirestoreDocument) => + parseFirestoreBoolean((doc.fields?.defaultLocale as { booleanValue?: boolean }) ?? undefined); + for (let i = 0; i < i18nDocs.length; i++) { + const doc = i18nDocs[i]; + const isDefault = defaultLocaleField(doc); + translations.push( + // Only pass true/false when explicitly stored; otherwise leave undefined + toTranslationDTO(doc, isDefault === true) + ); + } + + return { + id: appDoc.id, + name: appDoc.name ?? appDoc.id, + ...(appDoc.createdAt !== undefined && { createdAt: appDoc.createdAt }), + ...(appDoc.updatedAt !== undefined && { updatedAt: appDoc.updatedAt }), + widgets, + scripts, + // Actions are stored as internal_artifacts with type=internal_action. + ...(actions.length > 0 && { actions }), + screens, + ...(theme && { theme }), + ...(translations.length > 0 && { translations }), + ...(assets.length > 0 && { assets }), + ...(config && { config }), + ...(secrets && { secrets }), + }; +} + +/** + * Fetch only the name of the screen with isRoot: true. + * Uses a minimal Firestore runQuery (type=screen, isRoot=true, limit 1). + * Parent must be in the URL path for subcollection queries. + */ +export async function fetchRootScreenName( + appId: string, + idToken: string, + options?: FirestoreClientOptions +): Promise { + const project = getEnsembleFirebaseProject(); + const parent = `projects/${project}/databases/(default)/documents/apps/${appId}`; + const url = `https://firestore.googleapis.com/v1/${parent}:runQuery`; + + const structuredQuery = { + from: [{ collectionId: 'artifacts' }], + where: { + compositeFilter: { + op: 'AND' as const, + filters: [ + { + fieldFilter: { + field: { fieldPath: 'type' }, + op: 'EQUAL' as const, + value: { stringValue: 'screen' }, + }, + }, + { + fieldFilter: { + field: { fieldPath: 'isRoot' }, + op: 'EQUAL' as const, + value: { booleanValue: true }, + }, + }, + ], + }, + }, + limit: 1, + }; + + logDebug(options, { + kind: 'request', + method: 'POST', + url, + context: 'fetchRootScreenName', + }); + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ structuredQuery }), + }); + + if (!res.ok) return undefined; + + const body = await res.json(); + const results = Array.isArray(body) ? body : [body]; + const doc = results[0]?.document; + if (!doc?.fields) return undefined; + + return parseFirestoreString(doc.fields.name as { stringValue?: string }); +} + +/** + * Create a version (snapshot) document under apps/{appId}/versions. + * expiresAt must be stored as Firestore Timestamp for TTL policy to work. + */ +export async function createVersion( + appId: string, + idToken: string, + params: CreateVersionParams, + options?: FirestoreClientOptions +): Promise<{ id: string }> { + const project = getEnsembleFirebaseProject(); + const versionId = params.id; + const parent = `projects/${project}/databases/(default)/documents/apps/${appId}`; + const url = `https://firestore.googleapis.com/v1/${parent}/versions?documentId=${encodeURIComponent(versionId)}`; + + const createdByVal = encodeUpdatedBy(params.createdBy); + const fields: Record = { + message: { stringValue: params.message }, + createdAt: { timestampValue: params.createdAt }, + expiresAt: { timestampValue: params.expiresAt }, + ...(createdByVal && { createdBy: createdByVal }), + snapshotPath: { stringValue: params.snapshotPath }, + }; + + logDebug(options, { + kind: 'request', + method: 'POST', + url, + context: 'createVersion', + }); + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ fields }), + }); + + if (!res.ok) { + throw await toFirestoreError('create version', res, options); + } + + logDebug(options, { + kind: 'response', + method: 'POST', + url, + status: res.status, + context: 'createVersion', + }); + return { id: versionId }; +} + +function parseVersionDoc(doc: FirestoreDocument): VersionDoc | null { + if (!doc.name || !doc.fields) return null; + const fields = doc.fields as Record; + const id = getDocId(doc.name); + const message = parseFirestoreString(fields.message as { stringValue?: string }) ?? ''; + const createdAt = parseFirestoreTimestamp(fields.createdAt as { timestampValue?: string }); + const expiresAt = parseFirestoreTimestamp(fields.expiresAt as { timestampValue?: string }); + const createdBy = parseUpdatedBy(fields.createdBy as { referenceValue?: string }) ?? { + name: 'Unknown', + id: '', + }; + if (createdAt === undefined || expiresAt === undefined) return null; + const snapshotPath = parseFirestoreString(fields.snapshotPath as { stringValue?: string }); + if (!snapshotPath) return null; + + return { + id, + message, + createdAt, + createdBy, + expiresAt, + snapshotPath, + }; +} + +export interface ListVersionsResult { + versions: VersionDoc[]; + /** Pass as startAfter on next call to fetch the next page. Omitted when there are no more. */ + nextStartAfter?: string; +} + +/** + * List a page of version documents (newest first), 5 per page for efficient Firebase queries. + * Uses runQuery with orderBy createdAt desc. Requires a composite index on versions (createdAt desc). + */ +export async function listVersions( + appId: string, + idToken: string, + opts: { limit: number; startAfter?: string }, + options?: FirestoreClientOptions +): Promise { + const project = getEnsembleFirebaseProject(); + const parent = `projects/${project}/databases/(default)/documents/apps/${appId}`; + const url = `https://firestore.googleapis.com/v1/${parent}:runQuery`; + + const structuredQuery: { + from: { collectionId: string }[]; + orderBy: { field: { fieldPath: string }; direction: string }[]; + limit: number; + startAt?: { values: { timestampValue: string }[]; before: boolean }; + } = { + from: [{ collectionId: 'versions' }], + orderBy: [{ field: { fieldPath: 'createdAt' }, direction: 'DESCENDING' }], + limit: opts.limit, + }; + if (opts.startAfter !== undefined && opts.startAfter !== '') { + structuredQuery.startAt = { + values: [{ timestampValue: opts.startAfter }], + before: false, + }; + } + + logDebug(options, { + kind: 'request', + method: 'POST', + url, + context: 'listVersions', + }); + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${idToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ structuredQuery }), + }); + + if (!res.ok) { + throw await toFirestoreError('list versions', res, options); + } + + const body = (await res.json()) as { document?: FirestoreDocument }[]; + const items = Array.isArray(body) ? body : [body]; + const versions: VersionDoc[] = []; + let lastCreatedAt: string | undefined; + for (const item of items) { + const doc = item.document; + if (!doc) continue; + const parsed = parseVersionDoc(doc); + if (parsed) { + versions.push(parsed); + lastCreatedAt = parsed.createdAt; + } + } + const nextStartAfter = + versions.length === opts.limit && lastCreatedAt !== undefined ? lastCreatedAt : undefined; + return { versions, nextStartAfter }; +} + +/** + * Fetch a single version document including snapshot. + */ +export async function getVersion( + appId: string, + idToken: string, + versionId: string, + options?: FirestoreClientOptions +): Promise { + const project = getEnsembleFirebaseProject(); + const url = `https://firestore.googleapis.com/v1/projects/${project}/databases/(default)/documents/apps/${appId}/versions/${versionId}`; + + logDebug(options, { + kind: 'request', + method: 'GET', + url, + context: 'getVersion', + }); + const res = await fetch(url, { + headers: { Authorization: `Bearer ${idToken}` }, + }); + + if (!res.ok) { + if (res.status === 404) { + throw new FirestoreClientError({ + code: 'NOT_FOUND', + message: `Version "${versionId}" not found.`, + status: 404, + hint: 'It may have expired (versions are deleted after 30 days).', + }); + } + throw await toFirestoreError('get version', res, options); + } + + const doc = (await res.json()) as FirestoreDocument; + const parsed = parseVersionDoc(doc); + if (!parsed) { + throw new FirestoreClientError({ + code: 'UNKNOWN', + message: 'Version metadata is invalid.', + }); + } + return parsed; +} diff --git a/src/cloud/storageClient.ts b/src/cloud/storageClient.ts index b610152..c56ab9f 100644 --- a/src/cloud/storageClient.ts +++ b/src/cloud/storageClient.ts @@ -1,94 +1,103 @@ -import { getEnsembleFirebaseProject } from '../config/env.js'; - -export class StorageClientError extends Error { - status?: number; - hint?: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - cause?: any; - - constructor(params: { message: string; status?: number; hint?: string; cause?: unknown }) { - super(params.message); - this.name = 'StorageClientError'; - this.status = params.status; - this.hint = params.hint; - this.cause = params.cause; - } -} - -export interface UploadReleaseSnapshotResult { - bucket: string; - objectPath: string; -} - -function objectPathForRelease(appId: string, versionId: string): string { - return `releases/${appId}/${versionId}.json`; -} - -function storageAuthHeader(idToken: string): string { - // Firebase Storage v0 API accepts Firebase Auth tokens. - // Note: This is different from Google Cloud Storage OAuth tokens. - return `Firebase ${idToken}`; -} - -async function toStorageError(context: string, res: Response): Promise { - const text = await res.text(); - return new StorageClientError({ - message: `Storage ${context} failed (${res.status})`, - status: res.status, - hint: - res.status === 401 || res.status === 403 - ? 'Authentication/authorization failed for Storage. Check your login session and Storage rules/IAM.' - : undefined, - cause: text.slice(0, 500), - }); -} - -export async function uploadReleaseSnapshot( - appId: string, - idToken: string, - versionId: string, - snapshotJson: string -): Promise { - const bucket = `${getEnsembleFirebaseProject()}.appspot.com`; - const objectPath = objectPathForRelease(appId, versionId); - const url = `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent( - bucket - )}/o?uploadType=media&name=${encodeURIComponent(objectPath)}`; - - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: storageAuthHeader(idToken), - 'Content-Type': 'application/json', - }, - body: snapshotJson, - }); - - if (!res.ok) { - throw await toStorageError('upload release snapshot', res); - } - - return { bucket, objectPath }; -} - -export async function downloadReleaseSnapshotJson( - idToken: string, - snapshotPath: string -): Promise { - const bucket = `${getEnsembleFirebaseProject()}.appspot.com`; - const url = `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent( - bucket - )}/o/${encodeURIComponent(snapshotPath)}?alt=media`; - - const res = await fetch(url, { - headers: { - Authorization: storageAuthHeader(idToken), - }, - }); - - if (!res.ok) { - throw await toStorageError('download release snapshot', res); - } - - return await res.text(); -} +import { getEnsembleFirebaseProject } from '../config/env.js'; + +export class StorageClientError extends Error { + status?: number; + hint?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + cause?: unknown; + + constructor(params: { message: string; status?: number; hint?: string; cause?: unknown }) { + super(params.message); + this.name = 'StorageClientError'; + this.status = params.status; + this.hint = params.hint; + this.cause = params.cause; + } +} + +export interface UploadReleaseSnapshotResult { + bucket: string; + objectPath: string; +} + +export function objectPathForRelease(appId: string, versionId: string): string { + return `releases/${appId}/${versionId}.enc.json`; +} + +function storageAuthHeader(idToken: string): string { + return `Firebase ${idToken}`; +} + +async function toStorageError(context: string, res: Response): Promise { + const text = await res.text(); + return new StorageClientError({ + message: `Storage ${context} failed (${res.status})`, + status: res.status, + hint: + res.status === 401 || res.status === 403 + ? 'Authentication/authorization failed for Storage. Check your login session and Storage rules for releases/*.' + : res.status === 415 + ? 'Storage rejected the upload content type. Retry after updating the CLI.' + : undefined, + cause: text.slice(0, 500), + }); +} + +function toFetchBody(body: Buffer | string): BodyInit { + if (typeof body === 'string') return body; + const arrayBuffer = body.buffer.slice( + body.byteOffset, + body.byteOffset + body.byteLength + ) as ArrayBuffer; + return new Uint8Array(arrayBuffer); +} + +export async function uploadReleaseSnapshot( + appId: string, + idToken: string, + versionId: string, + body: Buffer | string +): Promise { + const bucket = `${getEnsembleFirebaseProject()}.appspot.com`; + const objectPath = objectPathForRelease(appId, versionId); + const url = `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent( + bucket + )}/o?uploadType=media&name=${encodeURIComponent(objectPath)}`; + + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: storageAuthHeader(idToken), + 'Content-Type': 'application/json', + }, + body: toFetchBody(body), + }); + + if (!res.ok) { + throw await toStorageError('upload release snapshot', res); + } + + return { bucket, objectPath }; +} + +export async function downloadReleaseSnapshotJson( + idToken: string, + objectPath: string +): Promise { + const bucket = `${getEnsembleFirebaseProject()}.appspot.com`; + const url = `https://firebasestorage.googleapis.com/v0/b/${encodeURIComponent( + bucket + )}/o/${encodeURIComponent(objectPath)}?alt=media`; + + const res = await fetch(url, { + headers: { + Authorization: storageAuthHeader(idToken), + }, + }); + + if (!res.ok) { + throw await toStorageError('download release snapshot', res); + } + + return res.text(); +} diff --git a/src/commands/pull.ts b/src/commands/pull.ts index b17cfe9..19b5551 100644 --- a/src/commands/pull.ts +++ b/src/commands/pull.ts @@ -1,357 +1,358 @@ -import fs from 'fs/promises'; -import path from 'path'; -import prompts from 'prompts'; -import pc from 'picocolors'; - -import { - checkAppAccess, - fetchCloudApp, - FirestoreClientError, - type CloudApp, -} from '../cloud/firestoreClient.js'; -import { collectAppFiles } from '../core/appCollector.js'; -import { ArtifactProps, type ArtifactProp } from '../core/artifacts.js'; -import { resolveVerboseFlag } from '../core/cliError.js'; -import { resolveAppContext } from '../config/projectConfig.js'; -import { getValidAuthSession } from '../auth/session.js'; -import { withSpinner } from '../lib/spinner.js'; -import { applyCloudStateToFs } from '../core/applyToFs.js'; -import { type RootManifest } from '../core/manifest.js'; -import { createFirestoreDebugOptions, writeVerboseJson } from '../core/debugFiles.js'; -import { computePullPlan, type PullSummary } from '../core/sync.js'; -import { applyCloudAssetsToFs, buildEnvConfigForCloudAssets } from '../core/pullAssets.js'; -import { upsertEnvFile } from '../core/envConfig.js'; -import { applyCloudEnvToFs, readProjectEnvFiles } from '../core/envSync.js'; -import { ui } from '../core/ui.js'; - -export interface PullOptions { - verbose?: boolean; - appKey?: string; - /** Skip confirmation prompt (e.g. for CI) */ - yes?: boolean; - /** Dry run: show what would change but do not modify files */ - dryRun?: boolean; -} - -const PULL_LABEL_TEXT = { - new: '🍀 new', - modified: '✏️ modified', - removed: '❌ removed', -} as const; - -const PULL_LABEL_WIDTH = 14; -const PULL_LINE_PREFIX = ' '; - -/** Format pull changes as grouped lines with icons. Used for both dry run and actual run. */ -function formatPullSummary(changes: PullSummary['changes']): string[] { - const lines: string[] = []; - const byKind = new Map< - string, - { operation: PullSummary['changes'][number]['operation']; file: string }[] - >(); - for (const c of changes) { - if (c.kind === 'manifest') continue; // Manifest is a generated file, never show in summary - const list = byKind.get(c.kind) ?? []; - list.push({ operation: c.operation, file: c.file }); - byKind.set(c.kind, list); - } - const kindToSection: Record = { - screen: 'screens', - widget: 'widgets', - script: 'scripts', - translation: 'translations', - theme: 'theme', - }; - const kindOrder = ['screen', 'widget', 'script', 'translation', 'theme']; - const processed = new Set(); - const pad = (label: string) => label.padEnd(PULL_LABEL_WIDTH); - const formatLabel = (raw: string, color: (value: string) => string) => color(pad(raw)); - const sortByOp = (list: { operation: string; file: string }[]) => - [...list].sort((a, b) => { - const order = { delete: 0, update: 1, create: 2 }; - return ( - (order[a.operation as keyof typeof order] ?? 3) - - (order[b.operation as keyof typeof order] ?? 3) - ); - }); - for (const kind of kindOrder) { - const list = byKind.get(kind); - if (!list?.length) continue; - processed.add(kind); - lines.push(pc.cyan(pc.bold(` ${kindToSection[kind] ?? kind}:`))); - for (const c of sortByOp(list)) { - const label = - c.operation === 'create' - ? formatLabel(PULL_LABEL_TEXT.new, pc.green) - : c.operation === 'update' - ? formatLabel(PULL_LABEL_TEXT.modified, pc.yellow) - : formatLabel(PULL_LABEL_TEXT.removed, pc.red); - lines.push(`${PULL_LINE_PREFIX}${label} ${path.basename(c.file)}`); - } - } - for (const [kind, list] of byKind) { - if (processed.has(kind) || kind === 'manifest') continue; - lines.push(pc.cyan(pc.bold(` ${kindToSection[kind] ?? kind}:`))); - for (const c of sortByOp(list)) { - const label = - c.operation === 'create' - ? formatLabel(PULL_LABEL_TEXT.new, pc.green) - : c.operation === 'update' - ? formatLabel(PULL_LABEL_TEXT.modified, pc.yellow) - : formatLabel(PULL_LABEL_TEXT.removed, pc.red); - lines.push(`${PULL_LINE_PREFIX}${label} ${path.basename(c.file)}`); - } - } - return lines; -} - -function printPullDryRun(summary: PullSummary): void { - const { appName, environment, changes } = summary; - - ui.heading(`Pull plan for ${appName} (${environment})`); - - if (changes.length === 0) { - ui.info('No changes. Local files are already up to date with the cloud app.'); - ui.note( - 'Dry run only: no files were changed. Run `ensemble pull` without `--dry-run` when you are ready to apply remote changes.' - ); - return; - } - - ui.note('The following changes would be applied:\n'); - for (const line of formatPullSummary(changes)) { - // eslint-disable-next-line no-console - console.log(line); - } - ui.note( - '\nDry run only: no files were changed. Run `ensemble pull` without `--dry-run` to apply these changes.' - ); -} - -function printPullSummary(summary: PullSummary): void { - const { appName, environment, created, updated, deleted, skipped } = summary; - const total = created + updated + deleted; - - if (total === 0) { - ui.info( - `Pulled app ${appName} (${environment}): no file changes were applied (metadata may have been updated).` - ); - } else { - ui.success( - `Pulled app ${appName} (${environment}): applied ${total} change${ - total === 1 ? '' : 's' - } (created: ${created}, updated: ${updated}, deleted: ${deleted}, skipped: ${skipped}).` - ); - } -} - -export async function pullCommand(options: PullOptions = {}): Promise { - const { projectRoot, config, appKey, appId } = await resolveAppContext(options.appKey); - const verbose = resolveVerboseFlag(options.verbose); - const appConfig = config.apps[appKey]; - const appName = (appConfig.name as string | undefined) ?? 'App'; - const environment = appKey; - const appOptions = (appConfig.options ?? {}) as Record; - const enabledByProp = Object.fromEntries( - ArtifactProps.map((prop) => [prop, appOptions[prop] !== false]) - ) as Record; - - const session = await getValidAuthSession(); - if (!session.ok) { - ui.error(session.message); - process.exitCode = 1; - return; - } - const { idToken, userId } = session; - - const firestoreOptions = verbose ? createFirestoreDebugOptions() : undefined; - - const manifestPath = path.join(projectRoot, '.manifest.json'); - const readManifest = (): Promise => - fs.readFile(manifestPath, 'utf8').then( - (raw) => JSON.parse(raw) as RootManifest, - () => ({}) - ); - - const [access, cloudAppResult, localFiles, manifestExisting] = await withSpinner( - 'Preparing app for pull...', - async () => { - const [accessRes, cloudRes, files, manifest] = await Promise.all([ - checkAppAccess(appId, idToken, userId, firestoreOptions), - fetchCloudApp(appId, idToken, firestoreOptions).catch((e: unknown) => e), - collectAppFiles(projectRoot), - readManifest(), - ]); - return [accessRes, cloudRes, files, manifest] as const; - } - ); - - if (!access.ok) { - ui.error(access.message); - process.exitCode = 1; - return; - } - - if (cloudAppResult instanceof Error) { - const err = cloudAppResult; - ui.error('Failed to fetch app from cloud.'); - if (err instanceof FirestoreClientError) { - // eslint-disable-next-line no-console - ui.error(`${err.message} (${err.code})`); - if (err.hint) { - ui.note(err.hint); - } - } else { - ui.error(err instanceof Error ? err.message : String(err)); - } - if (!(err instanceof FirestoreClientError)) { - // eslint-disable-next-line no-console - ui.error('Check your internet connection or proxy settings, then try again.'); - } else if (err.code === 'NETWORK_UNAVAILABLE') { - // eslint-disable-next-line no-console - ui.error('Check your internet connection or proxy settings, then try again.'); - } else if (err.code === 'AUTH_EXPIRED') { - ui.error('Run `ensemble login` and try again.'); - } - process.exitCode = 1; - return; - } - const cloudApp = cloudAppResult as CloudApp; - - await writeVerboseJson(projectRoot, 'ensemble-cloud-app.json', cloudApp, { - verbose, - }); - - const plan = computePullPlan({ - appName, - environment, - cloudApp, - localFiles, - manifestExisting, - enabledByProp, - localEnv: await readProjectEnvFiles(projectRoot, appKey, config.default), - }); - - if (plan.allArtifactsMatch && plan.manifestMatch) { - ui.info('Up to date. Nothing to pull.'); - return; - } - - const pullSummary: PullSummary = plan.summary; - - if (pullSummary.changes.length > 0 && !options.dryRun) { - ui.heading('Changes to be pulled:'); - for (const line of formatPullSummary(pullSummary.changes)) { - // eslint-disable-next-line no-console - console.log(line); - } - } - - if (options.dryRun) { - printPullDryRun(pullSummary); - return; - } - - if (pullSummary.updated > 0 || pullSummary.deleted > 0) { - ui.note( - 'If you are unsure, cancel this pull and re-run with `--dry-run` to inspect the plan, or back up your local changes first.' - ); - } - - const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); - let confirmed = options.yes ?? false; - if (!confirmed && !isInteractive) { - ui.error( - 'Refusing to run pull non-interactively without --yes. Re-run with --dry-run to inspect changes.' - ); - process.exitCode = 1; - return; - } - if (!confirmed) { - const { proceed } = await prompts({ - type: 'confirm', - name: 'proceed', - message: 'Proceed with pull (overwrite local files)?', - initial: false, - }); - confirmed = proceed === true; - } - - if (!confirmed) { - ui.warn('Pull cancelled.'); - process.exitCode = 130; - return; - } - - await withSpinner('Writing local files...', async () => { - await applyCloudStateToFs(projectRoot, cloudApp, localFiles, enabledByProp, { - manifestOptions: {}, - onProgress: (completed, total) => { - // eslint-disable-next-line no-console - console.log(`Writing files... (${completed}/${total})`); - }, - }); - }); - - // Sync assets/ after YAML files are written. - // This pulls binary files via each asset's publicUrl and deletes local extras. - await withSpinner('Syncing assets...', async () => { - const result = await applyCloudAssetsToFs({ - projectRoot, - cloudAssets: cloudApp.assets, - }); - // Fold any asset changes into the already-computed pullSummary so the final output reflects what we did. - if (result.created || result.deleted || result.skipped) { - ( - pullSummary.changes as PullSummary['changes'] as unknown as Array<{ - kind: string; - file: string; - operation: string; - }> - ).push(...result.changes); - (pullSummary as unknown as { created: number }).created += result.created; - (pullSummary as unknown as { deleted: number }).deleted += result.deleted; - (pullSummary as unknown as { skipped: number }).skipped += result.skipped; - } - - if (result.failures.length > 0) { - ui.warn(`Some assets failed to download (${result.failures.length}).`); - const maxLines = 8; - for (const f of result.failures.slice(0, maxLines)) { - ui.warn(f.message); - } - if (result.failures.length > maxLines) { - ui.note(`(and ${result.failures.length - maxLines} more asset download issues...)`); - } - } - - // Always (best-effort) update env config for assets so ${env.assets}${env.} references work after pull. - const envLayout = await readProjectEnvFiles(projectRoot, appKey, config.default); - const envResult = buildEnvConfigForCloudAssets(cloudApp.assets); - if (envResult.entries.length > 0) { - await upsertEnvFile(projectRoot, envLayout.configWriteFile, envResult.entries); - } - if (envResult.failures.length > 0) { - ui.warn( - `Some assets had invalid metadata and may be missing from ${envLayout.configWriteFile} (${envResult.failures.length}).` - ); - } - - await applyCloudEnvToFs( - projectRoot, - { - config: cloudApp.config, - secrets: cloudApp.secrets, - }, - (cloudApp.assets ?? []) - .map((asset) => asset.fileName) - .filter( - (fileName): fileName is string => typeof fileName === 'string' && fileName.length > 0 - ), - appKey, - config.default - ); - }); - - printPullSummary(pullSummary); -} +import fs from 'fs/promises'; +import path from 'path'; +import prompts from 'prompts'; +import pc from 'picocolors'; + +import { + checkAppAccess, + fetchCloudApp, + FirestoreClientError, + type CloudApp, +} from '../cloud/firestoreClient.js'; +import { collectAppFiles } from '../core/appCollector.js'; +import { ArtifactProps, type ArtifactProp } from '../core/artifacts.js'; +import { resolveVerboseFlag } from '../core/cliError.js'; +import { resolveAppContext } from '../config/projectConfig.js'; +import { getValidAuthSession } from '../auth/session.js'; +import { withSpinner } from '../lib/spinner.js'; +import { applyCloudStateToFs } from '../core/applyToFs.js'; +import { type RootManifest } from '../core/manifest.js'; +import { createFirestoreDebugOptions, writeVerboseJson } from '../core/debugFiles.js'; +import { computePullPlan, type PullSummary } from '../core/sync.js'; +import { applyCloudAssetsToFs, buildEnvConfigForCloudAssets } from '../core/pullAssets.js'; +import { upsertEnvFile } from '../core/envConfig.js'; +import { applyCloudEnvToFs, readProjectEnvFiles } from '../core/envSync.js'; +import { ui } from '../core/ui.js'; + +export interface PullOptions { + verbose?: boolean; + appKey?: string; + /** Skip confirmation prompt (e.g. for CI) */ + yes?: boolean; + /** Dry run: show what would change but do not modify files */ + dryRun?: boolean; +} + +const PULL_LABEL_TEXT = { + new: '🍀 new', + modified: '✏️ modified', + removed: '❌ removed', +} as const; + +const PULL_LABEL_WIDTH = 14; +const PULL_LINE_PREFIX = ' '; + +/** Format pull changes as grouped lines with icons. Used for both dry run and actual run. */ +function formatPullSummary(changes: PullSummary['changes']): string[] { + const lines: string[] = []; + const byKind = new Map< + string, + { operation: PullSummary['changes'][number]['operation']; file: string }[] + >(); + for (const c of changes) { + if (c.kind === 'manifest') continue; // Manifest is a generated file, never show in summary + const list = byKind.get(c.kind) ?? []; + list.push({ operation: c.operation, file: c.file }); + byKind.set(c.kind, list); + } + const kindToSection: Record = { + screen: 'screens', + widget: 'widgets', + script: 'scripts', + translation: 'translations', + theme: 'theme', + }; + const kindOrder = ['screen', 'widget', 'script', 'translation', 'theme']; + const processed = new Set(); + const pad = (label: string) => label.padEnd(PULL_LABEL_WIDTH); + const formatLabel = (raw: string, color: (value: string) => string) => color(pad(raw)); + const sortByOp = (list: { operation: string; file: string }[]) => + [...list].sort((a, b) => { + const order = { delete: 0, update: 1, create: 2 }; + return ( + (order[a.operation as keyof typeof order] ?? 3) - + (order[b.operation as keyof typeof order] ?? 3) + ); + }); + for (const kind of kindOrder) { + const list = byKind.get(kind); + if (!list?.length) continue; + processed.add(kind); + lines.push(pc.cyan(pc.bold(` ${kindToSection[kind] ?? kind}:`))); + for (const c of sortByOp(list)) { + const label = + c.operation === 'create' + ? formatLabel(PULL_LABEL_TEXT.new, pc.green) + : c.operation === 'update' + ? formatLabel(PULL_LABEL_TEXT.modified, pc.yellow) + : formatLabel(PULL_LABEL_TEXT.removed, pc.red); + lines.push(`${PULL_LINE_PREFIX}${label} ${path.basename(c.file)}`); + } + } + for (const [kind, list] of byKind) { + if (processed.has(kind) || kind === 'manifest') continue; + lines.push(pc.cyan(pc.bold(` ${kindToSection[kind] ?? kind}:`))); + for (const c of sortByOp(list)) { + const label = + c.operation === 'create' + ? formatLabel(PULL_LABEL_TEXT.new, pc.green) + : c.operation === 'update' + ? formatLabel(PULL_LABEL_TEXT.modified, pc.yellow) + : formatLabel(PULL_LABEL_TEXT.removed, pc.red); + lines.push(`${PULL_LINE_PREFIX}${label} ${path.basename(c.file)}`); + } + } + return lines; +} + +function printPullDryRun(summary: PullSummary): void { + const { appName, environment, changes } = summary; + + ui.heading(`Pull plan for ${appName} (${environment})`); + + if (changes.length === 0) { + ui.info('No changes. Local files are already up to date with the cloud app.'); + ui.note( + 'Dry run only: no files were changed. Run `ensemble pull` without `--dry-run` when you are ready to apply remote changes.' + ); + return; + } + + ui.note('The following changes would be applied:\n'); + for (const line of formatPullSummary(changes)) { + // eslint-disable-next-line no-console + console.log(line); + } + ui.note( + '\nDry run only: no files were changed. Run `ensemble pull` without `--dry-run` to apply these changes.' + ); +} + +function printPullSummary(summary: PullSummary): void { + const { appName, environment, created, updated, deleted, skipped } = summary; + const total = created + updated + deleted; + + if (total === 0) { + ui.info( + `Pulled app ${appName} (${environment}): no file changes were applied (metadata may have been updated).` + ); + } else { + ui.success( + `Pulled app ${appName} (${environment}): applied ${total} change${ + total === 1 ? '' : 's' + } (created: ${created}, updated: ${updated}, deleted: ${deleted}, skipped: ${skipped}).` + ); + } +} + +export async function pullCommand(options: PullOptions = {}): Promise { + const { projectRoot, config, appKey, appId } = await resolveAppContext(options.appKey); + const verbose = resolveVerboseFlag(options.verbose); + const appConfig = config.apps[appKey]; + const appName = (appConfig.name as string | undefined) ?? 'App'; + const environment = appKey; + const appOptions = (appConfig.options ?? {}) as Record; + const enabledByProp = Object.fromEntries( + ArtifactProps.map((prop) => [prop, appOptions[prop] !== false]) + ) as Record; + + const session = await getValidAuthSession(); + if (!session.ok) { + ui.error(session.message); + process.exitCode = 1; + return; + } + const { idToken, userId } = session; + + const firestoreOptions = verbose ? createFirestoreDebugOptions() : undefined; + + const manifestPath = path.join(projectRoot, '.manifest.json'); + const readManifest = (): Promise => + fs.readFile(manifestPath, 'utf8').then( + (raw) => JSON.parse(raw) as RootManifest, + () => ({}) + ); + + const [access, cloudAppResult, localFiles, manifestExisting] = await withSpinner( + 'Preparing app for pull...', + async () => { + const [accessRes, cloudRes, files, manifest] = await Promise.all([ + checkAppAccess(appId, idToken, userId, firestoreOptions), + fetchCloudApp(appId, idToken, firestoreOptions).catch((e: unknown) => e), + collectAppFiles(projectRoot), + readManifest(), + ]); + return [accessRes, cloudRes, files, manifest] as const; + } + ); + + if (!access.ok) { + ui.error(access.message); + process.exitCode = 1; + return; + } + + if (cloudAppResult instanceof Error) { + const err = cloudAppResult; + ui.error('Failed to fetch app from cloud.'); + if (err instanceof FirestoreClientError) { + // eslint-disable-next-line no-console + ui.error(`${err.message} (${err.code})`); + if (err.hint) { + ui.note(err.hint); + } + } else { + ui.error(err instanceof Error ? err.message : String(err)); + } + if (!(err instanceof FirestoreClientError)) { + // eslint-disable-next-line no-console + ui.error('Check your internet connection or proxy settings, then try again.'); + } else if (err.code === 'NETWORK_UNAVAILABLE') { + // eslint-disable-next-line no-console + ui.error('Check your internet connection or proxy settings, then try again.'); + } else if (err.code === 'AUTH_EXPIRED') { + ui.error('Run `ensemble login` and try again.'); + } + process.exitCode = 1; + return; + } + const cloudApp = cloudAppResult as CloudApp; + + await writeVerboseJson(projectRoot, 'ensemble-cloud-app.json', cloudApp, { + verbose, + }); + + const plan = computePullPlan({ + appName, + environment, + cloudApp, + localFiles, + manifestExisting, + enabledByProp, + localEnv: await readProjectEnvFiles(projectRoot, appKey, config.default), + }); + + if (plan.allArtifactsMatch && plan.manifestMatch) { + ui.info('Up to date. Nothing to pull.'); + return; + } + + const pullSummary: PullSummary = plan.summary; + + if (pullSummary.changes.length > 0 && !options.dryRun) { + ui.heading('Changes to be pulled:'); + for (const line of formatPullSummary(pullSummary.changes)) { + // eslint-disable-next-line no-console + console.log(line); + } + } + + if (options.dryRun) { + printPullDryRun(pullSummary); + return; + } + + if (pullSummary.updated > 0 || pullSummary.deleted > 0) { + ui.note( + 'If you are unsure, cancel this pull and re-run with `--dry-run` to inspect the plan, or back up your local changes first.' + ); + } + + const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); + let confirmed = options.yes ?? false; + if (!confirmed && !isInteractive) { + ui.error( + 'Refusing to run pull non-interactively without --yes. Re-run with --dry-run to inspect changes.' + ); + process.exitCode = 1; + return; + } + if (!confirmed) { + const { proceed } = await prompts({ + type: 'confirm', + name: 'proceed', + message: 'Proceed with pull (overwrite local files)?', + initial: false, + }); + confirmed = proceed === true; + } + + if (!confirmed) { + ui.warn('Pull cancelled.'); + process.exitCode = 130; + return; + } + + await withSpinner('Writing local files...', async () => { + await applyCloudStateToFs(projectRoot, cloudApp, localFiles, enabledByProp, { + refreshManifest: true, + onProgress: (completed, total) => { + // eslint-disable-next-line no-console + console.log(`Writing files... (${completed}/${total})`); + }, + }); + }); + + // Sync assets/ after YAML files are written. + // This pulls binary files via each asset's publicUrl and deletes local extras. + await withSpinner('Syncing assets...', async () => { + const result = await applyCloudAssetsToFs({ + projectRoot, + cloudAssets: cloudApp.assets, + }); + // Fold any asset changes into the already-computed pullSummary so the final output reflects what we did. + if (result.created || result.deleted || result.skipped) { + ( + pullSummary.changes as PullSummary['changes'] as unknown as Array<{ + kind: string; + file: string; + operation: string; + }> + ).push(...result.changes); + (pullSummary as unknown as { created: number }).created += result.created; + (pullSummary as unknown as { deleted: number }).deleted += result.deleted; + (pullSummary as unknown as { skipped: number }).skipped += result.skipped; + } + + if (result.failures.length > 0) { + ui.warn(`Some assets failed to download (${result.failures.length}).`); + const maxLines = 8; + for (const f of result.failures.slice(0, maxLines)) { + ui.warn(f.message); + } + if (result.failures.length > maxLines) { + ui.note(`(and ${result.failures.length - maxLines} more asset download issues...)`); + } + } + + // Always (best-effort) update env config for assets so ${env.assets}${env.} references work after pull. + const envLayout = await readProjectEnvFiles(projectRoot, appKey, config.default); + const envResult = buildEnvConfigForCloudAssets(cloudApp.assets); + if (envResult.entries.length > 0) { + await upsertEnvFile(projectRoot, envLayout.configWriteFile, envResult.entries); + } + if (envResult.failures.length > 0) { + ui.warn( + `Some assets had invalid metadata and may be missing from ${envLayout.configWriteFile} (${envResult.failures.length}).` + ); + } + + await applyCloudEnvToFs( + projectRoot, + { + config: cloudApp.config, + secrets: cloudApp.secrets, + }, + (cloudApp.assets ?? []) + .map((asset) => asset.fileName) + .filter( + (fileName): fileName is string => typeof fileName === 'string' && fileName.length > 0 + ), + appKey, + config.default, + cloudApp.assets + ); + }); + + printPullSummary(pullSummary); +} diff --git a/src/commands/push.ts b/src/commands/push.ts index 52888cd..0cb9023 100644 --- a/src/commands/push.ts +++ b/src/commands/push.ts @@ -1,585 +1,585 @@ -import fs from 'fs/promises'; -import path from 'path'; -import prompts from 'prompts'; - -import { - checkAppAccess, - fetchCloudApp, - submitCliPush, - submitEnvDocumentsPush, - FirestoreClientError, - type CloudApp, -} from '../cloud/firestoreClient.js'; -import { buildDocumentsFromParsed } from '../core/buildDocuments.js'; -import { buildPushPayload, formatDiffSummary, type BundleDiff } from '../core/bundleDiff.js'; -import { collectAppFiles } from '../core/appCollector.js'; -import { ArtifactProps, type ArtifactProp } from '../core/artifacts.js'; -import { resolveVerboseFlag } from '../core/cliError.js'; -import { resolveAppContext } from '../config/projectConfig.js'; -import { getValidAuthSession } from '../auth/session.js'; -import { withSpinner } from '../lib/spinner.js'; -import { createFirestoreDebugOptions, writeVerboseJson } from '../core/debugFiles.js'; -import { computePushPlan, type PushSummary, type PushCounts } from '../core/sync.js'; -import { buildAndWriteManifest } from '../core/manifest.js'; -import { prepareEnvPushState } from '../core/envSync.js'; -import { writeEnvFile } from '../core/envConfig.js'; -import { ui } from '../core/ui.js'; - -export interface PushOptions { - verbose?: boolean; - appKey?: string; - /** Skip confirmation prompt (e.g. for CI) */ - yes?: boolean; - /** Dry run: show diff but do not push to cloud */ - dryRun?: boolean; -} - -const DESTRUCTIVE_CHANGE_PROMPT_THRESHOLD = 25; - -function getCloudHomeScreenName(cloudApp: CloudApp): string | undefined { - const screens = (cloudApp.screens ?? []).filter((s) => s.isArchived !== true); - return screens.find((s) => s.isRoot === true)?.name ?? screens[0]?.name; -} - -/** Firestore/YAML artifact changes only (not asset uploads via studio function). */ -function yamlArtifactChangeTotal(summary: PushSummary): number { - const k = summary.byKind; - return ( - k.screens.created + - k.screens.updated + - k.screens.deleted + - k.widgets.created + - k.widgets.updated + - k.widgets.deleted + - k.scripts.created + - k.scripts.updated + - k.scripts.deleted + - k.actions.created + - k.actions.updated + - k.actions.deleted + - k.translations.created + - k.translations.updated + - k.translations.deleted + - k.theme.created + - k.theme.updated + - k.theme.deleted - ); -} - -function printPushSummary( - summary: PushSummary, - options: { verbose?: boolean; isNoop?: boolean; envChanged?: boolean } -) { - const { appName, environment, counts } = summary; - const totalChanges = counts.created + counts.updated + counts.deleted; - - if ((options.isNoop || totalChanges === 0) && !options.envChanged) { - ui.info( - `Pushed app "${appName}" to environment "${environment}" (no changes; already up to date).` - ); - return; - } - - const parts: string[] = []; - if (counts.created > 0) parts.push(`${counts.created} created`); - if (counts.updated > 0) parts.push(`${counts.updated} updated`); - if (counts.deleted > 0) parts.push(`${counts.deleted} deleted`); - if (options.envChanged) parts.push('env files updated'); - - ui.success(`Pushed app "${appName}" to environment "${environment}" (${parts.join(', ')}).`); - - if (options.verbose) { - const entries: [string, PushCounts][] = [ - ['screens', summary.byKind.screens], - ['widgets', summary.byKind.widgets], - ['scripts', summary.byKind.scripts], - ['actions', summary.byKind.actions], - ['translations', summary.byKind.translations], - ['theme', summary.byKind.theme], - ['assets', summary.byKind.assets], - ]; - - for (const [kind, c] of entries) { - if (c.created === 0 && c.updated === 0 && c.deleted === 0) continue; - // eslint-disable-next-line no-console - console.log(` ${kind}: ${c.created} created, ${c.updated} updated, ${c.deleted} deleted`); - } - } -} - -function printPushDryRun(diff: BundleDiff): void { - ui.heading('Push dry run'); - ui.note('The following changes would be applied:'); - for (const line of formatDiffSummary(diff)) { - // eslint-disable-next-line no-console - console.log(line); - } - ui.note('\nRun `ensemble push` without `--dry-run` to apply these changes.'); -} - -async function readDefaultLanguage(root: string): Promise { - const manifestPath = path.join(root, '.manifest.json'); - try { - const raw = await fs.readFile(manifestPath, 'utf8'); - const parsed = JSON.parse(raw) as { defaultLanguage?: unknown }; - const value = parsed.defaultLanguage; - return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; - } catch { - return undefined; - } -} - -function hasManifestRelevantChanges(cloudApp: CloudApp, diff: BundleDiff): boolean { - // Any new artifacts in these kinds will affect manifest lists or home screen/languages. - if ( - diff.screens.new.length > 0 || - diff.widgets.new.length > 0 || - diff.scripts.new.length > 0 || - diff.translations.new.length > 0 - ) { - return true; - } - - const scriptsCloudById = new Map((cloudApp.scripts ?? []).map((s) => [s.id, s])); - const scriptsCloudByName = new Map((cloudApp.scripts ?? []).map((s) => [s.name, s])); - for (const changed of diff.scripts.changed) { - const cloud = scriptsCloudById.get(changed.id) ?? scriptsCloudByName.get(changed.name); - if (!cloud) continue; - if ((changed.isArchived ?? false) !== (cloud.isArchived ?? false)) { - return true; - } - } - - const widgetsCloudById = new Map((cloudApp.widgets ?? []).map((w) => [w.id, w])); - const widgetsCloudByName = new Map((cloudApp.widgets ?? []).map((w) => [w.name, w])); - for (const changed of diff.widgets.changed) { - const cloud = widgetsCloudById.get(changed.id) ?? widgetsCloudByName.get(changed.name); - if (!cloud) continue; - if ((changed.isArchived ?? false) !== (cloud.isArchived ?? false)) { - return true; - } - } - - const translationsCloudById = new Map((cloudApp.translations ?? []).map((t) => [t.id, t])); - const translationsCloudByName = new Map((cloudApp.translations ?? []).map((t) => [t.name, t])); - for (const changed of diff.translations.changed) { - const cloud = - translationsCloudById.get(changed.id) ?? translationsCloudByName.get(changed.name); - if (!cloud) continue; - if ((changed.isArchived ?? false) !== (cloud.isArchived ?? false)) { - return true; - } - if ( - (changed as { defaultLocale?: boolean }).defaultLocale !== - (cloud as { defaultLocale?: boolean }).defaultLocale - ) { - return true; - } - } - - const screensCloudById = new Map((cloudApp.screens ?? []).map((s) => [s.id, s])); - const screensCloudByName = new Map((cloudApp.screens ?? []).map((s) => [s.name, s])); - for (const changed of diff.screens.changed) { - const cloud = screensCloudById.get(changed.id) ?? screensCloudByName.get(changed.name); - if (!cloud) continue; - if ((changed.isArchived ?? false) !== (cloud.isArchived ?? false)) { - return true; - } - if ((changed as { isRoot?: boolean }).isRoot !== (cloud as { isRoot?: boolean }).isRoot) { - return true; - } - } - - return false; -} - -export async function pushCommand(options: PushOptions = {}): Promise { - const root = process.cwd(); - const verbose = resolveVerboseFlag(options.verbose); - const { config, appKey, appId } = await resolveAppContext(options.appKey); - const appConfig = config.apps[appKey]; - const appName = (appConfig.name as string | undefined) ?? 'App'; - const appOptions = (appConfig.options ?? {}) as Record; - const enabledByProp = Object.fromEntries( - ArtifactProps.map((prop) => [prop, appOptions[prop] !== false]) - ) as Record; - - const session = await getValidAuthSession(); - if (!session.ok) { - console.error(session.message); - console.error('Run `ensemble login` and try again.'); - process.exitCode = 1; - return; - } - const { idToken, userId } = session; - - const firestoreOptions = verbose ? createFirestoreDebugOptions() : undefined; - - const [access, dataWithLang, cloudAppResult] = await withSpinner( - 'Preparing app for push...', - async () => { - const [accessRes, filesAndLang, cloudRes] = await Promise.all([ - checkAppAccess(appId, idToken, userId, firestoreOptions), - Promise.all([collectAppFiles(root), readDefaultLanguage(root)]).then( - ([files, defLang]) => [files, defLang] as const - ), - fetchCloudApp(appId, idToken, firestoreOptions).catch((e: unknown) => e), - ]); - return [accessRes, filesAndLang, cloudRes] as const; - } - ); - - if (!access.ok) { - console.error(access.message); - if (access.reason === 'not_logged_in') { - console.error('Run `ensemble login` and try again.'); - } else if (access.reason === 'network_error') { - console.error('Check your internet connection or proxy settings.'); - } - process.exitCode = 1; - return; - } - - const [data, defaultLanguage] = dataWithLang; - const localApp = buildDocumentsFromParsed( - data, - appId, - appName, - appConfig.appHome as string | undefined, - defaultLanguage - ); - await writeVerboseJson(root, 'ensemble-local-app.json', localApp, { - verbose, - }); - - let cloudApp: Awaited> | null = null; - if (cloudAppResult instanceof Error) { - const err = cloudAppResult; - console.error('Failed to fetch app from cloud.'); - if (err instanceof FirestoreClientError) { - console.error(`${err.message} (${err.code})`); - if (err.hint) { - console.error(err.hint); - } - } else { - console.error(err instanceof Error ? err.message : String(err)); - } - if (!(err instanceof FirestoreClientError)) { - console.error('Check your internet connection or proxy settings, then try again.'); - } else if (err instanceof FirestoreClientError && err.code === 'NETWORK_UNAVAILABLE') { - console.error('Check your internet connection or proxy settings, then try again.'); - } else if (err instanceof FirestoreClientError && err.code === 'AUTH_EXPIRED') { - console.error('Run `ensemble login` and try again.'); - } - process.exitCode = 1; - return; - } - cloudApp = cloudAppResult as CloudApp | null; - let bundle: typeof localApp | null = null; - if (cloudApp) { - await writeVerboseJson(root, 'ensemble-cloud-app.json', cloudApp, { - verbose, - }); - } - - if (cloudApp) { - const updatedBy = { - name: session.name ?? session.email ?? 'CLI', - email: session.email, - id: session.userId, - }; - const plan = computePushPlan({ - appId, - appName, - environment: appKey, - localApp, - cloudApp, - enabledByProp, - updatedBy, - }); - bundle = plan.bundle; - - const assetFileNames = data.assetFiles ?? []; - const envPush = await prepareEnvPushState({ - projectRoot: root, - appKey, - defaultAppKey: config.default, - cloudEnv: { config: cloudApp.config, secrets: cloudApp.secrets }, - assetFileNames, - cloudAssets: cloudApp.assets, - }); - const { - diff: envPushDiff, - pushConfigDto, - pushSecretsDto: localSecretsDto, - pendingLocalEnvConfigWrite, - localEnv: envLocal, - } = envPush; - const envConfigChanged = envPushDiff.configChanged; - const envSecretsChanged = envPushDiff.secretsChanged; - const { wouldClearConfig, wouldClearSecrets } = envPushDiff; - - await writeVerboseJson(root, 'ensemble-bundle.json', bundle, { - verbose, - }); - await writeVerboseJson( - root, - 'ensemble-diff.json', - { - ...plan.diff, - env: envPushDiff, - }, - { - verbose, - } - ); - - const summary = plan.summary; - const yamlChangeTotal = yamlArtifactChangeTotal(summary); - const assetsToUpload = plan.diff.assets.new - .map((item) => (item as { fileName?: string }).fileName) - .filter((fn): fn is string => typeof fn === 'string' && fn.length > 0); - const assetsToArchive = plan.diff.assets.changed.filter((item) => item.isArchived === true); - - if ( - yamlChangeTotal === 0 && - assetsToUpload.length === 0 && - assetsToArchive.length === 0 && - !envConfigChanged && - !envSecretsChanged - ) { - ui.info('Up to date. Nothing to push.'); - return; - } - - const pushPayload = buildPushPayload(bundle!, plan.diff, cloudApp, updatedBy); - await writeVerboseJson( - root, - 'ensemble-push-payload.json', - { - ...pushPayload, - ...(envConfigChanged || envSecretsChanged - ? { - env: { - ...(pushConfigDto && { config: pushConfigDto }), - ...(localSecretsDto && { secrets: localSecretsDto }), - }, - } - : {}), - }, - { - verbose, - } - ); - - if (options.dryRun) { - printPushDryRun(plan.diff); - if (envConfigChanged || envSecretsChanged) { - ui.note('Env file changes would also be pushed (.env.config / .env.secrets).'); - } - if (wouldClearConfig || wouldClearSecrets) { - ui.warn('Push would delete all cloud env/secrets (local env file present but empty).'); - } - return; - } - - ui.heading('Changes to be pushed'); - for (const line of formatDiffSummary(plan.diff)) { - // eslint-disable-next-line no-console - console.log(line); - } - if (envConfigChanged) { - // eslint-disable-next-line no-console - console.log(` env:\n ✏️ modified ${envLocal.configWriteFile}`); - } - if (envSecretsChanged) { - // eslint-disable-next-line no-console - console.log(` env:\n ✏️ modified ${envLocal.secretsWriteFile}`); - } - - const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); - - const appHome = appConfig.appHome as string | undefined; - const cloudHome = getCloudHomeScreenName(cloudApp); - const hasHomeConflict = appHome && cloudHome && appHome !== cloudHome; - if (hasHomeConflict && process.stdout.isTTY && process.stdin.isTTY && !options.yes) { - const { proceed } = await prompts({ - type: 'confirm', - name: 'proceed', - message: `Cloud has "${cloudHome}" as root. ensemble.config.json has appHome: "${appHome}". Pushing will set "${appHome}" as root. Continue?`, - initial: false, - }); - if (!proceed) { - ui.warn('Push cancelled.'); - process.exitCode = 130; - return; - } - } - - if (wouldClearConfig || wouldClearSecrets) { - const targets = [ - wouldClearConfig && `env variables (${envLocal.configWriteFile})`, - wouldClearSecrets && `secrets (${envLocal.secretsWriteFile})`, - ].filter((t): t is string => Boolean(t)); - ui.warn(`Pushing will delete all cloud ${targets.join(' and ')}.`); - - if (!options.yes) { - if (!isInteractive) { - ui.error( - 'Refusing to clear cloud env/secrets non-interactively without --yes. Re-run with --dry-run to inspect changes.' - ); - process.exitCode = 1; - return; - } - const { proceed: clearEnv } = await prompts({ - type: 'confirm', - name: 'proceed', - message: `Delete all ${targets.join(' and ')} from cloud? Continue? [y/N]`, - initial: false, - }); - if (!clearEnv) { - ui.warn('Push cancelled.'); - process.exitCode = 130; - return; - } - } else { - ui.note('Proceeding without interactive confirmation because --yes was provided.'); - } - } - - const manifestNeedsRefresh = hasManifestRelevantChanges(cloudApp, plan.diff); - - let confirmed = options.yes ?? false; - const hasDeletes = summary.counts.deleted > 0; - const largeChangeSet = yamlChangeTotal >= DESTRUCTIVE_CHANGE_PROMPT_THRESHOLD; - - if (!confirmed) { - if (!isInteractive) { - ui.error( - 'Refusing to run push non-interactively without --yes. Re-run with --dry-run to inspect changes.' - ); - process.exitCode = 1; - return; - } - - const yamlCreatedOrUpdated = - summary.byKind.screens.created + - summary.byKind.screens.updated + - summary.byKind.widgets.created + - summary.byKind.widgets.updated + - summary.byKind.scripts.created + - summary.byKind.scripts.updated + - summary.byKind.actions.created + - summary.byKind.actions.updated + - summary.byKind.translations.created + - summary.byKind.translations.updated + - summary.byKind.theme.created + - summary.byKind.theme.updated; - - const headline = - hasDeletes || largeChangeSet - ? `This will delete ${summary.counts.deleted} item(s) and apply ${yamlCreatedOrUpdated} other change(s)${ - assetsToUpload.length > 0 ? `, and upload ${assetsToUpload.length} asset(s)` : '' - }${ - envConfigChanged || envSecretsChanged ? ', and update env files' : '' - }. Continue? [y/N]` - : `Proceed with push${ - assetsToUpload.length > 0 ? ` and upload ${assetsToUpload.length} asset(s)` : '' - }${envConfigChanged || envSecretsChanged ? ' and update env files' : ''}?`; - - const { proceed } = await prompts({ - type: 'confirm', - name: 'proceed', - message: headline, - // Default to "No" for destructive operations. - initial: !(hasDeletes || largeChangeSet), - }); - confirmed = proceed === true; - } else if (hasDeletes || largeChangeSet) { - ui.note('Proceeding without interactive confirmation because --yes was provided.'); - } - - if (!confirmed) { - ui.warn('Push cancelled.'); - process.exitCode = 130; - return; - } - - try { - if (pendingLocalEnvConfigWrite) { - await writeEnvFile(root, envLocal.configWriteFile, pendingLocalEnvConfigWrite); - } - - if (yamlChangeTotal > 0 || assetsToUpload.length > 0 || assetsToArchive.length > 0) { - const { assetsUploaded } = await withSpinner('Pushing changes to cloud...', () => - submitCliPush(appId, idToken, pushPayload, firestoreOptions, { - projectRoot: root, - ...(assetsToUpload.length > 0 && { assetFileNames: assetsToUpload }), - }) - ); - if (assetsUploaded > 0) { - ui.success(`Uploaded ${assetsUploaded} asset(s) and updated .env.config.`); - } - } - - if (envConfigChanged || envSecretsChanged) { - await withSpinner('Pushing env files to cloud...', () => - submitEnvDocumentsPush( - appId, - idToken, - { - ...(pushConfigDto && { config: pushConfigDto }), - ...(localSecretsDto && { secrets: localSecretsDto }), - }, - firestoreOptions - ) - ); - } - - if (manifestNeedsRefresh && bundle) { - // Only refresh manifest when artifact changes can affect its contents. - try { - await withSpinner('Refreshing local manifest...', async () => { - await buildAndWriteManifest(root, bundle as CloudApp, {}); - }); - } catch (manifestErr) { - if (verbose) { - ui.warn( - 'Push succeeded, but failed to refresh .manifest.json. You can run "ensemble pull" later to regenerate it.' - ); - ui.note(manifestErr instanceof Error ? manifestErr.message : String(manifestErr)); - } - } - } - } catch (err) { - console.error('Push failed.'); - if (err instanceof FirestoreClientError) { - console.error(`${err.message} (${err.code})`); - if (err.hint) { - console.error(err.hint); - } - if (err.code === 'AUTH_EXPIRED') { - console.error('Authentication failed. Run `ensemble login` and try again.'); - } else if (err.code === 'NETWORK_UNAVAILABLE') { - console.error('Network error. Check your internet connection or proxy settings.'); - } - } else { - const message = err instanceof Error ? err.message : String(err); - console.error(message); - if (/401|403|unauth|expired/i.test(message)) { - console.error('Authentication failed. Run `ensemble login` and try again.'); - } else if (/network|ECONN|ENOTFOUND|ETIMEDOUT|timeout/i.test(message)) { - console.error('Network error. Check your internet connection or proxy settings.'); - } - } - process.exitCode = 1; - return; - } - - printPushSummary(summary, { - verbose, - isNoop: false, - envChanged: envConfigChanged || envSecretsChanged, - }); - } -} +import fs from 'fs/promises'; +import path from 'path'; +import prompts from 'prompts'; + +import { + checkAppAccess, + fetchCloudApp, + submitCliPush, + submitEnvDocumentsPush, + FirestoreClientError, + type CloudApp, +} from '../cloud/firestoreClient.js'; +import { buildDocumentsFromParsed } from '../core/buildDocuments.js'; +import { buildPushPayload, formatDiffSummary, type BundleDiff } from '../core/bundleDiff.js'; +import { collectAppFiles } from '../core/appCollector.js'; +import { ArtifactProps, type ArtifactProp } from '../core/artifacts.js'; +import { resolveVerboseFlag } from '../core/cliError.js'; +import { resolveAppContext } from '../config/projectConfig.js'; +import { getValidAuthSession } from '../auth/session.js'; +import { withSpinner } from '../lib/spinner.js'; +import { createFirestoreDebugOptions, writeVerboseJson } from '../core/debugFiles.js'; +import { computePushPlan, type PushSummary, type PushCounts } from '../core/sync.js'; +import { buildAndWriteManifest } from '../core/manifest.js'; +import { prepareEnvPushState } from '../core/envSync.js'; +import { writeEnvFile } from '../core/envConfig.js'; +import { ui } from '../core/ui.js'; + +export interface PushOptions { + verbose?: boolean; + appKey?: string; + /** Skip confirmation prompt (e.g. for CI) */ + yes?: boolean; + /** Dry run: show diff but do not push to cloud */ + dryRun?: boolean; +} + +const DESTRUCTIVE_CHANGE_PROMPT_THRESHOLD = 25; + +function getCloudHomeScreenName(cloudApp: CloudApp): string | undefined { + const screens = (cloudApp.screens ?? []).filter((s) => s.isArchived !== true); + return screens.find((s) => s.isRoot === true)?.name ?? screens[0]?.name; +} + +/** Firestore/YAML artifact changes only (not asset uploads via studio function). */ +function yamlArtifactChangeTotal(summary: PushSummary): number { + const k = summary.byKind; + return ( + k.screens.created + + k.screens.updated + + k.screens.deleted + + k.widgets.created + + k.widgets.updated + + k.widgets.deleted + + k.scripts.created + + k.scripts.updated + + k.scripts.deleted + + k.actions.created + + k.actions.updated + + k.actions.deleted + + k.translations.created + + k.translations.updated + + k.translations.deleted + + k.theme.created + + k.theme.updated + + k.theme.deleted + ); +} + +function printPushSummary( + summary: PushSummary, + options: { verbose?: boolean; isNoop?: boolean; envChanged?: boolean } +) { + const { appName, environment, counts } = summary; + const totalChanges = counts.created + counts.updated + counts.deleted; + + if ((options.isNoop || totalChanges === 0) && !options.envChanged) { + ui.info( + `Pushed app "${appName}" to environment "${environment}" (no changes; already up to date).` + ); + return; + } + + const parts: string[] = []; + if (counts.created > 0) parts.push(`${counts.created} created`); + if (counts.updated > 0) parts.push(`${counts.updated} updated`); + if (counts.deleted > 0) parts.push(`${counts.deleted} deleted`); + if (options.envChanged) parts.push('env files updated'); + + ui.success(`Pushed app "${appName}" to environment "${environment}" (${parts.join(', ')}).`); + + if (options.verbose) { + const entries: [string, PushCounts][] = [ + ['screens', summary.byKind.screens], + ['widgets', summary.byKind.widgets], + ['scripts', summary.byKind.scripts], + ['actions', summary.byKind.actions], + ['translations', summary.byKind.translations], + ['theme', summary.byKind.theme], + ['assets', summary.byKind.assets], + ]; + + for (const [kind, c] of entries) { + if (c.created === 0 && c.updated === 0 && c.deleted === 0) continue; + // eslint-disable-next-line no-console + console.log(` ${kind}: ${c.created} created, ${c.updated} updated, ${c.deleted} deleted`); + } + } +} + +function printPushDryRun(diff: BundleDiff): void { + ui.heading('Push dry run'); + ui.note('The following changes would be applied:'); + for (const line of formatDiffSummary(diff)) { + // eslint-disable-next-line no-console + console.log(line); + } + ui.note('\nRun `ensemble push` without `--dry-run` to apply these changes.'); +} + +async function readDefaultLanguage(root: string): Promise { + const manifestPath = path.join(root, '.manifest.json'); + try { + const raw = await fs.readFile(manifestPath, 'utf8'); + const parsed = JSON.parse(raw) as { defaultLanguage?: unknown }; + const value = parsed.defaultLanguage; + return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined; + } catch { + return undefined; + } +} + +function hasManifestRelevantChanges(cloudApp: CloudApp, diff: BundleDiff): boolean { + // Any new artifacts in these kinds will affect manifest lists or home screen/languages. + if ( + diff.screens.new.length > 0 || + diff.widgets.new.length > 0 || + diff.scripts.new.length > 0 || + diff.translations.new.length > 0 + ) { + return true; + } + + const scriptsCloudById = new Map((cloudApp.scripts ?? []).map((s) => [s.id, s])); + const scriptsCloudByName = new Map((cloudApp.scripts ?? []).map((s) => [s.name, s])); + for (const changed of diff.scripts.changed) { + const cloud = scriptsCloudById.get(changed.id) ?? scriptsCloudByName.get(changed.name); + if (!cloud) continue; + if ((changed.isArchived ?? false) !== (cloud.isArchived ?? false)) { + return true; + } + } + + const widgetsCloudById = new Map((cloudApp.widgets ?? []).map((w) => [w.id, w])); + const widgetsCloudByName = new Map((cloudApp.widgets ?? []).map((w) => [w.name, w])); + for (const changed of diff.widgets.changed) { + const cloud = widgetsCloudById.get(changed.id) ?? widgetsCloudByName.get(changed.name); + if (!cloud) continue; + if ((changed.isArchived ?? false) !== (cloud.isArchived ?? false)) { + return true; + } + } + + const translationsCloudById = new Map((cloudApp.translations ?? []).map((t) => [t.id, t])); + const translationsCloudByName = new Map((cloudApp.translations ?? []).map((t) => [t.name, t])); + for (const changed of diff.translations.changed) { + const cloud = + translationsCloudById.get(changed.id) ?? translationsCloudByName.get(changed.name); + if (!cloud) continue; + if ((changed.isArchived ?? false) !== (cloud.isArchived ?? false)) { + return true; + } + if ( + (changed as { defaultLocale?: boolean }).defaultLocale !== + (cloud as { defaultLocale?: boolean }).defaultLocale + ) { + return true; + } + } + + const screensCloudById = new Map((cloudApp.screens ?? []).map((s) => [s.id, s])); + const screensCloudByName = new Map((cloudApp.screens ?? []).map((s) => [s.name, s])); + for (const changed of diff.screens.changed) { + const cloud = screensCloudById.get(changed.id) ?? screensCloudByName.get(changed.name); + if (!cloud) continue; + if ((changed.isArchived ?? false) !== (cloud.isArchived ?? false)) { + return true; + } + if ((changed as { isRoot?: boolean }).isRoot !== (cloud as { isRoot?: boolean }).isRoot) { + return true; + } + } + + return false; +} + +export async function pushCommand(options: PushOptions = {}): Promise { + const root = process.cwd(); + const verbose = resolveVerboseFlag(options.verbose); + const { config, appKey, appId } = await resolveAppContext(options.appKey); + const appConfig = config.apps[appKey]; + const appName = (appConfig.name as string | undefined) ?? 'App'; + const appOptions = (appConfig.options ?? {}) as Record; + const enabledByProp = Object.fromEntries( + ArtifactProps.map((prop) => [prop, appOptions[prop] !== false]) + ) as Record; + + const session = await getValidAuthSession(); + if (!session.ok) { + console.error(session.message); + console.error('Run `ensemble login` and try again.'); + process.exitCode = 1; + return; + } + const { idToken, userId } = session; + + const firestoreOptions = verbose ? createFirestoreDebugOptions() : undefined; + + const [access, dataWithLang, cloudAppResult] = await withSpinner( + 'Preparing app for push...', + async () => { + const [accessRes, filesAndLang, cloudRes] = await Promise.all([ + checkAppAccess(appId, idToken, userId, firestoreOptions), + Promise.all([collectAppFiles(root), readDefaultLanguage(root)]).then( + ([files, defLang]) => [files, defLang] as const + ), + fetchCloudApp(appId, idToken, firestoreOptions).catch((e: unknown) => e), + ]); + return [accessRes, filesAndLang, cloudRes] as const; + } + ); + + if (!access.ok) { + console.error(access.message); + if (access.reason === 'not_logged_in') { + console.error('Run `ensemble login` and try again.'); + } else if (access.reason === 'network_error') { + console.error('Check your internet connection or proxy settings.'); + } + process.exitCode = 1; + return; + } + + const [data, defaultLanguage] = dataWithLang; + const localApp = buildDocumentsFromParsed( + data, + appId, + appName, + appConfig.appHome as string | undefined, + defaultLanguage + ); + await writeVerboseJson(root, 'ensemble-local-app.json', localApp, { + verbose, + }); + + let cloudApp: Awaited> | null = null; + if (cloudAppResult instanceof Error) { + const err = cloudAppResult; + console.error('Failed to fetch app from cloud.'); + if (err instanceof FirestoreClientError) { + console.error(`${err.message} (${err.code})`); + if (err.hint) { + console.error(err.hint); + } + } else { + console.error(err instanceof Error ? err.message : String(err)); + } + if (!(err instanceof FirestoreClientError)) { + console.error('Check your internet connection or proxy settings, then try again.'); + } else if (err instanceof FirestoreClientError && err.code === 'NETWORK_UNAVAILABLE') { + console.error('Check your internet connection or proxy settings, then try again.'); + } else if (err instanceof FirestoreClientError && err.code === 'AUTH_EXPIRED') { + console.error('Run `ensemble login` and try again.'); + } + process.exitCode = 1; + return; + } + cloudApp = cloudAppResult as CloudApp | null; + let bundle: typeof localApp | null = null; + if (cloudApp) { + await writeVerboseJson(root, 'ensemble-cloud-app.json', cloudApp, { + verbose, + }); + } + + if (cloudApp) { + const updatedBy = { + name: session.name ?? session.email ?? 'CLI', + email: session.email, + id: session.userId, + }; + const plan = computePushPlan({ + appId, + appName, + environment: appKey, + localApp, + cloudApp, + enabledByProp, + updatedBy, + }); + bundle = plan.bundle; + + const assetFileNames = data.assetFiles ?? []; + const envPush = await prepareEnvPushState({ + projectRoot: root, + appKey, + defaultAppKey: config.default, + cloudEnv: { config: cloudApp.config, secrets: cloudApp.secrets }, + assetFileNames, + cloudAssets: cloudApp.assets, + }); + const { + diff: envPushDiff, + pushConfigDto, + pushSecretsDto: localSecretsDto, + pendingLocalEnvConfigWrite, + localEnv: envLocal, + } = envPush; + const envConfigChanged = envPushDiff.configChanged; + const envSecretsChanged = envPushDiff.secretsChanged; + const { wouldClearConfig, wouldClearSecrets } = envPushDiff; + + await writeVerboseJson(root, 'ensemble-bundle.json', bundle, { + verbose, + }); + await writeVerboseJson( + root, + 'ensemble-diff.json', + { + ...plan.diff, + env: envPushDiff, + }, + { + verbose, + } + ); + + const summary = plan.summary; + const yamlChangeTotal = yamlArtifactChangeTotal(summary); + const assetsToUpload = plan.diff.assets.new + .map((item) => (item as { fileName?: string }).fileName) + .filter((fn): fn is string => typeof fn === 'string' && fn.length > 0); + const assetsToArchive = plan.diff.assets.changed.filter((item) => item.isArchived === true); + + if ( + yamlChangeTotal === 0 && + assetsToUpload.length === 0 && + assetsToArchive.length === 0 && + !envConfigChanged && + !envSecretsChanged + ) { + ui.info('Up to date. Nothing to push.'); + return; + } + + const pushPayload = buildPushPayload(bundle!, plan.diff, cloudApp, updatedBy); + await writeVerboseJson( + root, + 'ensemble-push-payload.json', + { + ...pushPayload, + ...(envConfigChanged || envSecretsChanged + ? { + env: { + ...(pushConfigDto && { config: pushConfigDto }), + ...(localSecretsDto && { secrets: localSecretsDto }), + }, + } + : {}), + }, + { + verbose, + } + ); + + if (options.dryRun) { + printPushDryRun(plan.diff); + if (envConfigChanged || envSecretsChanged) { + ui.note('Env file changes would also be pushed (.env.config / .env.secrets).'); + } + if (wouldClearConfig || wouldClearSecrets) { + ui.warn('Push would delete all cloud env/secrets (local env file present but empty).'); + } + return; + } + + ui.heading('Changes to be pushed'); + for (const line of formatDiffSummary(plan.diff)) { + // eslint-disable-next-line no-console + console.log(line); + } + if (envConfigChanged) { + // eslint-disable-next-line no-console + console.log(` env:\n ✏️ modified ${envLocal.configWriteFile}`); + } + if (envSecretsChanged) { + // eslint-disable-next-line no-console + console.log(` env:\n ✏️ modified ${envLocal.secretsWriteFile}`); + } + + const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); + + const appHome = appConfig.appHome as string | undefined; + const cloudHome = getCloudHomeScreenName(cloudApp); + const hasHomeConflict = appHome && cloudHome && appHome !== cloudHome; + if (hasHomeConflict && process.stdout.isTTY && process.stdin.isTTY && !options.yes) { + const { proceed } = await prompts({ + type: 'confirm', + name: 'proceed', + message: `Cloud has "${cloudHome}" as root. ensemble.config.json has appHome: "${appHome}". Pushing will set "${appHome}" as root. Continue?`, + initial: false, + }); + if (!proceed) { + ui.warn('Push cancelled.'); + process.exitCode = 130; + return; + } + } + + if (wouldClearConfig || wouldClearSecrets) { + const targets = [ + wouldClearConfig && `env variables (${envLocal.configWriteFile})`, + wouldClearSecrets && `secrets (${envLocal.secretsWriteFile})`, + ].filter((t): t is string => Boolean(t)); + ui.warn(`Pushing will delete all cloud ${targets.join(' and ')}.`); + + if (!options.yes) { + if (!isInteractive) { + ui.error( + 'Refusing to clear cloud env/secrets non-interactively without --yes. Re-run with --dry-run to inspect changes.' + ); + process.exitCode = 1; + return; + } + const { proceed: clearEnv } = await prompts({ + type: 'confirm', + name: 'proceed', + message: `Delete all ${targets.join(' and ')} from cloud? Continue? [y/N]`, + initial: false, + }); + if (!clearEnv) { + ui.warn('Push cancelled.'); + process.exitCode = 130; + return; + } + } else { + ui.note('Proceeding without interactive confirmation because --yes was provided.'); + } + } + + const manifestNeedsRefresh = hasManifestRelevantChanges(cloudApp, plan.diff); + + let confirmed = options.yes ?? false; + const hasDeletes = summary.counts.deleted > 0; + const largeChangeSet = yamlChangeTotal >= DESTRUCTIVE_CHANGE_PROMPT_THRESHOLD; + + if (!confirmed) { + if (!isInteractive) { + ui.error( + 'Refusing to run push non-interactively without --yes. Re-run with --dry-run to inspect changes.' + ); + process.exitCode = 1; + return; + } + + const yamlCreatedOrUpdated = + summary.byKind.screens.created + + summary.byKind.screens.updated + + summary.byKind.widgets.created + + summary.byKind.widgets.updated + + summary.byKind.scripts.created + + summary.byKind.scripts.updated + + summary.byKind.actions.created + + summary.byKind.actions.updated + + summary.byKind.translations.created + + summary.byKind.translations.updated + + summary.byKind.theme.created + + summary.byKind.theme.updated; + + const headline = + hasDeletes || largeChangeSet + ? `This will delete ${summary.counts.deleted} item(s) and apply ${yamlCreatedOrUpdated} other change(s)${ + assetsToUpload.length > 0 ? `, and upload ${assetsToUpload.length} asset(s)` : '' + }${ + envConfigChanged || envSecretsChanged ? ', and update env files' : '' + }. Continue? [y/N]` + : `Proceed with push${ + assetsToUpload.length > 0 ? ` and upload ${assetsToUpload.length} asset(s)` : '' + }${envConfigChanged || envSecretsChanged ? ' and update env files' : ''}?`; + + const { proceed } = await prompts({ + type: 'confirm', + name: 'proceed', + message: headline, + // Default to "No" for destructive operations. + initial: !(hasDeletes || largeChangeSet), + }); + confirmed = proceed === true; + } else if (hasDeletes || largeChangeSet) { + ui.note('Proceeding without interactive confirmation because --yes was provided.'); + } + + if (!confirmed) { + ui.warn('Push cancelled.'); + process.exitCode = 130; + return; + } + + try { + if (pendingLocalEnvConfigWrite) { + await writeEnvFile(root, envLocal.configWriteFile, pendingLocalEnvConfigWrite); + } + + if (yamlChangeTotal > 0 || assetsToUpload.length > 0 || assetsToArchive.length > 0) { + const { assetsUploaded } = await withSpinner('Pushing changes to cloud...', () => + submitCliPush(appId, idToken, pushPayload, firestoreOptions, { + projectRoot: root, + ...(assetsToUpload.length > 0 && { assetFileNames: assetsToUpload }), + }) + ); + if (assetsUploaded > 0) { + ui.success(`Uploaded ${assetsUploaded} asset(s) and updated .env.config.`); + } + } + + if (envConfigChanged || envSecretsChanged) { + await withSpinner('Pushing env files to cloud...', () => + submitEnvDocumentsPush( + appId, + idToken, + { + ...(pushConfigDto && { config: pushConfigDto }), + ...(localSecretsDto && { secrets: localSecretsDto }), + }, + firestoreOptions + ) + ); + } + + if (manifestNeedsRefresh && bundle) { + // Only refresh manifest when artifact changes can affect its contents. + try { + await withSpinner('Refreshing local manifest...', async () => { + await buildAndWriteManifest(root, bundle as CloudApp); + }); + } catch (manifestErr) { + if (verbose) { + ui.warn( + 'Push succeeded, but failed to refresh .manifest.json. You can run "ensemble pull" later to regenerate it.' + ); + ui.note(manifestErr instanceof Error ? manifestErr.message : String(manifestErr)); + } + } + } + } catch (err) { + console.error('Push failed.'); + if (err instanceof FirestoreClientError) { + console.error(`${err.message} (${err.code})`); + if (err.hint) { + console.error(err.hint); + } + if (err.code === 'AUTH_EXPIRED') { + console.error('Authentication failed. Run `ensemble login` and try again.'); + } else if (err.code === 'NETWORK_UNAVAILABLE') { + console.error('Network error. Check your internet connection or proxy settings.'); + } + } else { + const message = err instanceof Error ? err.message : String(err); + console.error(message); + if (/401|403|unauth|expired/i.test(message)) { + console.error('Authentication failed. Run `ensemble login` and try again.'); + } else if (/network|ECONN|ENOTFOUND|ETIMEDOUT|timeout/i.test(message)) { + console.error('Network error. Check your internet connection or proxy settings.'); + } + } + process.exitCode = 1; + return; + } + + printPushSummary(summary, { + verbose, + isNoop: false, + envChanged: envConfigChanged || envSecretsChanged, + }); + } +} diff --git a/src/commands/release.ts b/src/commands/release.ts index 9c8c102..90558bc 100644 --- a/src/commands/release.ts +++ b/src/commands/release.ts @@ -1,424 +1,547 @@ -import { Command } from 'commander'; -import crypto from 'crypto'; -import prompts from 'prompts'; - -import { - checkAppAccess, - createVersion, - listVersions, - getVersion, - FirestoreClientError, - type CloudApp, - type FirestoreClientOptions, - type VersionDoc, -} from '../cloud/firestoreClient.js'; -import { - downloadReleaseSnapshotJson, - StorageClientError, - uploadReleaseSnapshot, -} from '../cloud/storageClient.js'; -import { applyCloudStateToFs } from '../core/applyToFs.js'; -import { - applyReleaseConfigToFs, - buildConfigDtoFromEnvEntries, - readProjectEnvFiles, -} from '../core/envSync.js'; -import { buildDocumentsFromParsed } from '../core/buildDocuments.js'; -import { ArtifactProps, type ArtifactProp } from '../core/artifacts.js'; -import { collectAppFiles } from '../core/appCollector.js'; -import { resolveAppContext } from '../config/projectConfig.js'; -import { getValidAuthSession } from '../auth/session.js'; -import { withSpinner } from '../lib/spinner.js'; -import { ui } from '../core/ui.js'; - -export interface ReleaseCreateOptions { - /** App alias (defaults to config default) */ - appKey?: string; - /** Release message/label (skips prompt when provided) */ - message?: string; - /** Skip message prompt (use empty message) */ - yes?: boolean; - /** Show verbose error details */ - verbose?: boolean; -} - -export interface ReleaseListOptions { - /** App alias (defaults to config default) */ - appKey?: string; - /** Max releases to show (default: 20) */ - limit?: number; - /** When true, print releases as JSON (for scripting) and omit formatted output. */ - json?: boolean; -} - -export interface ReleaseUseOptions { - /** App alias (defaults to config default) */ - appKey?: string; - /** Non-interactive: hash (version id) of the release to use. */ - hash?: string; -} - -function formatReleaseLine(index: number, v: VersionDoc, showHash = true): string { - const date = v.createdAt ? new Date(v.createdAt).toLocaleString() : 'Unknown date'; - const msg = v.message?.trim() ? v.message : '(no message)'; - const hashSuffix = showHash ? ` [hash: ${v.id}]` : ''; - return `${index + 1}. ${date} — ${msg}${hashSuffix}`; -} - -function releaseUseHint(appKey: string, defaultAppKey: string): string { - return appKey === defaultAppKey ? 'ensemble release use' : `ensemble release use --app ${appKey}`; -} - -function releasePushHint(appKey: string, defaultAppKey: string): string { - return appKey === defaultAppKey ? 'ensemble push' : `ensemble push --app ${appKey}`; -} - -/** Commander stores --app on the release parent when subcommands also declare it; read parent opts. */ -export function resolveReleaseAppKey(command: Command): string | undefined { - return command.parent?.opts()?.app as string | undefined; -} - -export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): Promise { - const root = process.cwd(); - const { config, appKey, appId } = await resolveAppContext(options.appKey); - const appConfig = config.apps[appKey]; - if (!appConfig) { - ui.error(`No app configured for key "${appKey}".`); - process.exitCode = 1; - return; - } - - const session = await getValidAuthSession(); - if (!session.ok) { - ui.error(session.message); - process.exitCode = 1; - return; - } - const { idToken, userId } = session; - - const firestoreOptions: FirestoreClientOptions | undefined = undefined; - - let message = options.message ?? ''; - const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); - // In non-interactive contexts (e.g. tests, CI), skip prompt entirely. - if (message === '' && !options.yes && isInteractive) { - const result = await prompts({ - type: 'text', - name: 'message', - message: 'Release message (optional):', - initial: '', - }); - const promptMessage = result.message; - // If user cancels (Esc/Ctrl+C), do not create a release. - if (promptMessage === undefined) { - ui.warn('Release creation cancelled.'); - process.exitCode = 130; - return; - } - message = typeof promptMessage === 'string' ? promptMessage : ''; - } - - const now = new Date(); - const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(); - - // Build snapshot from local files, like git commit/tag. - const appName = (appConfig.name as string | undefined) ?? 'App'; - const appHome = appConfig.appHome as string | undefined; - const localFiles = await collectAppFiles(root); - const localEnv = await readProjectEnvFiles(root, appKey, config.default); - const localConfig = buildConfigDtoFromEnvEntries(localEnv.envConfig); - const localApp = buildDocumentsFromParsed(localFiles, appId, appName, appHome, undefined); - const snapshot: CloudApp = { - id: localApp.id, - name: localApp.name, - createdAt: localApp.createdAt, - updatedAt: localApp.updatedAt, - ...(localApp.screens && localApp.screens.length > 0 && { screens: localApp.screens }), - ...(localApp.widgets && localApp.widgets.length > 0 && { widgets: localApp.widgets }), - ...(localApp.scripts && localApp.scripts.length > 0 && { scripts: localApp.scripts }), - ...(localApp.actions && localApp.actions.length > 0 && { actions: localApp.actions }), - ...(localApp.translations && - localApp.translations.length > 0 && { translations: localApp.translations }), - ...(localApp.theme && { theme: localApp.theme }), - ...(localApp.assets && localApp.assets.length > 0 && { assets: localApp.assets }), - ...(localConfig && { config: localConfig }), - }; - - try { - const snapshotJson = JSON.stringify(snapshot); - const versionId = crypto.randomUUID().replace(/-/g, ''); - const upload = await withSpinner('Uploading snapshot to storage...', () => - uploadReleaseSnapshot(appId, idToken, versionId, snapshotJson) - ); - - await createVersion( - appId, - idToken, - { - id: versionId, - message: message.trim(), - createdAt: now.toISOString(), - createdBy: { name: session.name ?? 'User', id: userId }, - expiresAt, - snapshotPath: upload.objectPath, - }, - firestoreOptions - ); - ui.success(`Release saved. Run "${releaseUseHint(appKey, config.default)}" to use it.`); - } catch (err) { - if (err instanceof FirestoreClientError) { - ui.error(err.message); - if (err.hint) ui.note(err.hint); - if (options.verbose && err.cause) { - ui.note('Firestore response:'); - // eslint-disable-next-line no-console - console.log(typeof err.cause === 'string' ? err.cause : String(err.cause)); - } - } else if (err instanceof StorageClientError) { - ui.error(err.message); - if (err.hint) ui.note(err.hint); - if (options.verbose && err.cause) { - ui.note('Storage response:'); - // eslint-disable-next-line no-console - console.log(typeof err.cause === 'string' ? err.cause : String(err.cause)); - } - } else { - ui.error(err instanceof Error ? err.message : String(err)); - } - process.exitCode = 1; - } -} - -export async function releaseListCommand(options: ReleaseListOptions = {}): Promise { - const { config, appKey, appId } = await resolveAppContext(options.appKey); - const appConfig = config.apps[appKey]; - if (!appConfig) { - ui.error(`No app configured for key "${appKey}".`); - process.exitCode = 1; - return; - } - - const session = await getValidAuthSession(); - if (!session.ok) { - ui.error(session.message); - process.exitCode = 1; - return; - } - const { idToken, userId } = session; - - const firestoreOptions: FirestoreClientOptions | undefined = undefined; - const limit = options.limit ?? 20; - - try { - const accessAndFirst = await withSpinner('Loading releases...', () => - Promise.all([ - checkAppAccess(appId, idToken, userId, firestoreOptions), - listVersions(appId, idToken, { limit }, firestoreOptions), - ]) - ); - const access = accessAndFirst[0]; - let { versions, nextStartAfter } = accessAndFirst[1]; - - if (!access.ok) { - ui.error(access.message); - process.exitCode = 1; - return; - } - - if (versions.length === 0) { - ui.warn('No releases found. Create one with "ensemble release create".'); - return; - } - - while (nextStartAfter !== undefined && versions.length < limit) { - const next = await listVersions( - appId, - idToken, - { limit: limit - versions.length, startAfter: nextStartAfter }, - firestoreOptions - ); - versions = [...versions, ...next.versions]; - nextStartAfter = next.nextStartAfter; - } - - if (options.json) { - // Machine-readable output for scripting. Keep structure stable. - // eslint-disable-next-line no-console - console.log( - JSON.stringify( - { - appKey, - appName: appConfig.name ?? appKey, - versions, - }, - null, - 2 - ) - ); - return; - } - - ui.heading(`Releases for "${appKey}":`); - versions.forEach((v, idx) => { - ui.note(formatReleaseLine(idx, v)); - }); - } catch (err) { - if (err instanceof FirestoreClientError) { - ui.error(err.message); - if (err.hint) ui.note(err.hint); - } else { - ui.error(err instanceof Error ? err.message : String(err)); - } - process.exitCode = 1; - } -} - -export async function releaseUseCommand(options: ReleaseUseOptions = {}): Promise { - const { projectRoot, config, appKey, appId } = await resolveAppContext(options.appKey); - const appConfig = config.apps[appKey]; - if (!appConfig) { - ui.error(`No app configured for key "${appKey}".`); - process.exitCode = 1; - return; - } - const appOptions = (appConfig.options ?? {}) as Record; - const enabledByProp = Object.fromEntries( - ArtifactProps.map((prop) => [prop, appOptions[prop] !== false]) - ) as Record; - - const session = await getValidAuthSession(); - if (!session.ok) { - ui.error(session.message); - process.exitCode = 1; - return; - } - const { idToken, userId } = session; - - const firestoreOptions: FirestoreClientOptions | undefined = undefined; - - let versionDoc: VersionDoc; - try { - // Non-interactive: hash provided, fetch that specific version directly. - if (options.hash) { - const access = await checkAppAccess(appId, idToken, userId, firestoreOptions); - if (!access.ok) { - ui.error(access.message); - process.exitCode = 1; - return; - } - versionDoc = await withSpinner('Loading release...', () => - getVersion(appId, idToken, options.hash!, firestoreOptions) - ); - } else { - // Interactive picker over recent releases. - const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); - if (!isInteractive) { - ui.error( - 'Release use requires either interactive mode or --hash for non-interactive use.' - ); - process.exitCode = 1; - return; - } - - const PAGE_SIZE = 5; - const SHOW_MORE_VALUE = '__show_more__'; - - const { - access, - versions: initialVersions, - nextStartAfter: initialNext, - } = await withSpinner('Loading releases...', () => - Promise.all([ - checkAppAccess(appId, idToken, userId, firestoreOptions), - listVersions(appId, idToken, { limit: PAGE_SIZE }, firestoreOptions), - ]).then(([accessRes, listRes]) => ({ - access: accessRes, - versions: listRes.versions, - nextStartAfter: listRes.nextStartAfter, - })) - ); - - if (!access.ok) { - ui.error(access.message); - process.exitCode = 1; - return; - } - - if (initialVersions.length === 0) { - ui.warn('No releases found. Create one with "ensemble release create".'); - return; - } - - let allVersions = initialVersions; - let nextStartAfter: string | undefined = initialNext; - for (;;) { - const choices: { title: string; value: number | string }[] = allVersions.map((v, i) => ({ - title: formatReleaseLine(i, v, false), - value: i, - })); - if (nextStartAfter !== undefined) { - choices.push({ title: 'Show more (next 5)', value: SHOW_MORE_VALUE }); - } - - const { selected } = await prompts({ - type: 'select', - name: 'selected', - message: 'Choose a release to use (local files only):', - choices, - initial: 0, - }); - - if (selected === undefined) { - ui.warn('Release use cancelled.'); - process.exitCode = 130; - return; - } - if (selected === SHOW_MORE_VALUE) { - const nextPage = await withSpinner('Loading releases...', () => - listVersions( - appId, - idToken, - { limit: PAGE_SIZE, startAfter: nextStartAfter }, - firestoreOptions - ) - ); - allVersions = [...allVersions, ...nextPage.versions]; - nextStartAfter = nextPage.nextStartAfter; - continue; - } - versionDoc = allVersions[selected as number]; - break; - } - } - - const snapshotJson = await withSpinner('Downloading snapshot...', () => - downloadReleaseSnapshotJson(idToken, versionDoc.snapshotPath) - ); - const snapshot = JSON.parse(snapshotJson) as CloudApp; - - const localFiles = await collectAppFiles(projectRoot); - await withSpinner('Writing local files...', () => - applyCloudStateToFs(projectRoot, snapshot, localFiles, enabledByProp, { - manifestOptions: {}, - onProgress: (completed, total) => { - if (total > 0 && completed % 25 === 0) { - // eslint-disable-next-line no-console - console.log(`Writing files... (${completed}/${total})`); - } - }, - }) - ); - await applyReleaseConfigToFs(projectRoot, snapshot.config, appKey, config.default); - ui.success( - `Local files updated to selected release. Run "${releasePushHint(appKey, config.default)}" to apply to the cloud.` - ); - } catch (err) { - if (err instanceof FirestoreClientError) { - ui.error(err.message); - if (err.hint) ui.note(err.hint); - } else { - ui.error(err instanceof Error ? err.message : String(err)); - } - process.exitCode = 1; - } -} +import { Command } from 'commander'; +import crypto from 'crypto'; +import prompts from 'prompts'; + +import { + checkAppAccess, + createVersion, + listVersions, + getVersion, + FirestoreClientError, + type CloudApp, + type FirestoreClientOptions, + type VersionDoc, +} from '../cloud/firestoreClient.js'; +import { + StorageClientError, + downloadReleaseSnapshotJson, + uploadReleaseSnapshot, +} from '../cloud/storageClient.js'; +import { applyCloudStateToFs } from '../core/applyToFs.js'; +import { + applyReleaseEnvToFs, + buildConfigDtoFromEnvEntries, + buildSecretsDtoFromEnvSecretsFile, + readProjectEnvFiles, + type LocalEnvFiles, +} from '../core/envSync.js'; +import { + encryptReleaseSnapshot, + EncryptionError, + parseReleaseSnapshotBody, + requireReleaseEncryptionKey, +} from '../core/encryption.js'; +import { buildDocumentsFromParsed } from '../core/buildDocuments.js'; +import { + orderByManifestNames, + readProjectManifest, + writeManifestFromSnapshot, +} from '../core/manifest.js'; +import { ArtifactProps, type ArtifactProp } from '../core/artifacts.js'; +import { collectAppFiles } from '../core/appCollector.js'; +import { readEnvFilePreservingOrder } from '../core/envConfig.js'; +import { resolveAppContext } from '../config/projectConfig.js'; +import { getValidAuthSession } from '../auth/session.js'; +import { withSpinner } from '../lib/spinner.js'; +import { ui } from '../core/ui.js'; + +export interface ReleaseCreateOptions { + /** App alias (defaults to config default) */ + appKey?: string; + /** Release message/label (skips prompt when provided) */ + message?: string; + /** Skip message prompt (use empty message) */ + yes?: boolean; + /** Show verbose error details */ + verbose?: boolean; +} + +export interface ReleaseListOptions { + /** App alias (defaults to config default) */ + appKey?: string; + /** Max releases to show (default: 20) */ + limit?: number; + /** When true, print releases as JSON (for scripting) and omit formatted output. */ + json?: boolean; +} + +export interface ReleaseUseOptions { + /** App alias (defaults to config default) */ + appKey?: string; + /** Non-interactive: hash (version id) of the release to use. */ + hash?: string; +} + +function formatReleaseLine(index: number, v: VersionDoc, showHash = true): string { + const date = v.createdAt ? new Date(v.createdAt).toLocaleString() : 'Unknown date'; + const msg = v.message?.trim() ? v.message : '(no message)'; + const hashSuffix = showHash ? ` [hash: ${v.id}]` : ''; + return `${index + 1}. ${date} — ${msg}${hashSuffix}`; +} + +function releaseUseHint(appKey: string, defaultAppKey: string): string { + return appKey === defaultAppKey ? 'ensemble release use' : `ensemble release use --app ${appKey}`; +} + +function releasePushHint(appKey: string, defaultAppKey: string): string { + return appKey === defaultAppKey ? 'ensemble push' : `ensemble push --app ${appKey}`; +} + +interface ReleaseKeyContext { + key: string; + secretsWriteFile: string; + localEnv: LocalEnvFiles; +} + +function reportEncryptionError(err: unknown): void { + if (err instanceof EncryptionError) { + ui.error(err.message); + if (err.hint) ui.note(err.hint); + return; + } + ui.error(err instanceof Error ? err.message : String(err)); +} + +async function loadReleaseKeyOrFail( + projectRoot: string, + appKey: string, + defaultAppKey: string +): Promise { + const localEnv = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); + try { + const key = requireReleaseEncryptionKey(localEnv.envSecrets, localEnv.secretsWriteFile); + return { key, secretsWriteFile: localEnv.secretsWriteFile, localEnv }; + } catch (err) { + reportEncryptionError(err); + process.exitCode = 1; + return undefined; + } +} + +/** Commander stores --app on the release parent when subcommands also declare it; read parent opts. */ +export function resolveReleaseAppKey(command: Command): string | undefined { + return command.parent?.opts()?.app as string | undefined; +} + +export async function releaseCreateCommand(options: ReleaseCreateOptions = {}): Promise { + const root = process.cwd(); + const { config, appKey, appId } = await resolveAppContext(options.appKey); + const appConfig = config.apps[appKey]; + if (!appConfig) { + ui.error(`No app configured for key "${appKey}".`); + process.exitCode = 1; + return; + } + + const keyCtx = await loadReleaseKeyOrFail(root, appKey, config.default); + if (!keyCtx) { + return; + } + + const session = await getValidAuthSession(); + if (!session.ok) { + ui.error(session.message); + process.exitCode = 1; + return; + } + const { idToken, userId } = session; + + const firestoreOptions: FirestoreClientOptions | undefined = undefined; + + let message = options.message ?? ''; + const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); + // In non-interactive contexts (e.g. tests, CI), skip prompt entirely. + if (message === '' && !options.yes && isInteractive) { + const result = await prompts({ + type: 'text', + name: 'message', + message: 'Release message (optional):', + initial: '', + }); + const promptMessage = result.message; + // If user cancels (Esc/Ctrl+C), do not create a release. + if (promptMessage === undefined) { + ui.warn('Release creation cancelled.'); + process.exitCode = 130; + return; + } + message = typeof promptMessage === 'string' ? promptMessage : ''; + } + + const now = new Date(); + const expiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString(); + + // Build snapshot from local files, like git commit/tag. + const appName = (appConfig.name as string | undefined) ?? 'App'; + const appHome = appConfig.appHome as string | undefined; + const localFiles = await collectAppFiles(root); + const manifest = await readProjectManifest(root); + const defaultLanguage = + typeof manifest.defaultLanguage === 'string' && manifest.defaultLanguage.trim() !== '' + ? manifest.defaultLanguage.trim() + : undefined; + const { key: encryptionKey, localEnv } = keyCtx; + const orderedConfig = localEnv.envConfigPresent + ? await readEnvFilePreservingOrder(root, localEnv.configWriteFile) + : []; + const orderedSecrets = localEnv.envSecretsPresent + ? await readEnvFilePreservingOrder(root, localEnv.secretsWriteFile) + : []; + const localConfig = buildConfigDtoFromEnvEntries(orderedConfig); + const localSecrets = buildSecretsDtoFromEnvSecretsFile(orderedSecrets); + const localApp = buildDocumentsFromParsed(localFiles, appId, appName, appHome, defaultLanguage); + const orderedWidgets = localApp.widgets?.length + ? orderByManifestNames( + localApp.widgets, + manifest.widgets?.map((w) => w.name) + ) + : localApp.widgets; + const orderedScripts = localApp.scripts?.length + ? orderByManifestNames( + localApp.scripts, + manifest.scripts?.map((s) => s.name) + ) + : localApp.scripts; + const orderedActions = localApp.actions?.length + ? orderByManifestNames( + localApp.actions, + manifest.actions?.map((a) => a.name) + ) + : localApp.actions; + const orderedTranslations = localApp.translations?.length + ? orderByManifestNames(localApp.translations, manifest.languages) + : localApp.translations; + const snapshot: CloudApp = { + id: localApp.id, + name: localApp.name, + createdAt: localApp.createdAt, + updatedAt: localApp.updatedAt, + ...(localApp.screens && localApp.screens.length > 0 && { screens: localApp.screens }), + ...(orderedWidgets && orderedWidgets.length > 0 && { widgets: orderedWidgets }), + ...(orderedScripts && orderedScripts.length > 0 && { scripts: orderedScripts }), + ...(orderedActions && orderedActions.length > 0 && { actions: orderedActions }), + ...(orderedTranslations && + orderedTranslations.length > 0 && { translations: orderedTranslations }), + ...(localApp.theme && { theme: localApp.theme }), + ...(localApp.assets && localApp.assets.length > 0 && { assets: localApp.assets }), + ...(localConfig && { config: localConfig }), + ...(localSecrets && { secrets: localSecrets }), + }; + + try { + const snapshotJson = JSON.stringify(snapshot); + const envelopeJson = encryptReleaseSnapshot(snapshotJson, encryptionKey); + const versionId = crypto.randomUUID().replace(/-/g, ''); + const upload = await withSpinner('Uploading snapshot to storage...', () => + uploadReleaseSnapshot(appId, idToken, versionId, envelopeJson) + ); + + await createVersion( + appId, + idToken, + { + id: versionId, + message: message.trim(), + createdAt: now.toISOString(), + createdBy: { name: session.name ?? 'User', id: userId }, + expiresAt, + snapshotPath: upload.objectPath, + }, + firestoreOptions + ); + ui.success(`Release saved. Run "${releaseUseHint(appKey, config.default)}" to use it.`); + } catch (err) { + if (err instanceof FirestoreClientError) { + ui.error(err.message); + if (err.hint) ui.note(err.hint); + if (options.verbose && err.cause) { + ui.note('Firestore response:'); + // eslint-disable-next-line no-console + console.log(typeof err.cause === 'string' ? err.cause : String(err.cause)); + } + } else if (err instanceof StorageClientError) { + ui.error(err.message); + if (err.hint) ui.note(err.hint); + if (options.verbose && err.cause) { + ui.note('Storage response:'); + // eslint-disable-next-line no-console + console.log(typeof err.cause === 'string' ? err.cause : String(err.cause)); + } + } else if (err instanceof EncryptionError) { + reportEncryptionError(err); + } else { + ui.error(err instanceof Error ? err.message : String(err)); + } + process.exitCode = 1; + } +} + +export async function releaseListCommand(options: ReleaseListOptions = {}): Promise { + const { projectRoot, config, appKey, appId } = await resolveAppContext(options.appKey); + const appConfig = config.apps[appKey]; + if (!appConfig) { + ui.error(`No app configured for key "${appKey}".`); + process.exitCode = 1; + return; + } + + if (!(await loadReleaseKeyOrFail(projectRoot, appKey, config.default))) { + return; + } + + const session = await getValidAuthSession(); + if (!session.ok) { + ui.error(session.message); + process.exitCode = 1; + return; + } + const { idToken, userId } = session; + + const firestoreOptions: FirestoreClientOptions | undefined = undefined; + const limit = options.limit ?? 20; + + try { + const accessAndFirst = await withSpinner('Loading releases...', () => + Promise.all([ + checkAppAccess(appId, idToken, userId, firestoreOptions), + listVersions(appId, idToken, { limit }, firestoreOptions), + ]) + ); + const access = accessAndFirst[0]; + let { versions, nextStartAfter } = accessAndFirst[1]; + + if (!access.ok) { + ui.error(access.message); + process.exitCode = 1; + return; + } + + if (versions.length === 0) { + ui.warn('No releases found. Create one with "ensemble release create".'); + return; + } + + while (nextStartAfter !== undefined && versions.length < limit) { + const next = await listVersions( + appId, + idToken, + { limit: limit - versions.length, startAfter: nextStartAfter }, + firestoreOptions + ); + versions = [...versions, ...next.versions]; + nextStartAfter = next.nextStartAfter; + } + + if (options.json) { + // Machine-readable output for scripting. Keep structure stable. + // eslint-disable-next-line no-console + console.log( + JSON.stringify( + { + appKey, + appName: appConfig.name ?? appKey, + versions, + }, + null, + 2 + ) + ); + return; + } + + ui.heading(`Releases for "${appKey}":`); + versions.forEach((v, idx) => { + ui.note(formatReleaseLine(idx, v)); + }); + } catch (err) { + if (err instanceof FirestoreClientError) { + ui.error(err.message); + if (err.hint) ui.note(err.hint); + } else { + ui.error(err instanceof Error ? err.message : String(err)); + } + process.exitCode = 1; + } +} + +export async function releaseUseCommand(options: ReleaseUseOptions = {}): Promise { + const { projectRoot, config, appKey, appId } = await resolveAppContext(options.appKey); + const appConfig = config.apps[appKey]; + if (!appConfig) { + ui.error(`No app configured for key "${appKey}".`); + process.exitCode = 1; + return; + } + + const keyCtx = await loadReleaseKeyOrFail(projectRoot, appKey, config.default); + if (!keyCtx) { + return; + } + + const appOptions = (appConfig.options ?? {}) as Record; + const enabledByProp = Object.fromEntries( + ArtifactProps.map((prop) => [prop, appOptions[prop] !== false]) + ) as Record; + + const session = await getValidAuthSession(); + if (!session.ok) { + ui.error(session.message); + process.exitCode = 1; + return; + } + const { idToken, userId } = session; + + const firestoreOptions: FirestoreClientOptions | undefined = undefined; + + let versionDoc: VersionDoc; + try { + // Non-interactive: hash provided, fetch that specific version directly. + if (options.hash) { + const access = await checkAppAccess(appId, idToken, userId, firestoreOptions); + if (!access.ok) { + ui.error(access.message); + process.exitCode = 1; + return; + } + versionDoc = await withSpinner('Loading release...', () => + getVersion(appId, idToken, options.hash!, firestoreOptions) + ); + } else { + // Interactive picker over recent releases. + const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); + if (!isInteractive) { + ui.error( + 'Release use requires either interactive mode or --hash for non-interactive use.' + ); + process.exitCode = 1; + return; + } + + const PAGE_SIZE = 5; + const SHOW_MORE_VALUE = '__show_more__'; + + const { + access, + versions: initialVersions, + nextStartAfter: initialNext, + } = await withSpinner('Loading releases...', () => + Promise.all([ + checkAppAccess(appId, idToken, userId, firestoreOptions), + listVersions(appId, idToken, { limit: PAGE_SIZE }, firestoreOptions), + ]).then(([accessRes, listRes]) => ({ + access: accessRes, + versions: listRes.versions, + nextStartAfter: listRes.nextStartAfter, + })) + ); + + if (!access.ok) { + ui.error(access.message); + process.exitCode = 1; + return; + } + + if (initialVersions.length === 0) { + ui.warn('No releases found. Create one with "ensemble release create".'); + return; + } + + let allVersions = initialVersions; + let nextStartAfter: string | undefined = initialNext; + for (;;) { + const choices: { title: string; value: number | string }[] = allVersions.map((v, i) => ({ + title: formatReleaseLine(i, v, false), + value: i, + })); + if (nextStartAfter !== undefined) { + choices.push({ title: 'Show more (next 5)', value: SHOW_MORE_VALUE }); + } + + const { selected } = await prompts({ + type: 'select', + name: 'selected', + message: 'Choose a release to use (local files only):', + choices, + initial: 0, + }); + + if (selected === undefined) { + ui.warn('Release use cancelled.'); + process.exitCode = 130; + return; + } + if (selected === SHOW_MORE_VALUE) { + const nextPage = await withSpinner('Loading releases...', () => + listVersions( + appId, + idToken, + { limit: PAGE_SIZE, startAfter: nextStartAfter }, + firestoreOptions + ) + ); + allVersions = [...allVersions, ...nextPage.versions]; + nextStartAfter = nextPage.nextStartAfter; + continue; + } + versionDoc = allVersions[selected as number]; + break; + } + } + + const snapshotBody = await withSpinner('Downloading snapshot...', () => + downloadReleaseSnapshotJson(idToken, versionDoc.snapshotPath) + ); + const snapshotJson = parseReleaseSnapshotBody( + snapshotBody, + versionDoc.snapshotPath, + keyCtx.key, + keyCtx.secretsWriteFile + ); + const snapshot = JSON.parse(snapshotJson) as CloudApp; + + const localFiles = await collectAppFiles(projectRoot); + await withSpinner('Writing local files...', async () => { + await applyCloudStateToFs(projectRoot, snapshot, localFiles, enabledByProp, { + onProgress: (completed, total) => { + if (total > 0 && completed % 25 === 0) { + // eslint-disable-next-line no-console + console.log(`Writing files... (${completed}/${total})`); + } + }, + }); + await writeManifestFromSnapshot(projectRoot, snapshot); + }); + await applyReleaseEnvToFs( + projectRoot, + snapshot.config, + snapshot.secrets, + appKey, + config.default, + (snapshot.assets ?? []) + .filter( + (asset) => + asset.isArchived !== true && + typeof asset.fileName === 'string' && + asset.fileName.length > 0 + ) + .map((asset) => asset.fileName as string), + snapshot.assets + ); + ui.success( + `Local files updated to selected release. Run "${releasePushHint(appKey, config.default)}" to apply to the cloud.` + ); + } catch (err) { + if (err instanceof FirestoreClientError) { + ui.error(err.message); + if (err.hint) ui.note(err.hint); + } else if (err instanceof StorageClientError) { + ui.error(err.message); + if (err.hint) ui.note(err.hint); + } else if (err instanceof EncryptionError) { + reportEncryptionError(err); + } else { + ui.error(err instanceof Error ? err.message : String(err)); + } + process.exitCode = 1; + } +} diff --git a/src/core/applyToFs.ts b/src/core/applyToFs.ts index 8c29722..4e9f1cc 100644 --- a/src/core/applyToFs.ts +++ b/src/core/applyToFs.ts @@ -1,120 +1,120 @@ -/** - * Apply a cloud app state (or snapshot) to the local filesystem. - * Used by pull and release use to overwrite local artifact files. - */ - -import fs from 'fs/promises'; -import path from 'path'; - -import type { CloudApp } from '../cloud/firestoreClient.js'; -import type { ParsedAppFiles } from './appCollector.js'; -import { ARTIFACT_FS_CONFIG } from './artifacts.js'; -import type { ArtifactProp } from './artifacts.js'; -import { processWithConcurrency } from './concurrency.js'; -import { safeFileName } from './fileNames.js'; -import { buildAndWriteManifest, type BuildManifestOptions } from './manifest.js'; - -async function ensureDir(dir: string): Promise { - await fs.mkdir(dir, { recursive: true }); -} - -export interface ApplyCloudStateToFsOptions { - /** When set, manifest is built and written after applying files. */ - manifestOptions?: BuildManifestOptions; - /** Called every 25 completed tasks with (completed, total). */ - onProgress?: (completed: number, total: number) => void; -} - -type WriteTask = - | { op: 'write'; filePath: string; content: string } - | { op: 'delete'; filePath: string }; - -/** - * Write local artifact files to match the given cloud/snapshot state. - * Deletes files that are not in the state. Optionally refreshes .manifest.json. - */ -export async function applyCloudStateToFs( - projectRoot: string, - cloudApp: CloudApp, - localFiles: ParsedAppFiles, - enabledByProp: Record, - options: ApplyCloudStateToFsOptions = {} -): Promise { - const { manifestOptions, onProgress } = options; - - const tasks: WriteTask[] = []; - - for (const cfg of ARTIFACT_FS_CONFIG) { - const { prop, ext, isTheme } = cfg; - if (!enabledByProp[prop]) continue; - - if (isTheme) { - const themePath = path.join(projectRoot, 'theme.yaml'); - if (cloudApp.theme && cloudApp.theme.isArchived !== true) { - tasks.push({ - op: 'write', - filePath: themePath, - content: cloudApp.theme.content ?? '', - }); - } else { - tasks.push({ op: 'delete', filePath: themePath }); - } - continue; - } - - const baseDir = path.join(projectRoot, prop); - await ensureDir(baseDir); - - const cloudItems = (cloudApp as Record)[prop] as - | { name: string; content?: string; isArchived?: boolean }[] - | undefined; - - const expected: Record = {}; - for (const item of cloudItems ?? []) { - if (item.isArchived === true) continue; - expected[safeFileName(item.name, ext!)] = item.content ?? ''; - } - - const actual = (localFiles as unknown as Record)[prop] as - | Record - | undefined; - const actualMap = actual ?? {}; - - const expectedKeys = new Set(Object.keys(expected)); - const actualKeys = new Set(Object.keys(actualMap)); - - for (const file of expectedKeys) { - const content = expected[file] ?? ''; - const filePath = path.join(baseDir, file); - if (!actualKeys.has(file) || actualMap[file] !== content) { - tasks.push({ op: 'write', filePath, content }); - } - } - - for (const file of actualKeys) { - if (!expectedKeys.has(file)) { - const filePath = path.join(baseDir, file); - tasks.push({ op: 'delete', filePath }); - } - } - } - - let completed = 0; - const total = tasks.length; - - await processWithConcurrency(tasks, async (task) => { - if (task.op === 'write') { - await fs.writeFile(task.filePath, task.content, 'utf8'); - } else { - await fs.rm(task.filePath, { force: true }); - } - completed += 1; - if (total > 0 && completed % 25 === 0 && onProgress) { - onProgress(completed, total); - } - }); - - if (manifestOptions !== undefined) { - await buildAndWriteManifest(projectRoot, cloudApp, manifestOptions); - } -} +/** + * Apply a cloud app state (or snapshot) to the local filesystem. + * Used by pull and release use to overwrite local artifact files. + */ + +import fs from 'fs/promises'; +import path from 'path'; + +import type { CloudApp } from '../cloud/firestoreClient.js'; +import type { ParsedAppFiles } from './appCollector.js'; +import { ARTIFACT_FS_CONFIG } from './artifacts.js'; +import type { ArtifactProp } from './artifacts.js'; +import { processWithConcurrency } from './concurrency.js'; +import { safeFileName } from './fileNames.js'; +import { buildAndWriteManifest } from './manifest.js'; + +async function ensureDir(dir: string): Promise { + await fs.mkdir(dir, { recursive: true }); +} + +export interface ApplyCloudStateToFsOptions { + /** When true, merge cloud lists into existing .manifest.json (pull). */ + refreshManifest?: boolean; + /** Called every 25 completed tasks with (completed, total). */ + onProgress?: (completed: number, total: number) => void; +} + +type WriteTask = + | { op: 'write'; filePath: string; content: string } + | { op: 'delete'; filePath: string }; + +/** + * Write local artifact files to match the given cloud/snapshot state. + * Deletes files that are not in the state. Optionally refreshes .manifest.json. + */ +export async function applyCloudStateToFs( + projectRoot: string, + cloudApp: CloudApp, + localFiles: ParsedAppFiles, + enabledByProp: Record, + options: ApplyCloudStateToFsOptions = {} +): Promise { + const { refreshManifest, onProgress } = options; + + const tasks: WriteTask[] = []; + + for (const cfg of ARTIFACT_FS_CONFIG) { + const { prop, ext, isTheme } = cfg; + if (!enabledByProp[prop]) continue; + + if (isTheme) { + const themePath = path.join(projectRoot, 'theme.yaml'); + if (cloudApp.theme && cloudApp.theme.isArchived !== true) { + tasks.push({ + op: 'write', + filePath: themePath, + content: cloudApp.theme.content ?? '', + }); + } else { + tasks.push({ op: 'delete', filePath: themePath }); + } + continue; + } + + const baseDir = path.join(projectRoot, prop); + await ensureDir(baseDir); + + const cloudItems = (cloudApp as Record)[prop] as + | { name: string; content?: string; isArchived?: boolean }[] + | undefined; + + const expected: Record = {}; + for (const item of cloudItems ?? []) { + if (item.isArchived === true) continue; + expected[safeFileName(item.name, ext!)] = item.content ?? ''; + } + + const actual = (localFiles as unknown as Record)[prop] as + | Record + | undefined; + const actualMap = actual ?? {}; + + const expectedKeys = new Set(Object.keys(expected)); + const actualKeys = new Set(Object.keys(actualMap)); + + for (const file of expectedKeys) { + const content = expected[file] ?? ''; + const filePath = path.join(baseDir, file); + if (!actualKeys.has(file) || actualMap[file] !== content) { + tasks.push({ op: 'write', filePath, content }); + } + } + + for (const file of actualKeys) { + if (!expectedKeys.has(file)) { + const filePath = path.join(baseDir, file); + tasks.push({ op: 'delete', filePath }); + } + } + } + + let completed = 0; + const total = tasks.length; + + await processWithConcurrency(tasks, async (task) => { + if (task.op === 'write') { + await fs.writeFile(task.filePath, task.content, 'utf8'); + } else { + await fs.rm(task.filePath, { force: true }); + } + completed += 1; + if (total > 0 && completed % 25 === 0 && onProgress) { + onProgress(completed, total); + } + }); + + if (refreshManifest) { + await buildAndWriteManifest(projectRoot, cloudApp); + } +} diff --git a/src/core/bundleDiff.ts b/src/core/bundleDiff.ts index 19ac3f2..10d1bf9 100644 --- a/src/core/bundleDiff.ts +++ b/src/core/bundleDiff.ts @@ -1,645 +1,645 @@ -import type { CloudApp } from '../cloud/firestoreClient.js'; -import pc from 'picocolors'; -import type { - ApplicationDTO, - AssetDTO, - ScreenDTO, - WidgetDTO, - ScriptDTO, - ActionDTO, - ThemeDTO, - TranslationDTO, -} from './dto.js'; - -type ArtifactWithContent = { - id: string; - name: string; - content: string; - isArchived?: boolean; - isRoot?: boolean; - defaultLocale?: boolean; -}; - -/** Snapshot of artifact to archive in history sub-collection (for YAML artifacts). */ -export interface HistoryEntry { - content: string; - name: string; - type: string; - isRoot?: boolean; - isArchived?: boolean; - defaultLocale?: boolean; - updatedAt?: string; - updatedBy?: { name: string; email?: string; id: string }; -} - -type YamlDocument = ScreenDTO | WidgetDTO | ScriptDTO | ActionDTO | ThemeDTO | TranslationDTO; - -type YamlUpdates = { - content?: string; - name?: string; - isRoot?: boolean; - isArchived?: boolean; - updatedAt?: string; - updatedBy?: { - name: string; - email?: string; - id: string; - }; - defaultLocale?: boolean; -}; - -/** Create: full document for new artifact. Update: history (old→archive) + updates (only changed fields). */ -export type YamlArtifactPushItem = - | { operation: 'create'; document: YamlDocument } - | { - operation: 'update'; - id: string; - history: HistoryEntry; - updates: YamlUpdates; - }; - -export interface PushPayload { - id: string; - name?: string; - updatedAt: string; - screens?: YamlArtifactPushItem[]; - widgets?: YamlArtifactPushItem[]; - scripts?: YamlArtifactPushItem[]; - actions?: YamlArtifactPushItem[]; - translations?: YamlArtifactPushItem[]; - theme?: YamlArtifactPushItem; -} - -export interface BundleDiff { - screens: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }; - widgets: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }; - scripts: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }; - actions: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }; - themeChanged: boolean; - translations: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }; - /** Local asset files to upload (same fileName as Firestore `artifacts` doc with type asset). */ - assets: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }; -} - -/** Normalize content for comparison to avoid false diffs from line endings or trailing newlines. */ -export function normalizeContentForCompare(content: string): string { - return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n+$/, ''); -} - -function diffArtifacts( - bundleItems: ArtifactWithContent[] | undefined, - cloudItems: ArtifactWithContent[] | undefined -): { changed: ArtifactWithContent[]; new: ArtifactWithContent[] } { - const cloudById = new Map(); - for (const item of cloudItems ?? []) { - cloudById.set(item.id, item); - } - const cloudByName = new Map(); - for (const item of cloudItems ?? []) { - cloudByName.set(item.name, item); - } - - const changed: ArtifactWithContent[] = []; - const newItems: ArtifactWithContent[] = []; - - for (const bundle of bundleItems ?? []) { - const cloud = cloudById.get(bundle.id) ?? cloudByName.get(bundle.name); - if (cloud) { - const contentChanged = - normalizeContentForCompare(bundle.content) !== - normalizeContentForCompare((cloud as ArtifactWithContent).content); - const archivedChanged = - (bundle.isArchived ?? false) !== ((cloud as ArtifactWithContent).isArchived ?? false); - const isRootChanged = bundle.isRoot !== (cloud as ArtifactWithContent).isRoot; - const defaultLocaleChanged = - (bundle as { defaultLocale?: boolean }).defaultLocale !== - (cloud as { defaultLocale?: boolean }).defaultLocale; - if (contentChanged || archivedChanged || isRootChanged || defaultLocaleChanged) { - changed.push(bundle); - } - } else { - newItems.push(bundle); - } - } - - return { changed, new: newItems }; -} - -function diffAssets( - localAssets: AssetDTO[] | undefined, - cloudAssets: AssetDTO[] | undefined -): { changed: ArtifactWithContent[]; new: ArtifactWithContent[] } { - const localByFile = new Set((localAssets ?? []).map((asset) => asset.fileName)); - const cloudActive = (cloudAssets ?? []).filter((asset) => asset.isArchived !== true); - const cloudActiveByFile = new Map(cloudActive.map((asset) => [asset.fileName, asset])); - - const newItems: ArtifactWithContent[] = []; - for (const local of localAssets ?? []) { - if (!cloudActiveByFile.has(local.fileName)) { - newItems.push({ - id: local.id, - name: local.name, - content: local.content ?? '', - fileName: local.fileName, - } as ArtifactWithContent); - } - } - - const changedItems: ArtifactWithContent[] = []; - for (const cloud of cloudActive) { - if (!localByFile.has(cloud.fileName)) { - changedItems.push({ - id: cloud.id, - name: cloud.name, - content: cloud.content ?? '', - fileName: cloud.fileName, - isArchived: true, - } as ArtifactWithContent); - } - } - - return { changed: changedItems, new: newItems }; -} - -type ArtifactDisplay = ArtifactWithContent & { fileName?: string }; - -function artifactFileName(item: ArtifactDisplay, defaultExt: string): string { - const withFileName = item as { fileName?: string }; - if (withFileName.fileName) return withFileName.fileName; - if (item.id.includes('/') && !/^[0-9a-f-]{36}$/i.test(item.id)) { - return item.id.split('/').pop() ?? item.name; - } - return `${item.name}${defaultExt}`; -} - -const LINE_PREFIX = ' '; -const LABEL_WIDTH = 14; - -const LABEL_TEXT = { - new: '🍀 new', - modified: '✏️ modified', - removed: '❌ removed', -} as const; - -/** Format diff as grouped lines with icons (new/modified/removed). Used for both dry run and actual run. */ -export function formatDiffSummary(diff: BundleDiff): string[] { - const lines: string[] = []; - const pad = (label: string) => label.padEnd(LABEL_WIDTH); - const formatLabel = (raw: string, color: (value: string) => string) => color(pad(raw)); - - const addGroup = ( - title: string, - changed: ArtifactWithContent[], - added: ArtifactWithContent[], - type: string, - ext: string - ) => { - if (changed.length === 0 && added.length === 0) return; - lines.push(pc.cyan(pc.bold(` ${title}:`))); - // Group by status: removed first, then modified, then new - const removed = changed.filter((i) => i.isArchived); - const modified = changed.filter((i) => !i.isArchived); - for (const item of removed) { - const label = formatLabel(LABEL_TEXT.removed, pc.red); - lines.push(`${LINE_PREFIX}${label} ${artifactFileName(item, ext)}`); - } - for (const item of modified) { - const label = formatLabel(LABEL_TEXT.modified, pc.yellow); - lines.push(`${LINE_PREFIX}${label} ${artifactFileName(item, ext)}`); - } - for (const item of added) { - const label = formatLabel(LABEL_TEXT.new, pc.green); - lines.push(`${LINE_PREFIX}${label} ${artifactFileName(item, ext)}`); - } - }; - - addGroup('screens', diff.screens.changed, diff.screens.new, 'screen', '.yaml'); - addGroup('widgets', diff.widgets.changed, diff.widgets.new, 'widget', '.yaml'); - addGroup('scripts', diff.scripts.changed, diff.scripts.new, 'script', '.js'); - addGroup('actions', diff.actions.changed, diff.actions.new, 'action', '.yaml'); - addGroup( - 'translations', - diff.translations.changed, - diff.translations.new, - 'translation', - '.yaml' - ); - addGroup('assets', diff.assets.changed, diff.assets.new, 'asset', ''); - - if (diff.themeChanged) { - lines.push(pc.cyan(pc.bold(' theme:'))); - const label = formatLabel(LABEL_TEXT.modified, pc.yellow); - lines.push(`${LINE_PREFIX}${label} theme.yaml`); - } - - return lines; -} - -/** - * Compute which artifacts in the bundle have changed compared to the cloud app. - * Only changed and new items need to be pushed. - */ -export function computeBundleDiff( - bundle: ApplicationDTO, - cloudApp: CloudApp, - localAppForAssets?: ApplicationDTO -): BundleDiff { - const screens = diffArtifacts( - bundle.screens as ArtifactWithContent[] | undefined, - cloudApp.screens as ArtifactWithContent[] | undefined - ); - const widgets = diffArtifacts( - bundle.widgets as ArtifactWithContent[] | undefined, - cloudApp.widgets as ArtifactWithContent[] | undefined - ); - const scripts = diffArtifacts( - bundle.scripts as ArtifactWithContent[] | undefined, - cloudApp.scripts as ArtifactWithContent[] | undefined - ); - const actions = diffArtifacts( - bundle.actions as ArtifactWithContent[] | undefined, - (cloudApp.actions as ArtifactWithContent[] | undefined) ?? [] - ); - - const themeChanged = - !!bundle.theme && - (!cloudApp.theme || - normalizeContentForCompare(bundle.theme.content) !== - normalizeContentForCompare(cloudApp.theme.content)); - - const translations = diffArtifacts( - bundle.translations as ArtifactWithContent[] | undefined, - cloudApp.translations as ArtifactWithContent[] | undefined - ); - - const assets = diffAssets( - localAppForAssets?.assets ?? [], - cloudApp.assets as AssetDTO[] | undefined - ); - - return { - screens, - widgets, - scripts, - actions, - themeChanged, - translations, - assets, - }; -} - -function buildHistoryEntry( - cloud: ArtifactWithContent & { - type?: string; - updatedAt?: string; - updatedBy?: object; - defaultLocale?: boolean; - } -): HistoryEntry { - return { - content: cloud.content, - name: cloud.name, - type: cloud.type ?? 'unknown', - isRoot: (cloud as { isRoot?: boolean }).isRoot, - isArchived: cloud.isArchived, - defaultLocale: (cloud as { defaultLocale?: boolean }).defaultLocale, - updatedAt: cloud.updatedAt, - updatedBy: cloud.updatedBy as HistoryEntry['updatedBy'], - }; -} - -function buildPartialUpdates( - cloud: ArtifactWithContent & { isRoot?: boolean; updatedAt?: string; updatedBy?: object }, - bundle: ArtifactWithContent & { isRoot?: boolean; updatedAt?: string; updatedBy?: object } -): YamlUpdates { - const updates: Record = {}; - if (bundle.content !== cloud.content) updates.content = bundle.content; - if (bundle.name !== cloud.name) updates.name = bundle.name; - if (bundle.isRoot !== cloud.isRoot) updates.isRoot = bundle.isRoot; - if (bundle.isArchived !== cloud.isArchived) updates.isArchived = bundle.isArchived; - if (bundle.updatedAt !== cloud.updatedAt) updates.updatedAt = bundle.updatedAt; - if (bundle.defaultLocale !== (cloud as { defaultLocale?: boolean }).defaultLocale) { - updates.defaultLocale = bundle.defaultLocale; - } - if (JSON.stringify(bundle.updatedBy) !== JSON.stringify(cloud.updatedBy)) - updates.updatedBy = bundle.updatedBy; - return updates as YamlUpdates; -} - -function buildYamlPushItems( - diff: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }, - cloudItems: ArtifactWithContent[] | undefined, - bundleItems: - | (ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object })[] - | undefined, - cloudById: Map< - string, - ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object } - >, - cloudByName: Map< - string, - ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object } - >, - now: string, - updatedBy: { name: string; email?: string; id: string } -): YamlArtifactPushItem[] { - const items: YamlArtifactPushItem[] = []; - for (const doc of diff.new) { - const baseDoc = doc as ArtifactWithContent & { createdAt?: string }; - items.push({ - operation: 'create', - document: { - ...(baseDoc as unknown as YamlDocument), - isRoot: (baseDoc as { isRoot?: boolean }).isRoot ?? false, - isArchived: baseDoc.isArchived ?? false, - createdAt: baseDoc.createdAt ?? now, - updatedAt: now, - updatedBy, - // createdBy is not part of YamlDocument DTO, but Firestore encoder - // will look for it via a cast, so we attach it here. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...({ createdBy: updatedBy } as any), - }, - }); - } - for (const bundle of diff.changed) { - const cloud = cloudById.get(bundle.id) ?? cloudByName.get(bundle.name); - if (!cloud) continue; - const docWithMeta = { ...bundle, updatedAt: now, updatedBy }; - const updates = buildPartialUpdates(cloud, docWithMeta); - const cloudUpdatedBy = (cloud as { updatedBy?: object }).updatedBy; - const updatedByChanged = - !cloudUpdatedBy || JSON.stringify(updatedBy) !== JSON.stringify(cloudUpdatedBy); - items.push({ - operation: 'update', - id: cloud.id, - history: buildHistoryEntry(cloud), - updates: { - ...updates, - updatedAt: now, - ...(updatedByChanged && { updatedBy }), - }, - }); - } - return items; -} - -function buildAssetPushItems( - diff: { changed: ArtifactWithContent[] }, - cloudAssets: AssetDTO[] | undefined, - now: string, - updatedBy: { name: string; email?: string; id: string } -): YamlArtifactPushItem[] { - const cloudById = new Map((cloudAssets ?? []).map((asset) => [asset.id, asset])); - const items: YamlArtifactPushItem[] = []; - - for (const bundle of diff.changed) { - if (!bundle.isArchived) continue; - const cloud = cloudById.get(bundle.id); - if (!cloud) continue; - const cloudWithMeta = cloud as ArtifactWithContent & { - type?: string; - updatedAt?: string; - updatedBy?: object; - }; - items.push({ - operation: 'update', - id: cloud.id, - history: buildHistoryEntry(cloudWithMeta), - updates: { - isArchived: true, - updatedAt: now, - updatedBy, - }, - }); - } - - return items; -} - -/** - * Build push payload with history + partial updates for YAML artifacts. - * - create: full document for new items - * - update: history (old→archive) + only changed fields - */ -export function buildPushPayload( - bundle: ApplicationDTO, - diff: BundleDiff, - cloudApp: CloudApp, - updatedBy: { name: string; email?: string; id: string } -): PushPayload { - const now = bundle.updatedAt ?? new Date().toISOString(); - - const cloudById = (items: T[] | undefined) => - new Map((items ?? []).map((x) => [x.id, x])); - const cloudByName = (items: T[] | undefined) => - new Map((items ?? []).map((x) => [x.name, x])); - - const screens = buildYamlPushItems( - diff.screens, - cloudApp.screens as ArtifactWithContent[] | undefined, - bundle.screens as - | (ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object })[] - | undefined, - cloudById( - cloudApp.screens as { - id: string; - name: string; - content: string; - type?: string; - updatedAt?: string; - updatedBy?: object; - }[] - ), - cloudByName( - cloudApp.screens as { - id: string; - name: string; - content: string; - type?: string; - updatedAt?: string; - updatedBy?: object; - }[] - ), - now, - updatedBy - ); - const widgets = buildYamlPushItems( - diff.widgets, - cloudApp.widgets as ArtifactWithContent[] | undefined, - bundle.widgets as - | (ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object })[] - | undefined, - cloudById( - cloudApp.widgets as { - id: string; - name: string; - content: string; - type?: string; - updatedAt?: string; - updatedBy?: object; - }[] - ), - cloudByName( - cloudApp.widgets as { - id: string; - name: string; - content: string; - type?: string; - updatedAt?: string; - updatedBy?: object; - }[] - ), - now, - updatedBy - ); - const scripts = buildYamlPushItems( - diff.scripts, - cloudApp.scripts as ArtifactWithContent[] | undefined, - bundle.scripts as - | (ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object })[] - | undefined, - cloudById( - cloudApp.scripts as { - id: string; - name: string; - content: string; - type?: string; - updatedAt?: string; - updatedBy?: object; - }[] - ), - cloudByName( - cloudApp.scripts as { - id: string; - name: string; - content: string; - type?: string; - updatedAt?: string; - updatedBy?: object; - }[] - ), - now, - updatedBy - ); - const actions = buildYamlPushItems( - diff.actions, - (cloudApp.actions as ArtifactWithContent[] | undefined) ?? [], - bundle.actions as - | (ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object })[] - | undefined, - cloudById( - (cloudApp.actions as - | { - id: string; - name: string; - content: string; - type?: string; - updatedAt?: string; - updatedBy?: object; - }[] - | undefined) ?? [] - ), - cloudByName( - (cloudApp.actions as - | { - id: string; - name: string; - content: string; - type?: string; - updatedAt?: string; - updatedBy?: object; - }[] - | undefined) ?? [] - ), - now, - updatedBy - ); - const translations = buildYamlPushItems( - diff.translations, - cloudApp.translations as ArtifactWithContent[] | undefined, - bundle.translations as - | (ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object })[] - | undefined, - cloudById( - cloudApp.translations as { - id: string; - name: string; - content: string; - type?: string; - updatedAt?: string; - updatedBy?: object; - }[] - ), - cloudByName( - cloudApp.translations as { - id: string; - name: string; - content: string; - type?: string; - updatedAt?: string; - updatedBy?: object; - }[] - ), - now, - updatedBy - ); - - let theme: PushPayload['theme']; - if (diff.themeChanged && bundle.theme) { - if (cloudApp.theme) { - const cloudTheme = cloudApp.theme as ArtifactWithContent & { - updatedAt?: string; - updatedBy?: object; - }; - const themeUpdates = buildPartialUpdates(cloudTheme, { - ...bundle.theme, - updatedAt: now, - updatedBy, - } as ArtifactWithContent & { updatedAt?: string; updatedBy?: object }); - const themeUpdatedByChanged = - !cloudTheme.updatedBy || JSON.stringify(updatedBy) !== JSON.stringify(cloudTheme.updatedBy); - theme = { - operation: 'update', - id: cloudApp.theme.id, - history: buildHistoryEntry( - cloudApp.theme as ArtifactWithContent & { - type?: string; - updatedAt?: string; - updatedBy?: object; - } - ), - updates: { - ...themeUpdates, - updatedAt: now, - ...(themeUpdatedByChanged && { updatedBy }), - }, - }; - } else { - theme = { - operation: 'create', - document: { ...bundle.theme, updatedAt: now, updatedBy } as ThemeDTO, - }; - } - } - - const assets = buildAssetPushItems( - diff.assets, - cloudApp.assets as AssetDTO[] | undefined, - now, - updatedBy - ); - - return { - id: bundle.id, - name: bundle.name, - updatedAt: now, - ...(screens.length > 0 && { screens }), - ...(widgets.length > 0 && { widgets }), - ...(scripts.length > 0 && { scripts }), - ...(actions.length > 0 && { actions }), - ...(translations.length > 0 && { translations }), - ...(theme && { theme }), - ...(assets.length > 0 && { assets }), - }; -} +import type { CloudApp } from '../cloud/firestoreClient.js'; +import pc from 'picocolors'; +import type { + ApplicationDTO, + AssetDTO, + ScreenDTO, + WidgetDTO, + ScriptDTO, + ActionDTO, + ThemeDTO, + TranslationDTO, +} from './dto.js'; + +type ArtifactWithContent = { + id: string; + name: string; + content: string; + isArchived?: boolean; + isRoot?: boolean; + defaultLocale?: boolean; +}; + +/** Snapshot of artifact to archive in history sub-collection (for YAML artifacts). */ +export interface HistoryEntry { + content: string; + name: string; + type: string; + isRoot?: boolean; + isArchived?: boolean; + defaultLocale?: boolean; + updatedAt?: string; + updatedBy?: { name: string; email?: string; id: string }; +} + +type YamlDocument = ScreenDTO | WidgetDTO | ScriptDTO | ActionDTO | ThemeDTO | TranslationDTO; + +type YamlUpdates = { + content?: string; + name?: string; + isRoot?: boolean; + isArchived?: boolean; + updatedAt?: string; + updatedBy?: { + name: string; + email?: string; + id: string; + }; + defaultLocale?: boolean; +}; + +/** Create: full document for new artifact. Update: history (old→archive) + updates (only changed fields). */ +export type YamlArtifactPushItem = + | { operation: 'create'; document: YamlDocument } + | { + operation: 'update'; + id: string; + history: HistoryEntry; + updates: YamlUpdates; + }; + +export interface PushPayload { + id: string; + name?: string; + updatedAt: string; + screens?: YamlArtifactPushItem[]; + widgets?: YamlArtifactPushItem[]; + scripts?: YamlArtifactPushItem[]; + actions?: YamlArtifactPushItem[]; + translations?: YamlArtifactPushItem[]; + theme?: YamlArtifactPushItem; +} + +export interface BundleDiff { + screens: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }; + widgets: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }; + scripts: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }; + actions: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }; + themeChanged: boolean; + translations: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }; + /** Local asset files to upload (same fileName as Firestore `artifacts` doc with type asset). */ + assets: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }; +} + +/** Normalize content for comparison to avoid false diffs from line endings or trailing newlines. */ +export function normalizeContentForCompare(content: string): string { + return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n+$/, ''); +} + +function diffArtifacts( + bundleItems: ArtifactWithContent[] | undefined, + cloudItems: ArtifactWithContent[] | undefined +): { changed: ArtifactWithContent[]; new: ArtifactWithContent[] } { + const cloudById = new Map(); + for (const item of cloudItems ?? []) { + cloudById.set(item.id, item); + } + const cloudByName = new Map(); + for (const item of cloudItems ?? []) { + cloudByName.set(item.name, item); + } + + const changed: ArtifactWithContent[] = []; + const newItems: ArtifactWithContent[] = []; + + for (const bundle of bundleItems ?? []) { + const cloud = cloudById.get(bundle.id) ?? cloudByName.get(bundle.name); + if (cloud) { + const contentChanged = + normalizeContentForCompare(bundle.content) !== + normalizeContentForCompare((cloud as ArtifactWithContent).content); + const archivedChanged = + (bundle.isArchived ?? false) !== ((cloud as ArtifactWithContent).isArchived ?? false); + const isRootChanged = bundle.isRoot !== (cloud as ArtifactWithContent).isRoot; + const defaultLocaleChanged = + (bundle as { defaultLocale?: boolean }).defaultLocale !== + (cloud as { defaultLocale?: boolean }).defaultLocale; + if (contentChanged || archivedChanged || isRootChanged || defaultLocaleChanged) { + changed.push(bundle); + } + } else { + newItems.push(bundle); + } + } + + return { changed, new: newItems }; +} + +function diffAssets( + localAssets: AssetDTO[] | undefined, + cloudAssets: AssetDTO[] | undefined +): { changed: ArtifactWithContent[]; new: ArtifactWithContent[] } { + const localByFile = new Set((localAssets ?? []).map((asset) => asset.fileName)); + const cloudActive = (cloudAssets ?? []).filter((asset) => asset.isArchived !== true); + const cloudActiveByFile = new Map(cloudActive.map((asset) => [asset.fileName, asset])); + + const newItems: ArtifactWithContent[] = []; + for (const local of localAssets ?? []) { + if (!cloudActiveByFile.has(local.fileName)) { + newItems.push({ + id: local.id, + name: local.name, + content: local.content ?? '', + fileName: local.fileName, + } as ArtifactWithContent); + } + } + + const changedItems: ArtifactWithContent[] = []; + for (const cloud of cloudActive) { + if (!localByFile.has(cloud.fileName)) { + changedItems.push({ + id: cloud.id, + name: cloud.name, + content: cloud.content ?? '', + fileName: cloud.fileName, + isArchived: true, + } as ArtifactWithContent); + } + } + + return { changed: changedItems, new: newItems }; +} + +type ArtifactDisplay = ArtifactWithContent & { fileName?: string }; + +function artifactFileName(item: ArtifactDisplay, defaultExt: string): string { + const withFileName = item as { fileName?: string }; + if (withFileName.fileName) return withFileName.fileName; + if (item.id.includes('/') && !/^[0-9a-f-]{36}$/i.test(item.id)) { + return item.id.split('/').pop() ?? item.name; + } + return `${item.name}${defaultExt}`; +} + +const LINE_PREFIX = ' '; +const LABEL_WIDTH = 14; + +const LABEL_TEXT = { + new: '🍀 new', + modified: '✏️ modified', + removed: '❌ removed', +} as const; + +/** Format diff as grouped lines with icons (new/modified/removed). Used for both dry run and actual run. */ +export function formatDiffSummary(diff: BundleDiff): string[] { + const lines: string[] = []; + const pad = (label: string) => label.padEnd(LABEL_WIDTH); + const formatLabel = (raw: string, color: (value: string) => string) => color(pad(raw)); + + const addGroup = ( + title: string, + changed: ArtifactWithContent[], + added: ArtifactWithContent[], + type: string, + ext: string + ) => { + if (changed.length === 0 && added.length === 0) return; + lines.push(pc.cyan(pc.bold(` ${title}:`))); + // Group by status: removed first, then modified, then new + const removed = changed.filter((i) => i.isArchived); + const modified = changed.filter((i) => !i.isArchived); + for (const item of removed) { + const label = formatLabel(LABEL_TEXT.removed, pc.red); + lines.push(`${LINE_PREFIX}${label} ${artifactFileName(item, ext)}`); + } + for (const item of modified) { + const label = formatLabel(LABEL_TEXT.modified, pc.yellow); + lines.push(`${LINE_PREFIX}${label} ${artifactFileName(item, ext)}`); + } + for (const item of added) { + const label = formatLabel(LABEL_TEXT.new, pc.green); + lines.push(`${LINE_PREFIX}${label} ${artifactFileName(item, ext)}`); + } + }; + + addGroup('screens', diff.screens.changed, diff.screens.new, 'screen', '.yaml'); + addGroup('widgets', diff.widgets.changed, diff.widgets.new, 'widget', '.yaml'); + addGroup('scripts', diff.scripts.changed, diff.scripts.new, 'script', '.js'); + addGroup('actions', diff.actions.changed, diff.actions.new, 'action', '.yaml'); + addGroup( + 'translations', + diff.translations.changed, + diff.translations.new, + 'translation', + '.yaml' + ); + addGroup('assets', diff.assets.changed, diff.assets.new, 'asset', ''); + + if (diff.themeChanged) { + lines.push(pc.cyan(pc.bold(' theme:'))); + const label = formatLabel(LABEL_TEXT.modified, pc.yellow); + lines.push(`${LINE_PREFIX}${label} theme.yaml`); + } + + return lines; +} + +/** + * Compute which artifacts in the bundle have changed compared to the cloud app. + * Only changed and new items need to be pushed. + */ +export function computeBundleDiff( + bundle: ApplicationDTO, + cloudApp: CloudApp, + localAppForAssets?: ApplicationDTO +): BundleDiff { + const screens = diffArtifacts( + bundle.screens as ArtifactWithContent[] | undefined, + cloudApp.screens as ArtifactWithContent[] | undefined + ); + const widgets = diffArtifacts( + bundle.widgets as ArtifactWithContent[] | undefined, + cloudApp.widgets as ArtifactWithContent[] | undefined + ); + const scripts = diffArtifacts( + bundle.scripts as ArtifactWithContent[] | undefined, + cloudApp.scripts as ArtifactWithContent[] | undefined + ); + const actions = diffArtifacts( + bundle.actions as ArtifactWithContent[] | undefined, + (cloudApp.actions as ArtifactWithContent[] | undefined) ?? [] + ); + + const themeChanged = + !!bundle.theme && + (!cloudApp.theme || + normalizeContentForCompare(bundle.theme.content) !== + normalizeContentForCompare(cloudApp.theme.content)); + + const translations = diffArtifacts( + bundle.translations as ArtifactWithContent[] | undefined, + cloudApp.translations as ArtifactWithContent[] | undefined + ); + + const assets = diffAssets( + localAppForAssets?.assets ?? [], + cloudApp.assets as AssetDTO[] | undefined + ); + + return { + screens, + widgets, + scripts, + actions, + themeChanged, + translations, + assets, + }; +} + +function buildHistoryEntry( + cloud: ArtifactWithContent & { + type?: string; + updatedAt?: string; + updatedBy?: object; + defaultLocale?: boolean; + } +): HistoryEntry { + return { + content: cloud.content, + name: cloud.name, + type: cloud.type ?? 'unknown', + isRoot: (cloud as { isRoot?: boolean }).isRoot, + isArchived: cloud.isArchived, + defaultLocale: (cloud as { defaultLocale?: boolean }).defaultLocale, + updatedAt: cloud.updatedAt, + updatedBy: cloud.updatedBy as HistoryEntry['updatedBy'], + }; +} + +function buildPartialUpdates( + cloud: ArtifactWithContent & { isRoot?: boolean; updatedAt?: string; updatedBy?: object }, + bundle: ArtifactWithContent & { isRoot?: boolean; updatedAt?: string; updatedBy?: object } +): YamlUpdates { + const updates: Record = {}; + if (bundle.content !== cloud.content) updates.content = bundle.content; + if (bundle.name !== cloud.name) updates.name = bundle.name; + if (bundle.isRoot !== cloud.isRoot) updates.isRoot = bundle.isRoot; + if (bundle.isArchived !== cloud.isArchived) updates.isArchived = bundle.isArchived; + if (bundle.updatedAt !== cloud.updatedAt) updates.updatedAt = bundle.updatedAt; + if (bundle.defaultLocale !== (cloud as { defaultLocale?: boolean }).defaultLocale) { + updates.defaultLocale = bundle.defaultLocale; + } + if (JSON.stringify(bundle.updatedBy) !== JSON.stringify(cloud.updatedBy)) + updates.updatedBy = bundle.updatedBy; + return updates as YamlUpdates; +} + +function buildYamlPushItems( + diff: { changed: ArtifactWithContent[]; new: ArtifactWithContent[] }, + cloudItems: ArtifactWithContent[] | undefined, + bundleItems: + | (ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object })[] + | undefined, + cloudById: Map< + string, + ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object } + >, + cloudByName: Map< + string, + ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object } + >, + now: string, + updatedBy: { name: string; email?: string; id: string } +): YamlArtifactPushItem[] { + const items: YamlArtifactPushItem[] = []; + for (const doc of diff.new) { + const baseDoc = doc as ArtifactWithContent & { createdAt?: string }; + items.push({ + operation: 'create', + document: { + ...(baseDoc as unknown as YamlDocument), + isRoot: (baseDoc as { isRoot?: boolean }).isRoot ?? false, + isArchived: baseDoc.isArchived ?? false, + createdAt: baseDoc.createdAt ?? now, + updatedAt: now, + updatedBy, + // createdBy is not part of YamlDocument DTO, but Firestore encoder + // will look for it via a cast, so we attach it here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...({ createdBy: updatedBy } as any), + }, + }); + } + for (const bundle of diff.changed) { + const cloud = cloudById.get(bundle.id) ?? cloudByName.get(bundle.name); + if (!cloud) continue; + const docWithMeta = { ...bundle, updatedAt: now, updatedBy }; + const updates = buildPartialUpdates(cloud, docWithMeta); + const cloudUpdatedBy = (cloud as { updatedBy?: object }).updatedBy; + const updatedByChanged = + !cloudUpdatedBy || JSON.stringify(updatedBy) !== JSON.stringify(cloudUpdatedBy); + items.push({ + operation: 'update', + id: cloud.id, + history: buildHistoryEntry(cloud), + updates: { + ...updates, + updatedAt: now, + ...(updatedByChanged && { updatedBy }), + }, + }); + } + return items; +} + +function buildAssetPushItems( + diff: { changed: ArtifactWithContent[] }, + cloudAssets: AssetDTO[] | undefined, + now: string, + updatedBy: { name: string; email?: string; id: string } +): YamlArtifactPushItem[] { + const cloudById = new Map((cloudAssets ?? []).map((asset) => [asset.id, asset])); + const items: YamlArtifactPushItem[] = []; + + for (const bundle of diff.changed) { + if (!bundle.isArchived) continue; + const cloud = cloudById.get(bundle.id); + if (!cloud) continue; + const cloudWithMeta = cloud as ArtifactWithContent & { + type?: string; + updatedAt?: string; + updatedBy?: object; + }; + items.push({ + operation: 'update', + id: cloud.id, + history: buildHistoryEntry(cloudWithMeta), + updates: { + isArchived: true, + updatedAt: now, + updatedBy, + }, + }); + } + + return items; +} + +/** + * Build push payload with history + partial updates for YAML artifacts. + * - create: full document for new items + * - update: history (old→archive) + only changed fields + */ +export function buildPushPayload( + bundle: ApplicationDTO, + diff: BundleDiff, + cloudApp: CloudApp, + updatedBy: { name: string; email?: string; id: string } +): PushPayload { + const now = bundle.updatedAt ?? new Date().toISOString(); + + const cloudById = (items: T[] | undefined) => + new Map((items ?? []).map((x) => [x.id, x])); + const cloudByName = (items: T[] | undefined) => + new Map((items ?? []).map((x) => [x.name, x])); + + const screens = buildYamlPushItems( + diff.screens, + cloudApp.screens as ArtifactWithContent[] | undefined, + bundle.screens as + | (ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object })[] + | undefined, + cloudById( + cloudApp.screens as { + id: string; + name: string; + content: string; + type?: string; + updatedAt?: string; + updatedBy?: object; + }[] + ), + cloudByName( + cloudApp.screens as { + id: string; + name: string; + content: string; + type?: string; + updatedAt?: string; + updatedBy?: object; + }[] + ), + now, + updatedBy + ); + const widgets = buildYamlPushItems( + diff.widgets, + cloudApp.widgets as ArtifactWithContent[] | undefined, + bundle.widgets as + | (ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object })[] + | undefined, + cloudById( + cloudApp.widgets as { + id: string; + name: string; + content: string; + type?: string; + updatedAt?: string; + updatedBy?: object; + }[] + ), + cloudByName( + cloudApp.widgets as { + id: string; + name: string; + content: string; + type?: string; + updatedAt?: string; + updatedBy?: object; + }[] + ), + now, + updatedBy + ); + const scripts = buildYamlPushItems( + diff.scripts, + cloudApp.scripts as ArtifactWithContent[] | undefined, + bundle.scripts as + | (ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object })[] + | undefined, + cloudById( + cloudApp.scripts as { + id: string; + name: string; + content: string; + type?: string; + updatedAt?: string; + updatedBy?: object; + }[] + ), + cloudByName( + cloudApp.scripts as { + id: string; + name: string; + content: string; + type?: string; + updatedAt?: string; + updatedBy?: object; + }[] + ), + now, + updatedBy + ); + const actions = buildYamlPushItems( + diff.actions, + (cloudApp.actions as ArtifactWithContent[] | undefined) ?? [], + bundle.actions as + | (ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object })[] + | undefined, + cloudById( + (cloudApp.actions as + | { + id: string; + name: string; + content: string; + type?: string; + updatedAt?: string; + updatedBy?: object; + }[] + | undefined) ?? [] + ), + cloudByName( + (cloudApp.actions as + | { + id: string; + name: string; + content: string; + type?: string; + updatedAt?: string; + updatedBy?: object; + }[] + | undefined) ?? [] + ), + now, + updatedBy + ); + const translations = buildYamlPushItems( + diff.translations, + cloudApp.translations as ArtifactWithContent[] | undefined, + bundle.translations as + | (ArtifactWithContent & { type?: string; updatedAt?: string; updatedBy?: object })[] + | undefined, + cloudById( + cloudApp.translations as { + id: string; + name: string; + content: string; + type?: string; + updatedAt?: string; + updatedBy?: object; + }[] + ), + cloudByName( + cloudApp.translations as { + id: string; + name: string; + content: string; + type?: string; + updatedAt?: string; + updatedBy?: object; + }[] + ), + now, + updatedBy + ); + + let theme: PushPayload['theme']; + if (diff.themeChanged && bundle.theme) { + if (cloudApp.theme) { + const cloudTheme = cloudApp.theme as ArtifactWithContent & { + updatedAt?: string; + updatedBy?: object; + }; + const themeUpdates = buildPartialUpdates(cloudTheme, { + ...bundle.theme, + updatedAt: now, + updatedBy, + } as ArtifactWithContent & { updatedAt?: string; updatedBy?: object }); + const themeUpdatedByChanged = + !cloudTheme.updatedBy || JSON.stringify(updatedBy) !== JSON.stringify(cloudTheme.updatedBy); + theme = { + operation: 'update', + id: cloudApp.theme.id, + history: buildHistoryEntry( + cloudApp.theme as ArtifactWithContent & { + type?: string; + updatedAt?: string; + updatedBy?: object; + } + ), + updates: { + ...themeUpdates, + updatedAt: now, + ...(themeUpdatedByChanged && { updatedBy }), + }, + }; + } else { + theme = { + operation: 'create', + document: { ...bundle.theme, updatedAt: now, updatedBy } as ThemeDTO, + }; + } + } + + const assets = buildAssetPushItems( + diff.assets, + cloudApp.assets as AssetDTO[] | undefined, + now, + updatedBy + ); + + return { + id: bundle.id, + name: bundle.name, + updatedAt: now, + ...(screens.length > 0 && { screens }), + ...(widgets.length > 0 && { widgets }), + ...(scripts.length > 0 && { scripts }), + ...(actions.length > 0 && { actions }), + ...(translations.length > 0 && { translations }), + ...(theme && { theme }), + ...(assets.length > 0 && { assets }), + }; +} diff --git a/src/core/encryption.ts b/src/core/encryption.ts new file mode 100644 index 0000000..de9a218 --- /dev/null +++ b/src/core/encryption.ts @@ -0,0 +1,170 @@ +import crypto from 'node:crypto'; +import { brotliCompressSync, brotliDecompressSync, constants as zlibConstants } from 'node:zlib'; + +import type { EnvEntry } from './envConfig.js'; + +export const ENSEMBLE_ENCRYPTION_KEY_NAME = 'ENSEMBLE_ENCRYPTION_KEY'; + +const KEY_HINT = 'Get the key from your team, or generate one with: openssl rand -hex 32'; + +export class EncryptionError extends Error { + hint?: string; + + constructor(message: string, hint?: string) { + super(message); + this.name = 'EncryptionError'; + this.hint = hint; + } +} + +interface EncryptedEnvelope { + v: number; + alg: 'AES-256-GCM'; + comp: 'br'; + iv: string; + tag: string; + ciphertext: string; +} + +function throwReleaseKeyError( + kind: 'missing' | 'invalid' | 'wrong', + secretsWriteFile: string +): never { + if (kind === 'missing') { + throw new EncryptionError( + `Releases are encrypted. Add ENSEMBLE_ENCRYPTION_KEY to ${secretsWriteFile} before creating or restoring a release.`, + KEY_HINT + ); + } + if (kind === 'invalid') { + throw new EncryptionError( + `Invalid ENSEMBLE_ENCRYPTION_KEY in ${secretsWriteFile}. Use a 256-bit key (64 hex characters).`, + KEY_HINT + ); + } + throw new EncryptionError( + `Could not decrypt this release. ENSEMBLE_ENCRYPTION_KEY in ${secretsWriteFile} does not match the key used when the release was created.`, + 'Get the correct key from your team.' + ); +} + +export function parse256BitSecret(value: string, name: string): Buffer { + const hexLike = /^[0-9a-fA-F]+$/.test(value); + if (hexLike && value.length === 64) { + return Buffer.from(value, 'hex'); + } + + try { + const b64 = Buffer.from(value, 'base64'); + if (b64.length === 32) return b64; + } catch { + // ignore invalid base64 + } + + const utf8 = Buffer.from(value, 'utf8'); + if (utf8.length === 32) return utf8; + + throw new EncryptionError( + `Invalid ${name}: must be 256-bit (32 bytes) as base64, 64-char hex, or 32-byte UTF-8 string.`, + KEY_HINT + ); +} + +export function requireReleaseEncryptionKey( + envSecrets: EnvEntry[], + secretsWriteFile: string +): string { + const entry = envSecrets.find((e) => e.key === ENSEMBLE_ENCRYPTION_KEY_NAME); + const encryptionKey = typeof entry?.value === 'string' ? entry.value.trim() : ''; + if (!encryptionKey) { + throwReleaseKeyError('missing', secretsWriteFile); + } + try { + parse256BitSecret(encryptionKey, ENSEMBLE_ENCRYPTION_KEY_NAME); + } catch (err) { + if (err instanceof EncryptionError) { + throwReleaseKeyError('invalid', secretsWriteFile); + } + throw err; + } + return encryptionKey; +} + +function encryptAes256Gcm(plaintext: Buffer, key: Buffer): Omit { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + return { + alg: 'AES-256-GCM', + iv: iv.toString('base64'), + tag: tag.toString('base64'), + ciphertext: encrypted.toString('base64'), + }; +} + +function decryptAes256Gcm(envelope: EncryptedEnvelope, key: Buffer): Buffer { + const iv = Buffer.from(envelope.iv, 'base64'); + const tag = Buffer.from(envelope.tag, 'base64'); + const ciphertext = Buffer.from(envelope.ciphertext, 'base64'); + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +export function isEncryptedReleaseEnvelope(text: string): boolean { + try { + const parsed = JSON.parse(text) as unknown; + if (!parsed || typeof parsed !== 'object') return false; + const envelope = parsed as Partial; + return ( + envelope.v === 1 && + envelope.alg === 'AES-256-GCM' && + typeof envelope.iv === 'string' && + typeof envelope.tag === 'string' && + typeof envelope.ciphertext === 'string' + ); + } catch { + return false; + } +} + +export function encryptReleaseSnapshot(plaintextJson: string, encryptionKeyStr: string): string { + const key = parse256BitSecret(encryptionKeyStr, ENSEMBLE_ENCRYPTION_KEY_NAME); + const plaintextCompressed = brotliCompressSync(Buffer.from(plaintextJson, 'utf8'), { + params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 }, + }); + const encrypted = encryptAes256Gcm(plaintextCompressed, key); + const envelope: EncryptedEnvelope = { + v: 1, + comp: 'br', + ...encrypted, + }; + return JSON.stringify(envelope); +} + +export function parseReleaseSnapshotBody( + body: string, + snapshotPath: string, + encryptionKey: string, + secretsWriteFile = '.env.secrets' +): string { + if (!isEncryptedReleaseEnvelope(body)) { + if (snapshotPath.endsWith('.enc.json')) { + throw new EncryptionError('Invalid encrypted release envelope.'); + } + throw new EncryptionError( + 'This release is unencrypted legacy plaintext. Re-create it with `ensemble release create` after adding ENSEMBLE_ENCRYPTION_KEY.' + ); + } + + const parsed = JSON.parse(body) as EncryptedEnvelope; + const key = parse256BitSecret(encryptionKey, ENSEMBLE_ENCRYPTION_KEY_NAME); + try { + const decrypted = decryptAes256Gcm(parsed, key); + const decompressed = brotliDecompressSync(decrypted); + return decompressed.toString('utf8'); + } catch { + throwReleaseKeyError('wrong', secretsWriteFile); + } +} diff --git a/src/core/envConfig.ts b/src/core/envConfig.ts index 7b02ed3..2a043f2 100644 --- a/src/core/envConfig.ts +++ b/src/core/envConfig.ts @@ -1,111 +1,161 @@ -import fs from 'fs/promises'; -import path from 'path'; - -export interface EnvEntry { - key: string; - value: string; - overwrite?: boolean; -} - -export const ENV_CONFIG_BASE = '.env.config'; -export const ENV_SECRETS_BASE = '.env.secrets'; - -export function envConfigScopedFile(appKey: string): string { - return `${ENV_CONFIG_BASE}.${appKey}`; -} - -export function envSecretsScopedFile(appKey: string): string { - return `${ENV_SECRETS_BASE}.${appKey}`; -} - -function parseEnvFile(raw: string): { - lines: string[]; - keyToLineIndex: Map; -} { - const lines = raw.split(/\r?\n/); - const keyToLineIndex = new Map(); - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i].trim(); - if (!line || line.startsWith('#')) continue; - const eq = line.indexOf('='); - if (eq <= 0) continue; - const key = line.slice(0, eq).trim(); - if (key) keyToLineIndex.set(key, i); - } - return { lines, keyToLineIndex }; -} - -export async function envFileExists(projectRoot: string, fileName: string): Promise { - try { - await fs.access(path.join(projectRoot, fileName)); - return true; - } catch { - return false; - } -} - -export async function readEnvFile(projectRoot: string, fileName: string): Promise { - const envPath = path.join(projectRoot, fileName); - let raw = ''; - try { - raw = await fs.readFile(envPath, 'utf8'); - } catch { - return []; - } - const parsed = parseEnvFile(raw); - const entries: EnvEntry[] = []; - for (const [key, lineIndex] of parsed.keyToLineIndex) { - const line = parsed.lines[lineIndex] ?? ''; - const eq = line.indexOf('='); - if (eq <= 0) continue; - entries.push({ key, value: line.slice(eq + 1) }); - } - return entries.sort((a, b) => a.key.localeCompare(b.key)); -} - -export async function upsertEnvFile( - projectRoot: string, - fileName: string, - entries: EnvEntry[] -): Promise { - const envPath = path.join(projectRoot, fileName); - let raw = ''; - try { - raw = await fs.readFile(envPath, 'utf8'); - } catch { - raw = ''; - } - const parsed = parseEnvFile(raw); - while (parsed.lines.length > 0 && parsed.lines[parsed.lines.length - 1].trim() === '') { - parsed.lines.pop(); - } - for (const entry of entries) { - const line = `${entry.key}=${entry.value}`; - const existingIdx = parsed.keyToLineIndex.get(entry.key); - if (existingIdx === undefined) { - parsed.lines.push(line); - parsed.keyToLineIndex.set(entry.key, parsed.lines.length - 1); - } else if (entry.overwrite !== false) { - parsed.lines[existingIdx] = line; - } - } - const normalized = parsed.lines.join('\n').replace(/\n*$/, '\n'); - await fs.writeFile(envPath, normalized, 'utf8'); -} - -export async function writeEnvFile( - projectRoot: string, - fileName: string, - entries: EnvEntry[] -): Promise { - const envPath = path.join(projectRoot, fileName); - const normalized = entries - .map((entry) => `${entry.key}=${entry.value}`) - .join('\n') - .replace(/\n*$/, '\n'); - await fs.writeFile(envPath, normalized, 'utf8'); -} - -export async function upsertEnvConfig(projectRoot: string, entries: EnvEntry[]): Promise { - await upsertEnvFile(projectRoot, '.env.config', entries); -} +import fs from 'fs/promises'; +import path from 'path'; + +export interface EnvEntry { + key: string; + value: string; + overwrite?: boolean; +} + +export const ENV_CONFIG_BASE = '.env.config'; +export const ENV_SECRETS_BASE = '.env.secrets'; + +export function envConfigScopedFile(appKey: string): string { + return `${ENV_CONFIG_BASE}.${appKey}`; +} + +export function envSecretsScopedFile(appKey: string): string { + return `${ENV_SECRETS_BASE}.${appKey}`; +} + +function parseEnvFile(raw: string): { + lines: string[]; + keyToLineIndex: Map; +} { + const lines = raw.split(/\r?\n/); + const keyToLineIndex = new Map(); + for (let i = 0; i < lines.length; i += 1) { + const line = lines[i].trim(); + if (!line || line.startsWith('#')) continue; + const eq = line.indexOf('='); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + if (key) keyToLineIndex.set(key, i); + } + return { lines, keyToLineIndex }; +} + +export async function envFileExists(projectRoot: string, fileName: string): Promise { + try { + await fs.access(path.join(projectRoot, fileName)); + return true; + } catch { + return false; + } +} + +export async function readEnvFile(projectRoot: string, fileName: string): Promise { + const entries = await readEnvFilePreservingOrder(projectRoot, fileName); + return [...entries].sort((a, b) => a.key.localeCompare(b.key)); +} + +export function parseEnvEntriesPreservingOrder(raw: string): EnvEntry[] { + const parsed = parseEnvFile(raw); + const entries: EnvEntry[] = []; + const seen = new Set(); + for (const line of parsed.lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + if (!key || seen.has(key)) continue; + seen.add(key); + entries.push({ key, value: trimmed.slice(eq + 1) }); + } + return entries; +} + +export function entriesEqualOrdered(a: EnvEntry[], b: EnvEntry[]): boolean { + return ( + a.length === b.length && + a.every((entry, index) => { + const other = b[index]; + return other !== undefined && entry.key === other.key && entry.value === other.value; + }) + ); +} + +export function envEntriesFromRecordInKeyOrder( + record: Record | undefined, + skip?: (key: string) => boolean +): EnvEntry[] { + if (!record) return []; + const entries: EnvEntry[] = []; + for (const key of Object.keys(record)) { + if (skip?.(key)) continue; + const value = record[key]; + if (value === undefined || value === null) continue; + entries.push({ key, value: String(value) }); + } + return entries; +} + +export async function readEnvFilePreservingOrder( + projectRoot: string, + fileName: string +): Promise { + const envPath = path.join(projectRoot, fileName); + let raw = ''; + try { + raw = await fs.readFile(envPath, 'utf8'); + } catch { + return []; + } + return parseEnvEntriesPreservingOrder(raw); +} + +export async function upsertEnvFile( + projectRoot: string, + fileName: string, + entries: EnvEntry[] +): Promise { + const envPath = path.join(projectRoot, fileName); + let raw = ''; + try { + raw = await fs.readFile(envPath, 'utf8'); + } catch { + raw = ''; + } + const parsed = parseEnvFile(raw); + while (parsed.lines.length > 0 && parsed.lines[parsed.lines.length - 1].trim() === '') { + parsed.lines.pop(); + } + for (const entry of entries) { + const line = `${entry.key}=${entry.value}`; + const existingIdx = parsed.keyToLineIndex.get(entry.key); + if (existingIdx === undefined) { + parsed.lines.push(line); + parsed.keyToLineIndex.set(entry.key, parsed.lines.length - 1); + } else if (entry.overwrite !== false) { + parsed.lines[existingIdx] = line; + } + } + const normalized = parsed.lines.join('\n').replace(/\n*$/, '\n'); + await fs.writeFile(envPath, normalized, 'utf8'); +} + +export function formatEnvFileContent(entries: EnvEntry[]): string { + return formatEnvFileContentWithEol(entries, true); +} + +export function formatEnvFileContentWithEol(entries: EnvEntry[], trailingNewline: boolean): string { + const body = entries.map((entry) => `${entry.key}=${entry.value}`).join('\n'); + if (body === '') { + return trailingNewline ? '\n' : ''; + } + return trailingNewline ? `${body}\n` : body; +} + +export async function writeEnvFile( + projectRoot: string, + fileName: string, + entries: EnvEntry[] +): Promise { + const envPath = path.join(projectRoot, fileName); + await fs.writeFile(envPath, formatEnvFileContent(entries), 'utf8'); +} + +export async function upsertEnvConfig(projectRoot: string, entries: EnvEntry[]): Promise { + await upsertEnvFile(projectRoot, '.env.config', entries); +} diff --git a/src/core/envSync.ts b/src/core/envSync.ts index 09f5b3a..9d19040 100644 --- a/src/core/envSync.ts +++ b/src/core/envSync.ts @@ -1,522 +1,651 @@ -import path from 'node:path'; - -import type { ConfigDTO, SecretDTO } from './dto.js'; -import { deriveAssetEnvKey, resolveAssetEnvKey } from './pullAssets.js'; -import { - ENV_CONFIG_BASE, - ENV_SECRETS_BASE, - envConfigScopedFile, - envFileExists, - envSecretsScopedFile, - readEnvFile, - upsertEnvFile, - writeEnvFile, - type EnvEntry, -} from './envConfig.js'; - -export interface CloudEnvState { - config?: ConfigDTO; - secrets?: SecretDTO; -} - -export interface LocalEnvFiles { - appKey: string; - useScoped: boolean; - configWriteFile: string; - secretsWriteFile: string; - envConfig: EnvEntry[]; - envSecrets: EnvEntry[]; - baseConfig: EnvEntry[]; - scopedConfig: EnvEntry[]; - baseSecrets: EnvEntry[]; - scopedSecrets: EnvEntry[]; - envConfigPresent: boolean; - envSecretsPresent: boolean; - baseConfigPresent: boolean; - scopedConfigPresent: boolean; - baseSecretsPresent: boolean; - scopedSecretsPresent: boolean; -} - -export type CloudAssetEnvRef = { - fileName?: string; - copyText?: string; - isArchived?: boolean; -}; - -type AssetKeyContext = { - localKeys: Set; - excludedKeys: Set; - staleKeys: Set; - cloudByFile: Map; -}; - -const activeCloudAssets = (cloudAssets?: CloudAssetEnvRef[]) => - (cloudAssets ?? []).filter( - (a) => typeof a.fileName === 'string' && a.fileName !== '' && a.isArchived !== true - ); - -function buildAssetKeyContext( - localAssetFileNames: string[], - cloudAssets?: CloudAssetEnvRef[] -): AssetKeyContext { - const cloudByFile = new Map(activeCloudAssets(cloudAssets).map((a) => [a.fileName as string, a])); - const localFiles = new Set(localAssetFileNames); - const localKeys = new Set(['assets']); - const staleKeys = new Set(); - - for (const fileName of localAssetFileNames) { - const cloudAsset = cloudByFile.get(fileName); - localKeys.add(resolveAssetEnvKey({ fileName, copyText: cloudAsset?.copyText })); - localKeys.add(deriveAssetEnvKey(fileName)); - } - for (const [fileName, asset] of cloudByFile) { - if (!localFiles.has(fileName)) { - staleKeys.add(resolveAssetEnvKey({ fileName, copyText: asset.copyText })); - } - } - - return { - localKeys, - staleKeys, - excludedKeys: new Set([...localKeys, ...staleKeys]), - cloudByFile, - }; -} - -function entriesEqual(a: EnvEntry[], b: EnvEntry[]): boolean { - const mapB = new Map(b.map((e) => [e.key, e.value])); - return a.length === mapB.size && a.every((e) => mapB.get(e.key) === e.value); -} - -function configEntriesEqual(a?: ConfigDTO, b?: ConfigDTO): boolean { - return entriesEqual(configDtoToEnvEntries(a), configDtoToEnvEntries(b)); -} - -function entriesFromRecord( - record: Record | undefined, - skip?: (key: string) => boolean -): EnvEntry[] { - if (!record) return []; - return Object.entries(record) - .filter(([key, value]) => !skip?.(key) && value !== undefined && value !== null) - .map(([key, value]) => ({ key, value: String(value) })) - .sort((a, b) => a.key.localeCompare(b.key)); -} - -function dtoFromEntries(entries: EnvEntry[], excludeKeys?: Set): ConfigDTO | undefined { - const envVariables: Record = {}; - for (const entry of entries) { - if (!excludeKeys?.has(entry.key)) envVariables[entry.key] = entry.value; - } - return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; -} - -function omitAssetKeys( - entries: EnvEntry[], - assetFileNames: string[], - cloudAssets?: CloudAssetEnvRef[] -): ConfigDTO | undefined { - const excludeKeys = cloudAssets - ? buildAssetKeyContext(assetFileNames, cloudAssets).excludedKeys - : collectAssetEnvKeys(assetFileNames); - return dtoFromEntries(entries, excludeKeys); -} - -function assetFileNameFromEnvValue(rawValue: string): string | undefined { - const value = rawValue.trim(); - if (!value) return undefined; - const base = path.basename(value.split('?')[0] ?? value); - return base.includes('.') ? base : undefined; -} - -export function configDtoToEnvEntries(config: ConfigDTO | undefined): EnvEntry[] { - return entriesFromRecord(config?.envVariables as Record | undefined); -} - -export function secretsDtoToEnvEntries(secrets: SecretDTO | undefined): EnvEntry[] { - if (!secrets || typeof secrets !== 'object') return []; - const nested = - secrets.secrets && typeof secrets.secrets === 'object' - ? (secrets.secrets as Record) - : (secrets as Record); - return entriesFromRecord(nested, (key) => key === 'secrets'); -} - -export function collectAssetEnvKeys(assetFileNames: string[] = []): Set { - return new Set(['assets', ...assetFileNames.map(deriveAssetEnvKey)]); -} - -export function stripAssetKeysFromConfigDto( - config: ConfigDTO | undefined, - assetFileNames: string[] = [], - cloudAssets?: CloudAssetEnvRef[] -): ConfigDTO | undefined { - return omitAssetKeys(configDtoToEnvEntries(config), assetFileNames, cloudAssets); -} - -export function buildConfigDtoFromEnvConfigFile( - entries: EnvEntry[], - assetFileNames: string[] = [], - cloudAssets?: CloudAssetEnvRef[] -): ConfigDTO | undefined { - return omitAssetKeys(entries, assetFileNames, cloudAssets); -} - -export const buildConfigDtoFromEnvEntries = dtoFromEntries; - -export function buildSecretsDtoFromEnvSecretsFile(entries: EnvEntry[]): SecretDTO | undefined { - return entries.length > 0 - ? { secrets: Object.fromEntries(entries.map((e) => [e.key, e.value])) } - : undefined; -} - -function localNonAssetConfigEntries( - localEnv: LocalEnvFiles, - assetFileNames: string[], - cloudAssets?: CloudAssetEnvRef[] -): EnvEntry[] { - if (!localEnv.envConfigPresent) return []; - return configDtoToEnvEntries( - buildConfigDtoFromEnvConfigFile(localEnv.envConfig, assetFileNames, cloudAssets) - ); -} - -function wouldClearConfigOnPush( - localEnv: LocalEnvFiles, - cloudConfig: ConfigDTO | undefined, - assetFileNames: string[], - cloudAssets?: CloudAssetEnvRef[] -): boolean { - if (!localEnv.envConfigPresent) return false; - const cloudNonAsset = configDtoToEnvEntries( - stripAssetKeysFromConfigDto(cloudConfig, assetFileNames, cloudAssets) - ); - return ( - cloudNonAsset.length > 0 && - localNonAssetConfigEntries(localEnv, assetFileNames, cloudAssets).length === 0 - ); -} - -function wouldClearSecretsOnPush(localEnv: LocalEnvFiles, cloudSecrets?: SecretDTO): boolean { - if (!localEnv.envSecretsPresent) return false; - return secretsDtoToEnvEntries(cloudSecrets).length > 0 && localEnv.envSecrets.length === 0; -} - -export function pruneStaleAssetEnvEntries( - entries: EnvEntry[], - assetFileNames: string[], - cloudAssets?: CloudAssetEnvRef[] -): EnvEntry[] { - const { staleKeys } = buildAssetKeyContext(assetFileNames, cloudAssets); - const localFiles = new Set(assetFileNames); - return entries.filter((entry) => { - if (entry.key === 'assets') return assetFileNames.length > 0; - if (staleKeys.has(entry.key)) return false; - const fileName = assetFileNameFromEnvValue(entry.value); - return !(fileName && !localFiles.has(fileName) && entry.key === deriveAssetEnvKey(fileName)); - }); -} - -export function buildPushConfigDto( - localEnv: LocalEnvFiles, - cloudConfig: ConfigDTO | undefined, - assetFileNames: string[] = [], - cloudAssets?: CloudAssetEnvRef[] -): ConfigDTO { - const ctx = buildAssetKeyContext(assetFileNames, cloudAssets); - const localAsset: Record = {}; - for (const entry of localEnv.envConfig) { - if (ctx.localKeys.has(entry.key)) localAsset[entry.key] = entry.value; - } - - const envVariables: Record = { - ...((dtoFromEntries(localEnv.envConfig, ctx.excludedKeys)?.envVariables ?? {}) as Record< - string, - string - >), - }; - - if (assetFileNames.length === 0) return { envVariables }; - - const cloudAssetVars = cloudConfig?.envVariables ?? {}; - const assetsBase = - (typeof localAsset.assets === 'string' ? localAsset.assets.trim() : '') || - (typeof cloudAssetVars.assets === 'string' ? cloudAssetVars.assets.trim() : ''); - if (assetsBase) envVariables.assets = assetsBase; - - for (const fileName of assetFileNames) { - const envKey = resolveAssetEnvKey({ - fileName, - copyText: ctx.cloudByFile.get(fileName)?.copyText, - }); - const value = localAsset[envKey] ?? localAsset[deriveAssetEnvKey(fileName)]; - if (typeof value === 'string') envVariables[envKey] = value; - } - - return { envVariables }; -} - -export async function readProjectEnvFiles( - projectRoot: string, - appKey: string, - defaultAppKey: string -): Promise { - const scopedConfigFile = envConfigScopedFile(appKey); - const scopedSecretsFile = envSecretsScopedFile(appKey); - const [baseConfigPresent, scopedConfigPresent, baseSecretsPresent, scopedSecretsPresent] = - await Promise.all([ - envFileExists(projectRoot, ENV_CONFIG_BASE), - envFileExists(projectRoot, scopedConfigFile), - envFileExists(projectRoot, ENV_SECRETS_BASE), - envFileExists(projectRoot, scopedSecretsFile), - ]); - const scopedPairPresent = scopedConfigPresent && scopedSecretsPresent; - const useScoped = scopedPairPresent || appKey !== defaultAppKey; - const configWriteFile = useScoped ? scopedConfigFile : ENV_CONFIG_BASE; - const secretsWriteFile = useScoped ? scopedSecretsFile : ENV_SECRETS_BASE; - - const baseConfig = baseConfigPresent ? await readEnvFile(projectRoot, ENV_CONFIG_BASE) : []; - const scopedConfig = scopedConfigPresent ? await readEnvFile(projectRoot, scopedConfigFile) : []; - const baseSecrets = baseSecretsPresent ? await readEnvFile(projectRoot, ENV_SECRETS_BASE) : []; - const scopedSecrets = scopedSecretsPresent - ? await readEnvFile(projectRoot, scopedSecretsFile) - : []; - - return { - appKey, - useScoped, - configWriteFile, - secretsWriteFile, - baseConfig, - scopedConfig, - baseSecrets, - scopedSecrets, - envConfig: useScoped ? scopedConfig : baseConfig, - envSecrets: useScoped ? scopedSecrets : baseSecrets, - baseConfigPresent, - scopedConfigPresent, - baseSecretsPresent, - scopedSecretsPresent, - envConfigPresent: useScoped ? scopedConfigPresent : baseConfigPresent, - envSecretsPresent: useScoped ? scopedSecretsPresent : baseSecretsPresent, - }; -} - -export function mergeAssetFileNamesForEnvCompare( - localAssetFileNames: string[] = [], - cloudAssets: Array<{ fileName?: string; isArchived?: boolean }> | undefined = [] -): string[] { - const fromCloud = (cloudAssets ?? []) - .filter((a) => a.isArchived !== true && typeof a.fileName === 'string' && a.fileName !== '') - .map((a) => a.fileName as string); - return [...new Set([...localAssetFileNames, ...fromCloud])]; -} - -export function envConfigEntriesMatchCloud( - localEntries: EnvEntry[], - cloudConfig: ConfigDTO | undefined, - assetFileNames: string[] = [], - cloudAssets?: CloudAssetEnvRef[] -): boolean { - if ( - !configEntriesEqual( - buildConfigDtoFromEnvConfigFile(localEntries, assetFileNames, cloudAssets), - stripAssetKeysFromConfigDto(cloudConfig, assetFileNames, cloudAssets) - ) - ) { - return false; - } - - const assetKeys = collectAssetEnvKeys(assetFileNames); - const localMap = new Map(localEntries.map((entry) => [entry.key, entry.value])); - const cloudAssetVars = cloudConfig?.envVariables ?? {}; - for (const key of assetKeys) { - const cloudValue = cloudAssetVars[key]; - if (cloudValue !== undefined && localMap.get(key) !== String(cloudValue)) return false; - } - return true; -} - -export function envSecretsEntriesMatchCloud( - localEntries: EnvEntry[], - cloudSecrets: SecretDTO | undefined -): boolean { - return entriesEqual(localEntries, secretsDtoToEnvEntries(cloudSecrets)); -} - -export interface EnvPushDiff { - configChanged: boolean; - secretsChanged: boolean; - wouldClearConfig: boolean; - wouldClearSecrets: boolean; - local: { config?: ConfigDTO; secrets?: SecretDTO }; - cloud: { config?: ConfigDTO; secrets?: SecretDTO }; -} - -export function buildEnvPushDiff( - localEnv: LocalEnvFiles, - cloudEnv: CloudEnvState, - assetFileNames: string[] = [], - cloudAssets?: CloudAssetEnvRef[] -): EnvPushDiff { - const pushConfig = buildPushConfigDto(localEnv, cloudEnv.config, assetFileNames, cloudAssets); - const wouldClearConfig = wouldClearConfigOnPush( - localEnv, - cloudEnv.config, - assetFileNames, - cloudAssets - ); - const wouldClearSecrets = wouldClearSecretsOnPush(localEnv, cloudEnv.secrets); - const configChanged = - wouldClearConfig || - (localEnv.envConfigPresent && !configEntriesEqual(pushConfig, cloudEnv.config)); - const secretsChanged = - wouldClearSecrets || - (localEnv.envSecretsPresent && - !entriesEqual(localEnv.envSecrets, secretsDtoToEnvEntries(cloudEnv.secrets))); - const pushSecrets: SecretDTO = { - secrets: Object.fromEntries(localEnv.envSecrets.map((e) => [e.key, e.value])), - }; - - return { - configChanged, - secretsChanged, - wouldClearConfig, - wouldClearSecrets, - local: { - ...(configChanged && { config: pushConfig }), - ...(secretsChanged && { secrets: pushSecrets }), - }, - cloud: { - ...(configChanged && cloudEnv.config && { config: cloudEnv.config }), - ...(secretsChanged && cloudEnv.secrets && { secrets: cloudEnv.secrets }), - }, - }; -} - -export interface EnvPullChanges { - assetFileNames: string[]; - configMatch: boolean; - secretsMatch: boolean; - match: boolean; - filesToUpdate: string[]; -} - -export function computeEnvPullChanges( - localEnv: LocalEnvFiles | undefined, - cloudConfig: ConfigDTO | undefined, - cloudSecrets: SecretDTO | undefined, - localAssetFileNames: string[] = [], - cloudAssets: Array<{ fileName?: string; isArchived?: boolean }> | undefined = [] -): EnvPullChanges { - const assetFileNames = mergeAssetFileNamesForEnvCompare(localAssetFileNames, cloudAssets); - const configMatch = envConfigEntriesMatchCloud( - localEnv?.envConfig ?? [], - cloudConfig, - assetFileNames, - cloudAssets - ); - const secretsMatch = envSecretsEntriesMatchCloud(localEnv?.envSecrets ?? [], cloudSecrets); - const filesToUpdate: string[] = []; - if (!configMatch) filesToUpdate.push(localEnv?.configWriteFile ?? ENV_CONFIG_BASE); - if (!secretsMatch) filesToUpdate.push(localEnv?.secretsWriteFile ?? ENV_SECRETS_BASE); - return { - assetFileNames, - configMatch, - secretsMatch, - match: configMatch && secretsMatch, - filesToUpdate, - }; -} - -export interface EnvPushState { - localEnv: LocalEnvFiles; - diff: EnvPushDiff; - pushConfigDto?: ConfigDTO; - pushSecretsDto?: SecretDTO; - pendingLocalEnvConfigWrite?: EnvEntry[]; -} - -export async function prepareEnvPushState(params: { - projectRoot: string; - appKey: string; - defaultAppKey: string; - cloudEnv: CloudEnvState; - assetFileNames: string[]; - cloudAssets?: CloudAssetEnvRef[]; -}): Promise { - const localEnvRaw = await readProjectEnvFiles( - params.projectRoot, - params.appKey, - params.defaultAppKey - ); - const prunedConfigSource = localEnvRaw.envConfigPresent - ? pruneStaleAssetEnvEntries(localEnvRaw.envConfig, params.assetFileNames, params.cloudAssets) - : localEnvRaw.envConfig; - const localEnv: LocalEnvFiles = { - ...localEnvRaw, - envConfig: prunedConfigSource, - ...(localEnvRaw.useScoped - ? { scopedConfig: prunedConfigSource } - : { baseConfig: prunedConfigSource }), - }; - const diff = buildEnvPushDiff( - localEnv, - params.cloudEnv, - params.assetFileNames, - params.cloudAssets - ); - - return { - localEnv, - diff, - pushConfigDto: diff.local.config, - pushSecretsDto: diff.local.secrets, - ...(localEnvRaw.envConfigPresent && - !entriesEqual(prunedConfigSource, localEnvRaw.envConfig) && { - pendingLocalEnvConfigWrite: prunedConfigSource, - }), - }; -} - -export async function applyReleaseConfigToFs( - projectRoot: string, - config: ConfigDTO | undefined, - appKey: string, - defaultAppKey: string -): Promise { - const configEntries = configDtoToEnvEntries(config); - if (configEntries.length === 0) return; - const { configWriteFile } = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); - await writeEnvFile(projectRoot, configWriteFile, configEntries); -} - -export async function applyCloudEnvToFs( - projectRoot: string, - cloudEnv: CloudEnvState, - assetFileNames: string[] = [], - appKey = 'default', - defaultAppKey = appKey -): Promise { - const layout = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); - const configWriteFile = layout.configWriteFile; - const secretsWriteFile = layout.secretsWriteFile; - - const assetKeys = collectAssetEnvKeys(assetFileNames); - const cloudVars = cloudEnv.config?.envVariables ?? {}; - const assetEntries = [...assetKeys] - .map((key) => ({ key, value: cloudVars[key] })) - .filter((entry): entry is EnvEntry => typeof entry.value === 'string'); - if (assetEntries.length > 0) { - await upsertEnvFile(projectRoot, configWriteFile, assetEntries); - } - - const existing = await readEnvFile(projectRoot, configWriteFile); - const keptAssetEntries = existing.filter((entry) => assetKeys.has(entry.key)); - const nonAssetEntries = configDtoToEnvEntries( - stripAssetKeysFromConfigDto(cloudEnv.config, assetFileNames) - ); - await writeEnvFile(projectRoot, configWriteFile, [...keptAssetEntries, ...nonAssetEntries]); - await writeEnvFile(projectRoot, secretsWriteFile, secretsDtoToEnvEntries(cloudEnv.secrets)); -} +import fs from 'node:fs/promises'; +import path from 'node:path'; + +import type { ConfigDTO, SecretDTO } from './dto.js'; +import { deriveAssetEnvKey, resolveAssetEnvKey } from './pullAssets.js'; +import { + ENV_CONFIG_BASE, + ENV_SECRETS_BASE, + envConfigScopedFile, + envFileExists, + envSecretsScopedFile, + entriesEqualOrdered, + formatEnvFileContentWithEol, + parseEnvEntriesPreservingOrder, + readEnvFile, + writeEnvFile, + type EnvEntry, +} from './envConfig.js'; + +export interface CloudEnvState { + config?: ConfigDTO; + secrets?: SecretDTO; +} + +export interface LocalEnvFiles { + appKey: string; + useScoped: boolean; + configWriteFile: string; + secretsWriteFile: string; + envConfig: EnvEntry[]; + envSecrets: EnvEntry[]; + baseConfig: EnvEntry[]; + scopedConfig: EnvEntry[]; + baseSecrets: EnvEntry[]; + scopedSecrets: EnvEntry[]; + envConfigPresent: boolean; + envSecretsPresent: boolean; + baseConfigPresent: boolean; + scopedConfigPresent: boolean; + baseSecretsPresent: boolean; + scopedSecretsPresent: boolean; +} + +export type CloudAssetEnvRef = { + fileName?: string; + copyText?: string; + isArchived?: boolean; +}; + +type AssetKeyContext = { + localKeys: Set; + excludedKeys: Set; + staleKeys: Set; + cloudByFile: Map; +}; + +const activeCloudAssets = (cloudAssets?: CloudAssetEnvRef[]) => + (cloudAssets ?? []).filter( + (a) => typeof a.fileName === 'string' && a.fileName !== '' && a.isArchived !== true + ); + +function buildAssetKeyContext( + localAssetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): AssetKeyContext { + const cloudByFile = new Map(activeCloudAssets(cloudAssets).map((a) => [a.fileName as string, a])); + const localFiles = new Set(localAssetFileNames); + const localKeys = new Set(['assets']); + const staleKeys = new Set(); + + for (const fileName of localAssetFileNames) { + const cloudAsset = cloudByFile.get(fileName); + localKeys.add(resolveAssetEnvKey({ fileName, copyText: cloudAsset?.copyText })); + localKeys.add(deriveAssetEnvKey(fileName)); + } + for (const [fileName, asset] of cloudByFile) { + if (!localFiles.has(fileName)) { + staleKeys.add(resolveAssetEnvKey({ fileName, copyText: asset.copyText })); + } + } + + return { + localKeys, + staleKeys, + excludedKeys: new Set([...localKeys, ...staleKeys]), + cloudByFile, + }; +} + +function entriesEqual(a: EnvEntry[], b: EnvEntry[]): boolean { + const mapB = new Map(b.map((e) => [e.key, e.value])); + return a.length === mapB.size && a.every((e) => mapB.get(e.key) === e.value); +} + +function configEntriesEqual(a?: ConfigDTO, b?: ConfigDTO): boolean { + return entriesEqual(configDtoToEnvEntries(a), configDtoToEnvEntries(b)); +} + +function entriesFromRecord( + record: Record | undefined, + skip?: (key: string) => boolean +): EnvEntry[] { + if (!record) return []; + return Object.entries(record) + .filter(([key, value]) => !skip?.(key) && value !== undefined && value !== null) + .map(([key, value]) => ({ key, value: String(value) })) + .sort((a, b) => a.key.localeCompare(b.key)); +} + +function dtoFromEntries(entries: EnvEntry[], excludeKeys?: Set): ConfigDTO | undefined { + const envVariables: Record = {}; + for (const entry of entries) { + if (!excludeKeys?.has(entry.key)) envVariables[entry.key] = entry.value; + } + return Object.keys(envVariables).length > 0 ? { envVariables } : undefined; +} + +function omitAssetKeys( + entries: EnvEntry[], + assetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): ConfigDTO | undefined { + const excludeKeys = cloudAssets + ? buildAssetKeyContext(assetFileNames, cloudAssets).excludedKeys + : collectAssetEnvKeys(assetFileNames); + return dtoFromEntries(entries, excludeKeys); +} + +function assetFileNameFromEnvValue(rawValue: string): string | undefined { + const value = rawValue.trim(); + if (!value) return undefined; + const base = path.basename(value.split('?')[0] ?? value); + return base.includes('.') ? base : undefined; +} + +export function configDtoToEnvEntries(config: ConfigDTO | undefined): EnvEntry[] { + return entriesFromRecord(config?.envVariables as Record | undefined); +} + +/** Asset keys first (alpha), then non-asset config keys (alpha) — same layout as pull. */ +export function buildCanonicalEnvConfigEntries( + config: ConfigDTO | undefined, + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[], + existingEntries: EnvEntry[] = [], + retainLocalAssetValues = false +): EnvEntry[] { + const assetKeys = collectAssetEnvKeys(assetFileNames); + const cloudVars = config?.envVariables ?? {}; + const existingByKey = new Map(existingEntries.map((entry) => [entry.key, entry.value])); + + const assetEntries = [...assetKeys] + .sort((a, b) => a.localeCompare(b)) + .flatMap((key) => { + const cloudValue = cloudVars[key]; + const value = + cloudValue !== undefined && cloudValue !== null + ? String(cloudValue) + : retainLocalAssetValues + ? existingByKey.get(key) + : undefined; + return value !== undefined ? [{ key, value }] : []; + }); + + const nonAssetEntries = configDtoToEnvEntries( + stripAssetKeysFromConfigDto(config, assetFileNames, cloudAssets) + ); + return [...assetEntries, ...nonAssetEntries]; +} + +export function secretsDtoToEnvEntries(secrets: SecretDTO | undefined): EnvEntry[] { + if (!secrets || typeof secrets !== 'object') return []; + const nested = + secrets.secrets && typeof secrets.secrets === 'object' + ? (secrets.secrets as Record) + : (secrets as Record); + return entriesFromRecord(nested, (key) => key === 'secrets'); +} + +export function collectAssetEnvKeys(assetFileNames: string[] = []): Set { + return new Set(['assets', ...assetFileNames.map(deriveAssetEnvKey)]); +} + +export function stripAssetKeysFromConfigDto( + config: ConfigDTO | undefined, + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[] +): ConfigDTO | undefined { + return omitAssetKeys(configDtoToEnvEntries(config), assetFileNames, cloudAssets); +} + +export function buildConfigDtoFromEnvConfigFile( + entries: EnvEntry[], + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[] +): ConfigDTO | undefined { + return omitAssetKeys(entries, assetFileNames, cloudAssets); +} + +export const buildConfigDtoFromEnvEntries = dtoFromEntries; + +export function buildSecretsDtoFromEnvSecretsFile(entries: EnvEntry[]): SecretDTO | undefined { + return entries.length > 0 + ? { secrets: Object.fromEntries(entries.map((e) => [e.key, e.value])) } + : undefined; +} + +function localNonAssetConfigEntries( + localEnv: LocalEnvFiles, + assetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): EnvEntry[] { + if (!localEnv.envConfigPresent) return []; + return configDtoToEnvEntries( + buildConfigDtoFromEnvConfigFile(localEnv.envConfig, assetFileNames, cloudAssets) + ); +} + +function wouldClearConfigOnPush( + localEnv: LocalEnvFiles, + cloudConfig: ConfigDTO | undefined, + assetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): boolean { + if (!localEnv.envConfigPresent) return false; + const cloudNonAsset = configDtoToEnvEntries( + stripAssetKeysFromConfigDto(cloudConfig, assetFileNames, cloudAssets) + ); + return ( + cloudNonAsset.length > 0 && + localNonAssetConfigEntries(localEnv, assetFileNames, cloudAssets).length === 0 + ); +} + +function wouldClearSecretsOnPush(localEnv: LocalEnvFiles, cloudSecrets?: SecretDTO): boolean { + if (!localEnv.envSecretsPresent) return false; + return secretsDtoToEnvEntries(cloudSecrets).length > 0 && localEnv.envSecrets.length === 0; +} + +export function pruneStaleAssetEnvEntries( + entries: EnvEntry[], + assetFileNames: string[], + cloudAssets?: CloudAssetEnvRef[] +): EnvEntry[] { + const { staleKeys } = buildAssetKeyContext(assetFileNames, cloudAssets); + const localFiles = new Set(assetFileNames); + return entries.filter((entry) => { + if (entry.key === 'assets') return assetFileNames.length > 0; + if (staleKeys.has(entry.key)) return false; + const fileName = assetFileNameFromEnvValue(entry.value); + return !(fileName && !localFiles.has(fileName) && entry.key === deriveAssetEnvKey(fileName)); + }); +} + +export function buildPushConfigDto( + localEnv: LocalEnvFiles, + cloudConfig: ConfigDTO | undefined, + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[] +): ConfigDTO { + const ctx = buildAssetKeyContext(assetFileNames, cloudAssets); + const localAsset: Record = {}; + for (const entry of localEnv.envConfig) { + if (ctx.localKeys.has(entry.key)) localAsset[entry.key] = entry.value; + } + + const envVariables: Record = { + ...((dtoFromEntries(localEnv.envConfig, ctx.excludedKeys)?.envVariables ?? {}) as Record< + string, + string + >), + }; + + if (assetFileNames.length === 0) return { envVariables }; + + const cloudAssetVars = cloudConfig?.envVariables ?? {}; + const assetsBase = + (typeof localAsset.assets === 'string' ? localAsset.assets.trim() : '') || + (typeof cloudAssetVars.assets === 'string' ? cloudAssetVars.assets.trim() : ''); + if (assetsBase) envVariables.assets = assetsBase; + + for (const fileName of assetFileNames) { + const envKey = resolveAssetEnvKey({ + fileName, + copyText: ctx.cloudByFile.get(fileName)?.copyText, + }); + const value = localAsset[envKey] ?? localAsset[deriveAssetEnvKey(fileName)]; + if (typeof value === 'string') envVariables[envKey] = value; + } + + return { envVariables }; +} + +export async function readProjectEnvFiles( + projectRoot: string, + appKey: string, + defaultAppKey: string +): Promise { + const scopedConfigFile = envConfigScopedFile(appKey); + const scopedSecretsFile = envSecretsScopedFile(appKey); + const [baseConfigPresent, scopedConfigPresent, baseSecretsPresent, scopedSecretsPresent] = + await Promise.all([ + envFileExists(projectRoot, ENV_CONFIG_BASE), + envFileExists(projectRoot, scopedConfigFile), + envFileExists(projectRoot, ENV_SECRETS_BASE), + envFileExists(projectRoot, scopedSecretsFile), + ]); + const scopedPairPresent = scopedConfigPresent && scopedSecretsPresent; + const useScoped = scopedPairPresent || appKey !== defaultAppKey; + const configWriteFile = useScoped ? scopedConfigFile : ENV_CONFIG_BASE; + const secretsWriteFile = useScoped ? scopedSecretsFile : ENV_SECRETS_BASE; + + const baseConfig = baseConfigPresent ? await readEnvFile(projectRoot, ENV_CONFIG_BASE) : []; + const scopedConfig = scopedConfigPresent ? await readEnvFile(projectRoot, scopedConfigFile) : []; + const baseSecrets = baseSecretsPresent ? await readEnvFile(projectRoot, ENV_SECRETS_BASE) : []; + const scopedSecrets = scopedSecretsPresent + ? await readEnvFile(projectRoot, scopedSecretsFile) + : []; + + return { + appKey, + useScoped, + configWriteFile, + secretsWriteFile, + baseConfig, + scopedConfig, + baseSecrets, + scopedSecrets, + envConfig: useScoped ? scopedConfig : baseConfig, + envSecrets: useScoped ? scopedSecrets : baseSecrets, + baseConfigPresent, + scopedConfigPresent, + baseSecretsPresent, + scopedSecretsPresent, + envConfigPresent: useScoped ? scopedConfigPresent : baseConfigPresent, + envSecretsPresent: useScoped ? scopedSecretsPresent : baseSecretsPresent, + }; +} + +export function mergeAssetFileNamesForEnvCompare( + localAssetFileNames: string[] = [], + cloudAssets: Array<{ fileName?: string; isArchived?: boolean }> | undefined = [] +): string[] { + const fromCloud = (cloudAssets ?? []) + .filter((a) => a.isArchived !== true && typeof a.fileName === 'string' && a.fileName !== '') + .map((a) => a.fileName as string); + return [...new Set([...localAssetFileNames, ...fromCloud])]; +} + +export function envConfigEntriesMatchCloud( + localEntries: EnvEntry[], + cloudConfig: ConfigDTO | undefined, + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[] +): boolean { + if ( + !configEntriesEqual( + buildConfigDtoFromEnvConfigFile(localEntries, assetFileNames, cloudAssets), + stripAssetKeysFromConfigDto(cloudConfig, assetFileNames, cloudAssets) + ) + ) { + return false; + } + + const assetKeys = collectAssetEnvKeys(assetFileNames); + const localMap = new Map(localEntries.map((entry) => [entry.key, entry.value])); + const cloudAssetVars = cloudConfig?.envVariables ?? {}; + for (const key of assetKeys) { + const cloudValue = cloudAssetVars[key]; + if (cloudValue !== undefined && localMap.get(key) !== String(cloudValue)) return false; + } + return true; +} + +export function envSecretsEntriesMatchCloud( + localEntries: EnvEntry[], + cloudSecrets: SecretDTO | undefined +): boolean { + return entriesEqual(localEntries, secretsDtoToEnvEntries(cloudSecrets)); +} + +export interface EnvPushDiff { + configChanged: boolean; + secretsChanged: boolean; + wouldClearConfig: boolean; + wouldClearSecrets: boolean; + local: { config?: ConfigDTO; secrets?: SecretDTO }; + cloud: { config?: ConfigDTO; secrets?: SecretDTO }; +} + +export function buildEnvPushDiff( + localEnv: LocalEnvFiles, + cloudEnv: CloudEnvState, + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[] +): EnvPushDiff { + const pushConfig = buildPushConfigDto(localEnv, cloudEnv.config, assetFileNames, cloudAssets); + const wouldClearConfig = wouldClearConfigOnPush( + localEnv, + cloudEnv.config, + assetFileNames, + cloudAssets + ); + const wouldClearSecrets = wouldClearSecretsOnPush(localEnv, cloudEnv.secrets); + const configChanged = + wouldClearConfig || + (localEnv.envConfigPresent && !configEntriesEqual(pushConfig, cloudEnv.config)); + const secretsChanged = + wouldClearSecrets || + (localEnv.envSecretsPresent && + !entriesEqual(localEnv.envSecrets, secretsDtoToEnvEntries(cloudEnv.secrets))); + const pushSecrets: SecretDTO = { + secrets: Object.fromEntries(localEnv.envSecrets.map((e) => [e.key, e.value])), + }; + + return { + configChanged, + secretsChanged, + wouldClearConfig, + wouldClearSecrets, + local: { + ...(configChanged && { config: pushConfig }), + ...(secretsChanged && { secrets: pushSecrets }), + }, + cloud: { + ...(configChanged && cloudEnv.config && { config: cloudEnv.config }), + ...(secretsChanged && cloudEnv.secrets && { secrets: cloudEnv.secrets }), + }, + }; +} + +export interface EnvPullChanges { + assetFileNames: string[]; + configMatch: boolean; + secretsMatch: boolean; + match: boolean; + filesToUpdate: string[]; +} + +export function computeEnvPullChanges( + localEnv: LocalEnvFiles | undefined, + cloudConfig: ConfigDTO | undefined, + cloudSecrets: SecretDTO | undefined, + localAssetFileNames: string[] = [], + cloudAssets: Array<{ fileName?: string; isArchived?: boolean }> | undefined = [] +): EnvPullChanges { + const assetFileNames = mergeAssetFileNamesForEnvCompare(localAssetFileNames, cloudAssets); + const configMatch = envConfigEntriesMatchCloud( + localEnv?.envConfig ?? [], + cloudConfig, + assetFileNames, + cloudAssets + ); + const secretsMatch = envSecretsEntriesMatchCloud(localEnv?.envSecrets ?? [], cloudSecrets); + const filesToUpdate: string[] = []; + if (!configMatch) filesToUpdate.push(localEnv?.configWriteFile ?? ENV_CONFIG_BASE); + if (!secretsMatch) filesToUpdate.push(localEnv?.secretsWriteFile ?? ENV_SECRETS_BASE); + return { + assetFileNames, + configMatch, + secretsMatch, + match: configMatch && secretsMatch, + filesToUpdate, + }; +} + +export interface EnvPushState { + localEnv: LocalEnvFiles; + diff: EnvPushDiff; + pushConfigDto?: ConfigDTO; + pushSecretsDto?: SecretDTO; + pendingLocalEnvConfigWrite?: EnvEntry[]; +} + +export async function prepareEnvPushState(params: { + projectRoot: string; + appKey: string; + defaultAppKey: string; + cloudEnv: CloudEnvState; + assetFileNames: string[]; + cloudAssets?: CloudAssetEnvRef[]; +}): Promise { + const localEnvRaw = await readProjectEnvFiles( + params.projectRoot, + params.appKey, + params.defaultAppKey + ); + const prunedConfigSource = localEnvRaw.envConfigPresent + ? pruneStaleAssetEnvEntries(localEnvRaw.envConfig, params.assetFileNames, params.cloudAssets) + : localEnvRaw.envConfig; + const localEnv: LocalEnvFiles = { + ...localEnvRaw, + envConfig: prunedConfigSource, + ...(localEnvRaw.useScoped + ? { scopedConfig: prunedConfigSource } + : { baseConfig: prunedConfigSource }), + }; + const diff = buildEnvPushDiff( + localEnv, + params.cloudEnv, + params.assetFileNames, + params.cloudAssets + ); + + return { + localEnv, + diff, + pushConfigDto: diff.local.config, + pushSecretsDto: diff.local.secrets, + ...(localEnvRaw.envConfigPresent && + !entriesEqual(prunedConfigSource, localEnvRaw.envConfig) && { + pendingLocalEnvConfigWrite: prunedConfigSource, + }), + }; +} + +async function writeCanonicalEnvFileIfChanged( + projectRoot: string, + fileName: string, + config: ConfigDTO | undefined, + assetFileNames: string[], + cloudAssets: CloudAssetEnvRef[] | undefined, + filePresent: boolean, + retainLocalAssetValues: boolean +): Promise { + const envPath = path.join(projectRoot, fileName); + let raw = ''; + if (filePresent) { + try { + raw = await fs.readFile(envPath, 'utf8'); + } catch { + raw = ''; + } + } + const existing = parseEnvEntriesPreservingOrder(raw); + const canonicalEntries = buildCanonicalEnvConfigEntries( + config, + assetFileNames, + cloudAssets, + existing, + retainLocalAssetValues + ); + + if (!filePresent) { + if (canonicalEntries.length > 0) { + await writeEnvFile(projectRoot, fileName, canonicalEntries); + } + return; + } + + if (entriesEqualOrdered(existing, canonicalEntries)) { + return; + } + + const nextRaw = formatEnvFileContentWithEol(canonicalEntries, raw.endsWith('\n')); + if (nextRaw === raw) { + return; + } + await fs.writeFile(envPath, nextRaw, 'utf8'); +} + +async function writeCanonicalSecretsFileIfChanged( + projectRoot: string, + fileName: string, + secrets: SecretDTO | undefined, + filePresent: boolean +): Promise { + const canonicalEntries = secretsDtoToEnvEntries(secrets); + if (!filePresent) { + if (canonicalEntries.length > 0) { + await writeEnvFile(projectRoot, fileName, canonicalEntries); + } + return; + } + + const envPath = path.join(projectRoot, fileName); + let raw = ''; + try { + raw = await fs.readFile(envPath, 'utf8'); + } catch { + raw = ''; + } + + const existing = parseEnvEntriesPreservingOrder(raw); + if (entriesEqualOrdered(existing, canonicalEntries)) { + return; + } + + const nextRaw = formatEnvFileContentWithEol(canonicalEntries, raw.endsWith('\n')); + if (nextRaw === raw) { + return; + } + await fs.writeFile(envPath, nextRaw, 'utf8'); +} + +export async function applyReleaseEnvToFs( + projectRoot: string, + config: ConfigDTO | undefined, + secrets: SecretDTO | undefined, + appKey: string, + defaultAppKey: string, + assetFileNames: string[] = [], + cloudAssets?: CloudAssetEnvRef[] +): Promise { + const layout = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); + if (config !== undefined) { + await writeCanonicalEnvFileIfChanged( + projectRoot, + layout.configWriteFile, + config, + assetFileNames, + cloudAssets, + layout.envConfigPresent, + false + ); + } + if (secrets !== undefined) { + await writeCanonicalSecretsFileIfChanged( + projectRoot, + layout.secretsWriteFile, + secrets, + layout.envSecretsPresent + ); + } +} + +export async function applyCloudEnvToFs( + projectRoot: string, + cloudEnv: CloudEnvState, + assetFileNames: string[] = [], + appKey = 'default', + defaultAppKey = appKey, + cloudAssets?: CloudAssetEnvRef[] +): Promise { + const layout = await readProjectEnvFiles(projectRoot, appKey, defaultAppKey); + await writeCanonicalEnvFileIfChanged( + projectRoot, + layout.configWriteFile, + cloudEnv.config, + assetFileNames, + cloudAssets, + layout.envConfigPresent, + true + ); + await writeCanonicalSecretsFileIfChanged( + projectRoot, + layout.secretsWriteFile, + cloudEnv.secrets, + layout.envSecretsPresent + ); +} diff --git a/src/core/manifest.ts b/src/core/manifest.ts index 06b8155..7d8a8b1 100644 --- a/src/core/manifest.ts +++ b/src/core/manifest.ts @@ -1,139 +1,259 @@ -import fs from 'fs/promises'; -import path from 'path'; - -import type { CloudApp } from '../cloud/firestoreClient.js'; - -export type RootManifest = Record & { - scripts?: { name: string }[]; - widgets?: { name: string }[]; - actions?: { name: string }[]; - defaultLanguage?: string; - languages?: string[]; -}; - -export type BuildManifestOptions = Record; - -/** Preserve existing manifest entries by name and order; only add minimal { name } for new ones. */ -function mergeByName( - existing: T[] | undefined, - cloudNames: string[] -): T[] { - const existingList = existing ?? []; - const cloudNameSet = new Set(cloudNames); - - // 1. Keep existing entries that still exist in cloud, in the same order as manifest. - const keptExisting: T[] = existingList.filter((e) => cloudNameSet.has(e.name)); - - // 2. Append any new cloud names that are not already present. - const keptNames = new Set(keptExisting.map((e) => e.name)); - const appended: T[] = cloudNames - .filter((name) => !keptNames.has(name)) - .map((name) => ({ name }) as T); - - return [...keptExisting, ...appended]; -} - -export function buildManifestObject( - existing: RootManifest, - cloudApp: CloudApp, - options: BuildManifestOptions = {} -): RootManifest { - void options; - - const cloudWidgetNames = (cloudApp.widgets ?? []) - .filter((w) => w.isArchived !== true) - .map((w) => w.name); - const widgets = mergeByName(existing.widgets, cloudWidgetNames); - - const cloudScriptNames = (cloudApp.scripts ?? []) - .filter((s) => s.isArchived !== true) - .map((s) => s.name); - const scripts = mergeByName(existing.scripts, cloudScriptNames); - - const cloudActionNames = (cloudApp.actions ?? []) - .filter((a) => a.isArchived !== true) - .map((a) => a.name); - const actions = mergeByName(existing.actions, cloudActionNames); - - const translations = (cloudApp.translations ?? []).filter((t) => t.isArchived !== true); - const languages = translations.map((t) => t.name); - const defaultLanguage = - translations.find((t) => t.defaultLocale === true)?.name ?? - (typeof existing.defaultLanguage === 'string' ? existing.defaultLanguage : undefined) ?? - languages[0]; - - const merged: RootManifest = { - ...existing, - widgets, - scripts, - actions, - ...(languages.length > 0 ? { languages } : {}), - ...(defaultLanguage ? { defaultLanguage } : {}), - }; - - return merged; -} - -export async function buildAndWriteManifest( - projectRoot: string, - cloudApp: CloudApp, - options: BuildManifestOptions = {} -): Promise { - const manifestPath = path.join(projectRoot, '.manifest.json'); - let existing: RootManifest = {}; - try { - const raw = await fs.readFile(manifestPath, 'utf8'); - existing = JSON.parse(raw) as RootManifest; - } catch { - existing = {}; - } - - const merged = buildManifestObject(existing, cloudApp, options); - await fs.writeFile(manifestPath, JSON.stringify(merged, null, 2) + '\n', 'utf8'); -} - -async function readRootManifest(manifestPath: string): Promise { - try { - const raw = await fs.readFile(manifestPath, 'utf8'); - return JSON.parse(raw) as RootManifest; - } catch { - return {}; - } -} - -async function writeRootManifest(manifestPath: string, manifest: RootManifest): Promise { - await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8'); -} - -export async function upsertManifestEntry( - projectRoot: string, - kind: 'widget' | 'script' | 'action' | 'translation', - name: string -): Promise { - const manifestPath = path.join(projectRoot, '.manifest.json'); - const manifest = await readRootManifest(manifestPath); - - const listKeyByKind: Record<'widget' | 'script' | 'action', keyof RootManifest> = { - widget: 'widgets', - script: 'scripts', - action: 'actions', - }; - - if (kind in listKeyByKind) { - const key = listKeyByKind[kind as 'widget' | 'script' | 'action']; - const current = (manifest[key] as { name: string }[] | undefined) ?? []; - if (!current.some((entry) => entry.name === name)) { - (manifest as Record)[key] = [...current, { name }]; - } - } else if (kind === 'translation') { - const currentLangs = manifest.languages ?? []; - if (!currentLangs.includes(name)) { - manifest.languages = [...currentLangs, name]; - } - if (!manifest.defaultLanguage) { - manifest.defaultLanguage = name; - } - } - - await writeRootManifest(manifestPath, manifest); -} +import fs from 'fs/promises'; +import path from 'path'; + +import type { CloudApp } from '../cloud/firestoreClient.js'; + +export type RootManifest = Record & { + scripts?: { name: string }[]; + widgets?: { name: string }[]; + actions?: { name: string }[]; + defaultLanguage?: string; + languages?: string[]; +}; + +/** Preserve existing manifest entries by name and order; only add minimal { name } for new ones. */ +function mergeByName( + existing: T[] | undefined, + cloudNames: string[] +): T[] { + const existingList = existing ?? []; + const cloudNameSet = new Set(cloudNames); + + // 1. Keep existing entries that still exist in cloud, in the same order as manifest. + const keptExisting: T[] = existingList.filter((e) => cloudNameSet.has(e.name)); + + // 2. Append any new cloud names that are not already present. + const keptNames = new Set(keptExisting.map((e) => e.name)); + const appended: T[] = cloudNames + .filter((name) => !keptNames.has(name)) + .map((name) => ({ name }) as T); + + return [...keptExisting, ...appended]; +} + +function mergeLanguageNames(existing: string[] | undefined, cloudNames: string[]): string[] { + return mergeByName( + (existing ?? []).map((name) => ({ name })), + cloudNames + ).map((entry) => entry.name); +} + +function mergeSnapshotNameList( + existing: T[] | undefined, + snapshotNames: string[] +): T[] { + const existingByName = new Map((existing ?? []).map((entry) => [entry.name, entry])); + return snapshotNames.map((name) => { + const kept = existingByName.get(name); + return kept ? { ...kept, name } : ({ name } as T); + }); +} + +/** Sync snapshot list fields into an existing manifest; preserve other keys and entry metadata. */ +export function mergeManifestFromSnapshot( + existing: RootManifest, + cloudApp: CloudApp +): RootManifest { + const widgetNames = (cloudApp.widgets ?? []) + .filter((w) => w.isArchived !== true) + .map((w) => w.name); + const scriptNames = (cloudApp.scripts ?? []) + .filter((s) => s.isArchived !== true) + .map((s) => s.name); + const actionNames = (cloudApp.actions ?? []) + .filter((a) => a.isArchived !== true) + .map((a) => a.name); + + const translations = (cloudApp.translations ?? []).filter((t) => t.isArchived !== true); + const languages = translations.map((t) => t.name); + const defaultLanguage = translations.find((t) => t.defaultLocale === true)?.name ?? languages[0]; + + const merged: RootManifest = { ...existing }; + + for (const [key, names] of [ + ['widgets', widgetNames], + ['scripts', scriptNames], + ['actions', actionNames], + ] as const) { + if (names.length === 0) { + if (key in existing) { + merged[key] = []; + } else { + delete merged[key]; + } + } else { + merged[key] = mergeSnapshotNameList(merged[key] as { name: string }[] | undefined, names); + } + } + + if (languages.length === 0) { + if ('languages' in existing) { + merged.languages = []; + } else { + delete merged.languages; + } + if (!('defaultLanguage' in existing) || languages.length > 0) { + delete merged.defaultLanguage; + } + } else { + merged.languages = languages; + if (defaultLanguage) { + merged.defaultLanguage = defaultLanguage; + } else { + delete merged.defaultLanguage; + } + } + + return merged; +} + +export async function writeManifestFromSnapshot( + projectRoot: string, + cloudApp: CloudApp +): Promise { + const existing = await readProjectManifest(projectRoot); + const manifest = mergeManifestFromSnapshot(existing, cloudApp); + await fs.writeFile( + path.join(projectRoot, '.manifest.json'), + `${JSON.stringify(manifest, null, 2)}\n`, + 'utf8' + ); +} + +/** Merge cloud lists into an existing manifest (pull/push). */ +export function buildManifestObject(existing: RootManifest, cloudApp: CloudApp): RootManifest { + const cloudWidgetNames = (cloudApp.widgets ?? []) + .filter((w) => w.isArchived !== true) + .map((w) => w.name); + const cloudScriptNames = (cloudApp.scripts ?? []) + .filter((s) => s.isArchived !== true) + .map((s) => s.name); + const cloudActionNames = (cloudApp.actions ?? []) + .filter((a) => a.isArchived !== true) + .map((a) => a.name); + + const translations = (cloudApp.translations ?? []).filter((t) => t.isArchived !== true); + const languages = translations.map((t) => t.name); + const cloudDefault = translations.find((t) => t.defaultLocale === true)?.name; + + const widgets = mergeByName(existing.widgets, cloudWidgetNames); + const scripts = mergeByName(existing.scripts, cloudScriptNames); + const actions = mergeByName(existing.actions, cloudActionNames); + const mergedLanguages = mergeLanguageNames(existing.languages, languages); + + const existingDefault = + typeof existing.defaultLanguage === 'string' ? existing.defaultLanguage : undefined; + const mergedDefaultLanguage = + cloudDefault ?? + (existingDefault && mergedLanguages.includes(existingDefault) ? existingDefault : undefined) ?? + mergedLanguages[0]; + + const merged: RootManifest = { + ...existing, + languages: mergedLanguages, + }; + + for (const [key, value] of [ + ['widgets', widgets], + ['scripts', scripts], + ['actions', actions], + ] as const) { + if (value.length > 0) { + merged[key] = value; + } else if (key in existing) { + merged[key] = value; + } else { + delete merged[key]; + } + } + + if (mergedLanguages.length > 0 && mergedDefaultLanguage) { + merged.defaultLanguage = mergedDefaultLanguage; + } else { + delete merged.defaultLanguage; + } + + return merged; +} + +export async function buildAndWriteManifest( + projectRoot: string, + cloudApp: CloudApp +): Promise { + const manifestPath = path.join(projectRoot, '.manifest.json'); + let existing: RootManifest = {}; + try { + const raw = await fs.readFile(manifestPath, 'utf8'); + existing = JSON.parse(raw) as RootManifest; + } catch { + existing = {}; + } + + const merged = buildManifestObject(existing, cloudApp); + await fs.writeFile(manifestPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8'); +} + +export async function readProjectManifest(projectRoot: string): Promise { + return readRootManifest(path.join(projectRoot, '.manifest.json')); +} + +export function orderByManifestNames( + items: T[], + manifestNames: string[] | undefined +): T[] { + if (!manifestNames?.length) { + return items; + } + const order = new Map(manifestNames.map((name, index) => [name, index])); + return [...items].sort((a, b) => { + const ai = order.get(a.name) ?? Number.MAX_SAFE_INTEGER; + const bi = order.get(b.name) ?? Number.MAX_SAFE_INTEGER; + return ai - bi || a.name.localeCompare(b.name); + }); +} + +async function readRootManifest(manifestPath: string): Promise { + try { + const raw = await fs.readFile(manifestPath, 'utf8'); + return JSON.parse(raw) as RootManifest; + } catch { + return {}; + } +} + +async function writeRootManifest(manifestPath: string, manifest: RootManifest): Promise { + await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); +} + +export async function upsertManifestEntry( + projectRoot: string, + kind: 'widget' | 'script' | 'action' | 'translation', + name: string +): Promise { + const manifestPath = path.join(projectRoot, '.manifest.json'); + const manifest = await readRootManifest(manifestPath); + + const listKeyByKind: Record<'widget' | 'script' | 'action', keyof RootManifest> = { + widget: 'widgets', + script: 'scripts', + action: 'actions', + }; + + if (kind in listKeyByKind) { + const key = listKeyByKind[kind as 'widget' | 'script' | 'action']; + const current = (manifest[key] as { name: string }[] | undefined) ?? []; + if (!current.some((entry) => entry.name === name)) { + (manifest as Record)[key] = [...current, { name }]; + } + } else if (kind === 'translation') { + const currentLangs = manifest.languages ?? []; + if (!currentLangs.includes(name)) { + manifest.languages = [...currentLangs, name]; + } + if (!manifest.defaultLanguage) { + manifest.defaultLanguage = name; + } + } + + await writeRootManifest(manifestPath, manifest); +} diff --git a/src/core/modulesCache.ts b/src/core/modulesCache.ts index 08ef980..4689af6 100644 --- a/src/core/modulesCache.ts +++ b/src/core/modulesCache.ts @@ -1,171 +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); - } -} +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 index 7fc1909..db7d0e0 100644 --- a/src/core/starterProject.ts +++ b/src/core/starterProject.ts @@ -1,30 +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; -} +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/core/sync.ts b/src/core/sync.ts index e6534ee..5471ce1 100644 --- a/src/core/sync.ts +++ b/src/core/sync.ts @@ -1,475 +1,475 @@ -import type { CloudApp } from '../cloud/firestoreClient.js'; -import type { ParsedAppFiles } from './appCollector.js'; -import type { ApplicationDTO } from './dto.js'; -import { computeEnvPullChanges, type LocalEnvFiles } from './envSync.js'; -import { - ArtifactProps, - type ArtifactProp, - ARTIFACT_FS_CONFIG, - getArtifactConfig, -} from './artifacts.js'; -import type { BundleDiff } from './bundleDiff.js'; -import { computeBundleDiff, normalizeContentForCompare } from './bundleDiff.js'; -import { buildMergedBundle } from './buildDocuments.js'; -import type { RootManifest } from './manifest.js'; -import { buildManifestObject } from './manifest.js'; -import type { AssetDTO } from './dto.js'; - -export interface PushCounts { - created: number; - updated: number; - deleted: number; -} - -export interface PushSummary { - appId: string; - appName: string; - environment: string; - counts: PushCounts; - byKind: { - screens: PushCounts; - widgets: PushCounts; - scripts: PushCounts; - actions: PushCounts; - translations: PushCounts; - theme: PushCounts; - assets: PushCounts; - }; -} - -export interface PushPlan { - appId: string; - appName: string; - environment: string; - bundle: ApplicationDTO; - diff: BundleDiff; - summary: PushSummary; -} - -export interface ComputePushPlanArgs { - appId: string; - appName: string; - environment: string; - localApp: ApplicationDTO; - cloudApp: CloudApp; - enabledByProp: Record; - updatedBy: { name: string; email?: string; id: string }; -} - -function computeKindCounts(items: BundleDiff['screens']): PushCounts { - let created = 0; - let updated = 0; - let deleted = 0; - - created += items.new.length; - for (const item of items.changed) { - if (item.isArchived) { - deleted += 1; - } else { - updated += 1; - } - } - - return { created, updated, deleted }; -} - -function computeAssetCounts(items: BundleDiff['assets']): PushCounts { - const deleted = items.changed.filter((item) => item.isArchived === true).length; - return { - created: items.new.length, - updated: items.changed.filter((item) => item.isArchived !== true).length, - deleted, - }; -} - -function computePushSummary( - appId: string, - appName: string, - environment: string, - diff: BundleDiff -): PushSummary { - const screens = computeKindCounts(diff.screens); - const widgets = computeKindCounts(diff.widgets); - const scripts = computeKindCounts(diff.scripts); - const actions = computeKindCounts(diff.actions); - const translations = computeKindCounts(diff.translations); - - // Theme currently only supports modified (no explicit create/delete in diff), - // so treat a changed theme as an update. - const theme: PushCounts = diff.themeChanged - ? { created: 0, updated: 1, deleted: 0 } - : { created: 0, updated: 0, deleted: 0 }; - - const assets = computeAssetCounts(diff.assets); - - const counts: PushCounts = { - created: - screens.created + - widgets.created + - scripts.created + - actions.created + - translations.created + - theme.created + - assets.created, - updated: - screens.updated + - widgets.updated + - scripts.updated + - actions.updated + - translations.updated + - theme.updated + - assets.updated, - deleted: - screens.deleted + - widgets.deleted + - scripts.deleted + - actions.deleted + - translations.deleted + - theme.deleted + - assets.deleted, - }; - - return { - appId, - appName, - environment, - counts, - byKind: { - screens, - widgets, - scripts, - actions, - translations, - theme, - assets, - }, - }; -} - -export function computePushPlan(args: ComputePushPlanArgs): PushPlan { - const { appId, appName, environment, localApp, cloudApp, enabledByProp, updatedBy } = args; - - const bundle = buildMergedBundle(localApp, cloudApp, updatedBy); - let diff = computeBundleDiff(bundle, cloudApp, localApp); - - // Respect per-artifact app options: ignore changes for disabled kinds, driven by ArtifactProps. - for (const prop of ArtifactProps) { - if (enabledByProp[prop]) continue; - if (prop === 'theme') { - diff = { ...diff, themeChanged: false }; - continue; - } - const key = prop as Exclude; - const current = diff[key] ?? { changed: [], new: [] }; - diff = { - ...diff, - [key]: { - ...current, - changed: [], - new: [], - }, - } as BundleDiff; - } - - const summary = computePushSummary(appId, appName, environment, diff); - - return { - appId, - appName, - environment, - bundle, - diff, - summary, - }; -} - -export type PullOperation = 'create' | 'update' | 'delete'; - -export interface PullChange { - readonly kind: string; - readonly file: string; - readonly operation: PullOperation; -} - -export interface PullSummary { - readonly appName: string; - readonly environment: string; - readonly created: number; - readonly updated: number; - readonly deleted: number; - readonly skipped: number; - readonly changes: readonly PullChange[]; -} - -export interface PullPlan { - readonly summary: PullSummary; - readonly manifestExpected: RootManifest; - readonly allArtifactsMatch: boolean; - readonly manifestMatch: boolean; -} - -export interface ComputePullPlanArgs { - appName: string; - environment: string; - cloudApp: CloudApp; - localFiles: ParsedAppFiles; - manifestExisting: RootManifest; - enabledByProp: Record; - localEnv?: LocalEnvFiles; -} - -export function computePullPlan({ - appName, - environment, - cloudApp, - localFiles, - manifestExisting, - enabledByProp, - localEnv, -}: ComputePullPlanArgs): PullPlan { - const matchesByProp: Partial> = {}; - let assetsMatch = true; - - for (const cfg of ARTIFACT_FS_CONFIG) { - const { prop, isTheme, ext } = cfg; - if (!enabledByProp[prop]) { - matchesByProp[prop] = true; - continue; - } - - if (isTheme) { - const expectedThemeContent = - cloudApp.theme && cloudApp.theme.isArchived !== true - ? (cloudApp.theme.content ?? '') - : undefined; - let themeMatch = true; - if (expectedThemeContent === undefined) { - themeMatch = localFiles.theme === undefined; - } else { - themeMatch = - localFiles.theme !== undefined && - normalizeContentForCompare(localFiles.theme) === - normalizeContentForCompare(expectedThemeContent); - } - matchesByProp[prop] = themeMatch; - continue; - } - - const expected: Record = {}; - const cloudItems = (cloudApp as Record)[prop] as - | { name: string; content?: string; isArchived?: boolean }[] - | undefined; - for (const item of cloudItems ?? []) { - if (item.isArchived === true) continue; - expected[`${item.name}${ext!}`] = item.content ?? ''; - } - const actual = (localFiles as unknown as Record)[prop] as - | Record - | undefined; - const actualMap = actual ?? {}; - - const expectedKeys = Object.keys(expected).sort(); - const actualKeys = Object.keys(actualMap).sort(); - - let equal = expectedKeys.length === actualKeys.length; - for (let i = 0; equal && i < expectedKeys.length; i += 1) { - if (expectedKeys[i] !== actualKeys[i]) { - equal = false; - break; - } - const k = expectedKeys[i]!; - if ( - normalizeContentForCompare(expected[k] ?? '') !== - normalizeContentForCompare(actualMap[k] ?? '') - ) { - equal = false; - break; - } - } - matchesByProp[prop] = equal; - } - - const manifestExpected = buildManifestObject(manifestExisting, cloudApp); - const manifestExpectedRaw = JSON.stringify(manifestExpected, null, 2) + '\n'; - const manifestExistingRaw = JSON.stringify(manifestExisting, null, 2) + '\n'; - const manifestMatch = manifestExistingRaw === manifestExpectedRaw; - - // Asset files live under assets/ and are binary, so they are not part of ArtifactProps/ARTIFACT_FS_CONFIG. - // Track their match separately so "Nothing to pull" is only true when assets also match. - { - const cloudActiveAssets = ((cloudApp.assets ?? []) as AssetDTO[]).filter( - (a) => a.isArchived !== true - ); - const expected = new Set(cloudActiveAssets.map((a) => a.fileName).filter(Boolean)); - const actual = new Set((localFiles.assetFiles ?? []).filter(Boolean)); - assetsMatch = expected.size === actual.size; - if (assetsMatch) { - for (const f of expected) { - if (!actual.has(f)) { - assetsMatch = false; - break; - } - } - } - } - - const envPull = computeEnvPullChanges( - localEnv, - cloudApp.config, - cloudApp.secrets, - localFiles.assetFiles ?? [], - cloudApp.assets - ); - const envMatch = envPull.match; - - const allArtifactsMatch = - ArtifactProps.every((prop) => matchesByProp[prop] ?? true) && assetsMatch && envMatch; - - const changes: PullChange[] = []; - let createdCount = 0; - let updatedCount = 0; - let deletedCount = 0; - let skippedCount = 0; - - for (const cfg of ARTIFACT_FS_CONFIG) { - const { prop, isTheme, ext } = cfg; - if (!enabledByProp[prop]) continue; - - if (isTheme) { - const expectedThemeContent = - cloudApp.theme && cloudApp.theme.isArchived !== true - ? (cloudApp.theme.content ?? '') - : undefined; - const actualTheme = localFiles.theme; - if ( - expectedThemeContent === undefined - ? actualTheme === undefined - : actualTheme !== undefined && - normalizeContentForCompare(actualTheme) === - normalizeContentForCompare(expectedThemeContent) - ) - continue; - if (expectedThemeContent && !actualTheme) { - createdCount += 1; - changes.push({ - kind: 'theme', - file: 'theme.yaml', - operation: 'create', - }); - } else if (!expectedThemeContent && actualTheme) { - deletedCount += 1; - changes.push({ - kind: 'theme', - file: 'theme.yaml', - operation: 'delete', - }); - } else { - updatedCount += 1; - changes.push({ - kind: 'theme', - file: 'theme.yaml', - operation: 'update', - }); - } - continue; - } - - const kind = getArtifactConfig(prop as Exclude).label; - const expected: Record = {}; - const cloudItems = (cloudApp as Record)[prop] as - | { name: string; content?: string; isArchived?: boolean }[] - | undefined; - for (const item of cloudItems ?? []) { - if (item.isArchived === true) continue; - expected[`${item.name}${ext!}`] = item.content ?? ''; - } - const actual = (localFiles as unknown as Record)[prop] as - | Record - | undefined; - const actualMap = actual ?? {}; - - const expectedKeys = new Set(Object.keys(expected)); - const actualKeys = new Set(Object.keys(actualMap)); - - for (const file of expectedKeys) { - if (!actualKeys.has(file)) { - createdCount += 1; - changes.push({ - kind, - file: `${prop}/${file}`, - operation: 'create', - }); - } - } - for (const file of actualKeys) { - if (!expectedKeys.has(file)) { - deletedCount += 1; - changes.push({ - kind, - file: `${prop}/${file}`, - operation: 'delete', - }); - } - } - for (const file of expectedKeys) { - if (!actualKeys.has(file)) continue; - if (expected[file] !== actualMap[file]) { - updatedCount += 1; - changes.push({ - kind, - file: `${prop}/${file}`, - operation: 'update', - }); - } - } - } - - // Assets are binary files under assets/, so they are handled separately from ARTIFACT_FS_CONFIG. - // We only plan create/delete based on file presence; we do not attempt to detect modifications. - const cloudActiveAssets = ((cloudApp.assets ?? []) as AssetDTO[]).filter( - (a) => a.isArchived !== true - ); - const expected = new Set(cloudActiveAssets.map((a) => a.fileName).filter(Boolean)); - const actual = new Set((localFiles.assetFiles ?? []).filter(Boolean)); - - for (const fileName of expected) { - if (!actual.has(fileName)) { - createdCount += 1; - changes.push({ kind: 'asset', file: `assets/${fileName}`, operation: 'create' }); - } - } - for (const fileName of actual) { - if (!expected.has(fileName)) { - deletedCount += 1; - changes.push({ kind: 'asset', file: `assets/${fileName}`, operation: 'delete' }); - } - } - - // If the cloud has assets without publicUrl, we can't download them; count as skipped so the summary is honest. - const missingPublicUrl = cloudActiveAssets.filter( - (a) => !a.publicUrl || typeof a.publicUrl !== 'string' || a.publicUrl.trim() === '' - ).length; - skippedCount += missingPublicUrl; - - for (const envFile of envPull.filesToUpdate) { - updatedCount += 1; - changes.push({ kind: 'env', file: envFile, operation: 'update' }); - } - - const summary: PullSummary = { - appName, - environment, - created: createdCount, - updated: updatedCount, - deleted: deletedCount, - skipped: skippedCount, - changes, - }; - - return { - summary, - manifestExpected, - allArtifactsMatch, - manifestMatch, - }; -} +import type { CloudApp } from '../cloud/firestoreClient.js'; +import type { ParsedAppFiles } from './appCollector.js'; +import type { ApplicationDTO } from './dto.js'; +import { computeEnvPullChanges, type LocalEnvFiles } from './envSync.js'; +import { + ArtifactProps, + type ArtifactProp, + ARTIFACT_FS_CONFIG, + getArtifactConfig, +} from './artifacts.js'; +import type { BundleDiff } from './bundleDiff.js'; +import { computeBundleDiff, normalizeContentForCompare } from './bundleDiff.js'; +import { buildMergedBundle } from './buildDocuments.js'; +import type { RootManifest } from './manifest.js'; +import { buildManifestObject } from './manifest.js'; +import type { AssetDTO } from './dto.js'; + +export interface PushCounts { + created: number; + updated: number; + deleted: number; +} + +export interface PushSummary { + appId: string; + appName: string; + environment: string; + counts: PushCounts; + byKind: { + screens: PushCounts; + widgets: PushCounts; + scripts: PushCounts; + actions: PushCounts; + translations: PushCounts; + theme: PushCounts; + assets: PushCounts; + }; +} + +export interface PushPlan { + appId: string; + appName: string; + environment: string; + bundle: ApplicationDTO; + diff: BundleDiff; + summary: PushSummary; +} + +export interface ComputePushPlanArgs { + appId: string; + appName: string; + environment: string; + localApp: ApplicationDTO; + cloudApp: CloudApp; + enabledByProp: Record; + updatedBy: { name: string; email?: string; id: string }; +} + +function computeKindCounts(items: BundleDiff['screens']): PushCounts { + let created = 0; + let updated = 0; + let deleted = 0; + + created += items.new.length; + for (const item of items.changed) { + if (item.isArchived) { + deleted += 1; + } else { + updated += 1; + } + } + + return { created, updated, deleted }; +} + +function computeAssetCounts(items: BundleDiff['assets']): PushCounts { + const deleted = items.changed.filter((item) => item.isArchived === true).length; + return { + created: items.new.length, + updated: items.changed.filter((item) => item.isArchived !== true).length, + deleted, + }; +} + +function computePushSummary( + appId: string, + appName: string, + environment: string, + diff: BundleDiff +): PushSummary { + const screens = computeKindCounts(diff.screens); + const widgets = computeKindCounts(diff.widgets); + const scripts = computeKindCounts(diff.scripts); + const actions = computeKindCounts(diff.actions); + const translations = computeKindCounts(diff.translations); + + // Theme currently only supports modified (no explicit create/delete in diff), + // so treat a changed theme as an update. + const theme: PushCounts = diff.themeChanged + ? { created: 0, updated: 1, deleted: 0 } + : { created: 0, updated: 0, deleted: 0 }; + + const assets = computeAssetCounts(diff.assets); + + const counts: PushCounts = { + created: + screens.created + + widgets.created + + scripts.created + + actions.created + + translations.created + + theme.created + + assets.created, + updated: + screens.updated + + widgets.updated + + scripts.updated + + actions.updated + + translations.updated + + theme.updated + + assets.updated, + deleted: + screens.deleted + + widgets.deleted + + scripts.deleted + + actions.deleted + + translations.deleted + + theme.deleted + + assets.deleted, + }; + + return { + appId, + appName, + environment, + counts, + byKind: { + screens, + widgets, + scripts, + actions, + translations, + theme, + assets, + }, + }; +} + +export function computePushPlan(args: ComputePushPlanArgs): PushPlan { + const { appId, appName, environment, localApp, cloudApp, enabledByProp, updatedBy } = args; + + const bundle = buildMergedBundle(localApp, cloudApp, updatedBy); + let diff = computeBundleDiff(bundle, cloudApp, localApp); + + // Respect per-artifact app options: ignore changes for disabled kinds, driven by ArtifactProps. + for (const prop of ArtifactProps) { + if (enabledByProp[prop]) continue; + if (prop === 'theme') { + diff = { ...diff, themeChanged: false }; + continue; + } + const key = prop as Exclude; + const current = diff[key] ?? { changed: [], new: [] }; + diff = { + ...diff, + [key]: { + ...current, + changed: [], + new: [], + }, + } as BundleDiff; + } + + const summary = computePushSummary(appId, appName, environment, diff); + + return { + appId, + appName, + environment, + bundle, + diff, + summary, + }; +} + +export type PullOperation = 'create' | 'update' | 'delete'; + +export interface PullChange { + readonly kind: string; + readonly file: string; + readonly operation: PullOperation; +} + +export interface PullSummary { + readonly appName: string; + readonly environment: string; + readonly created: number; + readonly updated: number; + readonly deleted: number; + readonly skipped: number; + readonly changes: readonly PullChange[]; +} + +export interface PullPlan { + readonly summary: PullSummary; + readonly manifestExpected: RootManifest; + readonly allArtifactsMatch: boolean; + readonly manifestMatch: boolean; +} + +export interface ComputePullPlanArgs { + appName: string; + environment: string; + cloudApp: CloudApp; + localFiles: ParsedAppFiles; + manifestExisting: RootManifest; + enabledByProp: Record; + localEnv?: LocalEnvFiles; +} + +export function computePullPlan({ + appName, + environment, + cloudApp, + localFiles, + manifestExisting, + enabledByProp, + localEnv, +}: ComputePullPlanArgs): PullPlan { + const matchesByProp: Partial> = {}; + let assetsMatch = true; + + for (const cfg of ARTIFACT_FS_CONFIG) { + const { prop, isTheme, ext } = cfg; + if (!enabledByProp[prop]) { + matchesByProp[prop] = true; + continue; + } + + if (isTheme) { + const expectedThemeContent = + cloudApp.theme && cloudApp.theme.isArchived !== true + ? (cloudApp.theme.content ?? '') + : undefined; + let themeMatch = true; + if (expectedThemeContent === undefined) { + themeMatch = localFiles.theme === undefined; + } else { + themeMatch = + localFiles.theme !== undefined && + normalizeContentForCompare(localFiles.theme) === + normalizeContentForCompare(expectedThemeContent); + } + matchesByProp[prop] = themeMatch; + continue; + } + + const expected: Record = {}; + const cloudItems = (cloudApp as Record)[prop] as + | { name: string; content?: string; isArchived?: boolean }[] + | undefined; + for (const item of cloudItems ?? []) { + if (item.isArchived === true) continue; + expected[`${item.name}${ext!}`] = item.content ?? ''; + } + const actual = (localFiles as unknown as Record)[prop] as + | Record + | undefined; + const actualMap = actual ?? {}; + + const expectedKeys = Object.keys(expected).sort(); + const actualKeys = Object.keys(actualMap).sort(); + + let equal = expectedKeys.length === actualKeys.length; + for (let i = 0; equal && i < expectedKeys.length; i += 1) { + if (expectedKeys[i] !== actualKeys[i]) { + equal = false; + break; + } + const k = expectedKeys[i]!; + if ( + normalizeContentForCompare(expected[k] ?? '') !== + normalizeContentForCompare(actualMap[k] ?? '') + ) { + equal = false; + break; + } + } + matchesByProp[prop] = equal; + } + + const manifestExpected = buildManifestObject(manifestExisting, cloudApp); + const manifestExpectedRaw = JSON.stringify(manifestExpected, null, 2) + '\n'; + const manifestExistingRaw = JSON.stringify(manifestExisting, null, 2) + '\n'; + const manifestMatch = manifestExistingRaw === manifestExpectedRaw; + + // Asset files live under assets/ and are binary, so they are not part of ArtifactProps/ARTIFACT_FS_CONFIG. + // Track their match separately so "Nothing to pull" is only true when assets also match. + { + const cloudActiveAssets = ((cloudApp.assets ?? []) as AssetDTO[]).filter( + (a) => a.isArchived !== true + ); + const expected = new Set(cloudActiveAssets.map((a) => a.fileName).filter(Boolean)); + const actual = new Set((localFiles.assetFiles ?? []).filter(Boolean)); + assetsMatch = expected.size === actual.size; + if (assetsMatch) { + for (const f of expected) { + if (!actual.has(f)) { + assetsMatch = false; + break; + } + } + } + } + + const envPull = computeEnvPullChanges( + localEnv, + cloudApp.config, + cloudApp.secrets, + localFiles.assetFiles ?? [], + cloudApp.assets + ); + const envMatch = envPull.match; + + const allArtifactsMatch = + ArtifactProps.every((prop) => matchesByProp[prop] ?? true) && assetsMatch && envMatch; + + const changes: PullChange[] = []; + let createdCount = 0; + let updatedCount = 0; + let deletedCount = 0; + let skippedCount = 0; + + for (const cfg of ARTIFACT_FS_CONFIG) { + const { prop, isTheme, ext } = cfg; + if (!enabledByProp[prop]) continue; + + if (isTheme) { + const expectedThemeContent = + cloudApp.theme && cloudApp.theme.isArchived !== true + ? (cloudApp.theme.content ?? '') + : undefined; + const actualTheme = localFiles.theme; + if ( + expectedThemeContent === undefined + ? actualTheme === undefined + : actualTheme !== undefined && + normalizeContentForCompare(actualTheme) === + normalizeContentForCompare(expectedThemeContent) + ) + continue; + if (expectedThemeContent && !actualTheme) { + createdCount += 1; + changes.push({ + kind: 'theme', + file: 'theme.yaml', + operation: 'create', + }); + } else if (!expectedThemeContent && actualTheme) { + deletedCount += 1; + changes.push({ + kind: 'theme', + file: 'theme.yaml', + operation: 'delete', + }); + } else { + updatedCount += 1; + changes.push({ + kind: 'theme', + file: 'theme.yaml', + operation: 'update', + }); + } + continue; + } + + const kind = getArtifactConfig(prop as Exclude).label; + const expected: Record = {}; + const cloudItems = (cloudApp as Record)[prop] as + | { name: string; content?: string; isArchived?: boolean }[] + | undefined; + for (const item of cloudItems ?? []) { + if (item.isArchived === true) continue; + expected[`${item.name}${ext!}`] = item.content ?? ''; + } + const actual = (localFiles as unknown as Record)[prop] as + | Record + | undefined; + const actualMap = actual ?? {}; + + const expectedKeys = new Set(Object.keys(expected)); + const actualKeys = new Set(Object.keys(actualMap)); + + for (const file of expectedKeys) { + if (!actualKeys.has(file)) { + createdCount += 1; + changes.push({ + kind, + file: `${prop}/${file}`, + operation: 'create', + }); + } + } + for (const file of actualKeys) { + if (!expectedKeys.has(file)) { + deletedCount += 1; + changes.push({ + kind, + file: `${prop}/${file}`, + operation: 'delete', + }); + } + } + for (const file of expectedKeys) { + if (!actualKeys.has(file)) continue; + if (expected[file] !== actualMap[file]) { + updatedCount += 1; + changes.push({ + kind, + file: `${prop}/${file}`, + operation: 'update', + }); + } + } + } + + // Assets are binary files under assets/, so they are handled separately from ARTIFACT_FS_CONFIG. + // We only plan create/delete based on file presence; we do not attempt to detect modifications. + const cloudActiveAssets = ((cloudApp.assets ?? []) as AssetDTO[]).filter( + (a) => a.isArchived !== true + ); + const expected = new Set(cloudActiveAssets.map((a) => a.fileName).filter(Boolean)); + const actual = new Set((localFiles.assetFiles ?? []).filter(Boolean)); + + for (const fileName of expected) { + if (!actual.has(fileName)) { + createdCount += 1; + changes.push({ kind: 'asset', file: `assets/${fileName}`, operation: 'create' }); + } + } + for (const fileName of actual) { + if (!expected.has(fileName)) { + deletedCount += 1; + changes.push({ kind: 'asset', file: `assets/${fileName}`, operation: 'delete' }); + } + } + + // If the cloud has assets without publicUrl, we can't download them; count as skipped so the summary is honest. + const missingPublicUrl = cloudActiveAssets.filter( + (a) => !a.publicUrl || typeof a.publicUrl !== 'string' || a.publicUrl.trim() === '' + ).length; + skippedCount += missingPublicUrl; + + for (const envFile of envPull.filesToUpdate) { + updatedCount += 1; + changes.push({ kind: 'env', file: envFile, operation: 'update' }); + } + + const summary: PullSummary = { + appName, + environment, + created: createdCount, + updated: updatedCount, + deleted: deletedCount, + skipped: skippedCount, + changes, + }; + + return { + summary, + manifestExpected, + allArtifactsMatch, + manifestMatch, + }; +} diff --git a/src/index.ts b/src/index.ts index b6a3d9d..57ebd0a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,280 +1,280 @@ -#!/usr/bin/env node -import { Command } from 'commander'; -import { exec } from 'node:child_process'; -import prompts from 'prompts'; - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const pkg = require('../package.json') as { version: string }; -const LOCAL_VERSION = pkg.version; - -import { loginCommand } from './commands/login.js'; -import { logoutCommand } from './commands/logout.js'; -import { tokenCommand } from './commands/token.js'; -import { initCommand } from './commands/init.js'; -import { pushCommand } from './commands/push.js'; -import { addCommand } from './commands/add.js'; -import { pullCommand } from './commands/pull.js'; -import { - releaseCreateCommand, - releaseListCommand, - releaseUseCommand, - resolveReleaseAppKey, -} from './commands/release.js'; -import { updateCommand } from './commands/update.js'; -import { enableCommand } from './commands/enable.js'; -import { isUpdateCommand } from './core/cliArgs.js'; -import { printCliError, resolveDebugFlag } from './core/cliError.js'; -import { ui } from './core/ui.js'; - -const program = new Command(); - -program - .name('ensemble') - .description('Ensemble CLI for logging in and configuring Ensemble apps.') - .version(LOCAL_VERSION) - .option('--debug', 'Print full debug information and stack traces', false); - -program - .command('login') - .description('Log in to Ensemble.') - .option('--verbose', 'Print additional login details', false) - .action(async (options: { verbose?: boolean }) => { - await loginCommand({ verbose: options.verbose }); - }); - -program - .command('logout') - .description('Log out of Ensemble.') - .action(async () => { - await logoutCommand(); - }); - -program - .command('token') - .description('Print refresh token for CI (use as ENSEMBLE_TOKEN). Run "ensemble login" first.') - .option('--quiet', 'Print only the token (no extra text)', false) - .option('--json', 'Print the token as JSON (for scripts)', false) - .action(async (options: { quiet?: boolean; json?: boolean }) => { - await tokenCommand({ quiet: options.quiet, json: options.json }); - }); - -program - .command('init') - .description('Initialize or update Ensemble config in the current project.') - .action(async () => { - await initCommand(); - }); - -program - .command('push') - .description('Scan the current app directory and prepare data for upload.') - .option('--app ', 'App alias to use (defaults to "default")') - .option('--verbose', 'Print the full collected data as JSON', false) - .option('--dry-run', 'Show what would be pushed without sending to cloud', false) - .option('-y, --yes', 'Skip confirmation prompt') - .action(async (options: { verbose?: boolean; app?: string; yes?: boolean; dryRun?: boolean }) => { - await pushCommand({ - verbose: options.verbose, - appKey: options.app, - yes: options.yes, - dryRun: options.dryRun, - }); - }); - -program - .command('pull') - .description('Pull app artifacts from the cloud and overwrite local files.') - .option('--app ', 'App alias to use (defaults to "default")') - .option('--verbose', 'Write fetched cloud JSON to disk', false) - .option('--dry-run', 'Show what would be pulled without modifying files', false) - .option('-y, --yes', 'Skip confirmation prompt') - .action(async (options: { verbose?: boolean; app?: string; yes?: boolean; dryRun?: boolean }) => { - await pullCommand({ - verbose: options.verbose, - appKey: options.app, - yes: options.yes, - dryRun: options.dryRun, - }); - }); - -const releaseCmd = program - .command('release') - .description('Manage releases (snapshots) of your app.') - .option( - '--app ', - 'App alias (defaults to ensemble.config.json default; place before or after subcommand)' - ); - -releaseCmd - .command('create') - .description('Create a release (snapshot) from the current cloud state (no push required).') - .option('-m, --message ', 'Release message (skips prompt)') - .option('-y, --yes', 'Skip message prompt (use empty message)') - .action(async (options: { message?: string; yes?: boolean }, command) => { - await releaseCreateCommand({ - appKey: resolveReleaseAppKey(command), - message: options.message, - yes: options.yes, - }); - }); - -releaseCmd - .command('list') - .description('List releases for an app.') - .option('--limit ', 'Maximum number of releases to show (default: 20)', (v) => Number(v), 20) - .option('--json', 'Print releases as JSON (for scripts)', false) - .action(async (options: { limit?: number; json?: boolean }, command) => { - await releaseListCommand({ - appKey: resolveReleaseAppKey(command), - limit: options.limit, - json: options.json, - }); - }); - -releaseCmd - .command('use') - .description( - 'Use a release (snapshot) to update local files (run "ensemble push" to sync cloud).' - ) - .option('--hash ', 'Release hash to use (non-interactive).') - .action(async (options: { hash?: string }, command) => { - await releaseUseCommand({ appKey: resolveReleaseAppKey(command), hash: options.hash }); - }); - -// If user runs just `ensemble release`, offer an interactive menu. -releaseCmd.action(async (options: { app?: string }) => { - const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); - if (!isInteractive) { - ui.error( - 'Subcommand required for non-interactive use. Try "ensemble release create|list|use".' - ); - process.exitCode = 1; - return; - } - - const { action } = await prompts({ - type: 'select', - name: 'action', - message: 'What do you want to do?', - choices: [ - { title: 'Create release (snapshot) from local state', value: 'create' }, - { title: 'List releases', value: 'list' }, - { title: 'Use release (update local files)', value: 'use' }, - ], - initial: 0, - }); - - if (!action) { - ui.warn('Release command cancelled.'); - process.exitCode = 130; - return; - } - - if (action === 'create') { - await releaseCreateCommand({ appKey: options.app }); - } else if (action === 'list') { - await releaseListCommand({ appKey: options.app }); - } else if (action === 'use') { - await releaseUseCommand({ appKey: options.app }); - } -}); - -program - .command('add') - .description('Add a new screen, widget, script, action, translation, or asset.') - .argument('[kind]', 'Artifact type: screen | widget | script | action | translation | asset') - .argument('[name]', 'Name/path of the artifact (asset expects a file path)') - .option('--overwrite', 'Overwrite existing file when adding (assets)', false) - .action(async (kind?: string, name?: string, options?: { overwrite?: boolean }) => { - let normalizedKind: - | 'screen' - | 'widget' - | 'script' - | 'action' - | 'translation' - | 'asset' - | undefined; - if (kind) { - const k = kind.toLowerCase(); - if ( - k === 'screen' || - k === 'widget' || - k === 'script' || - k === 'action' || - k === 'translation' || - k === 'asset' - ) { - normalizedKind = k; - } else { - throw new Error( - `Unknown artifact type "${kind}". Expected one of: screen, widget, script, action, translation, asset.` - ); - } - } - await addCommand(normalizedKind, name, { overwrite: options?.overwrite }); - }); - -program - .command('update') - .description('Update the Ensemble CLI to the latest version.') - .action(async () => { - 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; - const noCheck = process.env.ENSEMBLE_NO_UPDATE_CHECK; - if (ci || (noCheck && noCheck.trim() !== '' && noCheck.toLowerCase() !== '0')) { - return; - } - - // user is already updating; don't suggest running `ensemble update` again. - if (isUpdateCommand()) { - return; - } - - // IMPORTANT: This command string must remain a static literal and MUST NOT - // interpolate user-controlled input to avoid shell injection risks. - const child = exec( - 'npm view @ensembleui/cli version --registry=https://registry.npmjs.org', - { timeout: 5_000 }, - (error, stdout) => { - if (error) { - return; - } - const latest = stdout.trim(); - if (!latest || latest === LOCAL_VERSION) return; - - ui.warn(`A new version of @ensembleui/cli is available (${LOCAL_VERSION} → ${latest}).`); - ui.note('Run "ensemble update" to upgrade.'); - } - ); - child.unref(); -} - -checkForUpdates(); - -program.parseAsync(process.argv).catch((err) => { - const globalOptions = program.opts<{ debug?: boolean }>(); - const debugEnabled = resolveDebugFlag(globalOptions.debug); - printCliError(err, { debug: debugEnabled }); - process.exitCode = 1; -}); +#!/usr/bin/env node +import { Command } from 'commander'; +import { exec } from 'node:child_process'; +import prompts from 'prompts'; + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const pkg = require('../package.json') as { version: string }; +const LOCAL_VERSION = pkg.version; + +import { loginCommand } from './commands/login.js'; +import { logoutCommand } from './commands/logout.js'; +import { tokenCommand } from './commands/token.js'; +import { initCommand } from './commands/init.js'; +import { pushCommand } from './commands/push.js'; +import { addCommand } from './commands/add.js'; +import { pullCommand } from './commands/pull.js'; +import { + releaseCreateCommand, + releaseListCommand, + releaseUseCommand, + resolveReleaseAppKey, +} from './commands/release.js'; +import { updateCommand } from './commands/update.js'; +import { enableCommand } from './commands/enable.js'; +import { isUpdateCommand } from './core/cliArgs.js'; +import { printCliError, resolveDebugFlag } from './core/cliError.js'; +import { ui } from './core/ui.js'; + +const program = new Command(); + +program + .name('ensemble') + .description('Ensemble CLI for logging in and configuring Ensemble apps.') + .version(LOCAL_VERSION) + .option('--debug', 'Print full debug information and stack traces', false); + +program + .command('login') + .description('Log in to Ensemble.') + .option('--verbose', 'Print additional login details', false) + .action(async (options: { verbose?: boolean }) => { + await loginCommand({ verbose: options.verbose }); + }); + +program + .command('logout') + .description('Log out of Ensemble.') + .action(async () => { + await logoutCommand(); + }); + +program + .command('token') + .description('Print refresh token for CI (use as ENSEMBLE_TOKEN). Run "ensemble login" first.') + .option('--quiet', 'Print only the token (no extra text)', false) + .option('--json', 'Print the token as JSON (for scripts)', false) + .action(async (options: { quiet?: boolean; json?: boolean }) => { + await tokenCommand({ quiet: options.quiet, json: options.json }); + }); + +program + .command('init') + .description('Initialize or update Ensemble config in the current project.') + .action(async () => { + await initCommand(); + }); + +program + .command('push') + .description('Scan the current app directory and prepare data for upload.') + .option('--app ', 'App alias to use (defaults to "default")') + .option('--verbose', 'Print the full collected data as JSON', false) + .option('--dry-run', 'Show what would be pushed without sending to cloud', false) + .option('-y, --yes', 'Skip confirmation prompt') + .action(async (options: { verbose?: boolean; app?: string; yes?: boolean; dryRun?: boolean }) => { + await pushCommand({ + verbose: options.verbose, + appKey: options.app, + yes: options.yes, + dryRun: options.dryRun, + }); + }); + +program + .command('pull') + .description('Pull app artifacts from the cloud and overwrite local files.') + .option('--app ', 'App alias to use (defaults to "default")') + .option('--verbose', 'Write fetched cloud JSON to disk', false) + .option('--dry-run', 'Show what would be pulled without modifying files', false) + .option('-y, --yes', 'Skip confirmation prompt') + .action(async (options: { verbose?: boolean; app?: string; yes?: boolean; dryRun?: boolean }) => { + await pullCommand({ + verbose: options.verbose, + appKey: options.app, + yes: options.yes, + dryRun: options.dryRun, + }); + }); + +const releaseCmd = program + .command('release') + .description('Manage releases (snapshots) of your app.') + .option( + '--app ', + 'App alias (defaults to ensemble.config.json default; place before or after subcommand)' + ); + +releaseCmd + .command('create') + .description('Create a release (snapshot) from the current cloud state (no push required).') + .option('-m, --message ', 'Release message (skips prompt)') + .option('-y, --yes', 'Skip message prompt (use empty message)') + .action(async (options: { message?: string; yes?: boolean }, command) => { + await releaseCreateCommand({ + appKey: resolveReleaseAppKey(command), + message: options.message, + yes: options.yes, + }); + }); + +releaseCmd + .command('list') + .description('List releases for an app.') + .option('--limit ', 'Maximum number of releases to show (default: 20)', (v) => Number(v), 20) + .option('--json', 'Print releases as JSON (for scripts)', false) + .action(async (options: { limit?: number; json?: boolean }, command) => { + await releaseListCommand({ + appKey: resolveReleaseAppKey(command), + limit: options.limit, + json: options.json, + }); + }); + +releaseCmd + .command('use') + .description( + 'Use a release (snapshot) to update local files (run "ensemble push" to sync cloud).' + ) + .option('--hash ', 'Release hash to use (non-interactive).') + .action(async (options: { hash?: string }, command) => { + await releaseUseCommand({ appKey: resolveReleaseAppKey(command), hash: options.hash }); + }); + +// If user runs just `ensemble release`, offer an interactive menu. +releaseCmd.action(async (options: { app?: string }) => { + const isInteractive = Boolean(process.stdout.isTTY && process.stdin.isTTY); + if (!isInteractive) { + ui.error( + 'Subcommand required for non-interactive use. Try "ensemble release create|list|use".' + ); + process.exitCode = 1; + return; + } + + const { action } = await prompts({ + type: 'select', + name: 'action', + message: 'What do you want to do?', + choices: [ + { title: 'Create release (snapshot) from local state', value: 'create' }, + { title: 'List releases', value: 'list' }, + { title: 'Use release (update local files)', value: 'use' }, + ], + initial: 0, + }); + + if (!action) { + ui.warn('Release command cancelled.'); + process.exitCode = 130; + return; + } + + if (action === 'create') { + await releaseCreateCommand({ appKey: options.app }); + } else if (action === 'list') { + await releaseListCommand({ appKey: options.app }); + } else if (action === 'use') { + await releaseUseCommand({ appKey: options.app }); + } +}); + +program + .command('add') + .description('Add a new screen, widget, script, action, translation, or asset.') + .argument('[kind]', 'Artifact type: screen | widget | script | action | translation | asset') + .argument('[name]', 'Name/path of the artifact (asset expects a file path)') + .option('--overwrite', 'Overwrite existing file when adding (assets)', false) + .action(async (kind?: string, name?: string, options?: { overwrite?: boolean }) => { + let normalizedKind: + | 'screen' + | 'widget' + | 'script' + | 'action' + | 'translation' + | 'asset' + | undefined; + if (kind) { + const k = kind.toLowerCase(); + if ( + k === 'screen' || + k === 'widget' || + k === 'script' || + k === 'action' || + k === 'translation' || + k === 'asset' + ) { + normalizedKind = k; + } else { + throw new Error( + `Unknown artifact type "${kind}". Expected one of: screen, widget, script, action, translation, asset.` + ); + } + } + await addCommand(normalizedKind, name, { overwrite: options?.overwrite }); + }); + +program + .command('update') + .description('Update the Ensemble CLI to the latest version.') + .action(async () => { + 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; + const noCheck = process.env.ENSEMBLE_NO_UPDATE_CHECK; + if (ci || (noCheck && noCheck.trim() !== '' && noCheck.toLowerCase() !== '0')) { + return; + } + + // user is already updating; don't suggest running `ensemble update` again. + if (isUpdateCommand()) { + return; + } + + // IMPORTANT: This command string must remain a static literal and MUST NOT + // interpolate user-controlled input to avoid shell injection risks. + const child = exec( + 'npm view @ensembleui/cli version --registry=https://registry.npmjs.org', + { timeout: 5_000 }, + (error, stdout) => { + if (error) { + return; + } + const latest = stdout.trim(); + if (!latest || latest === LOCAL_VERSION) return; + + ui.warn(`A new version of @ensembleui/cli is available (${LOCAL_VERSION} → ${latest}).`); + ui.note('Run "ensemble update" to upgrade.'); + } + ); + child.unref(); +} + +checkForUpdates(); + +program.parseAsync(process.argv).catch((err) => { + const globalOptions = program.opts<{ debug?: boolean }>(); + const debugEnabled = resolveDebugFlag(globalOptions.debug); + printCliError(err, { debug: debugEnabled }); + process.exitCode = 1; +}); diff --git a/src/lib/spinner.ts b/src/lib/spinner.ts index 4cf08aa..28a30ec 100644 --- a/src/lib/spinner.ts +++ b/src/lib/spinner.ts @@ -1,18 +1,23 @@ -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - -export async function withSpinner(message: string, fn: () => Promise): Promise { - let i = 0; - const id = setInterval(() => { - process.stdout.write(`\r ${SPINNER_FRAMES[i++ % SPINNER_FRAMES.length]} ${message} `); - }, 80); - try { - const result = await fn(); - clearInterval(id); - process.stdout.write(`\r ✓ ${message}\n`); - return result; - } catch (e) { - clearInterval(id); - process.stdout.write(`\r ✗ ${message}\n`); - throw e; - } -} +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +export async function withSpinner(message: string, fn: () => Promise): Promise { + const disabled = process.env.ENSEMBLE_NO_SPINNER === '1'; + if (disabled) { + return fn(); + } + + let i = 0; + const id = setInterval(() => { + process.stdout.write(`\r ${SPINNER_FRAMES[i++ % SPINNER_FRAMES.length]} ${message} `); + }, 80); + try { + const result = await fn(); + clearInterval(id); + process.stdout.write(`\r ✓ ${message}\n`); + return result; + } catch (e) { + clearInterval(id); + process.stdout.write(`\r ✗ ${message}\n`); + throw e; + } +} diff --git a/tests/auth/session.test.ts b/tests/auth/session.test.ts index c2ab841..0ec9826 100644 --- a/tests/auth/session.test.ts +++ b/tests/auth/session.test.ts @@ -1,264 +1,264 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { getValidAuthSession } from '../../src/auth/session.js'; -import type { EnsembleUserConfig } from '../../src/config/globalConfig.js'; -import * as globalConfig from '../../src/config/globalConfig.js'; - -vi.mock('../../src/config/globalConfig.js', () => ({ - readGlobalConfig: vi.fn(), - writeGlobalConfig: vi.fn(), -})); - -function makeJwt(payload: Record): string { - const base64url = (str: string) => - Buffer.from(str, 'utf8') - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); - const payloadB64 = base64url(JSON.stringify(payload)); - return `${header}.${payloadB64}.${base64url('sig')}`; -} - -describe('getValidAuthSession', () => { - const originalEnv = process.env.ENSEMBLE_FIREBASE_API_KEY; - const originalToken = process.env.ENSEMBLE_TOKEN; - - beforeEach(() => { - vi.mocked(globalConfig.readGlobalConfig).mockReset(); - process.env.ENSEMBLE_FIREBASE_API_KEY = 'test-api-key'; - delete process.env.ENSEMBLE_TOKEN; - }); - - afterEach(() => { - process.env.ENSEMBLE_FIREBASE_API_KEY = originalEnv; - if (originalToken !== undefined) process.env.ENSEMBLE_TOKEN = originalToken; - else delete process.env.ENSEMBLE_TOKEN; - }); - - it('returns session from ENSEMBLE_TOKEN when set and refresh succeeds', async () => { - process.env.ENSEMBLE_TOKEN = 'env-refresh-token'; - const newToken = makeJwt({ - userId: 'u2', - email: 'ci@example.com', - exp: Math.floor(Date.now() / 1000) + 3600, - }); - const originalFetch = globalThis.fetch; - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - id_token: newToken, - refresh_token: 'env-refresh-token', - expires_in: '3600', - }), - }); - - const result = await getValidAuthSession(); - - globalThis.fetch = originalFetch; - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.idToken).toBe(newToken); - expect(result.userId).toBe('u2'); - expect(result.email).toBe('ci@example.com'); - expect(result.refreshed).toBe(true); - } - expect(globalConfig.readGlobalConfig).not.toHaveBeenCalled(); - }); - - it('returns expired when ENSEMBLE_TOKEN is set but refresh fails', async () => { - process.env.ENSEMBLE_TOKEN = 'bad-refresh-token'; - const originalFetch = globalThis.fetch; - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: false, - status: 400, - json: async () => ({ error: { message: 'INVALID_GRANT' } }), - }); - - const result = await getValidAuthSession(); - - globalThis.fetch = originalFetch; - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe('expired'); - expect(result.message).toContain('ENSEMBLE_TOKEN'); - expect(result.message).toContain('ensemble token'); - } - expect(globalConfig.readGlobalConfig).not.toHaveBeenCalled(); - }); - - it('returns not_logged_in when no config', async () => { - vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue(null); - - const result = await getValidAuthSession(); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe('not_logged_in'); - expect(result.message).toContain('Run `ensemble login`'); - } - }); - - it('returns not_logged_in when user has no idToken', async () => { - vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue({ - user: { uid: 'u1' }, - } as EnsembleUserConfig); - - const result = await getValidAuthSession(); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe('not_logged_in'); - expect(result.message).toContain('Run `ensemble login`'); - } - }); - - it('returns ok when token is valid and not expired', async () => { - const token = makeJwt({ - userId: 'u1', - email: 'a@b.com', - exp: Math.floor(Date.now() / 1000) + 3600, - }); - vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue({ - user: { - uid: 'u1', - email: 'a@b.com', - idToken: token, - }, - }); - - const result = await getValidAuthSession(); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.idToken).toBe(token); - expect(result.userId).toBe('u1'); - expect(result.email).toBe('a@b.com'); - expect(result.refreshed).toBe(false); - } - }); - - it('returns expired when token expired and no refresh token', async () => { - const token = makeJwt({ - userId: 'u1', - exp: Math.floor(Date.now() / 1000) - 3600, - }); - vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue({ - user: { - uid: 'u1', - idToken: token, - }, - }); - - const result = await getValidAuthSession(); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe('expired'); - expect(result.message).toContain('Run `ensemble login` again.'); - } - }); - - it('returns expired with friendly hint when refresh fails', async () => { - const expiredToken = makeJwt({ - userId: 'u1', - exp: Math.floor(Date.now() / 1000) - 3600, - }); - vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue({ - user: { - uid: 'u1', - idToken: expiredToken, - refreshToken: 'refresh-token', - }, - }); - delete process.env.ENSEMBLE_FIREBASE_API_KEY; - - const result = await getValidAuthSession(); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe('expired'); - expect(result.message).toContain('Run `ensemble login` again.'); - } - }); - - it('returns ok without refresh when jwt is valid even if legacy config has stale expiresAt', async () => { - const token = makeJwt({ - userId: 'u1', - email: 'a@b.com', - exp: Math.floor(Date.now() / 1000) + 3600, - }); - vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue({ - user: { - uid: 'u1', - email: 'a@b.com', - idToken: token, - refreshToken: 'refresh-123', - expiresAt: Date.now() - 3600_000, - }, - } as EnsembleUserConfig); - - const fetchMock = vi.fn(); - const originalFetch = globalThis.fetch; - globalThis.fetch = fetchMock; - - const result = await getValidAuthSession(); - - globalThis.fetch = originalFetch; - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.idToken).toBe(token); - expect(result.refreshed).toBe(false); - } - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('refreshes token when expired and refresh token exists', async () => { - const oldToken = makeJwt({ - userId: 'u1', - exp: Math.floor(Date.now() / 1000) - 3600, - }); - const newToken = makeJwt({ - userId: 'u1', - email: 'a@b.com', - exp: Math.floor(Date.now() / 1000) + 3600, - }); - vi.mocked(globalConfig.readGlobalConfig) - .mockResolvedValueOnce({ - user: { - uid: 'u1', - idToken: oldToken, - refreshToken: 'refresh-123', - }, - }) - .mockResolvedValue({ - user: { - uid: 'u1', - idToken: newToken, - refreshToken: 'refresh-123', - }, - }); - - const originalFetch = globalThis.fetch; - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - id_token: newToken, - refresh_token: 'refresh-456', - expires_in: '3600', - }), - }); - - const result = await getValidAuthSession(); - - globalThis.fetch = originalFetch; - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.idToken).toBe(newToken); - expect(result.refreshed).toBe(true); - } - expect(globalConfig.writeGlobalConfig).toHaveBeenCalled(); - }); -}); +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { getValidAuthSession } from '../../src/auth/session.js'; +import type { EnsembleUserConfig } from '../../src/config/globalConfig.js'; +import * as globalConfig from '../../src/config/globalConfig.js'; + +vi.mock('../../src/config/globalConfig.js', () => ({ + readGlobalConfig: vi.fn(), + writeGlobalConfig: vi.fn(), +})); + +function makeJwt(payload: Record): string { + const base64url = (str: string) => + Buffer.from(str, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + const header = base64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payloadB64 = base64url(JSON.stringify(payload)); + return `${header}.${payloadB64}.${base64url('sig')}`; +} + +describe('getValidAuthSession', () => { + const originalEnv = process.env.ENSEMBLE_FIREBASE_API_KEY; + const originalToken = process.env.ENSEMBLE_TOKEN; + + beforeEach(() => { + vi.mocked(globalConfig.readGlobalConfig).mockReset(); + process.env.ENSEMBLE_FIREBASE_API_KEY = 'test-api-key'; + delete process.env.ENSEMBLE_TOKEN; + }); + + afterEach(() => { + process.env.ENSEMBLE_FIREBASE_API_KEY = originalEnv; + if (originalToken !== undefined) process.env.ENSEMBLE_TOKEN = originalToken; + else delete process.env.ENSEMBLE_TOKEN; + }); + + it('returns session from ENSEMBLE_TOKEN when set and refresh succeeds', async () => { + process.env.ENSEMBLE_TOKEN = 'env-refresh-token'; + const newToken = makeJwt({ + userId: 'u2', + email: 'ci@example.com', + exp: Math.floor(Date.now() / 1000) + 3600, + }); + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id_token: newToken, + refresh_token: 'env-refresh-token', + expires_in: '3600', + }), + }); + + const result = await getValidAuthSession(); + + globalThis.fetch = originalFetch; + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.idToken).toBe(newToken); + expect(result.userId).toBe('u2'); + expect(result.email).toBe('ci@example.com'); + expect(result.refreshed).toBe(true); + } + expect(globalConfig.readGlobalConfig).not.toHaveBeenCalled(); + }); + + it('returns expired when ENSEMBLE_TOKEN is set but refresh fails', async () => { + process.env.ENSEMBLE_TOKEN = 'bad-refresh-token'; + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ error: { message: 'INVALID_GRANT' } }), + }); + + const result = await getValidAuthSession(); + + globalThis.fetch = originalFetch; + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('expired'); + expect(result.message).toContain('ENSEMBLE_TOKEN'); + expect(result.message).toContain('ensemble token'); + } + expect(globalConfig.readGlobalConfig).not.toHaveBeenCalled(); + }); + + it('returns not_logged_in when no config', async () => { + vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue(null); + + const result = await getValidAuthSession(); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('not_logged_in'); + expect(result.message).toContain('Run `ensemble login`'); + } + }); + + it('returns not_logged_in when user has no idToken', async () => { + vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue({ + user: { uid: 'u1' }, + } as EnsembleUserConfig); + + const result = await getValidAuthSession(); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('not_logged_in'); + expect(result.message).toContain('Run `ensemble login`'); + } + }); + + it('returns ok when token is valid and not expired', async () => { + const token = makeJwt({ + userId: 'u1', + email: 'a@b.com', + exp: Math.floor(Date.now() / 1000) + 3600, + }); + vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue({ + user: { + uid: 'u1', + email: 'a@b.com', + idToken: token, + }, + }); + + const result = await getValidAuthSession(); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.idToken).toBe(token); + expect(result.userId).toBe('u1'); + expect(result.email).toBe('a@b.com'); + expect(result.refreshed).toBe(false); + } + }); + + it('returns expired when token expired and no refresh token', async () => { + const token = makeJwt({ + userId: 'u1', + exp: Math.floor(Date.now() / 1000) - 3600, + }); + vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue({ + user: { + uid: 'u1', + idToken: token, + }, + }); + + const result = await getValidAuthSession(); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('expired'); + expect(result.message).toContain('Run `ensemble login` again.'); + } + }); + + it('returns expired with friendly hint when refresh fails', async () => { + const expiredToken = makeJwt({ + userId: 'u1', + exp: Math.floor(Date.now() / 1000) - 3600, + }); + vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue({ + user: { + uid: 'u1', + idToken: expiredToken, + refreshToken: 'refresh-token', + }, + }); + delete process.env.ENSEMBLE_FIREBASE_API_KEY; + + const result = await getValidAuthSession(); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('expired'); + expect(result.message).toContain('Run `ensemble login` again.'); + } + }); + + it('returns ok without refresh when jwt is valid even if legacy config has stale expiresAt', async () => { + const token = makeJwt({ + userId: 'u1', + email: 'a@b.com', + exp: Math.floor(Date.now() / 1000) + 3600, + }); + vi.mocked(globalConfig.readGlobalConfig).mockResolvedValue({ + user: { + uid: 'u1', + email: 'a@b.com', + idToken: token, + refreshToken: 'refresh-123', + expiresAt: Date.now() - 3600_000, + }, + } as EnsembleUserConfig); + + const fetchMock = vi.fn(); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock; + + const result = await getValidAuthSession(); + + globalThis.fetch = originalFetch; + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.idToken).toBe(token); + expect(result.refreshed).toBe(false); + } + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('refreshes token when expired and refresh token exists', async () => { + const oldToken = makeJwt({ + userId: 'u1', + exp: Math.floor(Date.now() / 1000) - 3600, + }); + const newToken = makeJwt({ + userId: 'u1', + email: 'a@b.com', + exp: Math.floor(Date.now() / 1000) + 3600, + }); + vi.mocked(globalConfig.readGlobalConfig) + .mockResolvedValueOnce({ + user: { + uid: 'u1', + idToken: oldToken, + refreshToken: 'refresh-123', + }, + }) + .mockResolvedValue({ + user: { + uid: 'u1', + idToken: newToken, + refreshToken: 'refresh-123', + }, + }); + + const originalFetch = globalThis.fetch; + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id_token: newToken, + refresh_token: 'refresh-456', + expires_in: '3600', + }), + }); + + const result = await getValidAuthSession(); + + globalThis.fetch = originalFetch; + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.idToken).toBe(newToken); + expect(result.refreshed).toBe(true); + } + expect(globalConfig.writeGlobalConfig).toHaveBeenCalled(); + }); +}); diff --git a/tests/auth/token.test.ts b/tests/auth/token.test.ts index ce7d01c..c1ae299 100644 --- a/tests/auth/token.test.ts +++ b/tests/auth/token.test.ts @@ -1,120 +1,120 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { decodeIdTokenClaims, getIdTokenExpiryMs, isTokenExpired } from '../../src/auth/token.js'; - -function base64urlEncode(str: string): string { - return Buffer.from(str, 'utf8') - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); -} - -function makeJwt(payload: Record): string { - const header = base64urlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); - const payloadB64 = base64urlEncode(JSON.stringify(payload)); - const signature = base64urlEncode('signature'); - return `${header}.${payloadB64}.${signature}`; -} - -describe('decodeIdTokenClaims', () => { - it('decodes userId from payload', () => { - const token = makeJwt({ userId: 'user-123', email: 'a@b.com' }); - expect(decodeIdTokenClaims(token)).toEqual({ - uid: 'user-123', - name: null, - email: 'a@b.com', - exp: undefined, - }); - }); - - it('falls back to sub when userId is missing', () => { - const token = makeJwt({ sub: 'firebase-uid', email: 'x@y.com' }); - expect(decodeIdTokenClaims(token)).toEqual({ - uid: 'firebase-uid', - name: null, - email: 'x@y.com', - exp: undefined, - }); - }); - - it('decodes name and exp', () => { - const token = makeJwt({ - userId: 'u1', - name: 'Alice', - email: 'alice@test.com', - exp: 1735689600, - }); - expect(decodeIdTokenClaims(token)).toEqual({ - uid: 'u1', - name: 'Alice', - email: 'alice@test.com', - exp: 1735689600, - }); - }); - - it('returns nulls for invalid token', () => { - expect(decodeIdTokenClaims('invalid')).toEqual({ - uid: null, - name: null, - email: null, - }); - }); - - it('returns nulls for malformed JWT (too few parts)', () => { - expect(decodeIdTokenClaims('a.b')).toEqual({ - uid: null, - name: null, - email: null, - }); - }); -}); - -describe('getIdTokenExpiryMs', () => { - it('returns exp in milliseconds', () => { - const token = makeJwt({ userId: 'u1', exp: 1735689600 }); - expect(getIdTokenExpiryMs(token)).toBe(1735689600000); - }); - - it('returns undefined when exp is missing', () => { - const token = makeJwt({ userId: 'u1' }); - expect(getIdTokenExpiryMs(token)).toBeUndefined(); - }); -}); - -describe('isTokenExpired', () => { - beforeEach(() => { - vi.useFakeTimers(); - }); - - afterEach(() => { - vi.useRealTimers(); - }); - - it('returns false when token is not expired', () => { - vi.setSystemTime(new Date('2025-01-01T12:00:00Z')); - // exp = 2025-01-01 13:00 UTC (1 hour in future) - const token = makeJwt({ userId: 'u1', exp: 1735736400 }); - expect(isTokenExpired(token)).toBe(false); - }); - - it('returns true when token is expired', () => { - vi.setSystemTime(new Date('2025-01-01T13:00:00Z')); - // exp = 2025-01-01 12:00 UTC (1 hour ago) - const token = makeJwt({ userId: 'u1', exp: 1735732800 }); - expect(isTokenExpired(token)).toBe(true); - }); - - it('uses bufferSeconds for expiry check', () => { - vi.setSystemTime(new Date('2025-01-01T12:00:30Z')); - // exp = 2025-01-01 13:00 UTC; with 60s buffer, token still valid at 12:00:30 - const token = makeJwt({ userId: 'u1', exp: 1735736400 }); - expect(isTokenExpired(token, 60)).toBe(false); - expect(isTokenExpired(token, 0)).toBe(false); - }); - - it('returns true when jwt has no exp claim', () => { - vi.setSystemTime(new Date('2025-01-01T12:00:00Z')); - const token = makeJwt({ userId: 'u1' }); - expect(isTokenExpired(token)).toBe(true); - }); -}); +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { decodeIdTokenClaims, getIdTokenExpiryMs, isTokenExpired } from '../../src/auth/token.js'; + +function base64urlEncode(str: string): string { + return Buffer.from(str, 'utf8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function makeJwt(payload: Record): string { + const header = base64urlEncode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const payloadB64 = base64urlEncode(JSON.stringify(payload)); + const signature = base64urlEncode('signature'); + return `${header}.${payloadB64}.${signature}`; +} + +describe('decodeIdTokenClaims', () => { + it('decodes userId from payload', () => { + const token = makeJwt({ userId: 'user-123', email: 'a@b.com' }); + expect(decodeIdTokenClaims(token)).toEqual({ + uid: 'user-123', + name: null, + email: 'a@b.com', + exp: undefined, + }); + }); + + it('falls back to sub when userId is missing', () => { + const token = makeJwt({ sub: 'firebase-uid', email: 'x@y.com' }); + expect(decodeIdTokenClaims(token)).toEqual({ + uid: 'firebase-uid', + name: null, + email: 'x@y.com', + exp: undefined, + }); + }); + + it('decodes name and exp', () => { + const token = makeJwt({ + userId: 'u1', + name: 'Alice', + email: 'alice@test.com', + exp: 1735689600, + }); + expect(decodeIdTokenClaims(token)).toEqual({ + uid: 'u1', + name: 'Alice', + email: 'alice@test.com', + exp: 1735689600, + }); + }); + + it('returns nulls for invalid token', () => { + expect(decodeIdTokenClaims('invalid')).toEqual({ + uid: null, + name: null, + email: null, + }); + }); + + it('returns nulls for malformed JWT (too few parts)', () => { + expect(decodeIdTokenClaims('a.b')).toEqual({ + uid: null, + name: null, + email: null, + }); + }); +}); + +describe('getIdTokenExpiryMs', () => { + it('returns exp in milliseconds', () => { + const token = makeJwt({ userId: 'u1', exp: 1735689600 }); + expect(getIdTokenExpiryMs(token)).toBe(1735689600000); + }); + + it('returns undefined when exp is missing', () => { + const token = makeJwt({ userId: 'u1' }); + expect(getIdTokenExpiryMs(token)).toBeUndefined(); + }); +}); + +describe('isTokenExpired', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns false when token is not expired', () => { + vi.setSystemTime(new Date('2025-01-01T12:00:00Z')); + // exp = 2025-01-01 13:00 UTC (1 hour in future) + const token = makeJwt({ userId: 'u1', exp: 1735736400 }); + expect(isTokenExpired(token)).toBe(false); + }); + + it('returns true when token is expired', () => { + vi.setSystemTime(new Date('2025-01-01T13:00:00Z')); + // exp = 2025-01-01 12:00 UTC (1 hour ago) + const token = makeJwt({ userId: 'u1', exp: 1735732800 }); + expect(isTokenExpired(token)).toBe(true); + }); + + it('uses bufferSeconds for expiry check', () => { + vi.setSystemTime(new Date('2025-01-01T12:00:30Z')); + // exp = 2025-01-01 13:00 UTC; with 60s buffer, token still valid at 12:00:30 + const token = makeJwt({ userId: 'u1', exp: 1735736400 }); + expect(isTokenExpired(token, 60)).toBe(false); + expect(isTokenExpired(token, 0)).toBe(false); + }); + + it('returns true when jwt has no exp claim', () => { + vi.setSystemTime(new Date('2025-01-01T12:00:00Z')); + const token = makeJwt({ userId: 'u1' }); + expect(isTokenExpired(token)).toBe(true); + }); +}); diff --git a/tests/cloud/firestoreClient.test.ts b/tests/cloud/firestoreClient.test.ts index 2a613f3..461fe4f 100644 --- a/tests/cloud/firestoreClient.test.ts +++ b/tests/cloud/firestoreClient.test.ts @@ -1,967 +1,967 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { - checkAppAccess, - fetchCloudApp, - fetchRootScreenName, - submitCliPush, - submitEnvDocumentsPush, - listVersions, - createVersion, - getVersion, - FirestoreClientError, - type FirestoreDebugEvent, -} from '../../src/cloud/firestoreClient.js'; - -describe('checkAppAccess', () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - globalThis.fetch = originalFetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('returns ok when user has write access', async () => { - const firestoreDoc = { - fields: { - name: { stringValue: 'My App' }, - description: { stringValue: 'Test app' }, - collaborators: { - mapValue: { - fields: { - users_user123: { stringValue: 'write' }, - }, - }, - }, - }, - }; - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes('/documents/apps/')) { - return new Response(JSON.stringify(firestoreDoc), { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - const result = await checkAppAccess('app-1', 'token', 'user123'); - - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.app.name).toBe('My App'); - expect(result.app.description).toBe('Test app'); - } - }); - - it('returns ok when user has owner access', async () => { - const firestoreDoc = { - fields: { - name: { stringValue: 'App' }, - collaborators: { - mapValue: { - fields: { - users_owner1: { stringValue: 'owner' }, - }, - }, - }, - }, - }; - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes('/documents/apps/')) { - return new Response(JSON.stringify(firestoreDoc), { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - const result = await checkAppAccess('app-1', 'token', 'owner1'); - - expect(result.ok).toBe(true); - }); - - it('returns no_access when user has read role', async () => { - const firestoreDoc = { - fields: { - name: { stringValue: 'App' }, - collaborators: { - mapValue: { - fields: { - users_reader1: { stringValue: 'read' }, - }, - }, - }, - }, - }; - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes('/documents/apps/')) { - return new Response(JSON.stringify(firestoreDoc), { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - const result = await checkAppAccess('app-1', 'token', 'reader1'); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe('no_access'); - } - }); - - it('returns not_found for 404', async () => { - globalThis.fetch = async () => new Response('', { status: 404 }); - - const result = await checkAppAccess('nonexistent', 'token', 'user1'); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe('not_found'); - expect(result.message).toContain('does not exist'); - } - }); - - it('returns not_logged_in for 401', async () => { - globalThis.fetch = async () => new Response('', { status: 401 }); - - const result = await checkAppAccess('app-1', 'invalid-token', 'user1'); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe('not_logged_in'); - } - }); - - it('returns network_error on fetch failure', async () => { - globalThis.fetch = async () => { - throw new Error('Network error'); - }; - - const result = await checkAppAccess('app-1', 'token', 'user1'); - - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.reason).toBe('network_error'); - } - }); -}); - -describe('fetchCloudApp', () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - globalThis.fetch = originalFetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('fetches and transforms app with screens and widgets', async () => { - const appDoc = { - name: 'projects/p/databases/(default)/documents/apps/app-1', - createTime: '2025-01-01T00:00:00Z', - updateTime: '2025-01-02T00:00:00Z', - fields: { - name: { stringValue: 'My App' }, - }, - }; - - const internalArtifacts = [ - { - name: 'projects/p/databases/(default)/documents/apps/app-1/internal_artifacts/w1', - fields: { - type: { stringValue: 'internal_widget' }, - name: { stringValue: 'Button' }, - content: { stringValue: 'widget content' }, - }, - }, - { - name: 'projects/p/databases/(default)/documents/apps/app-1/internal_artifacts/s1', - fields: { - type: { stringValue: 'internal_script' }, - name: { stringValue: 'utils' }, - content: { stringValue: 'script content' }, - }, - }, - ]; - - const artifacts = [ - { - name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/screen1', - fields: { - type: { stringValue: 'screen' }, - name: { stringValue: 'Home' }, - content: { stringValue: 'screen content' }, - }, - }, - ]; - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if ( - urlStr.includes('/documents/apps/app-1') && - !urlStr.includes('/internal_artifacts') && - !urlStr.includes('/artifacts') - ) { - return new Response(JSON.stringify(appDoc), { status: 200 }); - } - if (urlStr.includes('internal_artifacts')) { - return new Response(JSON.stringify({ documents: internalArtifacts }), { status: 200 }); - } - if (urlStr.includes('/artifacts')) { - return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - const result = await fetchCloudApp('app-1', 'token'); - - expect(result.id).toBe('app-1'); - expect(result.name).toBe('My App'); - expect(result.widgets).toHaveLength(1); - expect(result.widgets![0].name).toBe('Button'); - expect(result.widgets![0].content).toBe('widget content'); - expect(result.scripts).toHaveLength(1); - expect(result.scripts![0].name).toBe('utils'); - expect(result.screens).toHaveLength(1); - expect(result.screens![0].name).toBe('Home'); - expect(result.screens![0].content).toBe('screen content'); - }); - - it('prefers theme doc with id "theme" when multiple themes exist', async () => { - const appDoc = { - name: 'projects/p/databases/(default)/documents/apps/app-1', - }; - - const artifacts = [ - { - name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/randomThemeId', - fields: { - type: { stringValue: 'theme' }, - name: { stringValue: 'theme' }, - content: { stringValue: 'random theme' }, - }, - }, - { - name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/theme', - fields: { - type: { stringValue: 'theme' }, - name: { stringValue: 'theme' }, - content: { stringValue: 'canonical theme' }, - }, - }, - ]; - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes('/documents/apps/app-1') && !urlStr.includes('/artifacts')) { - return new Response(JSON.stringify(appDoc), { status: 200 }); - } - if (urlStr.includes('/artifacts')) { - return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); - } - if (urlStr.includes('internal_artifacts')) { - return new Response(JSON.stringify({ documents: [] }), { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - const result = await fetchCloudApp('app-1', 'token'); - expect(result.theme).toBeDefined(); - expect(result.theme?.id).toBe('theme'); - expect(result.theme?.content).toBe('canonical theme'); - }); - - it('handles single random-id theme gracefully', async () => { - const appDoc = { - name: 'projects/p/databases/(default)/documents/apps/app-1', - }; - const artifacts = [ - { - name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/randomThemeId', - fields: { - type: { stringValue: 'theme' }, - name: { stringValue: 'theme' }, - content: { stringValue: 'random theme' }, - }, - }, - ]; - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes('/documents/apps/app-1') && !urlStr.includes('/artifacts')) { - return new Response(JSON.stringify(appDoc), { status: 200 }); - } - if (urlStr.includes('/artifacts')) { - return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); - } - if (urlStr.includes('internal_artifacts')) { - return new Response(JSON.stringify({ documents: [] }), { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - const result = await fetchCloudApp('app-1', 'token'); - expect(result.theme).toBeDefined(); - expect(result.theme?.content).toBe('random theme'); - }); - - it('fetches appConfig and secrets into config and secrets', async () => { - const appDoc = { name: 'projects/p/databases/(default)/documents/apps/app-1' }; - const fetchForArtifacts = (artifacts: unknown[]) => async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes('/documents/apps/app-1') && !urlStr.includes('/artifacts')) { - return new Response(JSON.stringify(appDoc), { status: 200 }); - } - if (urlStr.includes('/artifacts')) { - return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); - } - if (urlStr.includes('internal_artifacts')) { - return new Response(JSON.stringify({ documents: [] }), { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - globalThis.fetch = fetchForArtifacts([ - { - name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/appConfig', - fields: { - type: { stringValue: 'config' }, - name: { stringValue: 'appConfig' }, - content: { - stringValue: JSON.stringify({ envVariables: { API_URL: 'https://api.example.com' } }), - }, - }, - }, - { - name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/secrets', - fields: { - type: { stringValue: 'secrets' }, - name: { stringValue: 'secrets' }, - content: { stringValue: JSON.stringify({ secrets: { S1: 'secret-value' } }) }, - }, - }, - ]); - const withContent = await fetchCloudApp('app-1', 'token'); - expect(withContent.config?.envVariables?.API_URL).toBe('https://api.example.com'); - expect(withContent.secrets?.secrets).toEqual({ S1: 'secret-value' }); - - globalThis.fetch = fetchForArtifacts([ - { - name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/appConfig', - fields: { - type: { stringValue: 'config' }, - name: { stringValue: 'appConfig' }, - content: { - stringValue: JSON.stringify({ envVariables: { E1: 'from-content' } }), - }, - envVariables: { - mapValue: { fields: { E1: { stringValue: 'from-map' } } }, - }, - }, - }, - ]); - const withMap = await fetchCloudApp('app-1', 'token'); - expect(withMap.config?.envVariables?.E1).toBe('from-map'); - }); -}); - -describe('submitEnvDocumentsPush', () => { - const originalFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it('patches content, map fields, and updatedAt for env documents', async () => { - const patches: Array<{ url: string; body: string }> = []; - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes('/artifacts/') && init?.method === 'PATCH') { - patches.push({ url: urlStr, body: (init.body as string) ?? '' }); - return new Response('{}', { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - await submitEnvDocumentsPush('app-1', 'token', { - config: { envVariables: { E1: 'EV11', assets: 'https://cdn.example.com/' } }, - secrets: { secrets: { S1: 'SK1', S2: 'SK22' } }, - }); - - const configPatch = patches.find((patch) => patch.url.includes('/artifacts/appConfig')); - const secretsPatch = patches.find((patch) => patch.url.includes('/artifacts/secrets')); - expect(configPatch?.url).toContain('updateMask.fieldPaths=envVariables'); - expect(secretsPatch?.url).toContain('updateMask.fieldPaths=secrets'); - - const configBody = JSON.parse(configPatch!.body) as { - fields: { - content?: { stringValue?: string }; - envVariables?: { mapValue?: { fields?: Record } }; - }; - }; - expect(JSON.parse(configBody.fields.content?.stringValue ?? '{}')).toEqual({ - envVariables: { E1: 'EV11', assets: 'https://cdn.example.com/' }, - }); - expect(configBody.fields.envVariables?.mapValue?.fields?.E1?.stringValue).toBe('EV11'); - - const secretsBody = JSON.parse(secretsPatch!.body) as { - fields: { - secrets?: { mapValue?: { fields?: Record } }; - }; - }; - expect(secretsBody.fields.secrets?.mapValue?.fields?.S2?.stringValue).toBe('SK22'); - }); -}); - -describe('fetchRootScreenName', () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - globalThis.fetch = originalFetch; - }); - - describe('submitCliPush', () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - globalThis.fetch = originalFetch; - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - vi.restoreAllMocks(); - }); - - it('creates translation with correct id, defaultLocale and user references', async () => { - type CapturedBody = { - fields: { - defaultLocale?: { booleanValue: boolean }; - updatedBy?: { referenceValue: string }; - createdBy?: { referenceValue: string }; - [key: string]: unknown; - }; - [key: string]: unknown; - }; - - const calls: { url: string; body: CapturedBody }[] = []; - - globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (init?.method === 'POST' && urlStr.includes('/artifacts')) { - const parsedBody = init.body - ? (JSON.parse(String(init.body)) as CapturedBody) - : ({} as CapturedBody); - calls.push({ - url: urlStr, - body: parsedBody, - }); - return new Response(JSON.stringify({}), { status: 200 }); - } - // For other calls (screens/widgets/scripts/theme) that won't be used in this test. - return new Response(JSON.stringify({}), { status: 200 }); - }) as unknown as typeof fetch; - - const payload = { - id: 'app1', - name: 'App', - updatedAt: new Date().toISOString(), - translations: [ - { - operation: 'create' as const, - document: { - id: 'i18n_en', - name: 'en', - content: 'en: content', - type: 'i18n', - defaultLocale: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - updatedBy: { name: 'User', email: 'u@test.com', id: 'uid1' }, - createdBy: { name: 'User', email: 'u@test.com', id: 'uid1' }, - }, - }, - ], - }; - - await submitCliPush('app1', 'token', payload); - - expect(calls.length).toBe(1); - const { url, body } = calls[0]!; - expect(url).toContain('/apps/app1/artifacts'); - expect(url).toContain('documentId=i18n_en'); - expect(body).toHaveProperty('fields'); - expect(body.fields.defaultLocale).toEqual({ booleanValue: true }); - expect(body.fields.updatedBy).toBeDefined(); - expect(body.fields.createdBy).toBeDefined(); - - const updatedByRef = body.fields.updatedBy!.referenceValue as string; - const createdByRef = body.fields.createdBy!.referenceValue as string; - expect(updatedByRef).toContain('/users/uid1'); - expect(createdByRef).toContain('/users/uid1'); - }); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('returns name of screen with isRoot true', async () => { - const runQueryResponse = [ - { - document: { - name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/s1', - fields: { - type: { stringValue: 'screen' }, - name: { stringValue: 'Home' }, - isRoot: { booleanValue: true }, - }, - }, - }, - ]; - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes(':runQuery')) { - return new Response(JSON.stringify(runQueryResponse), { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - const result = await fetchRootScreenName('app-1', 'token'); - expect(result).toBe('Home'); - }); - - it('returns undefined when no root screen', async () => { - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (urlStr.includes(':runQuery')) { - return new Response(JSON.stringify([]), { status: 200 }); - } - return new Response('Not found', { status: 404 }); - }; - - const result = await fetchRootScreenName('app-1', 'token'); - expect(result).toBeUndefined(); - }); - - it('returns undefined on fetch failure', async () => { - globalThis.fetch = async () => new Response('', { status: 500 }); - - const result = await fetchRootScreenName('app-1', 'token'); - expect(result).toBeUndefined(); - }); -}); - -describe('Firestore client structured errors and debug logging', () => { - const originalFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('submitCliPush throws FirestoreClientError with mapped code on HTTP error', async () => { - globalThis.fetch = async () => - new Response('unauthorized', { - status: 401, - statusText: 'Unauthorized', - }); - - const payload = { - id: 'app1', - name: 'App', - updatedAt: new Date().toISOString(), - translations: [ - { - operation: 'create' as const, - document: { - id: 'i18n_en', - name: 'en', - content: 'en: content', - type: 'i18n', - defaultLocale: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - updatedBy: { name: 'User', email: 'u@test.com', id: 'uid1' }, - createdBy: { name: 'User', email: 'u@test.com', id: 'uid1' }, - }, - }, - ], - }; - - await expect(submitCliPush('app1', 'token', payload)).rejects.toBeInstanceOf( - FirestoreClientError - ); - }); - - it('submitCliPush invokes debug logger when provided', async () => { - const events: FirestoreDebugEvent[] = []; - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const urlStr = - typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - if (init?.method === 'POST' && urlStr.includes('/artifacts')) { - return new Response(JSON.stringify({}), { status: 200 }); - } - return new Response(JSON.stringify({}), { status: 200 }); - }; - - const payload = { - id: 'app1', - name: 'App', - updatedAt: new Date().toISOString(), - translations: [ - { - operation: 'create' as const, - document: { - id: 'i18n_en', - name: 'en', - content: 'en: content', - type: 'i18n', - defaultLocale: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - updatedBy: { name: 'User', email: 'u@test.com', id: 'uid1' }, - createdBy: { name: 'User', email: 'u@test.com', id: 'uid1' }, - }, - }, - ], - }; - - await submitCliPush('app1', 'token', payload, { - debug: (event) => { - events.push(event); - }, - }); - - expect(events.length).toBeGreaterThan(0); - }); -}); - -describe('listVersions', () => { - const originalFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('sends runQuery with parent in URL and only structuredQuery in body', async () => { - let capturedRequest: { url: string; body: string } | null = null; - const runQueryResponse = [ - { - document: { - name: 'projects/p/databases/(default)/documents/apps/app1/versions/v1', - fields: { - message: { stringValue: 'First version' }, - createdAt: { timestampValue: '2025-01-15T12:00:00Z' }, - expiresAt: { timestampValue: '2025-02-15T12:00:00Z' }, - createdBy: { referenceValue: 'projects/p/databases/(default)/documents/users/uid1' }, - snapshotPath: { stringValue: 'releases/app1/ver-1.json' }, - }, - }, - }, - ]; - - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const urlStr = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - if (urlStr.includes(':runQuery')) { - capturedRequest = { url: urlStr, body: (init?.body as string) ?? '' }; - return new Response(JSON.stringify(runQueryResponse), { status: 200 }); - } - return new Response('', { status: 404 }); - }; - - const result = await listVersions('app1', 'token', { limit: 5 }); - - expect(capturedRequest).not.toBeNull(); - expect(capturedRequest!.url).toContain('/documents/apps/app1:runQuery'); - const body = JSON.parse(capturedRequest!.body); - expect(body).toHaveProperty('structuredQuery'); - expect(body.structuredQuery.from).toEqual([{ collectionId: 'versions' }]); - expect(body.structuredQuery.orderBy).toEqual([ - { field: { fieldPath: 'createdAt' }, direction: 'DESCENDING' }, - ]); - expect(body.structuredQuery.limit).toBe(5); - expect(body).not.toHaveProperty('parent'); - - expect(result.versions).toHaveLength(1); - expect(result.versions[0]!.message).toBe('First version'); - expect(result.versions[0]!.createdAt).toBe('2025-01-15T12:00:00Z'); - expect(result.versions[0]!.snapshotPath).toEqual('releases/app1/ver-1.json'); - expect(result.nextStartAfter).toBeUndefined(); - }); - - it('returns nextStartAfter when limit results returned', async () => { - const ts = '2025-01-15T12:00:00Z'; - const runQueryResponse = Array.from({ length: 5 }, (_, i) => ({ - document: { - name: `projects/p/databases/(default)/documents/apps/app1/versions/v${i}`, - fields: { - message: { stringValue: `Version ${i}` }, - createdAt: { timestampValue: ts }, - expiresAt: { timestampValue: '2025-02-15T12:00:00Z' }, - snapshotPath: { stringValue: 'releases/app1/ver-1.json' }, - }, - }, - })); - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - if (urlStr.includes(':runQuery')) { - return new Response(JSON.stringify(runQueryResponse), { status: 200 }); - } - return new Response('', { status: 404 }); - }; - - const result = await listVersions('app1', 'token', { limit: 5 }); - expect(result.versions).toHaveLength(5); - expect(result.nextStartAfter).toBe(ts); - }); - - it('includes startAt in body when startAfter is provided', async () => { - let capturedBody: string | null = null; - globalThis.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => { - capturedBody = (init?.body as string) ?? null; - return new Response(JSON.stringify([]), { status: 200 }); - }; - - await listVersions('app1', 'token', { limit: 5, startAfter: '2025-01-10T00:00:00Z' }); - - const body = JSON.parse(capturedBody!); - expect(body.structuredQuery.startAt).toEqual({ - values: [{ timestampValue: '2025-01-10T00:00:00Z' }], - before: false, - }); - }); - - it('returns no nextStartAfter when fewer than limit returned', async () => { - const runQueryResponse = [ - { - document: { - name: 'projects/p/databases/(default)/documents/apps/app1/versions/v1', - fields: { - message: { stringValue: 'Only one' }, - createdAt: { timestampValue: '2025-01-15T12:00:00Z' }, - expiresAt: { timestampValue: '2025-02-15T12:00:00Z' }, - snapshotPath: { stringValue: 'releases/app1/ver-1.json' }, - }, - }, - }, - ]; - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - if (urlStr.includes(':runQuery')) { - return new Response(JSON.stringify(runQueryResponse), { status: 200 }); - } - return new Response('', { status: 404 }); - }; - - const result = await listVersions('app1', 'token', { limit: 5 }); - expect(result.versions).toHaveLength(1); - expect(result.nextStartAfter).toBeUndefined(); - }); - - it('throws FirestoreClientError on 403', async () => { - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - if (urlStr.includes(':runQuery')) { - return new Response( - JSON.stringify({ error: { code: 403, message: 'Permission denied' } }), - { status: 403 } - ); - } - return new Response('', { status: 404 }); - }; - - await expect(listVersions('app1', 'token', { limit: 5 })).rejects.toThrow(FirestoreClientError); - }); -}); - -describe('createVersion', () => { - const originalFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('POSTs to app versions collection with provided id', async () => { - let capturedUrl: string | null = null; - globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { - const urlStr = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - if (urlStr.includes('/versions') && init?.method === 'POST') { - capturedUrl = urlStr; - return new Response(JSON.stringify({}), { status: 200 }); - } - return new Response('', { status: 404 }); - }; - - const result = await createVersion('app1', 'token', { - id: 'abc123', - message: 'Release 1', - createdAt: '2025-01-15T12:00:00Z', - expiresAt: '2025-02-15T12:00:00Z', - createdBy: { name: 'User', id: 'uid1' }, - snapshotPath: 'releases/app1/abc123.json', - }); - - expect(result.id).toBe('abc123'); - expect(capturedUrl).toContain('documentId=abc123'); - }); - - it('throws FirestoreClientError on 403', async () => { - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - if (urlStr.includes('/versions')) { - return new Response(JSON.stringify({ error: { code: 403 } }), { status: 403 }); - } - return new Response('', { status: 404 }); - }; - - await expect( - createVersion('app1', 'token', { - id: 'ver-123', - message: 'v1', - createdAt: '2025-01-15T12:00:00Z', - expiresAt: '2025-02-15T12:00:00Z', - createdBy: { name: 'User', id: 'uid1' }, - snapshotPath: 'releases/app1/ver-123.json', - }) - ).rejects.toThrow(FirestoreClientError); - }); -}); - -describe('getVersion', () => { - const originalFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('returns version doc metadata with snapshotPath', async () => { - const versionDoc = { - name: 'projects/p/databases/(default)/documents/apps/app1/versions/ver-123', - fields: { - message: { stringValue: 'Saved state' }, - createdAt: { timestampValue: '2025-01-15T12:00:00Z' }, - expiresAt: { timestampValue: '2025-02-15T12:00:00Z' }, - createdBy: { referenceValue: 'projects/p/databases/(default)/documents/users/uid1' }, - snapshotPath: { stringValue: 'releases/app1/ver-123.json' }, - }, - }; - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - if (urlStr.includes('/versions/ver-123')) { - return new Response(JSON.stringify(versionDoc), { status: 200 }); - } - return new Response('', { status: 404 }); - }; - - const result = await getVersion('app1', 'token', 'ver-123'); - expect(result.id).toBe('ver-123'); - expect(result.message).toBe('Saved state'); - expect(result.createdAt).toBe('2025-01-15T12:00:00Z'); - expect(result.snapshotPath).toBe('releases/app1/ver-123.json'); - }); - - it('throws FirestoreClientError with NOT_FOUND on 404', async () => { - globalThis.fetch = async () => new Response(JSON.stringify({}), { status: 404 }); - - let thrown: FirestoreClientError | undefined; - try { - await getVersion('app1', 'token', 'missing-id'); - } catch (err) { - thrown = err as FirestoreClientError; - } - expect(thrown).toBeInstanceOf(FirestoreClientError); - expect(thrown!.code).toBe('NOT_FOUND'); - expect(thrown!.message).toContain('missing-id'); - }); - - it('throws FirestoreClientError when version doc metadata is invalid', async () => { - const versionDoc = { - name: 'projects/p/databases/(default)/documents/apps/app1/versions/ver-123', - fields: { - message: { stringValue: 'v1' }, - createdAt: { timestampValue: '2025-01-15T12:00:00Z' }, - expiresAt: { timestampValue: '2025-02-15T12:00:00Z' }, - snapshotPath: { stringValue: '' }, - }, - }; - - globalThis.fetch = async (input: RequestInfo | URL) => { - const urlStr = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - if (urlStr.includes('/versions/ver-123')) { - return new Response(JSON.stringify(versionDoc), { status: 200 }); - } - return new Response('', { status: 404 }); - }; - - let thrown: FirestoreClientError | undefined; - try { - await getVersion('app1', 'token', 'ver-123'); - } catch (err) { - thrown = err as FirestoreClientError; - } - expect(thrown).toBeInstanceOf(FirestoreClientError); - expect(thrown!.message).toContain('metadata is invalid'); - }); -}); +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { + checkAppAccess, + fetchCloudApp, + fetchRootScreenName, + submitCliPush, + submitEnvDocumentsPush, + listVersions, + createVersion, + getVersion, + FirestoreClientError, + type FirestoreDebugEvent, +} from '../../src/cloud/firestoreClient.js'; + +describe('checkAppAccess', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = originalFetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('returns ok when user has write access', async () => { + const firestoreDoc = { + fields: { + name: { stringValue: 'My App' }, + description: { stringValue: 'Test app' }, + collaborators: { + mapValue: { + fields: { + users_user123: { stringValue: 'write' }, + }, + }, + }, + }, + }; + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes('/documents/apps/')) { + return new Response(JSON.stringify(firestoreDoc), { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + const result = await checkAppAccess('app-1', 'token', 'user123'); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.app.name).toBe('My App'); + expect(result.app.description).toBe('Test app'); + } + }); + + it('returns ok when user has owner access', async () => { + const firestoreDoc = { + fields: { + name: { stringValue: 'App' }, + collaborators: { + mapValue: { + fields: { + users_owner1: { stringValue: 'owner' }, + }, + }, + }, + }, + }; + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes('/documents/apps/')) { + return new Response(JSON.stringify(firestoreDoc), { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + const result = await checkAppAccess('app-1', 'token', 'owner1'); + + expect(result.ok).toBe(true); + }); + + it('returns no_access when user has read role', async () => { + const firestoreDoc = { + fields: { + name: { stringValue: 'App' }, + collaborators: { + mapValue: { + fields: { + users_reader1: { stringValue: 'read' }, + }, + }, + }, + }, + }; + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes('/documents/apps/')) { + return new Response(JSON.stringify(firestoreDoc), { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + const result = await checkAppAccess('app-1', 'token', 'reader1'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('no_access'); + } + }); + + it('returns not_found for 404', async () => { + globalThis.fetch = async () => new Response('', { status: 404 }); + + const result = await checkAppAccess('nonexistent', 'token', 'user1'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('not_found'); + expect(result.message).toContain('does not exist'); + } + }); + + it('returns not_logged_in for 401', async () => { + globalThis.fetch = async () => new Response('', { status: 401 }); + + const result = await checkAppAccess('app-1', 'invalid-token', 'user1'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('not_logged_in'); + } + }); + + it('returns network_error on fetch failure', async () => { + globalThis.fetch = async () => { + throw new Error('Network error'); + }; + + const result = await checkAppAccess('app-1', 'token', 'user1'); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe('network_error'); + } + }); +}); + +describe('fetchCloudApp', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = originalFetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('fetches and transforms app with screens and widgets', async () => { + const appDoc = { + name: 'projects/p/databases/(default)/documents/apps/app-1', + createTime: '2025-01-01T00:00:00Z', + updateTime: '2025-01-02T00:00:00Z', + fields: { + name: { stringValue: 'My App' }, + }, + }; + + const internalArtifacts = [ + { + name: 'projects/p/databases/(default)/documents/apps/app-1/internal_artifacts/w1', + fields: { + type: { stringValue: 'internal_widget' }, + name: { stringValue: 'Button' }, + content: { stringValue: 'widget content' }, + }, + }, + { + name: 'projects/p/databases/(default)/documents/apps/app-1/internal_artifacts/s1', + fields: { + type: { stringValue: 'internal_script' }, + name: { stringValue: 'utils' }, + content: { stringValue: 'script content' }, + }, + }, + ]; + + const artifacts = [ + { + name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/screen1', + fields: { + type: { stringValue: 'screen' }, + name: { stringValue: 'Home' }, + content: { stringValue: 'screen content' }, + }, + }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if ( + urlStr.includes('/documents/apps/app-1') && + !urlStr.includes('/internal_artifacts') && + !urlStr.includes('/artifacts') + ) { + return new Response(JSON.stringify(appDoc), { status: 200 }); + } + if (urlStr.includes('internal_artifacts')) { + return new Response(JSON.stringify({ documents: internalArtifacts }), { status: 200 }); + } + if (urlStr.includes('/artifacts')) { + return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + const result = await fetchCloudApp('app-1', 'token'); + + expect(result.id).toBe('app-1'); + expect(result.name).toBe('My App'); + expect(result.widgets).toHaveLength(1); + expect(result.widgets![0].name).toBe('Button'); + expect(result.widgets![0].content).toBe('widget content'); + expect(result.scripts).toHaveLength(1); + expect(result.scripts![0].name).toBe('utils'); + expect(result.screens).toHaveLength(1); + expect(result.screens![0].name).toBe('Home'); + expect(result.screens![0].content).toBe('screen content'); + }); + + it('prefers theme doc with id "theme" when multiple themes exist', async () => { + const appDoc = { + name: 'projects/p/databases/(default)/documents/apps/app-1', + }; + + const artifacts = [ + { + name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/randomThemeId', + fields: { + type: { stringValue: 'theme' }, + name: { stringValue: 'theme' }, + content: { stringValue: 'random theme' }, + }, + }, + { + name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/theme', + fields: { + type: { stringValue: 'theme' }, + name: { stringValue: 'theme' }, + content: { stringValue: 'canonical theme' }, + }, + }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes('/documents/apps/app-1') && !urlStr.includes('/artifacts')) { + return new Response(JSON.stringify(appDoc), { status: 200 }); + } + if (urlStr.includes('/artifacts')) { + return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); + } + if (urlStr.includes('internal_artifacts')) { + return new Response(JSON.stringify({ documents: [] }), { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + const result = await fetchCloudApp('app-1', 'token'); + expect(result.theme).toBeDefined(); + expect(result.theme?.id).toBe('theme'); + expect(result.theme?.content).toBe('canonical theme'); + }); + + it('handles single random-id theme gracefully', async () => { + const appDoc = { + name: 'projects/p/databases/(default)/documents/apps/app-1', + }; + const artifacts = [ + { + name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/randomThemeId', + fields: { + type: { stringValue: 'theme' }, + name: { stringValue: 'theme' }, + content: { stringValue: 'random theme' }, + }, + }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes('/documents/apps/app-1') && !urlStr.includes('/artifacts')) { + return new Response(JSON.stringify(appDoc), { status: 200 }); + } + if (urlStr.includes('/artifacts')) { + return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); + } + if (urlStr.includes('internal_artifacts')) { + return new Response(JSON.stringify({ documents: [] }), { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + const result = await fetchCloudApp('app-1', 'token'); + expect(result.theme).toBeDefined(); + expect(result.theme?.content).toBe('random theme'); + }); + + it('fetches appConfig and secrets into config and secrets', async () => { + const appDoc = { name: 'projects/p/databases/(default)/documents/apps/app-1' }; + const fetchForArtifacts = (artifacts: unknown[]) => async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes('/documents/apps/app-1') && !urlStr.includes('/artifacts')) { + return new Response(JSON.stringify(appDoc), { status: 200 }); + } + if (urlStr.includes('/artifacts')) { + return new Response(JSON.stringify({ documents: artifacts }), { status: 200 }); + } + if (urlStr.includes('internal_artifacts')) { + return new Response(JSON.stringify({ documents: [] }), { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + globalThis.fetch = fetchForArtifacts([ + { + name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/appConfig', + fields: { + type: { stringValue: 'config' }, + name: { stringValue: 'appConfig' }, + content: { + stringValue: JSON.stringify({ envVariables: { API_URL: 'https://api.example.com' } }), + }, + }, + }, + { + name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/secrets', + fields: { + type: { stringValue: 'secrets' }, + name: { stringValue: 'secrets' }, + content: { stringValue: JSON.stringify({ secrets: { S1: 'secret-value' } }) }, + }, + }, + ]); + const withContent = await fetchCloudApp('app-1', 'token'); + expect(withContent.config?.envVariables?.API_URL).toBe('https://api.example.com'); + expect(withContent.secrets?.secrets).toEqual({ S1: 'secret-value' }); + + globalThis.fetch = fetchForArtifacts([ + { + name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/appConfig', + fields: { + type: { stringValue: 'config' }, + name: { stringValue: 'appConfig' }, + content: { + stringValue: JSON.stringify({ envVariables: { E1: 'from-content' } }), + }, + envVariables: { + mapValue: { fields: { E1: { stringValue: 'from-map' } } }, + }, + }, + }, + ]); + const withMap = await fetchCloudApp('app-1', 'token'); + expect(withMap.config?.envVariables?.E1).toBe('from-map'); + }); +}); + +describe('submitEnvDocumentsPush', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('patches content, map fields, and updatedAt for env documents', async () => { + const patches: Array<{ url: string; body: string }> = []; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes('/artifacts/') && init?.method === 'PATCH') { + patches.push({ url: urlStr, body: (init.body as string) ?? '' }); + return new Response('{}', { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + await submitEnvDocumentsPush('app-1', 'token', { + config: { envVariables: { E1: 'EV11', assets: 'https://cdn.example.com/' } }, + secrets: { secrets: { S1: 'SK1', S2: 'SK22' } }, + }); + + const configPatch = patches.find((patch) => patch.url.includes('/artifacts/appConfig')); + const secretsPatch = patches.find((patch) => patch.url.includes('/artifacts/secrets')); + expect(configPatch?.url).toContain('updateMask.fieldPaths=envVariables'); + expect(secretsPatch?.url).toContain('updateMask.fieldPaths=secrets'); + + const configBody = JSON.parse(configPatch!.body) as { + fields: { + content?: { stringValue?: string }; + envVariables?: { mapValue?: { fields?: Record } }; + }; + }; + expect(JSON.parse(configBody.fields.content?.stringValue ?? '{}')).toEqual({ + envVariables: { E1: 'EV11', assets: 'https://cdn.example.com/' }, + }); + expect(configBody.fields.envVariables?.mapValue?.fields?.E1?.stringValue).toBe('EV11'); + + const secretsBody = JSON.parse(secretsPatch!.body) as { + fields: { + secrets?: { mapValue?: { fields?: Record } }; + }; + }; + expect(secretsBody.fields.secrets?.mapValue?.fields?.S2?.stringValue).toBe('SK22'); + }); +}); + +describe('fetchRootScreenName', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = originalFetch; + }); + + describe('submitCliPush', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = originalFetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('creates translation with correct id, defaultLocale and user references', async () => { + type CapturedBody = { + fields: { + defaultLocale?: { booleanValue: boolean }; + updatedBy?: { referenceValue: string }; + createdBy?: { referenceValue: string }; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + + const calls: { url: string; body: CapturedBody }[] = []; + + globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (init?.method === 'POST' && urlStr.includes('/artifacts')) { + const parsedBody = init.body + ? (JSON.parse(String(init.body)) as CapturedBody) + : ({} as CapturedBody); + calls.push({ + url: urlStr, + body: parsedBody, + }); + return new Response(JSON.stringify({}), { status: 200 }); + } + // For other calls (screens/widgets/scripts/theme) that won't be used in this test. + return new Response(JSON.stringify({}), { status: 200 }); + }) as unknown as typeof fetch; + + const payload = { + id: 'app1', + name: 'App', + updatedAt: new Date().toISOString(), + translations: [ + { + operation: 'create' as const, + document: { + id: 'i18n_en', + name: 'en', + content: 'en: content', + type: 'i18n', + defaultLocale: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + updatedBy: { name: 'User', email: 'u@test.com', id: 'uid1' }, + createdBy: { name: 'User', email: 'u@test.com', id: 'uid1' }, + }, + }, + ], + }; + + await submitCliPush('app1', 'token', payload); + + expect(calls.length).toBe(1); + const { url, body } = calls[0]!; + expect(url).toContain('/apps/app1/artifacts'); + expect(url).toContain('documentId=i18n_en'); + expect(body).toHaveProperty('fields'); + expect(body.fields.defaultLocale).toEqual({ booleanValue: true }); + expect(body.fields.updatedBy).toBeDefined(); + expect(body.fields.createdBy).toBeDefined(); + + const updatedByRef = body.fields.updatedBy!.referenceValue as string; + const createdByRef = body.fields.createdBy!.referenceValue as string; + expect(updatedByRef).toContain('/users/uid1'); + expect(createdByRef).toContain('/users/uid1'); + }); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('returns name of screen with isRoot true', async () => { + const runQueryResponse = [ + { + document: { + name: 'projects/p/databases/(default)/documents/apps/app-1/artifacts/s1', + fields: { + type: { stringValue: 'screen' }, + name: { stringValue: 'Home' }, + isRoot: { booleanValue: true }, + }, + }, + }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes(':runQuery')) { + return new Response(JSON.stringify(runQueryResponse), { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + const result = await fetchRootScreenName('app-1', 'token'); + expect(result).toBe('Home'); + }); + + it('returns undefined when no root screen', async () => { + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (urlStr.includes(':runQuery')) { + return new Response(JSON.stringify([]), { status: 200 }); + } + return new Response('Not found', { status: 404 }); + }; + + const result = await fetchRootScreenName('app-1', 'token'); + expect(result).toBeUndefined(); + }); + + it('returns undefined on fetch failure', async () => { + globalThis.fetch = async () => new Response('', { status: 500 }); + + const result = await fetchRootScreenName('app-1', 'token'); + expect(result).toBeUndefined(); + }); +}); + +describe('Firestore client structured errors and debug logging', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('submitCliPush throws FirestoreClientError with mapped code on HTTP error', async () => { + globalThis.fetch = async () => + new Response('unauthorized', { + status: 401, + statusText: 'Unauthorized', + }); + + const payload = { + id: 'app1', + name: 'App', + updatedAt: new Date().toISOString(), + translations: [ + { + operation: 'create' as const, + document: { + id: 'i18n_en', + name: 'en', + content: 'en: content', + type: 'i18n', + defaultLocale: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + updatedBy: { name: 'User', email: 'u@test.com', id: 'uid1' }, + createdBy: { name: 'User', email: 'u@test.com', id: 'uid1' }, + }, + }, + ], + }; + + await expect(submitCliPush('app1', 'token', payload)).rejects.toBeInstanceOf( + FirestoreClientError + ); + }); + + it('submitCliPush invokes debug logger when provided', async () => { + const events: FirestoreDebugEvent[] = []; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const urlStr = + typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + if (init?.method === 'POST' && urlStr.includes('/artifacts')) { + return new Response(JSON.stringify({}), { status: 200 }); + } + return new Response(JSON.stringify({}), { status: 200 }); + }; + + const payload = { + id: 'app1', + name: 'App', + updatedAt: new Date().toISOString(), + translations: [ + { + operation: 'create' as const, + document: { + id: 'i18n_en', + name: 'en', + content: 'en: content', + type: 'i18n', + defaultLocale: true, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + updatedBy: { name: 'User', email: 'u@test.com', id: 'uid1' }, + createdBy: { name: 'User', email: 'u@test.com', id: 'uid1' }, + }, + }, + ], + }; + + await submitCliPush('app1', 'token', payload, { + debug: (event) => { + events.push(event); + }, + }); + + expect(events.length).toBeGreaterThan(0); + }); +}); + +describe('listVersions', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('sends runQuery with parent in URL and only structuredQuery in body', async () => { + let capturedRequest: { url: string; body: string } | null = null; + const runQueryResponse = [ + { + document: { + name: 'projects/p/databases/(default)/documents/apps/app1/versions/v1', + fields: { + message: { stringValue: 'First version' }, + createdAt: { timestampValue: '2025-01-15T12:00:00Z' }, + expiresAt: { timestampValue: '2025-02-15T12:00:00Z' }, + createdBy: { referenceValue: 'projects/p/databases/(default)/documents/users/uid1' }, + snapshotPath: { stringValue: 'releases/app1/ver-1.json' }, + }, + }, + }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const urlStr = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (urlStr.includes(':runQuery')) { + capturedRequest = { url: urlStr, body: (init?.body as string) ?? '' }; + return new Response(JSON.stringify(runQueryResponse), { status: 200 }); + } + return new Response('', { status: 404 }); + }; + + const result = await listVersions('app1', 'token', { limit: 5 }); + + expect(capturedRequest).not.toBeNull(); + expect(capturedRequest!.url).toContain('/documents/apps/app1:runQuery'); + const body = JSON.parse(capturedRequest!.body); + expect(body).toHaveProperty('structuredQuery'); + expect(body.structuredQuery.from).toEqual([{ collectionId: 'versions' }]); + expect(body.structuredQuery.orderBy).toEqual([ + { field: { fieldPath: 'createdAt' }, direction: 'DESCENDING' }, + ]); + expect(body.structuredQuery.limit).toBe(5); + expect(body).not.toHaveProperty('parent'); + + expect(result.versions).toHaveLength(1); + expect(result.versions[0]!.message).toBe('First version'); + expect(result.versions[0]!.createdAt).toBe('2025-01-15T12:00:00Z'); + expect(result.versions[0]!.snapshotPath).toEqual('releases/app1/ver-1.json'); + expect(result.nextStartAfter).toBeUndefined(); + }); + + it('returns nextStartAfter when limit results returned', async () => { + const ts = '2025-01-15T12:00:00Z'; + const runQueryResponse = Array.from({ length: 5 }, (_, i) => ({ + document: { + name: `projects/p/databases/(default)/documents/apps/app1/versions/v${i}`, + fields: { + message: { stringValue: `Version ${i}` }, + createdAt: { timestampValue: ts }, + expiresAt: { timestampValue: '2025-02-15T12:00:00Z' }, + snapshotPath: { stringValue: 'releases/app1/ver-1.json' }, + }, + }, + })); + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (urlStr.includes(':runQuery')) { + return new Response(JSON.stringify(runQueryResponse), { status: 200 }); + } + return new Response('', { status: 404 }); + }; + + const result = await listVersions('app1', 'token', { limit: 5 }); + expect(result.versions).toHaveLength(5); + expect(result.nextStartAfter).toBe(ts); + }); + + it('includes startAt in body when startAfter is provided', async () => { + let capturedBody: string | null = null; + globalThis.fetch = async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = (init?.body as string) ?? null; + return new Response(JSON.stringify([]), { status: 200 }); + }; + + await listVersions('app1', 'token', { limit: 5, startAfter: '2025-01-10T00:00:00Z' }); + + const body = JSON.parse(capturedBody!); + expect(body.structuredQuery.startAt).toEqual({ + values: [{ timestampValue: '2025-01-10T00:00:00Z' }], + before: false, + }); + }); + + it('returns no nextStartAfter when fewer than limit returned', async () => { + const runQueryResponse = [ + { + document: { + name: 'projects/p/databases/(default)/documents/apps/app1/versions/v1', + fields: { + message: { stringValue: 'Only one' }, + createdAt: { timestampValue: '2025-01-15T12:00:00Z' }, + expiresAt: { timestampValue: '2025-02-15T12:00:00Z' }, + snapshotPath: { stringValue: 'releases/app1/ver-1.json' }, + }, + }, + }, + ]; + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (urlStr.includes(':runQuery')) { + return new Response(JSON.stringify(runQueryResponse), { status: 200 }); + } + return new Response('', { status: 404 }); + }; + + const result = await listVersions('app1', 'token', { limit: 5 }); + expect(result.versions).toHaveLength(1); + expect(result.nextStartAfter).toBeUndefined(); + }); + + it('throws FirestoreClientError on 403', async () => { + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (urlStr.includes(':runQuery')) { + return new Response( + JSON.stringify({ error: { code: 403, message: 'Permission denied' } }), + { status: 403 } + ); + } + return new Response('', { status: 404 }); + }; + + await expect(listVersions('app1', 'token', { limit: 5 })).rejects.toThrow(FirestoreClientError); + }); +}); + +describe('createVersion', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('POSTs to app versions collection with provided id', async () => { + let capturedUrl: string | null = null; + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const urlStr = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (urlStr.includes('/versions') && init?.method === 'POST') { + capturedUrl = urlStr; + return new Response(JSON.stringify({}), { status: 200 }); + } + return new Response('', { status: 404 }); + }; + + const result = await createVersion('app1', 'token', { + id: 'abc123', + message: 'Release 1', + createdAt: '2025-01-15T12:00:00Z', + expiresAt: '2025-02-15T12:00:00Z', + createdBy: { name: 'User', id: 'uid1' }, + snapshotPath: 'releases/app1/abc123.enc.json', + }); + + expect(result.id).toBe('abc123'); + expect(capturedUrl).toContain('documentId=abc123'); + }); + + it('throws FirestoreClientError on 403', async () => { + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (urlStr.includes('/versions')) { + return new Response(JSON.stringify({ error: { code: 403 } }), { status: 403 }); + } + return new Response('', { status: 404 }); + }; + + await expect( + createVersion('app1', 'token', { + id: 'ver-123', + message: 'v1', + createdAt: '2025-01-15T12:00:00Z', + expiresAt: '2025-02-15T12:00:00Z', + createdBy: { name: 'User', id: 'uid1' }, + snapshotPath: 'releases/app1/ver-123.json', + }) + ).rejects.toThrow(FirestoreClientError); + }); +}); + +describe('getVersion', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('returns version doc metadata with snapshotPath', async () => { + const versionDoc = { + name: 'projects/p/databases/(default)/documents/apps/app1/versions/ver-123', + fields: { + message: { stringValue: 'Saved state' }, + createdAt: { timestampValue: '2025-01-15T12:00:00Z' }, + expiresAt: { timestampValue: '2025-02-15T12:00:00Z' }, + createdBy: { referenceValue: 'projects/p/databases/(default)/documents/users/uid1' }, + snapshotPath: { stringValue: 'releases/app1/ver-123.json' }, + }, + }; + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (urlStr.includes('/versions/ver-123')) { + return new Response(JSON.stringify(versionDoc), { status: 200 }); + } + return new Response('', { status: 404 }); + }; + + const result = await getVersion('app1', 'token', 'ver-123'); + expect(result.id).toBe('ver-123'); + expect(result.message).toBe('Saved state'); + expect(result.createdAt).toBe('2025-01-15T12:00:00Z'); + expect(result.snapshotPath).toBe('releases/app1/ver-123.json'); + }); + + it('throws FirestoreClientError with NOT_FOUND on 404', async () => { + globalThis.fetch = async () => new Response(JSON.stringify({}), { status: 404 }); + + let thrown: FirestoreClientError | undefined; + try { + await getVersion('app1', 'token', 'missing-id'); + } catch (err) { + thrown = err as FirestoreClientError; + } + expect(thrown).toBeInstanceOf(FirestoreClientError); + expect(thrown!.code).toBe('NOT_FOUND'); + expect(thrown!.message).toContain('missing-id'); + }); + + it('throws FirestoreClientError when version doc metadata is invalid', async () => { + const versionDoc = { + name: 'projects/p/databases/(default)/documents/apps/app1/versions/ver-123', + fields: { + message: { stringValue: 'v1' }, + createdAt: { timestampValue: '2025-01-15T12:00:00Z' }, + expiresAt: { timestampValue: '2025-02-15T12:00:00Z' }, + snapshotPath: { stringValue: '' }, + }, + }; + + globalThis.fetch = async (input: RequestInfo | URL) => { + const urlStr = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + if (urlStr.includes('/versions/ver-123')) { + return new Response(JSON.stringify(versionDoc), { status: 200 }); + } + return new Response('', { status: 404 }); + }; + + let thrown: FirestoreClientError | undefined; + try { + await getVersion('app1', 'token', 'ver-123'); + } catch (err) { + thrown = err as FirestoreClientError; + } + expect(thrown).toBeInstanceOf(FirestoreClientError); + expect(thrown!.message).toContain('metadata is invalid'); + }); +}); diff --git a/tests/cloud/storageClient.test.ts b/tests/cloud/storageClient.test.ts index edf2181..64db34b 100644 --- a/tests/cloud/storageClient.test.ts +++ b/tests/cloud/storageClient.test.ts @@ -1,94 +1,74 @@ -import { describe, it, expect, afterEach } from 'vitest'; - -import { - downloadReleaseSnapshotJson, - uploadReleaseSnapshot, -} from '../../src/cloud/storageClient.js'; - -describe('storageClient', () => { - const originalFetch = globalThis.fetch; - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - it('uploadReleaseSnapshot posts to releases path with Firebase auth header', async () => { - let captured: { url: string; method?: string; headers?: HeadersInit; body?: string } | null = - null; - - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const urlStr = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - captured = { - url: urlStr, - method: init?.method, - headers: init?.headers, - body: init?.body as string | undefined, - }; - return new Response(JSON.stringify({}), { status: 200 }); - }) as unknown as typeof fetch; - - const result = await uploadReleaseSnapshot('app1', 'id-token', 'ver-123', '{"foo":"bar"}'); - - // Bucket is derived from env/project; we assert the path and headers, not the exact bucket. - expect(result.objectPath).toBe('releases/app1/ver-123.json'); - expect(captured).not.toBeNull(); - expect(captured!.url).toContain('https://firebasestorage.googleapis.com/v0/b/'); - expect(captured!.url).toContain('name=' + encodeURIComponent('releases/app1/ver-123.json')); - expect(captured!.method).toBe('POST'); - const headers = new Headers(captured!.headers); - expect(headers.get('Authorization')).toBe('Firebase id-token'); - expect(headers.get('Content-Type')).toBe('application/json'); - expect(captured!.body).toBe('{"foo":"bar"}'); - }); - - it('uploadReleaseSnapshot throws StorageClientError on non-2xx', async () => { - globalThis.fetch = (async () => { - return new Response('forbidden', { status: 403 }); - }) as unknown as typeof fetch; - - await expect( - uploadReleaseSnapshot('app1', 'id-token', 'ver-123', '{"foo":"bar"}') - ).rejects.toThrow('Storage upload release snapshot failed (403)'); - }); - - it('downloadReleaseSnapshotJson GETs from releases path with Firebase auth header', async () => { - let captured: { url: string; headers?: HeadersInit } | null = null; - - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const urlStr = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : (input as Request).url; - captured = { url: urlStr, headers: init?.headers }; - return new Response('{"id":"app1"}', { status: 200 }); - }) as unknown as typeof fetch; - - const json = await downloadReleaseSnapshotJson('id-token', 'releases/app1/ver-123.json'); - - expect(json).toBe('{"id":"app1"}'); - expect(captured).not.toBeNull(); - expect(captured!.url).toContain('https://firebasestorage.googleapis.com/v0/b/'); - expect(captured!.url).toContain( - encodeURIComponent('releases/app1/ver-123.json') + '?alt=media' - ); - const headers = new Headers(captured!.headers); - expect(headers.get('Authorization')).toBe('Firebase id-token'); - }); - - it('downloadReleaseSnapshotJson throws StorageClientError on non-2xx', async () => { - globalThis.fetch = (async () => { - return new Response('not found', { status: 404 }); - }) as unknown as typeof fetch; - - await expect( - downloadReleaseSnapshotJson('id-token', 'releases/app1/ver-123.json') - ).rejects.toThrow('Storage download release snapshot failed (404)'); - }); -}); +import { describe, it, expect, afterEach } from 'vitest'; + +import { + downloadReleaseSnapshotJson, + objectPathForRelease, + uploadReleaseSnapshot, +} from '../../src/cloud/storageClient.js'; + +describe('storageClient', () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it('objectPathForRelease uses encrypted .enc.json suffix', () => { + expect(objectPathForRelease('app1', 'ver-123')).toBe('releases/app1/ver-123.enc.json'); + }); + + it('uploadReleaseSnapshot posts envelope json to encrypted releases path', async () => { + let captured: { url: string; method?: string; headers?: HeadersInit; body?: string } | null = + null; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const urlStr = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + captured = { + url: urlStr, + method: init?.method, + headers: init?.headers, + body: init?.body as string | undefined, + }; + return new Response(JSON.stringify({}), { status: 200 }); + }) as unknown as typeof fetch; + + const envelope = '{"v":1,"alg":"AES-256-GCM","comp":"br","iv":"a","tag":"b","ciphertext":"c"}'; + const result = await uploadReleaseSnapshot('app1', 'id-token', 'ver-123', envelope); + + expect(result.objectPath).toBe('releases/app1/ver-123.enc.json'); + expect(captured!.method).toBe('POST'); + const headers = new Headers(captured!.headers); + expect(headers.get('Authorization')).toBe('Firebase id-token'); + expect(headers.get('Content-Type')).toBe('application/json'); + expect(captured!.body).toBe(envelope); + }); + + it('downloadReleaseSnapshotJson GETs from releases path with Firebase auth header', async () => { + let captured: { url: string; headers?: HeadersInit } | null = null; + + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const urlStr = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : (input as Request).url; + captured = { url: urlStr, headers: init?.headers }; + return new Response('{"v":1}', { status: 200 }); + }) as unknown as typeof fetch; + + const json = await downloadReleaseSnapshotJson('id-token', 'releases/app1/ver-123.enc.json'); + + expect(json).toBe('{"v":1}'); + expect(captured!.url).toContain( + encodeURIComponent('releases/app1/ver-123.enc.json') + '?alt=media' + ); + const headers = new Headers(captured!.headers); + expect(headers.get('Authorization')).toBe('Firebase id-token'); + }); +}); diff --git a/tests/commands/pushPull.test.ts b/tests/commands/pushPull.test.ts index 8bfe090..ff08fea 100644 --- a/tests/commands/pushPull.test.ts +++ b/tests/commands/pushPull.test.ts @@ -1,1105 +1,1105 @@ -import fs from 'fs/promises'; -import os from 'os'; -import path from 'path'; - -import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; - -// We will mock these modules to control environment for push/pull. -let projectRoot: string; - -const appOptionsRef = vi.hoisted(() => ({ value: {} as Record })); - -vi.mock('../../src/config/projectConfig.js', () => { - return { - resolveAppContext: vi.fn(async (requestedAppKey?: string) => { - const appKey = requestedAppKey ?? 'dev'; - return { - projectRoot, - config: { - default: 'dev', - apps: { - dev: { - appId: 'app1', - name: 'App', - appHome: 'Home', - options: appOptionsRef.value, - }, - }, - }, - appKey, - appId: 'app1', - }; - }), - }; -}); - -vi.mock('../../src/auth/session.js', () => { - return { - getValidAuthSession: vi.fn(async () => ({ - ok: true as const, - idToken: 'token', - userId: 'uid1', - name: 'User', - email: 'u@test.com', - refreshed: false, - })), - }; -}); - -const cloudModuleMock = vi.hoisted(() => { - return { - checkAppAccess: vi.fn(async () => ({ - ok: true as const, - app: { name: 'App', description: 'Test app' }, - })), - fetchCloudApp: vi.fn(async () => ({ - id: 'app1', - name: 'App', - screens: [], - widgets: [], - scripts: [], - translations: [], - theme: undefined, - })), - submitCliPush: vi.fn( - async ( - appId: string, - idToken: string, - _payload: unknown, - _opts: unknown, - extras?: { projectRoot?: string; assetFileNames?: string[] } - ) => { - if (extras?.assetFileNames?.length && extras.projectRoot) { - const { uploadProjectAssetsForPush } = await import('../../src/core/pushAssets.js'); - const n = await uploadProjectAssetsForPush( - appId, - idToken, - extras.projectRoot, - extras.assetFileNames - ); - return { assetsUploaded: n }; - } - return { assetsUploaded: 0 }; - } - ), - submitEnvDocumentsPush: vi.fn(async () => {}), - }; -}); - -vi.mock('../../src/cloud/firestoreClient.js', () => cloudModuleMock); - -const assetClientMock = vi.hoisted(() => ({ - uploadAssetToStudio: vi.fn(async (_appId: string, fileName: string) => ({ - success: true, - assetBaseUrl: 'https://cdn.example.com/assets/', - envVariable: { - key: fileName.replace(/[^\w]+/g, '_'), - value: `${fileName}?token=abc`, - }, - usageKey: '${env.assets}${env.file}', - })), -})); - -vi.mock('../../src/cloud/assetClient.js', () => assetClientMock); - -const promptsModuleMock = vi.hoisted(() => ({ - default: vi.fn(async () => ({ proceed: true })), -})); - -vi.mock('prompts', () => promptsModuleMock); - -// Import after mocks -import { resolveAppContext } from '../../src/config/projectConfig.js'; -import { getValidAuthSession } from '../../src/auth/session.js'; -import { pushCommand } from '../../src/commands/push.js'; -import { pullCommand } from '../../src/commands/pull.js'; -import { collectAppFiles } from '../../src/core/appCollector.js'; - -describe('push/pull integration (commands)', () => { - const originalCwd = process.cwd(); - - beforeEach(async () => { - projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ensemble-cli-push-pull-')); - process.chdir(projectRoot); - // Ensure minimal project structure - await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); - await fs.mkdir(path.join(projectRoot, 'translations'), { recursive: true }); - appOptionsRef.value = {}; - }); - - afterEach(async () => { - process.chdir(originalCwd); - await fs.rm(projectRoot, { recursive: true, force: true }); - vi.clearAllMocks(); - }); - - it('push uses defaultLanguage from .manifest and sends correct translation payload', async () => { - // Arrange: create a minimal Home screen, translation files, and manifest - await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); - await fs.writeFile(path.join(projectRoot, 'translations', 'en.yaml'), 'en: content', 'utf8'); - await fs.writeFile(path.join(projectRoot, 'translations', 'ar.yaml'), 'ar: محتوى', 'utf8'); - await fs.writeFile( - path.join(projectRoot, '.manifest.json'), - JSON.stringify( - { - scripts: [], - widgets: [], - defaultLanguage: 'ar', - languages: ['ar', 'en'], - }, - null, - 2 - ) + '\n', - 'utf8' - ); - - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // Act - await pushCommand({ verbose: false, yes: true }); - - // Assert - const { submitCliPush } = cloudModuleMock as { - submitCliPush: ReturnType; - }; - expect(submitCliPush).toHaveBeenCalledTimes(1); - const [appId, , payload] = submitCliPush.mock.calls[0] as [string, string, unknown]; - expect(appId).toBe('app1'); - const p = payload as { - translations?: { - operation: string; - document: { id: string; name: string; defaultLocale?: boolean }; - }[]; - }; - expect(p.translations).toBeDefined(); - const ar = p.translations!.find((t) => t.operation === 'create' && t.document.name === 'ar'); - const en = p.translations!.find((t) => t.operation === 'create' && t.document.name === 'en'); - expect(ar).toBeDefined(); - expect(en).toBeDefined(); - expect(ar!.document.id).toBe('i18n_ar'); - expect(en!.document.id).toBe('i18n_en'); - expect(ar!.document.defaultLocale).toBe(true); - expect(en!.document.defaultLocale ?? false).toBe(false); - - // Should print a success summary with counts. - expect( - logSpy.mock.calls.some( - ([msg]) => typeof msg === 'string' && msg.includes('Pushed app "App" to environment "dev"') - ) - ).toBe(true); - - logSpy.mockRestore(); - }); - - it('push respects app options and does not include disabled screens in diff/payload', async () => { - // Disable screens in app options - appOptionsRef.value = { screens: false }; - - // For this test, simulate an app config without a configured home screen so that - // the validation logic in buildDocumentsFromParsed does not require any screens. - const resolveAppContextMock = resolveAppContext as unknown as ReturnType; - resolveAppContextMock.mockResolvedValueOnce({ - projectRoot, - config: { - default: 'dev', - apps: { - dev: { - appId: 'app1', - name: 'App', - appHome: undefined, - options: appOptionsRef.value, - }, - }, - }, - appKey: 'dev', - appId: 'app1', - }); - - // Arrange: create a minimal Home screen so buildDocumentsFromParsed succeeds. - await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); - - // Cloud app has screens, but they should be ignored due to options. - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [ - { - id: 'screen-id-1', - name: 'Home', - content: 'home: content', - type: 'screen', - isRoot: true, - }, - { - id: 'screen-id-2', - name: 'Test', - content: 'test: content', - type: 'screen', - isRoot: false, - }, - ] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, - }); - - await pushCommand({ verbose: false, yes: true }); - - const { submitCliPush } = cloudModuleMock as { - submitCliPush: ReturnType; - }; - // With screens disabled, no changes should be pushed even though cloud has screens. - expect(submitCliPush).not.toHaveBeenCalled(); - }); - - it('push dry run shows diff but does not submit payload', async () => { - // Arrange: create a minimal Home screen plus a simple local file and cloud app with no existing artifacts. - await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); - await fs.writeFile(path.join(projectRoot, 'translations', 'en.yaml'), 'en: content', 'utf8'); - - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, - }); - - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - await pushCommand({ verbose: false, yes: false, dryRun: true }); - - const { submitCliPush } = cloudModuleMock as { - submitCliPush: ReturnType; - }; - expect(submitCliPush).not.toHaveBeenCalled(); - - // Dry run output should clearly indicate non-destructive behavior and how to apply. - const lines = logSpy.mock.calls.map(([msg]) => String(msg)); - expect(lines.some((l) => l.includes('Push dry run'))).toBe(true); - expect( - lines.some((l) => - l.includes('Run `ensemble push` without `--dry-run` to apply these changes.') - ) - ).toBe(true); - - logSpy.mockRestore(); - }); - - it('push without --yes in non-interactive mode refuses to run', async () => { - // Arrange: create a minimal Home screen and a simple local file so there is at least one change to push. - await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); - await fs.writeFile(path.join(projectRoot, 'translations', 'en.yaml'), 'en: content', 'utf8'); - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const { submitCliPush } = cloudModuleMock as { - submitCliPush: ReturnType; - }; - - // Act: do not pass --yes; in test environment the process is effectively non-interactive. - await pushCommand({ verbose: false }); - - // Assert: no network writes and a clear error message. - expect(submitCliPush).not.toHaveBeenCalled(); - expect( - errorSpy.mock.calls.some( - ([msg]) => - typeof msg === 'string' && - msg.includes('Refusing to run push non-interactively without --yes') - ) - ).toBe(true); - - // Reset exit code for other tests. - process.exitCode = 0; - errorSpy.mockRestore(); - }); - - it('push uploads assets and updates .env.config', async () => { - await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); - await fs.mkdir(path.join(projectRoot, 'assets'), { recursive: true }); - await fs.writeFile(path.join(projectRoot, 'assets', 'logo.png'), Buffer.from([1, 2, 3])); - - // Ensure push proceeds in test environment by providing --yes. - await pushCommand({ verbose: false, yes: true }); - - const uploadAssetMock = assetClientMock.uploadAssetToStudio as ReturnType; - expect(uploadAssetMock).toHaveBeenCalledTimes(1); - expect(uploadAssetMock.mock.calls[0]?.[0]).toBe('app1'); - expect(uploadAssetMock.mock.calls[0]?.[1]).toBe('logo.png'); - - const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); - expect(envConfig).toContain('assets=https://cdn.example.com/assets/'); - expect(envConfig).toContain('logo_png=logo.png?token=abc'); - }); - - it('push skips asset upload when cloud already has same fileName', async () => { - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [ - { - id: 'screen-id-1', - name: 'Home', - content: 'home: content', - type: 'screen', - isRoot: true, - }, - ] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, - assets: [ - { - id: 'a1', - name: 'logo.png', - fileName: 'logo.png', - content: 'builds/app1/assets/logo.png', - type: 'asset', - }, - ] as unknown[], - }); - - await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); - await fs.mkdir(path.join(projectRoot, 'assets'), { recursive: true }); - await fs.writeFile(path.join(projectRoot, 'assets', 'logo.png'), Buffer.from([9, 9, 9])); - - const uploadMock = assetClientMock.uploadAssetToStudio as ReturnType; - uploadMock.mockClear(); - - await pushCommand({ verbose: false, yes: true }); - - expect(uploadMock).not.toHaveBeenCalled(); - }); - - it('pull without --yes in non-interactive mode refuses to run', async () => { - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [ - { - id: 'screen-id-1', - name: 'Home', - content: 'home: from cloud', - type: 'screen', - isRoot: true, - }, - ] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, - }); - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - await pullCommand({ verbose: false }); - - expect( - errorSpy.mock.calls.some( - ([msg]) => - typeof msg === 'string' && - msg.includes('Refusing to run pull non-interactively without --yes') - ) - ).toBe(true); - expect(process.exitCode).toBe(1); - - process.exitCode = 0; - errorSpy.mockRestore(); - }); - - it('pull writes artifacts and .manifest and is idempotent', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - const themeContent = 'colors:\n primary: blue'; - - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [ - { - id: 'screen-id-1', - name: 'Home', - content: 'home: content', - type: 'screen', - isRoot: true, - }, - ] as unknown[], - widgets: [ - { - id: 'widget-id-1', - name: 'W1', - content: 'widget: w1', - type: 'internal_widget', - }, - ] as unknown[], - scripts: [ - { - id: 'script-id-1', - name: 'S1', - content: 'console.log(1);', - type: 'internal_script', - }, - ] as unknown[], - translations: [ - { - id: 'i18n_ar', - name: 'ar', - content: 'ar: محتوى', - type: 'i18n', - defaultLocale: true, - }, - { - id: 'i18n_en', - name: 'en', - content: 'en: content', - type: 'i18n', - defaultLocale: false, - }, - ] as unknown[], - theme: { - id: 'theme', - name: 'theme', - content: themeContent, - type: 'theme', - } as unknown, - }); - - // First pull (overwrite) - await pullCommand({ verbose: false, yes: true }); - - // Verify files exist and manifest content - const files = await collectAppFiles(projectRoot); - expect(Object.keys(files.screens)).toContain('Home.yaml'); - expect(Object.keys(files.scripts)).toContain('S1.js'); - expect(Object.keys(files.translations)).toContain('ar.yaml'); - expect(Object.keys(files.translations)).toContain('en.yaml'); - expect(files.theme).toBe(themeContent); - - const manifestRaw = await fs.readFile(path.join(projectRoot, '.manifest.json'), 'utf8'); - const manifest = JSON.parse(manifestRaw) as { - widgets?: { name: string }[]; - scripts?: { name: string }[]; - defaultLanguage?: string; - languages?: string[]; - }; - expect(manifest.widgets?.some((w) => w.name === 'W1')).toBe(true); - expect(manifest.scripts?.some((s) => s.name === 'S1')).toBe(true); - expect(manifest.defaultLanguage).toBe('ar'); - expect(manifest.languages).toEqual(['ar', 'en']); - - // Second pull should be effectively a no-op from the FS perspective. - const fetchSpy = cloudModuleMock.fetchCloudApp as ReturnType; - await pullCommand({ verbose: false, yes: true }); - expect(fetchSpy).toHaveBeenCalledTimes(2); - - const messages = logSpy.mock.calls.map((args) => args[0]); - expect( - messages.some( - (m) => typeof m === 'string' && m.includes('Pulled app') && m.includes('applied') - ) - ).toBe(true); - - logSpy.mockRestore(); - }); - - it('pull then push with no local changes: push reports nothing to push (consistency)', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - const cloudApp = { - id: 'app1', - name: 'App', - screens: [ - { - id: 'screen-id-1', - name: 'Home', - content: 'home: content', - type: 'screen', - isRoot: true, - }, - ] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [ - { - id: 'i18n_en', - name: 'en', - content: 'en: content', - type: 'i18n', - defaultLocale: true, - }, - ] as unknown[], - theme: undefined, - }; - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValue(cloudApp); - - await pullCommand({ verbose: false, yes: true }); - const { submitCliPush } = cloudModuleMock as { - submitCliPush: ReturnType; - }; - submitCliPush.mockClear(); - - await pushCommand({ verbose: false, yes: true }); - - expect(submitCliPush).not.toHaveBeenCalled(); - const messages = logSpy.mock.calls.map((args) => args[0]); - expect( - messages.some( - (m) => typeof m === 'string' && m.includes('Up to date') && m.includes('Nothing to push') - ) - ).toBe(true); - - logSpy.mockRestore(); - }); - - it('pull then push with no changes when cloud has duplicate names (archived + active)', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - const activeContent = 'home: content'; - const cloudApp = { - id: 'app1', - name: 'App', - screens: [ - { - id: 'archived-id', - name: 'Home', - content: 'archived: old', - type: 'screen', - isRoot: false, - isArchived: true, - }, - { - id: 'active-id', - name: 'Home', - content: activeContent, - type: 'screen', - isRoot: true, - }, - ] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, - }; - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValue(cloudApp); - - await pullCommand({ verbose: false, yes: true }); - const { submitCliPush } = cloudModuleMock as { - submitCliPush: ReturnType; - }; - submitCliPush.mockClear(); - - await pushCommand({ verbose: false, yes: true }); - - expect(submitCliPush).not.toHaveBeenCalled(); - const messages = logSpy.mock.calls.map((args) => args[0]); - expect( - messages.some( - (m) => typeof m === 'string' && m.includes('Up to date') && m.includes('Nothing to push') - ) - ).toBe(true); - - logSpy.mockRestore(); - }); - - it('pull respects app options and does not overwrite disabled artifact kinds', async () => { - // Disable screens in app options - appOptionsRef.value = { screens: false }; - - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [ - { - id: 'screen-id-1', - name: 'Home', - content: 'home: content', - type: 'screen', - isRoot: true, - }, - ] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, - }); - - await pullCommand({ verbose: false, yes: true }); - - const files = await collectAppFiles(projectRoot); - // Since screens are disabled via options, pull should not have written any screens. - expect(Object.keys(files.screens)).toEqual([]); - }); - - it('pull dry run shows summary but does not modify files', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [ - { - id: 'screen-id-1', - name: 'Home', - content: 'home: content', - type: 'screen', - isRoot: true, - }, - ] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, - }); - - await pullCommand({ verbose: false, yes: true, dryRun: true }); - - const files = await collectAppFiles(projectRoot); - expect(Object.keys(files.screens)).toEqual([]); - expect(Object.keys(files.widgets)).toEqual([]); - expect(Object.keys(files.scripts)).toEqual([]); - expect(Object.keys(files.translations)).toEqual([]); - expect(files.theme).toBeUndefined(); - - const messages = logSpy.mock.calls.map((args) => args[0]); - expect( - messages.some( - (m) => typeof m === 'string' && m.includes('Pull plan for') && m.includes('(dev)') - ) - ).toBe(true); - expect( - messages.some((m) => typeof m === 'string' && m.includes('🍀 new') && m.includes('Home.yaml')) - ).toBe(true); - expect( - messages.some( - (m) => typeof m === 'string' && m.includes('Dry run only: no files were changed.') - ) - ).toBe(true); - - logSpy.mockRestore(); - }); - - it('pull dry run shows asset-only changes', async () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - // Cloud has a new asset; no YAML changes. - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, - assets: [ - { - id: 'a1', - name: 'logo.png', - fileName: 'logo.png', - content: '', - type: 'asset', - publicUrl: 'https://cdn.example.com/logo.png', - }, - ] as unknown[], - }); - - await pullCommand({ verbose: false, yes: true, dryRun: true }); - - const files = await collectAppFiles(projectRoot); - expect(files.assetFiles ?? []).toEqual([]); - - const messages = logSpy.mock.calls.map((args) => String(args[0])); - expect(messages.some((m) => m.includes('asset:'))).toBe(true); - expect(messages.some((m) => m.includes('logo.png'))).toBe(true); - - logSpy.mockRestore(); - }); - - it('pull downloads missing assets to assets/ when publicUrl is present', async () => { - const fetchMock = vi.fn(async (url: string) => { - if (url === 'https://cdn.example.com/logo.png') { - return { - ok: true, - status: 200, - arrayBuffer: async () => Uint8Array.from([1, 2, 3, 4]).buffer, - } as unknown as Response; - } - return { - ok: false, - status: 404, - arrayBuffer: async () => new ArrayBuffer(0), - } as unknown as Response; - }); - vi.stubGlobal('fetch', fetchMock); - - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, - assets: [ - { - id: 'a1', - name: 'logo.png', - fileName: 'logo.png', - content: '', - type: 'asset', - publicUrl: 'https://cdn.example.com/logo.png', - }, - ] as unknown[], - }); - - await pullCommand({ verbose: false, yes: true }); - - const buf = await fs.readFile(path.join(projectRoot, 'assets', 'logo.png')); - expect([...buf]).toEqual([1, 2, 3, 4]); - - const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); - expect(envConfig).toContain('assets=https://cdn.example.com/'); - expect(envConfig).toContain('logo_png=logo.png'); - - vi.unstubAllGlobals(); - }); - - it('pull continues when an asset download fails (e.g. 403) and reports a warning', async () => { - const fetchMock = vi.fn(async (url: string) => { - if (url === 'https://cdn.example.com/forbidden.png') { - return { - ok: false, - status: 403, - arrayBuffer: async () => new ArrayBuffer(0), - } as unknown as Response; - } - return { - ok: true, - status: 200, - arrayBuffer: async () => new ArrayBuffer(0), - } as unknown as Response; - }); - vi.stubGlobal('fetch', fetchMock); - - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [ - { - id: 'screen-id-1', - name: 'Home', - content: 'home: content', - type: 'screen', - isRoot: true, - }, - ] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, - assets: [ - { - id: 'a1', - name: 'forbidden.png', - fileName: 'forbidden.png', - content: '', - type: 'asset', - publicUrl: 'https://cdn.example.com/forbidden.png', - }, - ] as unknown[], - }); - - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - // Should not throw even though asset fetch fails. - await expect(pullCommand({ verbose: false, yes: true })).resolves.toBeUndefined(); - - // Should warn about the failed asset download. - const errors = errorSpy.mock.calls.map(([msg]) => String(msg)).join('\n'); - expect(errors).toContain('Some assets failed to download'); - expect(errors).toContain('Failed to download asset (403)'); - - // Failed asset should not have been written. - await expect( - fs.readFile(path.join(projectRoot, 'assets', 'forbidden.png')) - ).rejects.toBeTruthy(); - - // .env.config should still be created/updated so env references exist. - const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); - expect(envConfig).toContain('assets=https://cdn.example.com/'); - expect(envConfig).toContain('forbidden_png=forbidden.png'); - - errorSpy.mockRestore(); - vi.unstubAllGlobals(); - }); - - it('pull explains overwrites/deletes and suggests dry run for conflicts', async () => { - // Existing local file that will be overwritten plus one that will be deleted. - await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); - await fs.writeFile( - path.join(projectRoot, 'screens', 'Home.yaml'), - 'home: local-changes', - 'utf8' - ); - await fs.writeFile( - path.join(projectRoot, 'screens', 'Stale.yaml'), - 'stale: to-be-deleted', - 'utf8' - ); - - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [ - { - id: 'screen-id-1', - name: 'Home', - content: 'home: cloud-version', - type: 'screen', - isRoot: true, - }, - ] as unknown[], - widgets: [] as unknown[], - scripts: [] as unknown[], - translations: [] as unknown[], - theme: undefined, - }); - - await pullCommand({ verbose: false, yes: true }); - - const messages = logSpy.mock.calls.map((args) => args[0]); - expect(messages.some((m) => typeof m === 'string' && m.includes('Changes to be pulled:'))).toBe( - true - ); - expect( - messages.some( - (m) => typeof m === 'string' && m.includes('removed') && m.includes('Stale.yaml') - ) - ).toBe(true); - expect( - messages.some( - (m) => typeof m === 'string' && m.includes('re-run with `--dry-run` to inspect the plan') - ) - ).toBe(true); - - logSpy.mockRestore(); - }); - - it('push surfaces auth failures with a hint to login', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const originalExitCode = process.exitCode; - - const sessionMock = getValidAuthSession as unknown as ReturnType; - sessionMock.mockResolvedValueOnce({ - ok: false as const, - message: 'Auth failed. Run `ensemble login` and try again.', - }); - - await pushCommand({ verbose: false, yes: true }); - - const errors = errorSpy.mock.calls.map(([msg]) => String(msg)).join('\n'); - expect(errors).toContain('Auth failed.'); - expect(errors).toContain('ensemble login'); - expect(process.exitCode).toBe(1); - - const { checkAppAccess, fetchCloudApp, submitCliPush } = cloudModuleMock as { - checkAppAccess: ReturnType; - fetchCloudApp: ReturnType; - submitCliPush: ReturnType; - }; - expect(checkAppAccess).not.toHaveBeenCalled(); - expect(fetchCloudApp).not.toHaveBeenCalled(); - expect(submitCliPush).not.toHaveBeenCalled(); - - process.exitCode = originalExitCode; - errorSpy.mockRestore(); - }); - - it('pull surfaces auth failures with a hint to login', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const originalExitCode = process.exitCode; - - const sessionMock = getValidAuthSession as unknown as ReturnType; - sessionMock.mockResolvedValueOnce({ - ok: false as const, - message: 'Auth failed. Run `ensemble login` and try again.', - }); - - await pullCommand({ verbose: false, yes: true }); - - const errors = errorSpy.mock.calls.map(([msg]) => String(msg)).join('\n'); - expect(errors).toContain('Auth failed.'); - expect(errors).toContain('ensemble login'); - expect(process.exitCode).toBe(1); - - const { checkAppAccess, fetchCloudApp } = cloudModuleMock as { - checkAppAccess: ReturnType; - fetchCloudApp: ReturnType; - }; - expect(checkAppAccess).not.toHaveBeenCalled(); - expect(fetchCloudApp).not.toHaveBeenCalled(); - - process.exitCode = originalExitCode; - errorSpy.mockRestore(); - }); - - it('push surfaces app access failures with a clear message', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const originalExitCode = process.exitCode; - - (cloudModuleMock.checkAppAccess as ReturnType).mockResolvedValueOnce({ - ok: false as const, - message: 'You do not have access to this app. Ask an Ensemble admin to grant you access.', - }); - - await pushCommand({ verbose: false, yes: true }); - - const errors = errorSpy.mock.calls.map(([msg]) => String(msg)).join('\n'); - expect(errors).toContain('You do not have access to this app.'); - expect(process.exitCode).toBe(1); - - const { submitCliPush } = cloudModuleMock as { - submitCliPush: ReturnType; - }; - expect(submitCliPush).not.toHaveBeenCalled(); - - process.exitCode = originalExitCode; - errorSpy.mockRestore(); - }); - - it('pull surfaces app access failures with a clear message', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const originalExitCode = process.exitCode; - - (cloudModuleMock.checkAppAccess as ReturnType).mockResolvedValueOnce({ - ok: false as const, - message: 'You do not have access to this app. Ask an Ensemble admin to grant you access.', - }); - - await pullCommand({ verbose: false, yes: true }); - - const errors = errorSpy.mock.calls.map(([msg]) => String(msg)).join('\n'); - expect(errors).toContain('You do not have access to this app.'); - expect(process.exitCode).toBe(1); - - // fetchCloudApp runs in parallel with checkAppAccess, so it may be called - // The important assertion is we exit early (process.exitCode 1) and show the message - - process.exitCode = originalExitCode; - errorSpy.mockRestore(); - }); - - it('push uploads local env changes via submitEnvDocumentsPush', async () => { - await fs.writeFile( - path.join(projectRoot, '.env.config'), - 'API_URL=https://local.example.com\n', - 'utf8' - ); - await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=local-secret\n', 'utf8'); - (resolveAppContext as ReturnType).mockResolvedValueOnce({ - projectRoot, - config: { - default: 'dev', - apps: { - dev: { appId: 'app1', name: 'App', appHome: undefined, options: appOptionsRef.value }, - }, - }, - appKey: 'dev', - appId: 'app1', - }); - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [], - widgets: [], - scripts: [], - translations: [], - config: { envVariables: { API_URL: 'https://cloud.example.com' } }, - secrets: { secrets: { S1: 'cloud-secret' } }, - }); - - await pushCommand({ yes: true }); - - const { submitEnvDocumentsPush } = cloudModuleMock; - expect(submitEnvDocumentsPush).toHaveBeenCalledTimes(1); - const rawCall = submitEnvDocumentsPush.mock.calls[0]; - expect(rawCall).toBeDefined(); - const payload = ( - rawCall as unknown as [ - string, - string, - { - config?: { envVariables?: Record }; - secrets?: { secrets?: Record }; - }, - ] - )[2]; - expect(payload.config?.envVariables?.API_URL).toBe('https://local.example.com'); - expect(payload.secrets?.secrets?.S1).toBe('local-secret'); - }); - - it('push clears cloud secrets when .env.secrets is empty', async () => { - await fs.writeFile(path.join(projectRoot, '.env.secrets'), '', 'utf8'); - (resolveAppContext as ReturnType).mockResolvedValueOnce({ - projectRoot, - config: { - default: 'dev', - apps: { - dev: { appId: 'app1', name: 'App', appHome: undefined, options: appOptionsRef.value }, - }, - }, - appKey: 'dev', - appId: 'app1', - }); - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [], - widgets: [], - scripts: [], - translations: [], - secrets: { secrets: { S1: 'cloud-secret' } }, - }); - - await pushCommand({ yes: true }); - - const payload = ( - (cloudModuleMock.submitEnvDocumentsPush as ReturnType).mock.calls[0] as [ - string, - string, - { secrets?: { secrets?: Record } }, - ] - )[2]; - expect(payload.secrets?.secrets).toEqual({}); - }); - - it('push skips secrets when .env.secrets is missing', async () => { - (resolveAppContext as ReturnType).mockResolvedValueOnce({ - projectRoot, - config: { - default: 'dev', - apps: { - dev: { appId: 'app1', name: 'App', appHome: undefined, options: appOptionsRef.value }, - }, - }, - appKey: 'dev', - appId: 'app1', - }); - (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ - id: 'app1', - name: 'App', - screens: [], - widgets: [], - scripts: [], - translations: [], - secrets: { secrets: { S1: 'cloud-secret' } }, - }); - - await pushCommand({ yes: true }); - - expect(cloudModuleMock.submitEnvDocumentsPush).not.toHaveBeenCalled(); - }); -}); +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; + +// We will mock these modules to control environment for push/pull. +let projectRoot: string; + +const appOptionsRef = vi.hoisted(() => ({ value: {} as Record })); + +vi.mock('../../src/config/projectConfig.js', () => { + return { + resolveAppContext: vi.fn(async (requestedAppKey?: string) => { + const appKey = requestedAppKey ?? 'dev'; + return { + projectRoot, + config: { + default: 'dev', + apps: { + dev: { + appId: 'app1', + name: 'App', + appHome: 'Home', + options: appOptionsRef.value, + }, + }, + }, + appKey, + appId: 'app1', + }; + }), + }; +}); + +vi.mock('../../src/auth/session.js', () => { + return { + getValidAuthSession: vi.fn(async () => ({ + ok: true as const, + idToken: 'token', + userId: 'uid1', + name: 'User', + email: 'u@test.com', + refreshed: false, + })), + }; +}); + +const cloudModuleMock = vi.hoisted(() => { + return { + checkAppAccess: vi.fn(async () => ({ + ok: true as const, + app: { name: 'App', description: 'Test app' }, + })), + fetchCloudApp: vi.fn(async () => ({ + id: 'app1', + name: 'App', + screens: [], + widgets: [], + scripts: [], + translations: [], + theme: undefined, + })), + submitCliPush: vi.fn( + async ( + appId: string, + idToken: string, + _payload: unknown, + _opts: unknown, + extras?: { projectRoot?: string; assetFileNames?: string[] } + ) => { + if (extras?.assetFileNames?.length && extras.projectRoot) { + const { uploadProjectAssetsForPush } = await import('../../src/core/pushAssets.js'); + const n = await uploadProjectAssetsForPush( + appId, + idToken, + extras.projectRoot, + extras.assetFileNames + ); + return { assetsUploaded: n }; + } + return { assetsUploaded: 0 }; + } + ), + submitEnvDocumentsPush: vi.fn(async () => {}), + }; +}); + +vi.mock('../../src/cloud/firestoreClient.js', () => cloudModuleMock); + +const assetClientMock = vi.hoisted(() => ({ + uploadAssetToStudio: vi.fn(async (_appId: string, fileName: string) => ({ + success: true, + assetBaseUrl: 'https://cdn.example.com/assets/', + envVariable: { + key: fileName.replace(/[^\w]+/g, '_'), + value: `${fileName}?token=abc`, + }, + usageKey: '${env.assets}${env.file}', + })), +})); + +vi.mock('../../src/cloud/assetClient.js', () => assetClientMock); + +const promptsModuleMock = vi.hoisted(() => ({ + default: vi.fn(async () => ({ proceed: true })), +})); + +vi.mock('prompts', () => promptsModuleMock); + +// Import after mocks +import { resolveAppContext } from '../../src/config/projectConfig.js'; +import { getValidAuthSession } from '../../src/auth/session.js'; +import { pushCommand } from '../../src/commands/push.js'; +import { pullCommand } from '../../src/commands/pull.js'; +import { collectAppFiles } from '../../src/core/appCollector.js'; + +describe('push/pull integration (commands)', () => { + const originalCwd = process.cwd(); + + beforeEach(async () => { + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ensemble-cli-push-pull-')); + process.chdir(projectRoot); + // Ensure minimal project structure + await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); + await fs.mkdir(path.join(projectRoot, 'translations'), { recursive: true }); + appOptionsRef.value = {}; + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.rm(projectRoot, { recursive: true, force: true }); + vi.clearAllMocks(); + }); + + it('push uses defaultLanguage from .manifest and sends correct translation payload', async () => { + // Arrange: create a minimal Home screen, translation files, and manifest + await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); + await fs.writeFile(path.join(projectRoot, 'translations', 'en.yaml'), 'en: content', 'utf8'); + await fs.writeFile(path.join(projectRoot, 'translations', 'ar.yaml'), 'ar: محتوى', 'utf8'); + await fs.writeFile( + path.join(projectRoot, '.manifest.json'), + JSON.stringify( + { + scripts: [], + widgets: [], + defaultLanguage: 'ar', + languages: ['ar', 'en'], + }, + null, + 2 + ) + '\n', + 'utf8' + ); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Act + await pushCommand({ verbose: false, yes: true }); + + // Assert + const { submitCliPush } = cloudModuleMock as { + submitCliPush: ReturnType; + }; + expect(submitCliPush).toHaveBeenCalledTimes(1); + const [appId, , payload] = submitCliPush.mock.calls[0] as [string, string, unknown]; + expect(appId).toBe('app1'); + const p = payload as { + translations?: { + operation: string; + document: { id: string; name: string; defaultLocale?: boolean }; + }[]; + }; + expect(p.translations).toBeDefined(); + const ar = p.translations!.find((t) => t.operation === 'create' && t.document.name === 'ar'); + const en = p.translations!.find((t) => t.operation === 'create' && t.document.name === 'en'); + expect(ar).toBeDefined(); + expect(en).toBeDefined(); + expect(ar!.document.id).toBe('i18n_ar'); + expect(en!.document.id).toBe('i18n_en'); + expect(ar!.document.defaultLocale).toBe(true); + expect(en!.document.defaultLocale ?? false).toBe(false); + + // Should print a success summary with counts. + expect( + logSpy.mock.calls.some( + ([msg]) => typeof msg === 'string' && msg.includes('Pushed app "App" to environment "dev"') + ) + ).toBe(true); + + logSpy.mockRestore(); + }); + + it('push respects app options and does not include disabled screens in diff/payload', async () => { + // Disable screens in app options + appOptionsRef.value = { screens: false }; + + // For this test, simulate an app config without a configured home screen so that + // the validation logic in buildDocumentsFromParsed does not require any screens. + const resolveAppContextMock = resolveAppContext as unknown as ReturnType; + resolveAppContextMock.mockResolvedValueOnce({ + projectRoot, + config: { + default: 'dev', + apps: { + dev: { + appId: 'app1', + name: 'App', + appHome: undefined, + options: appOptionsRef.value, + }, + }, + }, + appKey: 'dev', + appId: 'app1', + }); + + // Arrange: create a minimal Home screen so buildDocumentsFromParsed succeeds. + await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); + + // Cloud app has screens, but they should be ignored due to options. + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [ + { + id: 'screen-id-1', + name: 'Home', + content: 'home: content', + type: 'screen', + isRoot: true, + }, + { + id: 'screen-id-2', + name: 'Test', + content: 'test: content', + type: 'screen', + isRoot: false, + }, + ] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + }); + + await pushCommand({ verbose: false, yes: true }); + + const { submitCliPush } = cloudModuleMock as { + submitCliPush: ReturnType; + }; + // With screens disabled, no changes should be pushed even though cloud has screens. + expect(submitCliPush).not.toHaveBeenCalled(); + }); + + it('push dry run shows diff but does not submit payload', async () => { + // Arrange: create a minimal Home screen plus a simple local file and cloud app with no existing artifacts. + await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); + await fs.writeFile(path.join(projectRoot, 'translations', 'en.yaml'), 'en: content', 'utf8'); + + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await pushCommand({ verbose: false, yes: false, dryRun: true }); + + const { submitCliPush } = cloudModuleMock as { + submitCliPush: ReturnType; + }; + expect(submitCliPush).not.toHaveBeenCalled(); + + // Dry run output should clearly indicate non-destructive behavior and how to apply. + const lines = logSpy.mock.calls.map(([msg]) => String(msg)); + expect(lines.some((l) => l.includes('Push dry run'))).toBe(true); + expect( + lines.some((l) => + l.includes('Run `ensemble push` without `--dry-run` to apply these changes.') + ) + ).toBe(true); + + logSpy.mockRestore(); + }); + + it('push without --yes in non-interactive mode refuses to run', async () => { + // Arrange: create a minimal Home screen and a simple local file so there is at least one change to push. + await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); + await fs.writeFile(path.join(projectRoot, 'translations', 'en.yaml'), 'en: content', 'utf8'); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const { submitCliPush } = cloudModuleMock as { + submitCliPush: ReturnType; + }; + + // Act: do not pass --yes; in test environment the process is effectively non-interactive. + await pushCommand({ verbose: false }); + + // Assert: no network writes and a clear error message. + expect(submitCliPush).not.toHaveBeenCalled(); + expect( + errorSpy.mock.calls.some( + ([msg]) => + typeof msg === 'string' && + msg.includes('Refusing to run push non-interactively without --yes') + ) + ).toBe(true); + + // Reset exit code for other tests. + process.exitCode = 0; + errorSpy.mockRestore(); + }); + + it('push uploads assets and updates .env.config', async () => { + await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); + await fs.mkdir(path.join(projectRoot, 'assets'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, 'assets', 'logo.png'), Buffer.from([1, 2, 3])); + + // Ensure push proceeds in test environment by providing --yes. + await pushCommand({ verbose: false, yes: true }); + + const uploadAssetMock = assetClientMock.uploadAssetToStudio as ReturnType; + expect(uploadAssetMock).toHaveBeenCalledTimes(1); + expect(uploadAssetMock.mock.calls[0]?.[0]).toBe('app1'); + expect(uploadAssetMock.mock.calls[0]?.[1]).toBe('logo.png'); + + const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); + expect(envConfig).toContain('assets=https://cdn.example.com/assets/'); + expect(envConfig).toContain('logo_png=logo.png?token=abc'); + }); + + it('push skips asset upload when cloud already has same fileName', async () => { + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [ + { + id: 'screen-id-1', + name: 'Home', + content: 'home: content', + type: 'screen', + isRoot: true, + }, + ] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + assets: [ + { + id: 'a1', + name: 'logo.png', + fileName: 'logo.png', + content: 'builds/app1/assets/logo.png', + type: 'asset', + }, + ] as unknown[], + }); + + await fs.writeFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'home: content', 'utf8'); + await fs.mkdir(path.join(projectRoot, 'assets'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, 'assets', 'logo.png'), Buffer.from([9, 9, 9])); + + const uploadMock = assetClientMock.uploadAssetToStudio as ReturnType; + uploadMock.mockClear(); + + await pushCommand({ verbose: false, yes: true }); + + expect(uploadMock).not.toHaveBeenCalled(); + }); + + it('pull without --yes in non-interactive mode refuses to run', async () => { + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [ + { + id: 'screen-id-1', + name: 'Home', + content: 'home: from cloud', + type: 'screen', + isRoot: true, + }, + ] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + await pullCommand({ verbose: false }); + + expect( + errorSpy.mock.calls.some( + ([msg]) => + typeof msg === 'string' && + msg.includes('Refusing to run pull non-interactively without --yes') + ) + ).toBe(true); + expect(process.exitCode).toBe(1); + + process.exitCode = 0; + errorSpy.mockRestore(); + }); + + it('pull writes artifacts and .manifest and is idempotent', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const themeContent = 'colors:\n primary: blue'; + + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [ + { + id: 'screen-id-1', + name: 'Home', + content: 'home: content', + type: 'screen', + isRoot: true, + }, + ] as unknown[], + widgets: [ + { + id: 'widget-id-1', + name: 'W1', + content: 'widget: w1', + type: 'internal_widget', + }, + ] as unknown[], + scripts: [ + { + id: 'script-id-1', + name: 'S1', + content: 'console.log(1);', + type: 'internal_script', + }, + ] as unknown[], + translations: [ + { + id: 'i18n_ar', + name: 'ar', + content: 'ar: محتوى', + type: 'i18n', + defaultLocale: true, + }, + { + id: 'i18n_en', + name: 'en', + content: 'en: content', + type: 'i18n', + defaultLocale: false, + }, + ] as unknown[], + theme: { + id: 'theme', + name: 'theme', + content: themeContent, + type: 'theme', + } as unknown, + }); + + // First pull (overwrite) + await pullCommand({ verbose: false, yes: true }); + + // Verify files exist and manifest content + const files = await collectAppFiles(projectRoot); + expect(Object.keys(files.screens)).toContain('Home.yaml'); + expect(Object.keys(files.scripts)).toContain('S1.js'); + expect(Object.keys(files.translations)).toContain('ar.yaml'); + expect(Object.keys(files.translations)).toContain('en.yaml'); + expect(files.theme).toBe(themeContent); + + const manifestRaw = await fs.readFile(path.join(projectRoot, '.manifest.json'), 'utf8'); + const manifest = JSON.parse(manifestRaw) as { + widgets?: { name: string }[]; + scripts?: { name: string }[]; + defaultLanguage?: string; + languages?: string[]; + }; + expect(manifest.widgets?.some((w) => w.name === 'W1')).toBe(true); + expect(manifest.scripts?.some((s) => s.name === 'S1')).toBe(true); + expect(manifest.defaultLanguage).toBe('ar'); + expect(manifest.languages).toEqual(['ar', 'en']); + + // Second pull should be effectively a no-op from the FS perspective. + const fetchSpy = cloudModuleMock.fetchCloudApp as ReturnType; + await pullCommand({ verbose: false, yes: true }); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + const messages = logSpy.mock.calls.map((args) => args[0]); + expect( + messages.some( + (m) => typeof m === 'string' && m.includes('Pulled app') && m.includes('applied') + ) + ).toBe(true); + + logSpy.mockRestore(); + }); + + it('pull then push with no local changes: push reports nothing to push (consistency)', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const cloudApp = { + id: 'app1', + name: 'App', + screens: [ + { + id: 'screen-id-1', + name: 'Home', + content: 'home: content', + type: 'screen', + isRoot: true, + }, + ] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [ + { + id: 'i18n_en', + name: 'en', + content: 'en: content', + type: 'i18n', + defaultLocale: true, + }, + ] as unknown[], + theme: undefined, + }; + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValue(cloudApp); + + await pullCommand({ verbose: false, yes: true }); + const { submitCliPush } = cloudModuleMock as { + submitCliPush: ReturnType; + }; + submitCliPush.mockClear(); + + await pushCommand({ verbose: false, yes: true }); + + expect(submitCliPush).not.toHaveBeenCalled(); + const messages = logSpy.mock.calls.map((args) => args[0]); + expect( + messages.some( + (m) => typeof m === 'string' && m.includes('Up to date') && m.includes('Nothing to push') + ) + ).toBe(true); + + logSpy.mockRestore(); + }); + + it('pull then push with no changes when cloud has duplicate names (archived + active)', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const activeContent = 'home: content'; + const cloudApp = { + id: 'app1', + name: 'App', + screens: [ + { + id: 'archived-id', + name: 'Home', + content: 'archived: old', + type: 'screen', + isRoot: false, + isArchived: true, + }, + { + id: 'active-id', + name: 'Home', + content: activeContent, + type: 'screen', + isRoot: true, + }, + ] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + }; + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValue(cloudApp); + + await pullCommand({ verbose: false, yes: true }); + const { submitCliPush } = cloudModuleMock as { + submitCliPush: ReturnType; + }; + submitCliPush.mockClear(); + + await pushCommand({ verbose: false, yes: true }); + + expect(submitCliPush).not.toHaveBeenCalled(); + const messages = logSpy.mock.calls.map((args) => args[0]); + expect( + messages.some( + (m) => typeof m === 'string' && m.includes('Up to date') && m.includes('Nothing to push') + ) + ).toBe(true); + + logSpy.mockRestore(); + }); + + it('pull respects app options and does not overwrite disabled artifact kinds', async () => { + // Disable screens in app options + appOptionsRef.value = { screens: false }; + + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [ + { + id: 'screen-id-1', + name: 'Home', + content: 'home: content', + type: 'screen', + isRoot: true, + }, + ] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + }); + + await pullCommand({ verbose: false, yes: true }); + + const files = await collectAppFiles(projectRoot); + // Since screens are disabled via options, pull should not have written any screens. + expect(Object.keys(files.screens)).toEqual([]); + }); + + it('pull dry run shows summary but does not modify files', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [ + { + id: 'screen-id-1', + name: 'Home', + content: 'home: content', + type: 'screen', + isRoot: true, + }, + ] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + }); + + await pullCommand({ verbose: false, yes: true, dryRun: true }); + + const files = await collectAppFiles(projectRoot); + expect(Object.keys(files.screens)).toEqual([]); + expect(Object.keys(files.widgets)).toEqual([]); + expect(Object.keys(files.scripts)).toEqual([]); + expect(Object.keys(files.translations)).toEqual([]); + expect(files.theme).toBeUndefined(); + + const messages = logSpy.mock.calls.map((args) => args[0]); + expect( + messages.some( + (m) => typeof m === 'string' && m.includes('Pull plan for') && m.includes('(dev)') + ) + ).toBe(true); + expect( + messages.some((m) => typeof m === 'string' && m.includes('🍀 new') && m.includes('Home.yaml')) + ).toBe(true); + expect( + messages.some( + (m) => typeof m === 'string' && m.includes('Dry run only: no files were changed.') + ) + ).toBe(true); + + logSpy.mockRestore(); + }); + + it('pull dry run shows asset-only changes', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + // Cloud has a new asset; no YAML changes. + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + assets: [ + { + id: 'a1', + name: 'logo.png', + fileName: 'logo.png', + content: '', + type: 'asset', + publicUrl: 'https://cdn.example.com/logo.png', + }, + ] as unknown[], + }); + + await pullCommand({ verbose: false, yes: true, dryRun: true }); + + const files = await collectAppFiles(projectRoot); + expect(files.assetFiles ?? []).toEqual([]); + + const messages = logSpy.mock.calls.map((args) => String(args[0])); + expect(messages.some((m) => m.includes('asset:'))).toBe(true); + expect(messages.some((m) => m.includes('logo.png'))).toBe(true); + + logSpy.mockRestore(); + }); + + it('pull downloads missing assets to assets/ when publicUrl is present', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url === 'https://cdn.example.com/logo.png') { + return { + ok: true, + status: 200, + arrayBuffer: async () => Uint8Array.from([1, 2, 3, 4]).buffer, + } as unknown as Response; + } + return { + ok: false, + status: 404, + arrayBuffer: async () => new ArrayBuffer(0), + } as unknown as Response; + }); + vi.stubGlobal('fetch', fetchMock); + + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + assets: [ + { + id: 'a1', + name: 'logo.png', + fileName: 'logo.png', + content: '', + type: 'asset', + publicUrl: 'https://cdn.example.com/logo.png', + }, + ] as unknown[], + }); + + await pullCommand({ verbose: false, yes: true }); + + const buf = await fs.readFile(path.join(projectRoot, 'assets', 'logo.png')); + expect([...buf]).toEqual([1, 2, 3, 4]); + + const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); + expect(envConfig).toContain('assets=https://cdn.example.com/'); + expect(envConfig).toContain('logo_png=logo.png'); + + vi.unstubAllGlobals(); + }); + + it('pull continues when an asset download fails (e.g. 403) and reports a warning', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url === 'https://cdn.example.com/forbidden.png') { + return { + ok: false, + status: 403, + arrayBuffer: async () => new ArrayBuffer(0), + } as unknown as Response; + } + return { + ok: true, + status: 200, + arrayBuffer: async () => new ArrayBuffer(0), + } as unknown as Response; + }); + vi.stubGlobal('fetch', fetchMock); + + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [ + { + id: 'screen-id-1', + name: 'Home', + content: 'home: content', + type: 'screen', + isRoot: true, + }, + ] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + assets: [ + { + id: 'a1', + name: 'forbidden.png', + fileName: 'forbidden.png', + content: '', + type: 'asset', + publicUrl: 'https://cdn.example.com/forbidden.png', + }, + ] as unknown[], + }); + + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + // Should not throw even though asset fetch fails. + await expect(pullCommand({ verbose: false, yes: true })).resolves.toBeUndefined(); + + // Should warn about the failed asset download. + const errors = errorSpy.mock.calls.map(([msg]) => String(msg)).join('\n'); + expect(errors).toContain('Some assets failed to download'); + expect(errors).toContain('Failed to download asset (403)'); + + // Failed asset should not have been written. + await expect( + fs.readFile(path.join(projectRoot, 'assets', 'forbidden.png')) + ).rejects.toBeTruthy(); + + // .env.config should still be created/updated so env references exist. + const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); + expect(envConfig).toContain('assets=https://cdn.example.com/'); + expect(envConfig).toContain('forbidden_png=forbidden.png'); + + errorSpy.mockRestore(); + vi.unstubAllGlobals(); + }); + + it('pull explains overwrites/deletes and suggests dry run for conflicts', async () => { + // Existing local file that will be overwritten plus one that will be deleted. + await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); + await fs.writeFile( + path.join(projectRoot, 'screens', 'Home.yaml'), + 'home: local-changes', + 'utf8' + ); + await fs.writeFile( + path.join(projectRoot, 'screens', 'Stale.yaml'), + 'stale: to-be-deleted', + 'utf8' + ); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [ + { + id: 'screen-id-1', + name: 'Home', + content: 'home: cloud-version', + type: 'screen', + isRoot: true, + }, + ] as unknown[], + widgets: [] as unknown[], + scripts: [] as unknown[], + translations: [] as unknown[], + theme: undefined, + }); + + await pullCommand({ verbose: false, yes: true }); + + const messages = logSpy.mock.calls.map((args) => args[0]); + expect(messages.some((m) => typeof m === 'string' && m.includes('Changes to be pulled:'))).toBe( + true + ); + expect( + messages.some( + (m) => typeof m === 'string' && m.includes('removed') && m.includes('Stale.yaml') + ) + ).toBe(true); + expect( + messages.some( + (m) => typeof m === 'string' && m.includes('re-run with `--dry-run` to inspect the plan') + ) + ).toBe(true); + + logSpy.mockRestore(); + }); + + it('push surfaces auth failures with a hint to login', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const originalExitCode = process.exitCode; + + const sessionMock = getValidAuthSession as unknown as ReturnType; + sessionMock.mockResolvedValueOnce({ + ok: false as const, + message: 'Auth failed. Run `ensemble login` and try again.', + }); + + await pushCommand({ verbose: false, yes: true }); + + const errors = errorSpy.mock.calls.map(([msg]) => String(msg)).join('\n'); + expect(errors).toContain('Auth failed.'); + expect(errors).toContain('ensemble login'); + expect(process.exitCode).toBe(1); + + const { checkAppAccess, fetchCloudApp, submitCliPush } = cloudModuleMock as { + checkAppAccess: ReturnType; + fetchCloudApp: ReturnType; + submitCliPush: ReturnType; + }; + expect(checkAppAccess).not.toHaveBeenCalled(); + expect(fetchCloudApp).not.toHaveBeenCalled(); + expect(submitCliPush).not.toHaveBeenCalled(); + + process.exitCode = originalExitCode; + errorSpy.mockRestore(); + }); + + it('pull surfaces auth failures with a hint to login', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const originalExitCode = process.exitCode; + + const sessionMock = getValidAuthSession as unknown as ReturnType; + sessionMock.mockResolvedValueOnce({ + ok: false as const, + message: 'Auth failed. Run `ensemble login` and try again.', + }); + + await pullCommand({ verbose: false, yes: true }); + + const errors = errorSpy.mock.calls.map(([msg]) => String(msg)).join('\n'); + expect(errors).toContain('Auth failed.'); + expect(errors).toContain('ensemble login'); + expect(process.exitCode).toBe(1); + + const { checkAppAccess, fetchCloudApp } = cloudModuleMock as { + checkAppAccess: ReturnType; + fetchCloudApp: ReturnType; + }; + expect(checkAppAccess).not.toHaveBeenCalled(); + expect(fetchCloudApp).not.toHaveBeenCalled(); + + process.exitCode = originalExitCode; + errorSpy.mockRestore(); + }); + + it('push surfaces app access failures with a clear message', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const originalExitCode = process.exitCode; + + (cloudModuleMock.checkAppAccess as ReturnType).mockResolvedValueOnce({ + ok: false as const, + message: 'You do not have access to this app. Ask an Ensemble admin to grant you access.', + }); + + await pushCommand({ verbose: false, yes: true }); + + const errors = errorSpy.mock.calls.map(([msg]) => String(msg)).join('\n'); + expect(errors).toContain('You do not have access to this app.'); + expect(process.exitCode).toBe(1); + + const { submitCliPush } = cloudModuleMock as { + submitCliPush: ReturnType; + }; + expect(submitCliPush).not.toHaveBeenCalled(); + + process.exitCode = originalExitCode; + errorSpy.mockRestore(); + }); + + it('pull surfaces app access failures with a clear message', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const originalExitCode = process.exitCode; + + (cloudModuleMock.checkAppAccess as ReturnType).mockResolvedValueOnce({ + ok: false as const, + message: 'You do not have access to this app. Ask an Ensemble admin to grant you access.', + }); + + await pullCommand({ verbose: false, yes: true }); + + const errors = errorSpy.mock.calls.map(([msg]) => String(msg)).join('\n'); + expect(errors).toContain('You do not have access to this app.'); + expect(process.exitCode).toBe(1); + + // fetchCloudApp runs in parallel with checkAppAccess, so it may be called + // The important assertion is we exit early (process.exitCode 1) and show the message + + process.exitCode = originalExitCode; + errorSpy.mockRestore(); + }); + + it('push uploads local env changes via submitEnvDocumentsPush', async () => { + await fs.writeFile( + path.join(projectRoot, '.env.config'), + 'API_URL=https://local.example.com\n', + 'utf8' + ); + await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=local-secret\n', 'utf8'); + (resolveAppContext as ReturnType).mockResolvedValueOnce({ + projectRoot, + config: { + default: 'dev', + apps: { + dev: { appId: 'app1', name: 'App', appHome: undefined, options: appOptionsRef.value }, + }, + }, + appKey: 'dev', + appId: 'app1', + }); + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [], + widgets: [], + scripts: [], + translations: [], + config: { envVariables: { API_URL: 'https://cloud.example.com' } }, + secrets: { secrets: { S1: 'cloud-secret' } }, + }); + + await pushCommand({ yes: true }); + + const { submitEnvDocumentsPush } = cloudModuleMock; + expect(submitEnvDocumentsPush).toHaveBeenCalledTimes(1); + const rawCall = submitEnvDocumentsPush.mock.calls[0]; + expect(rawCall).toBeDefined(); + const payload = ( + rawCall as unknown as [ + string, + string, + { + config?: { envVariables?: Record }; + secrets?: { secrets?: Record }; + }, + ] + )[2]; + expect(payload.config?.envVariables?.API_URL).toBe('https://local.example.com'); + expect(payload.secrets?.secrets?.S1).toBe('local-secret'); + }); + + it('push clears cloud secrets when .env.secrets is empty', async () => { + await fs.writeFile(path.join(projectRoot, '.env.secrets'), '', 'utf8'); + (resolveAppContext as ReturnType).mockResolvedValueOnce({ + projectRoot, + config: { + default: 'dev', + apps: { + dev: { appId: 'app1', name: 'App', appHome: undefined, options: appOptionsRef.value }, + }, + }, + appKey: 'dev', + appId: 'app1', + }); + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [], + widgets: [], + scripts: [], + translations: [], + secrets: { secrets: { S1: 'cloud-secret' } }, + }); + + await pushCommand({ yes: true }); + + const payload = ( + (cloudModuleMock.submitEnvDocumentsPush as ReturnType).mock.calls[0] as [ + string, + string, + { secrets?: { secrets?: Record } }, + ] + )[2]; + expect(payload.secrets?.secrets).toEqual({}); + }); + + it('push skips secrets when .env.secrets is missing', async () => { + (resolveAppContext as ReturnType).mockResolvedValueOnce({ + projectRoot, + config: { + default: 'dev', + apps: { + dev: { appId: 'app1', name: 'App', appHome: undefined, options: appOptionsRef.value }, + }, + }, + appKey: 'dev', + appId: 'app1', + }); + (cloudModuleMock.fetchCloudApp as ReturnType).mockResolvedValueOnce({ + id: 'app1', + name: 'App', + screens: [], + widgets: [], + scripts: [], + translations: [], + secrets: { secrets: { S1: 'cloud-secret' } }, + }); + + await pushCommand({ yes: true }); + + expect(cloudModuleMock.submitEnvDocumentsPush).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/commands/release.test.ts b/tests/commands/release.test.ts index d738f93..5ebe5c0 100644 --- a/tests/commands/release.test.ts +++ b/tests/commands/release.test.ts @@ -1,396 +1,521 @@ -import fs from 'fs/promises'; -import os from 'os'; -import path from 'path'; - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; - -const appOptionsRef = vi.hoisted(() => ({ value: {} as Record })); -const projectRootRef = vi.hoisted(() => ({ value: '' })); -const checkAppAccessMock = vi.hoisted(() => vi.fn()); -const createVersionMock = vi.hoisted(() => vi.fn()); -const listVersionsMock = vi.hoisted(() => vi.fn()); -const getVersionMock = vi.hoisted(() => vi.fn()); -const uploadReleaseSnapshotMock = vi.hoisted(() => vi.fn()); -const downloadReleaseSnapshotJsonMock = vi.hoisted(() => vi.fn()); -const promptsMock = vi.hoisted(() => vi.fn()); -const uiErrorMock = vi.hoisted(() => vi.fn()); -const uiWarnMock = vi.hoisted(() => vi.fn()); -const uiSuccessMock = vi.hoisted(() => vi.fn()); -const uiNoteMock = vi.hoisted(() => vi.fn()); - -let projectRoot: string; - -vi.mock('../../src/config/projectConfig.js', () => ({ - resolveAppContext: vi.fn(), -})); - -vi.mock('../../src/auth/session.js', () => ({ - getValidAuthSession: vi.fn(async () => ({ - ok: true as const, - idToken: 'token', - userId: 'uid1', - name: 'User', - email: 'u@test.com', - refreshed: false, - })), -})); - -vi.mock('../../src/cloud/firestoreClient.js', async (importOriginal) => { - const mod = await importOriginal(); - return { - ...mod, - checkAppAccess: (...args: unknown[]) => checkAppAccessMock(...args), - createVersion: (...args: unknown[]) => createVersionMock(...args), - listVersions: (...args: unknown[]) => listVersionsMock(...args), - getVersion: (...args: unknown[]) => getVersionMock(...args), - }; -}); - -vi.mock('../../src/cloud/storageClient.js', () => ({ - uploadReleaseSnapshot: (...args: unknown[]) => uploadReleaseSnapshotMock(...args), - downloadReleaseSnapshotJson: (...args: unknown[]) => downloadReleaseSnapshotJsonMock(...args), - StorageClientError: class StorageClientError extends Error {}, -})); - -vi.mock('prompts', () => ({ default: promptsMock })); - -vi.mock('../../src/core/ui.js', () => ({ - ui: { - error: (...args: unknown[]) => uiErrorMock(...args), - warn: (...args: unknown[]) => uiWarnMock(...args), - success: (...args: unknown[]) => uiSuccessMock(...args), - note: (...args: unknown[]) => uiNoteMock(...args), - heading: vi.fn(), - }, -})); - -vi.mock('../../src/lib/spinner.js', () => ({ - withSpinner: vi.fn(async (_msg: string, fn: () => Promise) => fn()), -})); - -import { - releaseCreateCommand, - releaseListCommand, - releaseUseCommand, -} from '../../src/commands/release.js'; -import { resolveAppContext } from '../../src/config/projectConfig.js'; -import type { CloudApp } from '../../src/cloud/firestoreClient.js'; -import { EnsembleDocumentType } from '../../src/core/dto.js'; - -function defaultAppContext(requestedAppKey?: string) { - const appKey = requestedAppKey ?? 'default'; - return { - projectRoot: projectRootRef.value, - config: { - default: 'default', - apps: { - default: { - appId: 'app1', - name: 'App', - appHome: undefined, - options: appOptionsRef.value, - }, - }, - }, - appKey, - appId: 'app1', - }; -} - -async function writeEnvConfig(projectRoot: string, lines: string[]): Promise { - await fs.writeFile(path.join(projectRoot, '.env.config'), `${lines.join('\n')}\n`, 'utf8'); -} - -function snapshotFromUploadMock(): CloudApp { - expect(uploadReleaseSnapshotMock).toHaveBeenCalledTimes(1); - const snapshotJson = uploadReleaseSnapshotMock.mock.calls[0]?.[3]; - expect(typeof snapshotJson).toBe('string'); - return JSON.parse(snapshotJson as string) as CloudApp; -} - -describe('release commands', () => { - const originalCwd = process.cwd(); - - beforeEach(async () => { - projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ensemble-cli-release-')); - projectRootRef.value = projectRoot; - appOptionsRef.value = {}; - process.chdir(projectRoot); - vi.mocked(resolveAppContext).mockImplementation(async (requestedAppKey?: string) => - defaultAppContext(requestedAppKey) - ); - - // Minimal app files for buildDocumentsFromParsed: appHome is "Home". - await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); - await fs.writeFile( - path.join(projectRoot, 'screens', 'Home.yaml'), - 'View:\n body:\n Text:\n text: Hello', - 'utf8' - ); - - checkAppAccessMock.mockResolvedValue({ ok: true as const, app: { name: 'App' } }); - createVersionMock.mockResolvedValue({ id: 'ver-123' }); - listVersionsMock.mockResolvedValue({ - versions: [ - { - id: 'hash-1', - message: 'First release', - createdAt: '2025-01-15T12:00:00Z', - createdBy: { name: 'User', id: 'uid1' }, - expiresAt: '2025-02-15T12:00:00Z', - snapshotPath: 'releases/app1/hash-1.json', - }, - ], - nextStartAfter: undefined, - }); - getVersionMock.mockResolvedValue({ - id: 'hash-1', - message: 'First release', - createdAt: '2025-01-15T12:00:00Z', - createdBy: { name: 'User', id: 'uid1' }, - expiresAt: '2025-02-15T12:00:00Z', - snapshotPath: 'releases/app1/hash-1.json', - }); - uploadReleaseSnapshotMock.mockResolvedValue({ - bucket: 'bucket', - objectPath: 'releases/app1/ver-123.json', - }); - downloadReleaseSnapshotJsonMock.mockResolvedValue('{"id":"app1","name":"App","screens":[]}'); - promptsMock.mockResolvedValue({ message: 'My release' }); - uiErrorMock.mockImplementation(() => {}); - uiWarnMock.mockImplementation(() => {}); - uiSuccessMock.mockImplementation(() => {}); - uiNoteMock.mockImplementation(() => {}); - }); - - afterEach(async () => { - process.chdir(originalCwd); - await fs.rm(projectRoot, { recursive: true, force: true }).catch(() => {}); - process.exitCode = 0; - vi.clearAllMocks(); - }); - - it('release create stores env config in snapshot without secrets or asset publicUrl', async () => { - const assetsDir = path.join(projectRoot, 'assets'); - await fs.mkdir(assetsDir, { recursive: true }); - await fs.writeFile(path.join(assetsDir, 'logo.png'), 'png-bytes', 'utf8'); - await fs.writeFile(path.join(assetsDir, 'Case1_Working.png'), 'png-bytes', 'utf8'); - await writeEnvConfig(projectRoot, [ - 'assets=https://cdn.example.com/base/', - 'logo_png=logo.png?token=abc', - 'E1=EV1', - ]); - await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=SK1\n', 'utf8'); - - await releaseCreateCommand({ message: 'env snapshot', yes: true }); - - expect(uiSuccessMock).toHaveBeenCalledWith( - 'Release saved. Run "ensemble release use" to use it.' - ); - const snapshot = snapshotFromUploadMock(); - expect(snapshot.config?.envVariables).toEqual({ - assets: 'https://cdn.example.com/base/', - logo_png: 'logo.png?token=abc', - E1: 'EV1', - }); - expect(snapshot.secrets).toBeUndefined(); - expect(snapshot.config?.envVariables?.Case1_Working_png).toBeUndefined(); - for (const asset of snapshot.assets ?? []) { - expect(asset.publicUrl).toBeUndefined(); - expect(asset.copyText).toBeUndefined(); - } - }); - - it('release create hints alias-specific use command for non-default app', async () => { - vi.mocked(resolveAppContext).mockResolvedValueOnce({ - projectRoot, - config: { - default: 'dev', - apps: { - dev: { appId: 'app-dev', name: 'Dev App' }, - uat: { appId: 'app-uat', name: 'Uat App' }, - }, - }, - appKey: 'uat', - appId: 'app-uat', - }); - - await releaseCreateCommand({ appKey: 'uat', message: 'uat release', yes: true }); - - expect(uiSuccessMock).toHaveBeenCalledWith( - 'Release saved. Run "ensemble release use --app uat" to use it.' - ); - }); - - it('release create passes the same version id to storage upload and Firestore', async () => { - await releaseCreateCommand({ message: 'sync ids', yes: true }); - - const uploadVersionId = uploadReleaseSnapshotMock.mock.calls[0]?.[2]; - const createParams = createVersionMock.mock.calls[0]?.[2] as { id: string }; - expect(typeof uploadVersionId).toBe('string'); - expect(createParams.id).toBe(uploadVersionId); - }); - - it('release use restores config to scoped alias file for non-default app', async () => { - vi.mocked(resolveAppContext).mockResolvedValueOnce({ - projectRoot, - config: { - default: 'dev', - apps: { - dev: { appId: 'app-dev', name: 'Dev App' }, - uat: { appId: 'app-uat', name: 'Uat App' }, - }, - }, - appKey: 'uat', - appId: 'app-uat', - }); - getVersionMock.mockResolvedValue({ - id: 'hash-1', - message: 'Uat release', - createdAt: '2025-01-15T12:00:00Z', - createdBy: { name: 'User', id: 'uid1' }, - expiresAt: '2025-02-15T12:00:00Z', - snapshotPath: 'releases/app-uat/hash-1.json', - }); - downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( - JSON.stringify({ - id: 'app-uat', - name: 'Uat App', - screens: [], - config: { envVariables: { E1: 'UAT-EV1' } }, - } satisfies CloudApp) - ); - await fs.writeFile(path.join(projectRoot, '.env.config'), 'E1=dev\n', 'utf8'); - await fs.writeFile(path.join(projectRoot, '.env.config.uat'), 'E1=old-uat\n', 'utf8'); - - Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); - Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); - - await releaseUseCommand({ appKey: 'uat', hash: 'hash-1' }); - - const baseConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); - const uatConfig = await fs.readFile(path.join(projectRoot, '.env.config.uat'), 'utf8'); - expect(baseConfig).toContain('E1=dev'); - expect(uatConfig).toContain('E1=UAT-EV1'); - expect(uatConfig).not.toContain('old-uat'); - }); - - it('release use hints alias-specific push command for non-default app', async () => { - vi.mocked(resolveAppContext).mockResolvedValueOnce({ - projectRoot, - config: { - default: 'dev', - apps: { - dev: { appId: 'app-dev', name: 'Dev App' }, - uat: { appId: 'app-uat', name: 'Uat App' }, - }, - }, - appKey: 'uat', - appId: 'app-uat', - }); - downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( - JSON.stringify({ - id: 'app-uat', - name: 'Uat App', - screens: [], - config: { envVariables: { E1: 'UAT-EV1' } }, - } satisfies CloudApp) - ); - - Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); - Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); - - await releaseUseCommand({ appKey: 'uat', hash: 'hash-1' }); - - expect(uiSuccessMock).toHaveBeenCalledWith( - 'Local files updated to selected release. Run "ensemble push --app uat" to apply to the cloud.' - ); - }); - - it('release use restores snapshot config and never touches secrets', async () => { - downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( - JSON.stringify({ - id: 'app1', - name: 'App', - screens: [], - assets: [ - { - id: 'asset:Case1_Working.png', - name: 'Case1_Working.png', - fileName: 'Case1_Working.png', - content: '', - type: EnsembleDocumentType.Asset, - }, - ], - config: { envVariables: { assets: 'https://cdn.example.com/base/', E1: 'EV1' } }, - secrets: { secrets: { S1: 'SNAPSHOT-SECRET' } }, - } satisfies CloudApp) - ); - await writeEnvConfig(projectRoot, [ - 'assets=https://cdn.example.com/old/', - 'Case1_Working_png=Case1_Working.png?token=old', - 'E1=EV-WRONG', - ]); - await fs.writeFile(path.join(projectRoot, '.env.secrets'), 'S1=LOCAL-SECRET\n', 'utf8'); - - Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); - Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); - - await releaseUseCommand({ hash: 'hash-1' }); - - const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); - const envSecrets = await fs.readFile(path.join(projectRoot, '.env.secrets'), 'utf8'); - expect(envConfig).toContain('assets=https://cdn.example.com/base/'); - expect(envConfig).toContain('E1=EV1'); - expect(envConfig).not.toContain('Case1_Working_png='); - expect(envSecrets).toContain('S1=LOCAL-SECRET'); - expect(envSecrets).not.toContain('SNAPSHOT-SECRET'); - }); - - it('release list prints heading and lines when versions exist', async () => { - await releaseListCommand({}); - - expect(checkAppAccessMock).toHaveBeenCalledTimes(1); - expect(listVersionsMock).toHaveBeenCalledTimes(1); - expect(uiWarnMock).not.toHaveBeenCalled(); - }); - - it('release list warns when no versions exist', async () => { - listVersionsMock.mockResolvedValueOnce({ versions: [], nextStartAfter: undefined }); - - await releaseListCommand({}); - - expect(uiWarnMock).toHaveBeenCalledWith( - 'No releases found. Create one with "ensemble release create".' - ); - }); - - it('release use interactive picker omits hash from release labels', async () => { - Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true }); - Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); - promptsMock.mockResolvedValueOnce({ selected: 0 }); - - await releaseUseCommand({}); - - const promptArgs = promptsMock.mock.calls[0]?.[0] as { - choices: { title: string; value: number | string }[]; - }; - expect(promptArgs.choices[0]?.title).toContain('First release'); - expect(promptArgs.choices[0]?.title).not.toContain('[hash:'); - }); - - it('release use --hash uses non-interactive path', async () => { - // Make non-interactive by clearing TTY flags; hash should still work. - Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); - Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); - - await releaseUseCommand({ hash: 'hash-1' }); - - expect(getVersionMock).toHaveBeenCalledWith('app1', 'token', 'hash-1', undefined); - expect(downloadReleaseSnapshotJsonMock).toHaveBeenCalledWith( - 'token', - 'releases/app1/hash-1.json' - ); - expect(uiErrorMock).not.toHaveBeenCalled(); - }); -}); +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const appOptionsRef = vi.hoisted(() => ({ value: {} as Record })); +const projectRootRef = vi.hoisted(() => ({ value: '' })); +const checkAppAccessMock = vi.hoisted(() => vi.fn()); +const createVersionMock = vi.hoisted(() => vi.fn()); +const listVersionsMock = vi.hoisted(() => vi.fn()); +const getVersionMock = vi.hoisted(() => vi.fn()); +const uploadReleaseSnapshotMock = vi.hoisted(() => vi.fn()); +const downloadReleaseSnapshotJsonMock = vi.hoisted(() => vi.fn()); +const promptsMock = vi.hoisted(() => vi.fn()); +const uiErrorMock = vi.hoisted(() => vi.fn()); +const uiWarnMock = vi.hoisted(() => vi.fn()); +const uiSuccessMock = vi.hoisted(() => vi.fn()); +const uiNoteMock = vi.hoisted(() => vi.fn()); + +let projectRoot: string; + +vi.mock('../../src/config/projectConfig.js', () => ({ + resolveAppContext: vi.fn(), +})); + +vi.mock('../../src/auth/session.js', () => ({ + getValidAuthSession: vi.fn(async () => ({ + ok: true as const, + idToken: 'token', + userId: 'uid1', + name: 'User', + email: 'u@test.com', + refreshed: false, + })), +})); + +vi.mock('../../src/cloud/firestoreClient.js', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + checkAppAccess: (...args: unknown[]) => checkAppAccessMock(...args), + createVersion: (...args: unknown[]) => createVersionMock(...args), + listVersions: (...args: unknown[]) => listVersionsMock(...args), + getVersion: (...args: unknown[]) => getVersionMock(...args), + }; +}); + +vi.mock('../../src/cloud/storageClient.js', () => ({ + uploadReleaseSnapshot: (...args: unknown[]) => uploadReleaseSnapshotMock(...args), + downloadReleaseSnapshotJson: (...args: unknown[]) => downloadReleaseSnapshotJsonMock(...args), + StorageClientError: class StorageClientError extends Error {}, +})); + +vi.mock('prompts', () => ({ default: promptsMock })); + +vi.mock('../../src/core/ui.js', () => ({ + ui: { + error: (...args: unknown[]) => uiErrorMock(...args), + warn: (...args: unknown[]) => uiWarnMock(...args), + success: (...args: unknown[]) => uiSuccessMock(...args), + note: (...args: unknown[]) => uiNoteMock(...args), + heading: vi.fn(), + }, +})); + +vi.mock('../../src/lib/spinner.js', () => ({ + withSpinner: vi.fn(async (_msg: string, fn: () => Promise) => fn()), +})); + +import { + releaseCreateCommand, + releaseListCommand, + releaseUseCommand, +} from '../../src/commands/release.js'; +import { resolveAppContext } from '../../src/config/projectConfig.js'; +import type { CloudApp } from '../../src/cloud/firestoreClient.js'; +import { EnsembleDocumentType } from '../../src/core/dto.js'; +import { encryptReleaseSnapshot, parseReleaseSnapshotBody } from '../../src/core/encryption.js'; +import { TEST_ENCRYPTION_KEY } from '../core/encryption.test.js'; + +function defaultAppContext(requestedAppKey?: string) { + const appKey = requestedAppKey ?? 'default'; + return { + projectRoot: projectRootRef.value, + config: { + default: 'default', + apps: { + default: { + appId: 'app1', + name: 'App', + appHome: undefined, + options: appOptionsRef.value, + }, + }, + }, + appKey, + appId: 'app1', + }; +} + +async function writeEnvConfig(projectRoot: string, lines: string[]): Promise { + await fs.writeFile(path.join(projectRoot, '.env.config'), `${lines.join('\n')}\n`, 'utf8'); +} + +async function writeEncryptionKey(root: string, alias?: string): Promise { + const secretsFile = alias ? `.env.secrets.${alias}` : '.env.secrets'; + await fs.writeFile( + path.join(root, secretsFile), + `ENSEMBLE_ENCRYPTION_KEY=${TEST_ENCRYPTION_KEY}\n`, + 'utf8' + ); +} + +function snapshotFromUploadMock(): CloudApp { + expect(uploadReleaseSnapshotMock).toHaveBeenCalledTimes(1); + const body = uploadReleaseSnapshotMock.mock.calls[0]?.[3]; + const envelopeJson = typeof body === 'string' ? body : (body as Buffer).toString('utf8'); + const snapshotJson = parseReleaseSnapshotBody( + envelopeJson, + 'releases/app1/ver.enc.json', + TEST_ENCRYPTION_KEY + ); + return JSON.parse(snapshotJson) as CloudApp; +} + +function mockEncryptedSnapshot(snapshot: CloudApp): string { + return encryptReleaseSnapshot(JSON.stringify(snapshot), TEST_ENCRYPTION_KEY); +} + +describe('release commands', () => { + const originalCwd = process.cwd(); + + beforeEach(async () => { + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ensemble-cli-release-')); + projectRootRef.value = projectRoot; + appOptionsRef.value = {}; + process.chdir(projectRoot); + vi.mocked(resolveAppContext).mockImplementation(async (requestedAppKey?: string) => + defaultAppContext(requestedAppKey) + ); + + await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); + await fs.writeFile( + path.join(projectRoot, 'screens', 'Home.yaml'), + 'View:\n body:\n Text:\n text: Hello', + 'utf8' + ); + await writeEncryptionKey(projectRoot); + + checkAppAccessMock.mockResolvedValue({ ok: true as const, app: { name: 'App' } }); + createVersionMock.mockResolvedValue({ id: 'ver-123' }); + listVersionsMock.mockResolvedValue({ + versions: [ + { + id: 'hash-1', + message: 'First release', + createdAt: '2025-01-15T12:00:00Z', + createdBy: { name: 'User', id: 'uid1' }, + expiresAt: '2025-02-15T12:00:00Z', + snapshotPath: 'releases/app1/hash-1.enc.json', + }, + ], + nextStartAfter: undefined, + }); + getVersionMock.mockResolvedValue({ + id: 'hash-1', + message: 'First release', + createdAt: '2025-01-15T12:00:00Z', + createdBy: { name: 'User', id: 'uid1' }, + expiresAt: '2025-02-15T12:00:00Z', + snapshotPath: 'releases/app1/hash-1.enc.json', + }); + uploadReleaseSnapshotMock.mockResolvedValue({ + bucket: 'bucket', + objectPath: 'releases/app1/ver-123.enc.json', + }); + downloadReleaseSnapshotJsonMock.mockReset(); + downloadReleaseSnapshotJsonMock.mockResolvedValue( + mockEncryptedSnapshot({ id: 'app1', name: 'App', screens: [] }) + ); + promptsMock.mockResolvedValue({ message: 'My release' }); + uiErrorMock.mockImplementation(() => {}); + uiWarnMock.mockImplementation(() => {}); + uiSuccessMock.mockImplementation(() => {}); + uiNoteMock.mockImplementation(() => {}); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await fs.rm(projectRoot, { recursive: true, force: true }).catch(() => {}); + process.exitCode = 0; + vi.clearAllMocks(); + }); + + it('release create blocks when ENSEMBLE_ENCRYPTION_KEY is missing', async () => { + await fs.rm(path.join(projectRoot, '.env.secrets')); + + await releaseCreateCommand({ message: 'missing key', yes: true }); + + expect(uploadReleaseSnapshotMock).not.toHaveBeenCalled(); + expect(uiErrorMock).toHaveBeenCalledWith(expect.stringContaining('Releases are encrypted')); + expect(uiNoteMock).toHaveBeenCalledWith(expect.stringContaining('openssl rand -hex 32')); + expect(process.exitCode).toBe(1); + }); + + it('release create stores env config and secrets in encrypted snapshot', async () => { + const assetsDir = path.join(projectRoot, 'assets'); + await fs.mkdir(assetsDir, { recursive: true }); + await fs.writeFile(path.join(assetsDir, 'logo.png'), 'png-bytes', 'utf8'); + await writeEnvConfig(projectRoot, ['E1=EV1']); + await fs.appendFile(path.join(projectRoot, '.env.secrets'), 'S1=SK1\n', 'utf8'); + + await releaseCreateCommand({ message: 'env snapshot', yes: true }); + + const snapshot = snapshotFromUploadMock(); + expect(snapshot.config?.envVariables).toEqual({ E1: 'EV1' }); + expect(snapshot.secrets?.secrets).toMatchObject({ + ENSEMBLE_ENCRYPTION_KEY: TEST_ENCRYPTION_KEY, + S1: 'SK1', + }); + }); + + it('release create stores manifest list order in snapshot', async () => { + await fs.mkdir(path.join(projectRoot, 'widgets'), { recursive: true }); + await fs.mkdir(path.join(projectRoot, 'translations'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, 'widgets', 'Wid2.yaml'), 'View:\n', 'utf8'); + await fs.writeFile(path.join(projectRoot, 'widgets', 'Wid1.yaml'), 'View:\n', 'utf8'); + await fs.writeFile(path.join(projectRoot, 'translations', 'en.yaml'), 'k: v\n', 'utf8'); + await fs.writeFile(path.join(projectRoot, 'translations', 'ar.yaml'), 'k: v\n', 'utf8'); + await fs.writeFile( + path.join(projectRoot, '.manifest.json'), + `${JSON.stringify( + { + widgets: [{ name: 'Wid1' }, { name: 'Wid2' }], + languages: ['ar', 'en'], + defaultLanguage: 'ar', + }, + null, + 2 + )}\n`, + 'utf8' + ); + + await releaseCreateCommand({ message: 'manifest order', yes: true }); + + const snapshot = snapshotFromUploadMock(); + expect(snapshot.widgets?.map((widget) => widget.name)).toEqual(['Wid1', 'Wid2']); + expect(snapshot.translations?.map((t) => t.name)).toEqual(['ar', 'en']); + expect(snapshot.translations?.find((t) => t.defaultLocale)?.name).toBe('ar'); + }); + + it('release create then use leaves manifest unchanged', async () => { + await fs.mkdir(path.join(projectRoot, 'widgets'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, 'widgets', 'Wid2.yaml'), 'View:\n', 'utf8'); + await fs.writeFile(path.join(projectRoot, 'widgets', 'Wid1.yaml'), 'View:\n', 'utf8'); + const manifestBefore = { + studioVersion: 2, + actions: [], + widgets: [{ name: 'Wid1', customId: 'local-id' }, { name: 'Wid2' }], + }; + const manifestRaw = `${JSON.stringify(manifestBefore, null, 2)}\n`; + await fs.writeFile(path.join(projectRoot, '.manifest.json'), manifestRaw, 'utf8'); + + await releaseCreateCommand({ message: 'roundtrip', yes: true }); + const snapshot = snapshotFromUploadMock(); + + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + encryptReleaseSnapshot(JSON.stringify(snapshot), TEST_ENCRYPTION_KEY) + ); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ hash: 'hash-1' }); + + const manifestAfter = await fs.readFile(path.join(projectRoot, '.manifest.json'), 'utf8'); + expect(manifestAfter).toBe(manifestRaw); + }); + + it('release use restores latest manifest after visiting older release', async () => { + const olderSnapshot = mockEncryptedSnapshot({ + id: 'app1', + name: 'App', + screens: [], + translations: [ + { + id: 't-en', + name: 'en', + content: 'hello: hello', + type: EnsembleDocumentType.I18n, + defaultLocale: true, + }, + { + id: 't-ar', + name: 'ar', + content: 'hello: marhaba', + type: EnsembleDocumentType.I18n, + }, + ], + }); + const latestSnapshot = mockEncryptedSnapshot({ + id: 'app1', + name: 'App', + screens: [], + translations: [ + { + id: 't-en', + name: 'en', + content: 'hello: hello', + type: EnsembleDocumentType.I18n, + }, + { + id: 't-de', + name: 'de', + content: 'hello: hallo', + type: EnsembleDocumentType.I18n, + }, + { + id: 't-ar', + name: 'ar', + content: 'hello: marhaba', + type: EnsembleDocumentType.I18n, + defaultLocale: true, + }, + ], + }); + + await fs.mkdir(path.join(projectRoot, 'translations'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, 'translations', 'en.yaml'), 'hello: hello\n', 'utf8'); + await fs.writeFile( + path.join(projectRoot, 'translations', 'ar.yaml'), + 'hello: marhaba\n', + 'utf8' + ); + await fs.writeFile(path.join(projectRoot, 'translations', 'de.yaml'), 'hello: hallo\n', 'utf8'); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + let downloadCall = 0; + downloadReleaseSnapshotJsonMock.mockImplementation(async () => { + downloadCall += 1; + return downloadCall === 1 ? olderSnapshot : latestSnapshot; + }); + await releaseUseCommand({ hash: 'hash-old' }); + await releaseUseCommand({ hash: 'hash-latest' }); + + expect(downloadCall).toBe(2); + + const manifestAfter = JSON.parse( + await fs.readFile(path.join(projectRoot, '.manifest.json'), 'utf8') + ) as { languages: string[]; defaultLanguage: string }; + expect(manifestAfter.languages).toEqual(['en', 'de', 'ar']); + expect(manifestAfter.defaultLanguage).toBe('ar'); + await expect( + fs.access(path.join(projectRoot, 'translations', 'de.yaml')) + ).resolves.toBeUndefined(); + }); + + it('release use blocks when ENSEMBLE_ENCRYPTION_KEY is missing', async () => { + await fs.rm(path.join(projectRoot, '.env.secrets')); + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ hash: 'hash-1' }); + + expect(getVersionMock).not.toHaveBeenCalled(); + expect(downloadReleaseSnapshotJsonMock).not.toHaveBeenCalled(); + expect(uiErrorMock).toHaveBeenCalledWith(expect.stringContaining('Releases are encrypted')); + expect(uiNoteMock).toHaveBeenCalledWith(expect.stringContaining('openssl rand -hex 32')); + expect(process.exitCode).toBe(1); + }); + + it('release list blocks when ENSEMBLE_ENCRYPTION_KEY is missing', async () => { + await fs.rm(path.join(projectRoot, '.env.secrets')); + + await releaseListCommand({}); + + expect(listVersionsMock).not.toHaveBeenCalled(); + expect(uiErrorMock).toHaveBeenCalledWith(expect.stringContaining('Releases are encrypted')); + expect(uiNoteMock).toHaveBeenCalledWith(expect.stringContaining('openssl rand -hex 32')); + expect(process.exitCode).toBe(1); + }); + + it('release use restores snapshot config and secrets', async () => { + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + mockEncryptedSnapshot({ + id: 'app1', + name: 'App', + screens: [], + config: { envVariables: { E1: 'EV1' } }, + secrets: { secrets: { S1: 'SNAPSHOT-SECRET' } }, + }) + ); + await writeEnvConfig(projectRoot, ['E1=EV-WRONG']); + await fs.appendFile(path.join(projectRoot, '.env.secrets'), 'S1=LOCAL-SECRET\n', 'utf8'); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ hash: 'hash-1' }); + + const envConfig = await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8'); + const envSecrets = await fs.readFile(path.join(projectRoot, '.env.secrets'), 'utf8'); + expect(envConfig).toContain('E1=EV1'); + expect(envSecrets).toContain('S1=SNAPSHOT-SECRET'); + expect(envSecrets).not.toContain('LOCAL-SECRET'); + }); + + it('release use removes env keys not in snapshot', async () => { + await writeEnvConfig(projectRoot, ['A1=a', 'E1=local-only', 'B1=b']); + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + mockEncryptedSnapshot({ + id: 'app1', + name: 'App', + screens: [], + config: { envVariables: { A1: 'a', B1: 'b' } }, + }) + ); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ hash: 'hash-1' }); + + const lines = (await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8')) + .trim() + .split('\n'); + expect(lines).toEqual(['A1=a', 'B1=b']); + }); + + it('release use writes canonical asset-then-config layout', async () => { + await fs.writeFile( + path.join(projectRoot, '.env.config'), + 'assets=https://old/\nkwnd_png=old.png\nE1=old\n', + 'utf8' + ); + + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + mockEncryptedSnapshot({ + id: 'app1', + name: 'App', + screens: [], + assets: [ + { + id: 'asset-kwnd', + name: 'kwnd.png', + fileName: 'kwnd.png', + content: '', + type: EnsembleDocumentType.Asset, + }, + ], + config: { + envVariables: { + E1: 'EV1', + assets: 'https://new/', + kwnd_png: 'new.png', + }, + }, + }) + ); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ hash: 'hash-1' }); + + const lines = (await fs.readFile(path.join(projectRoot, '.env.config'), 'utf8')) + .trim() + .split('\n'); + expect(lines[0]).toMatch(/^assets=https:\/\/new\//); + expect(lines[1]).toMatch(/^kwnd_png=new\.png$/); + expect(lines[2]).toMatch(/^E1=EV1$/); + }); + + it('release use rejects legacy plain json snapshots', async () => { + getVersionMock.mockResolvedValueOnce({ + id: 'legacy-1', + message: 'Legacy', + createdAt: '2025-01-15T12:00:00Z', + createdBy: { name: 'User', id: 'uid1' }, + expiresAt: '2025-02-15T12:00:00Z', + snapshotPath: 'releases/app1/legacy-1.json', + }); + downloadReleaseSnapshotJsonMock.mockResolvedValueOnce( + JSON.stringify({ id: 'app1', name: 'App', screens: [] }) + ); + + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ hash: 'legacy-1' }); + + expect(uiErrorMock).toHaveBeenCalledWith( + expect.stringContaining('unencrypted legacy plaintext') + ); + expect(process.exitCode).toBe(1); + }); + + it('release use --hash downloads from storage directly', async () => { + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + + await releaseUseCommand({ hash: 'hash-1' }); + + expect(downloadReleaseSnapshotJsonMock).toHaveBeenCalledWith( + 'token', + 'releases/app1/hash-1.enc.json' + ); + expect(uiErrorMock).not.toHaveBeenCalled(); + }); + + it('release list warns when no versions exist', async () => { + listVersionsMock.mockResolvedValueOnce({ versions: [], nextStartAfter: undefined }); + await releaseListCommand({}); + expect(uiWarnMock).toHaveBeenCalledWith( + 'No releases found. Create one with "ensemble release create".' + ); + }); +}); diff --git a/tests/core/applyToFs.test.ts b/tests/core/applyToFs.test.ts index d05826b..d5253d6 100644 --- a/tests/core/applyToFs.test.ts +++ b/tests/core/applyToFs.test.ts @@ -1,237 +1,237 @@ -import fs from 'fs/promises'; -import os from 'os'; -import path from 'path'; - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; - -import { applyCloudStateToFs } from '../../src/core/applyToFs.js'; -import type { ParsedAppFiles } from '../../src/core/appCollector.js'; -import type { ArtifactProp } from '../../src/core/artifacts.js'; -import type { CloudApp } from '../../src/cloud/firestoreClient.js'; -import { EnsembleDocumentType, ScreenDTO } from '../../src/core/dto.js'; - -const allEnabled: Record = { - screens: true, - widgets: true, - scripts: true, - actions: true, - translations: true, - theme: true, -}; - -describe('applyCloudStateToFs', () => { - let projectRoot: string; - - beforeEach(async () => { - projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'applyToFs-')); - }); - - afterEach(async () => { - await fs.rm(projectRoot, { recursive: true, force: true }); - vi.restoreAllMocks(); - }); - - it('writes screen and translation files from cloud state', async () => { - const cloudApp: CloudApp = { - id: 'app1', - name: 'App', - screens: [ - { - id: 's1', - name: 'Home', - content: 'View:\n body:\n Text:\n text: Hi', - type: EnsembleDocumentType.Screen, - isRoot: true, - }, - ], - widgets: [], - scripts: [], - translations: [ - { - id: 'i18n_en', - name: 'en', - content: 'en: content', - type: EnsembleDocumentType.I18n, - defaultLocale: true, - }, - ], - }; - const localFiles: ParsedAppFiles = { - screens: {}, - widgets: {}, - scripts: {}, - actions: {}, - translations: {}, - }; - - await applyCloudStateToFs(projectRoot, cloudApp, localFiles, allEnabled); - - const homeContent = await fs.readFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'utf8'); - expect(homeContent).toBe('View:\n body:\n Text:\n text: Hi'); - const enContent = await fs.readFile(path.join(projectRoot, 'translations', 'en.yaml'), 'utf8'); - expect(enContent).toBe('en: content'); - }); - - it('deletes local files not present in cloud state', async () => { - await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); - await fs.writeFile(path.join(projectRoot, 'screens', 'Obsolete.yaml'), 'old', 'utf8'); - - const cloudApp: CloudApp = { - id: 'app1', - name: 'App', - screens: [], - widgets: [], - scripts: [], - translations: [], - }; - const localFiles: ParsedAppFiles = { - screens: { 'Obsolete.yaml': 'old' }, - widgets: {}, - scripts: {}, - actions: {}, - translations: {}, - }; - - await applyCloudStateToFs(projectRoot, cloudApp, localFiles, allEnabled); - - await expect(fs.access(path.join(projectRoot, 'screens', 'Obsolete.yaml'))).rejects.toThrow(); - }); - - it('writes theme.yaml when cloud has theme, deletes when not', async () => { - const cloudWithTheme: CloudApp = { - id: 'app1', - name: 'App', - screens: [], - widgets: [], - scripts: [], - translations: [], - theme: { - id: 't1', - name: 'theme', - content: 'colors:\n primary: red', - type: EnsembleDocumentType.Theme, - }, - }; - const localFiles: ParsedAppFiles = { - screens: {}, - widgets: {}, - scripts: {}, - actions: {}, - translations: {}, - }; - - await applyCloudStateToFs(projectRoot, cloudWithTheme, localFiles, allEnabled); - const themeContent = await fs.readFile(path.join(projectRoot, 'theme.yaml'), 'utf8'); - expect(themeContent).toBe('colors:\n primary: red'); - - const cloudNoTheme: CloudApp = { - id: 'app1', - name: 'App', - screens: [], - widgets: [], - scripts: [], - translations: [], - }; - await applyCloudStateToFs(projectRoot, cloudNoTheme, localFiles, allEnabled); - await expect(fs.access(path.join(projectRoot, 'theme.yaml'))).rejects.toThrow(); - }); - - it('skips artifact kinds when disabled in enabledByProp', async () => { - await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); - const cloudApp: CloudApp = { - id: 'app1', - name: 'App', - screens: [ - { id: 's1', name: 'Home', content: 'x', type: EnsembleDocumentType.Screen, isRoot: true }, - ], - widgets: [], - scripts: [], - translations: [], - }; - const localFiles: ParsedAppFiles = { - screens: {}, - widgets: {}, - scripts: {}, - actions: {}, - translations: {}, - }; - const enabledNoScreens = { ...allEnabled, screens: false }; - - await applyCloudStateToFs(projectRoot, cloudApp, localFiles, enabledNoScreens); - - await expect(fs.access(path.join(projectRoot, 'screens', 'Home.yaml'))).rejects.toThrow(); - }); - - it('writes .manifest.json when manifestOptions provided', async () => { - const cloudApp: CloudApp = { - id: 'app1', - name: 'App', - screens: [ - { id: 's1', name: 'Home', content: 'x', type: EnsembleDocumentType.Screen, isRoot: true }, - ], - widgets: [], - scripts: [], - translations: [], - }; - const localFiles: ParsedAppFiles = { - screens: {}, - widgets: {}, - scripts: {}, - actions: {}, - translations: {}, - }; - - await applyCloudStateToFs(projectRoot, cloudApp, localFiles, allEnabled, { - manifestOptions: {}, - }); - - const manifestPath = path.join(projectRoot, '.manifest.json'); - const raw = await fs.readFile(manifestPath, 'utf8'); - const manifest = JSON.parse(raw) as { - scripts?: unknown[]; - widgets?: unknown[]; - actions?: unknown[]; - languages?: unknown[]; - defaultLanguage?: unknown; - }; - expect(typeof manifest).toBe('object'); - // Ensure it's valid JSON and a root object; specific fields are asserted elsewhere. - expect(Array.isArray(manifest.scripts ?? [])).toBe(true); - }); - - it('invokes onProgress every 25 completed tasks', async () => { - const screens = Array.from({ length: 30 }, (_, i) => ({ - id: `s${i}`, - name: `Screen${i}`, - content: `content ${i}`, - type: 'screen' as const, - isRoot: i === 0, - })) as ScreenDTO[]; - const cloudApp = { - id: 'app1', - name: 'App', - screens: screens, - widgets: [], - scripts: [], - translations: [], - }; - const localFiles: ParsedAppFiles = { - screens: {}, - widgets: {}, - scripts: {}, - actions: {}, - translations: {}, - }; - const progressCalls: [number, number][] = []; - - await applyCloudStateToFs(projectRoot, cloudApp, localFiles, allEnabled, { - onProgress: (completed, total) => progressCalls.push([completed, total]), - }); - - expect(progressCalls.length).toBeGreaterThan(0); - progressCalls.forEach(([completed, total]) => { - expect(completed % 25).toBe(0); - expect(total).toBeGreaterThanOrEqual(30); - }); - }); -}); +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { applyCloudStateToFs } from '../../src/core/applyToFs.js'; +import type { ParsedAppFiles } from '../../src/core/appCollector.js'; +import type { ArtifactProp } from '../../src/core/artifacts.js'; +import type { CloudApp } from '../../src/cloud/firestoreClient.js'; +import { EnsembleDocumentType, ScreenDTO } from '../../src/core/dto.js'; + +const allEnabled: Record = { + screens: true, + widgets: true, + scripts: true, + actions: true, + translations: true, + theme: true, +}; + +describe('applyCloudStateToFs', () => { + let projectRoot: string; + + beforeEach(async () => { + projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'applyToFs-')); + }); + + afterEach(async () => { + await fs.rm(projectRoot, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('writes screen and translation files from cloud state', async () => { + const cloudApp: CloudApp = { + id: 'app1', + name: 'App', + screens: [ + { + id: 's1', + name: 'Home', + content: 'View:\n body:\n Text:\n text: Hi', + type: EnsembleDocumentType.Screen, + isRoot: true, + }, + ], + widgets: [], + scripts: [], + translations: [ + { + id: 'i18n_en', + name: 'en', + content: 'en: content', + type: EnsembleDocumentType.I18n, + defaultLocale: true, + }, + ], + }; + const localFiles: ParsedAppFiles = { + screens: {}, + widgets: {}, + scripts: {}, + actions: {}, + translations: {}, + }; + + await applyCloudStateToFs(projectRoot, cloudApp, localFiles, allEnabled); + + const homeContent = await fs.readFile(path.join(projectRoot, 'screens', 'Home.yaml'), 'utf8'); + expect(homeContent).toBe('View:\n body:\n Text:\n text: Hi'); + const enContent = await fs.readFile(path.join(projectRoot, 'translations', 'en.yaml'), 'utf8'); + expect(enContent).toBe('en: content'); + }); + + it('deletes local files not present in cloud state', async () => { + await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); + await fs.writeFile(path.join(projectRoot, 'screens', 'Obsolete.yaml'), 'old', 'utf8'); + + const cloudApp: CloudApp = { + id: 'app1', + name: 'App', + screens: [], + widgets: [], + scripts: [], + translations: [], + }; + const localFiles: ParsedAppFiles = { + screens: { 'Obsolete.yaml': 'old' }, + widgets: {}, + scripts: {}, + actions: {}, + translations: {}, + }; + + await applyCloudStateToFs(projectRoot, cloudApp, localFiles, allEnabled); + + await expect(fs.access(path.join(projectRoot, 'screens', 'Obsolete.yaml'))).rejects.toThrow(); + }); + + it('writes theme.yaml when cloud has theme, deletes when not', async () => { + const cloudWithTheme: CloudApp = { + id: 'app1', + name: 'App', + screens: [], + widgets: [], + scripts: [], + translations: [], + theme: { + id: 't1', + name: 'theme', + content: 'colors:\n primary: red', + type: EnsembleDocumentType.Theme, + }, + }; + const localFiles: ParsedAppFiles = { + screens: {}, + widgets: {}, + scripts: {}, + actions: {}, + translations: {}, + }; + + await applyCloudStateToFs(projectRoot, cloudWithTheme, localFiles, allEnabled); + const themeContent = await fs.readFile(path.join(projectRoot, 'theme.yaml'), 'utf8'); + expect(themeContent).toBe('colors:\n primary: red'); + + const cloudNoTheme: CloudApp = { + id: 'app1', + name: 'App', + screens: [], + widgets: [], + scripts: [], + translations: [], + }; + await applyCloudStateToFs(projectRoot, cloudNoTheme, localFiles, allEnabled); + await expect(fs.access(path.join(projectRoot, 'theme.yaml'))).rejects.toThrow(); + }); + + it('skips artifact kinds when disabled in enabledByProp', async () => { + await fs.mkdir(path.join(projectRoot, 'screens'), { recursive: true }); + const cloudApp: CloudApp = { + id: 'app1', + name: 'App', + screens: [ + { id: 's1', name: 'Home', content: 'x', type: EnsembleDocumentType.Screen, isRoot: true }, + ], + widgets: [], + scripts: [], + translations: [], + }; + const localFiles: ParsedAppFiles = { + screens: {}, + widgets: {}, + scripts: {}, + actions: {}, + translations: {}, + }; + const enabledNoScreens = { ...allEnabled, screens: false }; + + await applyCloudStateToFs(projectRoot, cloudApp, localFiles, enabledNoScreens); + + await expect(fs.access(path.join(projectRoot, 'screens', 'Home.yaml'))).rejects.toThrow(); + }); + + it('writes .manifest.json when refreshManifest is true', async () => { + const cloudApp: CloudApp = { + id: 'app1', + name: 'App', + screens: [ + { id: 's1', name: 'Home', content: 'x', type: EnsembleDocumentType.Screen, isRoot: true }, + ], + widgets: [], + scripts: [], + translations: [], + }; + const localFiles: ParsedAppFiles = { + screens: {}, + widgets: {}, + scripts: {}, + actions: {}, + translations: {}, + }; + + await applyCloudStateToFs(projectRoot, cloudApp, localFiles, allEnabled, { + refreshManifest: true, + }); + + const manifestPath = path.join(projectRoot, '.manifest.json'); + const raw = await fs.readFile(manifestPath, 'utf8'); + const manifest = JSON.parse(raw) as { + scripts?: unknown[]; + widgets?: unknown[]; + actions?: unknown[]; + languages?: unknown[]; + defaultLanguage?: unknown; + }; + expect(typeof manifest).toBe('object'); + // Ensure it's valid JSON and a root object; specific fields are asserted elsewhere. + expect(Array.isArray(manifest.scripts ?? [])).toBe(true); + }); + + it('invokes onProgress every 25 completed tasks', async () => { + const screens = Array.from({ length: 30 }, (_, i) => ({ + id: `s${i}`, + name: `Screen${i}`, + content: `content ${i}`, + type: 'screen' as const, + isRoot: i === 0, + })) as ScreenDTO[]; + const cloudApp = { + id: 'app1', + name: 'App', + screens: screens, + widgets: [], + scripts: [], + translations: [], + }; + const localFiles: ParsedAppFiles = { + screens: {}, + widgets: {}, + scripts: {}, + actions: {}, + translations: {}, + }; + const progressCalls: [number, number][] = []; + + await applyCloudStateToFs(projectRoot, cloudApp, localFiles, allEnabled, { + onProgress: (completed, total) => progressCalls.push([completed, total]), + }); + + expect(progressCalls.length).toBeGreaterThan(0); + progressCalls.forEach(([completed, total]) => { + expect(completed % 25).toBe(0); + expect(total).toBeGreaterThanOrEqual(30); + }); + }); +}); diff --git a/tests/core/encryption.test.ts b/tests/core/encryption.test.ts new file mode 100644 index 0000000..a889ef3 --- /dev/null +++ b/tests/core/encryption.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from 'vitest'; + +import { + encryptReleaseSnapshot, + EncryptionError, + isEncryptedReleaseEnvelope, + parse256BitSecret, + parseReleaseSnapshotBody, + requireReleaseEncryptionKey, +} from '../../src/core/encryption.js'; +import type { EnvEntry } from '../../src/core/envConfig.js'; + +export const TEST_ENCRYPTION_KEY = 'a'.repeat(64); +const TEST_SNAPSHOT_PATH = 'releases/app1/ver.enc.json'; + +describe('encryption', () => { + it('parse256BitSecret accepts 64-char hex', () => { + const key = parse256BitSecret(TEST_ENCRYPTION_KEY, 'ENSEMBLE_ENCRYPTION_KEY'); + expect(key).toHaveLength(32); + }); + + it('encryptReleaseSnapshot roundtrips plaintext json', () => { + const plaintext = JSON.stringify({ id: 'app1', name: 'App' }); + const envelope = encryptReleaseSnapshot(plaintext, TEST_ENCRYPTION_KEY); + expect(isEncryptedReleaseEnvelope(envelope)).toBe(true); + expect(parseReleaseSnapshotBody(envelope, TEST_SNAPSHOT_PATH, TEST_ENCRYPTION_KEY)).toBe( + plaintext + ); + }); + + it('requireReleaseEncryptionKey returns key when present', () => { + const entries: EnvEntry[] = [{ key: 'ENSEMBLE_ENCRYPTION_KEY', value: TEST_ENCRYPTION_KEY }]; + expect(requireReleaseEncryptionKey(entries, '.env.secrets')).toBe(TEST_ENCRYPTION_KEY); + }); + + it('requireReleaseEncryptionKey throws with secrets file hint when key missing', () => { + expect(() => requireReleaseEncryptionKey([], '.env.secrets.uat')).toThrow(EncryptionError); + try { + requireReleaseEncryptionKey([], '.env.secrets.uat'); + } catch (err) { + expect(err).toBeInstanceOf(EncryptionError); + const encErr = err as EncryptionError; + expect(encErr.message).toContain('.env.secrets.uat'); + expect(encErr.message).toContain('Releases are encrypted'); + expect(encErr.hint).toContain('openssl rand -hex 32'); + } + }); + + it('requireReleaseEncryptionKey throws when key format is invalid', () => { + const entries: EnvEntry[] = [{ key: 'ENSEMBLE_ENCRYPTION_KEY', value: 'tooshort' }]; + expect(() => requireReleaseEncryptionKey(entries, '.env.secrets')).toThrow(EncryptionError); + try { + requireReleaseEncryptionKey(entries, '.env.secrets'); + } catch (err) { + const encErr = err as EncryptionError; + expect(encErr.message).toContain('Invalid ENSEMBLE_ENCRYPTION_KEY in .env.secrets'); + expect(encErr.hint).toContain('openssl rand -hex 32'); + } + }); + + it('parseReleaseSnapshotBody throws when encryption key does not match', () => { + const plaintext = JSON.stringify({ id: 'app1' }); + const envelope = encryptReleaseSnapshot(plaintext, TEST_ENCRYPTION_KEY); + const wrongKey = `${TEST_ENCRYPTION_KEY.slice(0, -1)}c`; + expect(() => + parseReleaseSnapshotBody(envelope, TEST_SNAPSHOT_PATH, wrongKey, '.env.secrets') + ).toThrow(EncryptionError); + try { + parseReleaseSnapshotBody(envelope, TEST_SNAPSHOT_PATH, wrongKey, '.env.secrets'); + } catch (err) { + const encErr = err as EncryptionError; + expect(encErr.message).toContain('Could not decrypt this release'); + expect(encErr.message).toContain('.env.secrets'); + expect(encErr.hint).toContain('Get the correct key from your team'); + } + }); + + it('parseReleaseSnapshotBody rejects legacy plain json', () => { + expect(() => + parseReleaseSnapshotBody('{"id":"app1"}', 'releases/app1/ver.json', TEST_ENCRYPTION_KEY) + ).toThrow('unencrypted legacy plaintext'); + }); +}); diff --git a/tests/core/envSync.test.ts b/tests/core/envSync.test.ts index 97dd683..8646b5a 100644 --- a/tests/core/envSync.test.ts +++ b/tests/core/envSync.test.ts @@ -1,575 +1,746 @@ -import fs from 'fs/promises'; -import os from 'os'; -import path from 'path'; - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; - -import { writeEnvFile, type EnvEntry } from '../../src/core/envConfig.js'; -import { - applyCloudEnvToFs, - applyReleaseConfigToFs, - buildEnvPushDiff, - buildPushConfigDto, - computeEnvPullChanges, - pruneStaleAssetEnvEntries, - prepareEnvPushState, - readProjectEnvFiles, - type CloudEnvState, - type LocalEnvFiles, -} from '../../src/core/envSync.js'; -import { envConfigScopedFile, envSecretsScopedFile } from '../../src/core/envConfig.js'; - -function localEnv( - overrides: Partial & Pick -): LocalEnvFiles { - const baseConfig = overrides.baseConfig ?? overrides.envConfig; - return { - appKey: 'default', - useScoped: false, - configWriteFile: '.env.config', - secretsWriteFile: '.env.secrets', - envSecrets: [], - baseConfig, - scopedConfig: [], - baseSecrets: [], - scopedSecrets: [], - envConfigPresent: true, - envSecretsPresent: false, - baseConfigPresent: true, - scopedConfigPresent: false, - baseSecretsPresent: false, - scopedSecretsPresent: false, - ...overrides, - envConfig: overrides.envConfig, - }; -} - -function localEnvFromParts( - configEntries: EnvEntry[], - assetEntries: EnvEntry[] = [] -): LocalEnvFiles { - const envConfig = [...configEntries, ...assetEntries]; - return localEnv({ envConfig, baseConfig: envConfig }); -} - -describe('envSync', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ensemble-envSync-')); - }); - - afterEach(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }); - }); - - it('applyCloudEnvToFs syncs cloud env, preserves asset keys, and drops removed keys', async () => { - await fs.writeFile( - path.join(tmpDir, '.env.config'), - 'assets=https://cdn.example.com/\nlogo_png=logo.png\nE1=EV1\nE2=EV2\n', - 'utf8' - ); - - await applyCloudEnvToFs( - tmpDir, - { - config: { envVariables: { API_URL: 'https://api.example.com', E1: 'EV1' } }, - secrets: { secrets: { S1: 'secret-value' } }, - }, - ['logo.png'], - 'default' - ); - - const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); - const envSecrets = await fs.readFile(path.join(tmpDir, '.env.secrets'), 'utf8'); - expect(envConfig).toContain('assets=https://cdn.example.com/'); - expect(envConfig).toContain('logo_png=logo.png'); - expect(envConfig).toContain('API_URL=https://api.example.com'); - expect(envConfig).toContain('E1=EV1'); - expect(envConfig).not.toContain('E2='); - expect(envSecrets).toContain('S1=secret-value'); - }); - - it.each<{ - name: string; - local: LocalEnvFiles; - cloud: CloudEnvState; - assets: string[]; - cloudAssets?: Array<{ fileName?: string; copyText?: string }>; - configChanged: boolean; - secretsChanged: boolean; - wouldClearConfig?: boolean; - wouldClearSecrets?: boolean; - cloudConfig?: Record; - localConfig?: Record; - }>([ - { - name: 'detects config and secrets changes', - local: localEnv({ - envConfig: [{ key: 'E1', value: 'local' }], - envSecrets: [{ key: 'S1', value: 'local' }], - envSecretsPresent: true, - }), - cloud: { - config: { envVariables: { E1: 'cloud' } }, - secrets: { secrets: { S1: 'cloud' } }, - }, - assets: [], - configChanged: true, - secretsChanged: true, - }, - { - name: 'omits snapshots when in sync', - local: localEnv({ - envConfig: [{ key: 'E1', value: 'EV1' }], - envSecrets: [{ key: 'S1', value: 'SK1' }], - envSecretsPresent: true, - }), - cloud: { - config: { envVariables: { E1: 'EV1' } }, - secrets: { secrets: { S1: 'SK1' } }, - }, - assets: [], - configChanged: false, - secretsChanged: false, - }, - { - name: 'skips env push when env files are missing but cloud has values', - local: localEnv({ - envConfig: [], - baseConfig: [], - envConfigPresent: false, - envSecretsPresent: false, - baseConfigPresent: false, - }), - cloud: { - config: { envVariables: { E1: 'EV1' } }, - secrets: { secrets: { S1: 'SK1' } }, - }, - assets: [], - configChanged: false, - secretsChanged: false, - wouldClearConfig: false, - wouldClearSecrets: false, - }, - { - name: 'wipes cloud when env files are present but empty', - local: localEnv({ - envConfig: [], - baseConfig: [], - envSecrets: [], - envConfigPresent: true, - envSecretsPresent: true, - }), - cloud: { - config: { envVariables: { E1: 'EV1' } }, - secrets: { secrets: { S1: 'SK1' } }, - }, - assets: [], - configChanged: true, - secretsChanged: true, - wouldClearConfig: true, - wouldClearSecrets: true, - }, - { - name: 'shows full push config vs cloud including asset keys', - local: localEnv({ - envConfig: [ - { key: 'E1', value: 'EV11' }, - { key: 'assets', value: 'https://cdn.example.com/' }, - { key: 'logo_png', value: 'logo.png?local=abc' }, - ], - }), - cloud: { - config: { - envVariables: { - assets: 'https://cdn.example.com/', - logo_png: 'logo.png', - E1: 'EV1', - E2: 'EV2', - }, - }, - }, - assets: ['logo.png'], - configChanged: true, - secretsChanged: false, - cloudConfig: { - assets: 'https://cdn.example.com/', - logo_png: 'logo.png', - E1: 'EV1', - E2: 'EV2', - }, - localConfig: { - assets: 'https://cdn.example.com/', - logo_png: 'logo.png?local=abc', - E1: 'EV11', - }, - }, - { - name: 'flags configChanged when only stale asset env keys differ from cloud', - local: localEnv({ - envConfig: [{ key: 'E1', value: 'EV1' }], - }), - cloud: { - config: { - envVariables: { - assets: 'https://cdn.example.com/', - DSA_Viva_rtf: 'DSA%20Viva.rtf?token=abc', - github_html: 'first%20token%20github.html?token=def', - E1: 'EV1', - }, - }, - }, - assets: [], - cloudAssets: [ - { fileName: 'DSA Viva.rtf' }, - { fileName: 'first token github.html', copyText: '${env.github_html}' }, - ], - configChanged: true, - secretsChanged: false, - localConfig: { E1: 'EV1' }, - cloudConfig: { - assets: 'https://cdn.example.com/', - DSA_Viva_rtf: 'DSA%20Viva.rtf?token=abc', - github_html: 'first%20token%20github.html?token=def', - E1: 'EV1', - }, - }, - ])( - '$name', - ({ - local, - cloud, - assets, - cloudAssets, - configChanged, - secretsChanged, - wouldClearConfig = false, - wouldClearSecrets = false, - cloudConfig, - localConfig, - }) => { - const diff = buildEnvPushDiff(local, cloud, assets, cloudAssets); - expect(diff.configChanged).toBe(configChanged); - expect(diff.secretsChanged).toBe(secretsChanged); - expect(diff.wouldClearConfig).toBe(wouldClearConfig); - expect(diff.wouldClearSecrets).toBe(wouldClearSecrets); - if (!configChanged && !secretsChanged) { - expect(diff.local).toEqual({}); - expect(diff.cloud).toEqual({}); - return; - } - if (cloudConfig) expect(diff.cloud.config?.envVariables).toEqual(cloudConfig); - if (localConfig) expect(diff.local.config?.envVariables).toEqual(localConfig); - } - ); - - it('buildPushConfigDto keeps local assets and drops deleted cloud asset keys', () => { - expect( - buildPushConfigDto( - localEnvFromParts( - [ - { key: 'E1', value: 'EV11' }, - { key: 'E2', value: 'EV2' }, - ], - [ - { key: 'assets', value: 'https://cdn.example.com/' }, - { key: 'logo_png', value: 'logo.png?local=abc' }, - ] - ), - { - envVariables: { - assets: 'https://cdn.example.com/', - logo_png: 'logo.png?token=abc', - E1: 'EV1', - }, - }, - ['logo.png'] - ).envVariables - ).toEqual({ - assets: 'https://cdn.example.com/', - logo_png: 'logo.png?local=abc', - E1: 'EV11', - E2: 'EV2', - }); - - expect( - buildPushConfigDto( - localEnvFromParts( - [{ key: 'E1', value: 'EV11' }], - [ - { key: 'assets', value: 'https://cdn.example.com/' }, - { key: 'report_html', value: 'report.html?local=abc' }, - ] - ), - { - envVariables: { - assets: 'https://cdn.example.com/', - report_html: 'report.html?token=abc', - sheet_xlsx: 'sheet.xlsx?token=def', - E1: 'EV1', - }, - }, - ['report.html'], - [ - { fileName: 'report.html', copyText: '${env.report_html}' }, - { fileName: 'sheet.xlsx', copyText: '${env.sheet_xlsx}' }, - ] - ).envVariables - ).toEqual({ - assets: 'https://cdn.example.com/', - report_html: 'report.html?local=abc', - E1: 'EV11', - }); - - expect( - buildPushConfigDto( - localEnvFromParts([{ key: 'E1', value: 'EV11' }]), - { - envVariables: { - assets: 'https://cdn.example.com/', - logo_png: 'logo.png?token=abc', - E1: 'EV1', - E2: 'EV2', - }, - }, - [] - ).envVariables - ).toEqual({ E1: 'EV11' }); - }); - - it.each<{ - name: string; - entries: Array<{ key: string; value: string }>; - assetFileNames: string[]; - cloudAssets?: Array<{ fileName?: string; copyText?: string }>; - expected: Array<{ key: string; value: string }>; - }>([ - { - name: 'removes copyText and derived keys for deleted assets', - entries: [ - { key: 'assets', value: 'https://cdn.example.com/' }, - { key: 'report_html', value: 'report.html?token=abc' }, - { key: 'sheet_xlsx', value: 'sheet.xlsx?token=def' }, - { key: 'MIH_4735_pdf', value: 'MIH-4735.pdf?token=xyz' }, - { key: 'E1', value: 'EV1' }, - ], - assetFileNames: [], - cloudAssets: [ - { fileName: 'report.html', copyText: '${env.report_html}' }, - { fileName: 'sheet.xlsx', copyText: '${env.sheet_xlsx}' }, - ], - expected: [{ key: 'E1', value: 'EV1' }], - }, - { - name: 'keeps local assets and user config', - entries: [ - { key: 'assets', value: 'https://cdn.example.com/' }, - { key: 'img1_png', value: 'img1.png?token=abc' }, - { key: 'deleted_png', value: 'deleted.png?token=def' }, - { key: 'E1', value: 'EV1' }, - ], - assetFileNames: ['img1.png'], - expected: [ - { key: 'assets', value: 'https://cdn.example.com/' }, - { key: 'img1_png', value: 'img1.png?token=abc' }, - { key: 'E1', value: 'EV1' }, - ], - }, - { - name: 'keeps non-asset keys whose value looks like a file URL', - entries: [{ key: 'download_url', value: 'https://cdn.example.com/guide.pdf?token=abc' }], - assetFileNames: [], - expected: [{ key: 'download_url', value: 'https://cdn.example.com/guide.pdf?token=abc' }], - }, - ])('$name', ({ entries, assetFileNames, cloudAssets, expected }) => { - expect(pruneStaleAssetEnvEntries(entries, assetFileNames, cloudAssets)).toEqual(expected); - }); - - it('computeEnvPullChanges flags config mismatch including missing asset env keys', () => { - const result = computeEnvPullChanges( - localEnv({ - envConfig: [ - { key: 'assets', value: 'https://cdn.example.com/' }, - { key: 'E1', value: 'EV111' }, - ], - envSecrets: [{ key: 'S1', value: 'SK1' }], - envSecretsPresent: true, - }), - { - envVariables: { - assets: 'https://cdn.example.com/', - Case1_Working_png: 'Case1_Working.png?alt=media&token=abc', - E1: 'EV111', - }, - }, - { secrets: { S1: 'SK1' } }, - ['Case1_Working.png'], - [{ fileName: 'Case1_Working.png' }] - ); - - expect(result.configMatch).toBe(false); - expect(result.secretsMatch).toBe(true); - expect(result.filesToUpdate).toEqual(['.env.config']); - }); - - it('computeEnvPullChanges flags missing asset keys in scoped alias config files', () => { - const result = computeEnvPullChanges( - localEnv({ - useScoped: true, - configWriteFile: '.env.config.uat', - envConfig: [{ key: 'E2', value: 'EK2' }], - envConfigPresent: true, - }), - { - envVariables: { - assets: 'https://cdn.example.com/', - logo_png: 'logo.png?token=abc', - E2: 'EK2', - }, - }, - undefined, - ['logo.png'], - [{ fileName: 'logo.png' }] - ); - - expect(result.configMatch).toBe(false); - expect(result.filesToUpdate).toEqual(['.env.config.uat']); - }); - - it('prepareEnvPushState omits cloud asset keys when no local asset files exist', async () => { - await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=EV11\n', 'utf8'); - - const state = await prepareEnvPushState({ - projectRoot: tmpDir, - appKey: 'default', - defaultAppKey: 'default', - cloudEnv: { - config: { envVariables: { assets: 'https://cdn.example.com/', E1: 'EV1' } }, - }, - assetFileNames: [], - }); - - expect(state.diff.configChanged).toBe(true); - expect(state.pushConfigDto?.envVariables).toEqual({ E1: 'EV11' }); - }); - - it('prepareEnvPushState prunes stale asset env keys only after confirm', async () => { - await fs.writeFile( - path.join(tmpDir, '.env.config'), - 'assets=https://cdn.example.com/\nimg1_png=img1.png?token=abc\ndel_png=del.png?token=def\nE1=EV11\n', - 'utf8' - ); - - const state = await prepareEnvPushState({ - projectRoot: tmpDir, - appKey: 'default', - defaultAppKey: 'default', - cloudEnv: { config: { envVariables: { E1: 'EV1' } } }, - assetFileNames: ['img1.png'], - }); - - const envConfigBeforeConfirm = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); - expect(envConfigBeforeConfirm).toContain('del_png='); - expect(state.pendingLocalEnvConfigWrite?.some((entry) => entry.key === 'del_png')).toBe(false); - - await writeEnvFile(tmpDir, '.env.config', state.pendingLocalEnvConfigWrite!); - - const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); - expect(envConfig).toContain('img1_png=img1.png?token=abc'); - expect(envConfig).not.toContain('del_png='); - }); - - it('applyReleaseConfigToFs restores full snapshot config', async () => { - await applyReleaseConfigToFs( - tmpDir, - { - envVariables: { - assets: 'https://cdn.example.com/', - logo_png: 'logo.png', - E1: 'EV1', - }, - }, - 'default', - 'default' - ); - - const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); - expect(envConfig).toContain('assets=https://cdn.example.com/'); - expect(envConfig).toContain('logo_png=logo.png'); - expect(envConfig).toContain('E1=EV1'); - }); - - it('readProjectEnvFiles uses scoped pair when both alias files exist', async () => { - await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\nE2=shared\n', 'utf8'); - await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); - await fs.writeFile(path.join(tmpDir, envConfigScopedFile('uat')), 'E1=uat\n', 'utf8'); - await fs.writeFile(path.join(tmpDir, envSecretsScopedFile('uat')), 'S1=uat\n', 'utf8'); - - const env = await readProjectEnvFiles(tmpDir, 'uat', 'dev'); - expect(env.useScoped).toBe(true); - expect(env.configWriteFile).toBe('.env.config.uat'); - expect(env.secretsWriteFile).toBe('.env.secrets.uat'); - expect(env.envConfig).toEqual([{ key: 'E1', value: 'uat' }]); - expect(env.envSecrets).toEqual([{ key: 'S1', value: 'uat' }]); - }); - - it('readProjectEnvFiles uses base for default alias when only base files exist', async () => { - await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\n', 'utf8'); - await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); - - const env = await readProjectEnvFiles(tmpDir, 'dev', 'dev'); - expect(env.useScoped).toBe(false); - expect(env.configWriteFile).toBe('.env.config'); - expect(env.envConfig).toEqual([{ key: 'E1', value: 'base' }]); - expect(env.envSecrets).toEqual([{ key: 'S1', value: 'base' }]); - }); - - it('readProjectEnvFiles targets scoped paths for non-default alias even before files exist', async () => { - await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\n', 'utf8'); - await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); - - const env = await readProjectEnvFiles(tmpDir, 'uat', 'dev'); - expect(env.useScoped).toBe(true); - expect(env.configWriteFile).toBe('.env.config.uat'); - expect(env.secretsWriteFile).toBe('.env.secrets.uat'); - expect(env.envConfig).toEqual([]); - expect(env.envSecrets).toEqual([]); - expect(env.envConfigPresent).toBe(false); - expect(env.envSecretsPresent).toBe(false); - }); - - it('applyCloudEnvToFs creates scoped files for non-default alias without touching base', async () => { - await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\n', 'utf8'); - await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); - await applyCloudEnvToFs( - tmpDir, - { - config: { - envVariables: { - assets: 'https://cdn.example.com/', - logo_png: 'logo.png', - E1: 'uat', - E2: 'new', - }, - }, - secrets: { secrets: { S1: 'sk' } }, - }, - ['logo.png'], - 'uat', - 'dev' - ); - - const baseConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); - const baseSecrets = await fs.readFile(path.join(tmpDir, '.env.secrets'), 'utf8'); - const scoped = await fs.readFile(path.join(tmpDir, envConfigScopedFile('uat')), 'utf8'); - const secrets = await fs.readFile(path.join(tmpDir, envSecretsScopedFile('uat')), 'utf8'); - expect(baseConfig).toContain('E1=base'); - expect(baseSecrets).toContain('S1=base'); - expect(scoped).toContain('E1=uat'); - expect(scoped).toContain('E2=new'); - expect(scoped).toContain('assets=https://cdn.example.com/'); - expect(scoped).toContain('logo_png=logo.png'); - expect(secrets).toContain('S1=sk'); - }); -}); +import fs from 'fs/promises'; +import os from 'os'; +import path from 'path'; + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; + +import { writeEnvFile, type EnvEntry } from '../../src/core/envConfig.js'; +import { + applyCloudEnvToFs, + applyReleaseEnvToFs, + buildCanonicalEnvConfigEntries, + buildEnvPushDiff, + buildPushConfigDto, + computeEnvPullChanges, + pruneStaleAssetEnvEntries, + prepareEnvPushState, + readProjectEnvFiles, + type CloudEnvState, + type LocalEnvFiles, +} from '../../src/core/envSync.js'; +import { envConfigScopedFile, envSecretsScopedFile } from '../../src/core/envConfig.js'; + +function localEnv( + overrides: Partial & Pick +): LocalEnvFiles { + const baseConfig = overrides.baseConfig ?? overrides.envConfig; + return { + appKey: 'default', + useScoped: false, + configWriteFile: '.env.config', + secretsWriteFile: '.env.secrets', + envSecrets: [], + baseConfig, + scopedConfig: [], + baseSecrets: [], + scopedSecrets: [], + envConfigPresent: true, + envSecretsPresent: false, + baseConfigPresent: true, + scopedConfigPresent: false, + baseSecretsPresent: false, + scopedSecretsPresent: false, + ...overrides, + envConfig: overrides.envConfig, + }; +} + +function localEnvFromParts( + configEntries: EnvEntry[], + assetEntries: EnvEntry[] = [] +): LocalEnvFiles { + const envConfig = [...configEntries, ...assetEntries]; + return localEnv({ envConfig, baseConfig: envConfig }); +} + +describe('envSync', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ensemble-envSync-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('applyCloudEnvToFs syncs cloud env, preserves asset keys, and drops removed keys', async () => { + await fs.writeFile( + path.join(tmpDir, '.env.config'), + 'assets=https://cdn.example.com/\nlogo_png=logo.png\nE1=EV1\nE2=EV2\n', + 'utf8' + ); + + await applyCloudEnvToFs( + tmpDir, + { + config: { envVariables: { API_URL: 'https://api.example.com', E1: 'EV1' } }, + secrets: { secrets: { S1: 'secret-value' } }, + }, + ['logo.png'], + 'default' + ); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + const envSecrets = await fs.readFile(path.join(tmpDir, '.env.secrets'), 'utf8'); + expect(envConfig).toContain('assets=https://cdn.example.com/'); + expect(envConfig).toContain('logo_png=logo.png'); + expect(envConfig).toContain('API_URL=https://api.example.com'); + expect(envConfig).toContain('E1=EV1'); + expect(envConfig).not.toContain('E2='); + expect(envSecrets).toContain('S1=secret-value'); + }); + + it.each<{ + name: string; + local: LocalEnvFiles; + cloud: CloudEnvState; + assets: string[]; + cloudAssets?: Array<{ fileName?: string; copyText?: string }>; + configChanged: boolean; + secretsChanged: boolean; + wouldClearConfig?: boolean; + wouldClearSecrets?: boolean; + cloudConfig?: Record; + localConfig?: Record; + }>([ + { + name: 'detects config and secrets changes', + local: localEnv({ + envConfig: [{ key: 'E1', value: 'local' }], + envSecrets: [{ key: 'S1', value: 'local' }], + envSecretsPresent: true, + }), + cloud: { + config: { envVariables: { E1: 'cloud' } }, + secrets: { secrets: { S1: 'cloud' } }, + }, + assets: [], + configChanged: true, + secretsChanged: true, + }, + { + name: 'omits snapshots when in sync', + local: localEnv({ + envConfig: [{ key: 'E1', value: 'EV1' }], + envSecrets: [{ key: 'S1', value: 'SK1' }], + envSecretsPresent: true, + }), + cloud: { + config: { envVariables: { E1: 'EV1' } }, + secrets: { secrets: { S1: 'SK1' } }, + }, + assets: [], + configChanged: false, + secretsChanged: false, + }, + { + name: 'skips env push when env files are missing but cloud has values', + local: localEnv({ + envConfig: [], + baseConfig: [], + envConfigPresent: false, + envSecretsPresent: false, + baseConfigPresent: false, + }), + cloud: { + config: { envVariables: { E1: 'EV1' } }, + secrets: { secrets: { S1: 'SK1' } }, + }, + assets: [], + configChanged: false, + secretsChanged: false, + wouldClearConfig: false, + wouldClearSecrets: false, + }, + { + name: 'wipes cloud when env files are present but empty', + local: localEnv({ + envConfig: [], + baseConfig: [], + envSecrets: [], + envConfigPresent: true, + envSecretsPresent: true, + }), + cloud: { + config: { envVariables: { E1: 'EV1' } }, + secrets: { secrets: { S1: 'SK1' } }, + }, + assets: [], + configChanged: true, + secretsChanged: true, + wouldClearConfig: true, + wouldClearSecrets: true, + }, + { + name: 'shows full push config vs cloud including asset keys', + local: localEnv({ + envConfig: [ + { key: 'E1', value: 'EV11' }, + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'logo_png', value: 'logo.png?local=abc' }, + ], + }), + cloud: { + config: { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png', + E1: 'EV1', + E2: 'EV2', + }, + }, + }, + assets: ['logo.png'], + configChanged: true, + secretsChanged: false, + cloudConfig: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png', + E1: 'EV1', + E2: 'EV2', + }, + localConfig: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?local=abc', + E1: 'EV11', + }, + }, + { + name: 'flags configChanged when only stale asset env keys differ from cloud', + local: localEnv({ + envConfig: [{ key: 'E1', value: 'EV1' }], + }), + cloud: { + config: { + envVariables: { + assets: 'https://cdn.example.com/', + DSA_Viva_rtf: 'DSA%20Viva.rtf?token=abc', + github_html: 'first%20token%20github.html?token=def', + E1: 'EV1', + }, + }, + }, + assets: [], + cloudAssets: [ + { fileName: 'DSA Viva.rtf' }, + { fileName: 'first token github.html', copyText: '${env.github_html}' }, + ], + configChanged: true, + secretsChanged: false, + localConfig: { E1: 'EV1' }, + cloudConfig: { + assets: 'https://cdn.example.com/', + DSA_Viva_rtf: 'DSA%20Viva.rtf?token=abc', + github_html: 'first%20token%20github.html?token=def', + E1: 'EV1', + }, + }, + ])( + '$name', + ({ + local, + cloud, + assets, + cloudAssets, + configChanged, + secretsChanged, + wouldClearConfig = false, + wouldClearSecrets = false, + cloudConfig, + localConfig, + }) => { + const diff = buildEnvPushDiff(local, cloud, assets, cloudAssets); + expect(diff.configChanged).toBe(configChanged); + expect(diff.secretsChanged).toBe(secretsChanged); + expect(diff.wouldClearConfig).toBe(wouldClearConfig); + expect(diff.wouldClearSecrets).toBe(wouldClearSecrets); + if (!configChanged && !secretsChanged) { + expect(diff.local).toEqual({}); + expect(diff.cloud).toEqual({}); + return; + } + if (cloudConfig) expect(diff.cloud.config?.envVariables).toEqual(cloudConfig); + if (localConfig) expect(diff.local.config?.envVariables).toEqual(localConfig); + } + ); + + it('buildPushConfigDto keeps local assets and drops deleted cloud asset keys', () => { + expect( + buildPushConfigDto( + localEnvFromParts( + [ + { key: 'E1', value: 'EV11' }, + { key: 'E2', value: 'EV2' }, + ], + [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'logo_png', value: 'logo.png?local=abc' }, + ] + ), + { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?token=abc', + E1: 'EV1', + }, + }, + ['logo.png'] + ).envVariables + ).toEqual({ + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?local=abc', + E1: 'EV11', + E2: 'EV2', + }); + + expect( + buildPushConfigDto( + localEnvFromParts( + [{ key: 'E1', value: 'EV11' }], + [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'report_html', value: 'report.html?local=abc' }, + ] + ), + { + envVariables: { + assets: 'https://cdn.example.com/', + report_html: 'report.html?token=abc', + sheet_xlsx: 'sheet.xlsx?token=def', + E1: 'EV1', + }, + }, + ['report.html'], + [ + { fileName: 'report.html', copyText: '${env.report_html}' }, + { fileName: 'sheet.xlsx', copyText: '${env.sheet_xlsx}' }, + ] + ).envVariables + ).toEqual({ + assets: 'https://cdn.example.com/', + report_html: 'report.html?local=abc', + E1: 'EV11', + }); + + expect( + buildPushConfigDto( + localEnvFromParts([{ key: 'E1', value: 'EV11' }]), + { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?token=abc', + E1: 'EV1', + E2: 'EV2', + }, + }, + [] + ).envVariables + ).toEqual({ E1: 'EV11' }); + }); + + it.each<{ + name: string; + entries: Array<{ key: string; value: string }>; + assetFileNames: string[]; + cloudAssets?: Array<{ fileName?: string; copyText?: string }>; + expected: Array<{ key: string; value: string }>; + }>([ + { + name: 'removes copyText and derived keys for deleted assets', + entries: [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'report_html', value: 'report.html?token=abc' }, + { key: 'sheet_xlsx', value: 'sheet.xlsx?token=def' }, + { key: 'MIH_4735_pdf', value: 'MIH-4735.pdf?token=xyz' }, + { key: 'E1', value: 'EV1' }, + ], + assetFileNames: [], + cloudAssets: [ + { fileName: 'report.html', copyText: '${env.report_html}' }, + { fileName: 'sheet.xlsx', copyText: '${env.sheet_xlsx}' }, + ], + expected: [{ key: 'E1', value: 'EV1' }], + }, + { + name: 'keeps local assets and user config', + entries: [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'img1_png', value: 'img1.png?token=abc' }, + { key: 'deleted_png', value: 'deleted.png?token=def' }, + { key: 'E1', value: 'EV1' }, + ], + assetFileNames: ['img1.png'], + expected: [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'img1_png', value: 'img1.png?token=abc' }, + { key: 'E1', value: 'EV1' }, + ], + }, + { + name: 'keeps non-asset keys whose value looks like a file URL', + entries: [{ key: 'download_url', value: 'https://cdn.example.com/guide.pdf?token=abc' }], + assetFileNames: [], + expected: [{ key: 'download_url', value: 'https://cdn.example.com/guide.pdf?token=abc' }], + }, + ])('$name', ({ entries, assetFileNames, cloudAssets, expected }) => { + expect(pruneStaleAssetEnvEntries(entries, assetFileNames, cloudAssets)).toEqual(expected); + }); + + it('computeEnvPullChanges flags config mismatch including missing asset env keys', () => { + const result = computeEnvPullChanges( + localEnv({ + envConfig: [ + { key: 'assets', value: 'https://cdn.example.com/' }, + { key: 'E1', value: 'EV111' }, + ], + envSecrets: [{ key: 'S1', value: 'SK1' }], + envSecretsPresent: true, + }), + { + envVariables: { + assets: 'https://cdn.example.com/', + Case1_Working_png: 'Case1_Working.png?alt=media&token=abc', + E1: 'EV111', + }, + }, + { secrets: { S1: 'SK1' } }, + ['Case1_Working.png'], + [{ fileName: 'Case1_Working.png' }] + ); + + expect(result.configMatch).toBe(false); + expect(result.secretsMatch).toBe(true); + expect(result.filesToUpdate).toEqual(['.env.config']); + }); + + it('computeEnvPullChanges flags missing asset keys in scoped alias config files', () => { + const result = computeEnvPullChanges( + localEnv({ + useScoped: true, + configWriteFile: '.env.config.uat', + envConfig: [{ key: 'E2', value: 'EK2' }], + envConfigPresent: true, + }), + { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png?token=abc', + E2: 'EK2', + }, + }, + undefined, + ['logo.png'], + [{ fileName: 'logo.png' }] + ); + + expect(result.configMatch).toBe(false); + expect(result.filesToUpdate).toEqual(['.env.config.uat']); + }); + + it('prepareEnvPushState omits cloud asset keys when no local asset files exist', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=EV11\n', 'utf8'); + + const state = await prepareEnvPushState({ + projectRoot: tmpDir, + appKey: 'default', + defaultAppKey: 'default', + cloudEnv: { + config: { envVariables: { assets: 'https://cdn.example.com/', E1: 'EV1' } }, + }, + assetFileNames: [], + }); + + expect(state.diff.configChanged).toBe(true); + expect(state.pushConfigDto?.envVariables).toEqual({ E1: 'EV11' }); + }); + + it('prepareEnvPushState prunes stale asset env keys only after confirm', async () => { + await fs.writeFile( + path.join(tmpDir, '.env.config'), + 'assets=https://cdn.example.com/\nimg1_png=img1.png?token=abc\ndel_png=del.png?token=def\nE1=EV11\n', + 'utf8' + ); + + const state = await prepareEnvPushState({ + projectRoot: tmpDir, + appKey: 'default', + defaultAppKey: 'default', + cloudEnv: { config: { envVariables: { E1: 'EV1' } } }, + assetFileNames: ['img1.png'], + }); + + const envConfigBeforeConfirm = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + expect(envConfigBeforeConfirm).toContain('del_png='); + expect(state.pendingLocalEnvConfigWrite?.some((entry) => entry.key === 'del_png')).toBe(false); + + await writeEnvFile(tmpDir, '.env.config', state.pendingLocalEnvConfigWrite!); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + expect(envConfig).toContain('img1_png=img1.png?token=abc'); + expect(envConfig).not.toContain('del_png='); + }); + + it('buildCanonicalEnvConfigEntries places asset keys before non-asset config keys', () => { + const entries = buildCanonicalEnvConfigEntries( + { + envVariables: { + E1: 'K1', + assets: 'https://cdn/', + logo_png: 'logo.png', + }, + }, + ['logo.png'] + ); + + expect(entries.map((entry) => entry.key)).toEqual(['assets', 'logo_png', 'E1']); + }); + + it('applyReleaseEnvToFs restores full snapshot config', async () => { + await applyReleaseEnvToFs( + tmpDir, + { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png', + E1: 'EV1', + }, + }, + undefined, + 'default', + 'default', + ['logo.png'] + ); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + expect(envConfig).toContain('assets=https://cdn.example.com/'); + expect(envConfig).toContain('logo_png=logo.png'); + expect(envConfig).toContain('E1=EV1'); + }); + + it('applyReleaseEnvToFs writes canonical asset-then-config layout', async () => { + await fs.writeFile( + path.join(tmpDir, '.env.config'), + 'assets=https://old/\nkwnd_png=old.png\nE1=old\n', + 'utf8' + ); + + await applyReleaseEnvToFs( + tmpDir, + { + envVariables: { + E1: 'EV1', + assets: 'https://new/', + kwnd_png: 'new.png', + }, + }, + undefined, + 'default', + 'default', + ['kwnd.png'] + ); + + const lines = (await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8')).trim().split('\n'); + expect(lines[0]).toMatch(/^assets=https:\/\/new\//); + expect(lines[1]).toMatch(/^kwnd_png=new\.png$/); + expect(lines[2]).toMatch(/^E1=EV1$/); + }); + + it('applyReleaseEnvToFs removes config keys not in snapshot', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'A1=old\nE1=local-only\nB1=old\n', 'utf8'); + + await applyReleaseEnvToFs( + tmpDir, + { envVariables: { A1: 'a', B1: 'b' } }, + undefined, + 'default', + 'default' + ); + + const lines = (await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8')).trim().split('\n'); + expect(lines).toEqual(['A1=a', 'B1=b']); + }); + + it('applyReleaseEnvToFs removes secret keys not in snapshot', async () => { + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=old\nS2=extra\n', 'utf8'); + + await applyReleaseEnvToFs(tmpDir, undefined, { secrets: { S1: 'new' } }, 'default', 'default'); + + const lines = (await fs.readFile(path.join(tmpDir, '.env.secrets'), 'utf8')).trim().split('\n'); + expect(lines).toEqual(['S1=new']); + }); + + it('applyReleaseEnvToFs clears env file when snapshot config is empty', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=local\n', 'utf8'); + + await applyReleaseEnvToFs(tmpDir, { envVariables: {} }, undefined, 'default', 'default'); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + expect(envConfig.trim()).toBe(''); + }); + + it('applyReleaseEnvToFs does not rewrite env files when snapshot matches local', async () => { + const envPath = path.join(tmpDir, '.env.config'); + const secretsPath = path.join(tmpDir, '.env.secrets'); + const envRaw = 'assets=https://cdn/\nE1=EV1'; + const secretsRaw = `ENSEMBLE_ENCRYPTION_KEY=${'a'.repeat(64)}\nS1=SK1`; + await fs.writeFile(envPath, envRaw, 'utf8'); + await fs.writeFile(secretsPath, secretsRaw, 'utf8'); + + await applyReleaseEnvToFs( + tmpDir, + { envVariables: { assets: 'https://cdn/', E1: 'EV1' } }, + { + secrets: { + ENSEMBLE_ENCRYPTION_KEY: 'a'.repeat(64), + S1: 'SK1', + }, + }, + 'default', + 'default', + [] + ); + + expect(await fs.readFile(envPath, 'utf8')).toBe(envRaw); + expect(await fs.readFile(secretsPath, 'utf8')).toBe(secretsRaw); + }); + + it('applyReleaseEnvToFs normalizes env config to canonical layout', async () => { + const envPath = path.join(tmpDir, '.env.config'); + const assetFiles = ['V12_Aansluiten.png', 't-3276-unenroll-mw-after.png']; + await fs.writeFile( + envPath, + 'E1=K1\nV12_Aansluiten_png=token\nt_3276_unenroll_mw_after_png=token2', + 'utf8' + ); + + await applyReleaseEnvToFs( + tmpDir, + { + envVariables: { + V12_Aansluiten_png: 'token', + t_3276_unenroll_mw_after_png: 'token2', + E1: 'K1', + }, + }, + undefined, + 'default', + 'default', + assetFiles + ); + + expect(await fs.readFile(envPath, 'utf8')).toBe( + 't_3276_unenroll_mw_after_png=token2\nV12_Aansluiten_png=token\nE1=K1' + ); + }); + + it('applyReleaseEnvToFs skips env files when snapshot omits config and secrets', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=local\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=local\n', 'utf8'); + + await applyReleaseEnvToFs(tmpDir, undefined, undefined, 'default', 'default'); + + const envConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + const envSecrets = await fs.readFile(path.join(tmpDir, '.env.secrets'), 'utf8'); + expect(envConfig).toContain('E1=local'); + expect(envSecrets).toContain('S1=local'); + }); + + it('applyReleaseEnvToFs removes local-only keys in canonical layout', async () => { + const envPath = path.join(tmpDir, '.env.config'); + const assetFiles = ['V12_Aansluiten.png', 't-3276-unenroll-mw-after.png']; + const envRaw = 'V12_Aansluiten_png=token\nt_3276_unenroll_mw_after_png=token2\nE1=K1'; + await fs.writeFile(envPath, envRaw, 'utf8'); + + await applyReleaseEnvToFs( + tmpDir, + { + envVariables: { + V12_Aansluiten_png: 'token', + t_3276_unenroll_mw_after_png: 'token2', + }, + }, + undefined, + 'default', + 'default', + assetFiles + ); + + expect(await fs.readFile(envPath, 'utf8')).toBe( + 't_3276_unenroll_mw_after_png=token2\nV12_Aansluiten_png=token' + ); + }); + + it('readProjectEnvFiles uses scoped pair when both alias files exist', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\nE2=shared\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, envConfigScopedFile('uat')), 'E1=uat\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, envSecretsScopedFile('uat')), 'S1=uat\n', 'utf8'); + + const env = await readProjectEnvFiles(tmpDir, 'uat', 'dev'); + expect(env.useScoped).toBe(true); + expect(env.configWriteFile).toBe('.env.config.uat'); + expect(env.secretsWriteFile).toBe('.env.secrets.uat'); + expect(env.envConfig).toEqual([{ key: 'E1', value: 'uat' }]); + expect(env.envSecrets).toEqual([{ key: 'S1', value: 'uat' }]); + }); + + it('readProjectEnvFiles uses base for default alias when only base files exist', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); + + const env = await readProjectEnvFiles(tmpDir, 'dev', 'dev'); + expect(env.useScoped).toBe(false); + expect(env.configWriteFile).toBe('.env.config'); + expect(env.envConfig).toEqual([{ key: 'E1', value: 'base' }]); + expect(env.envSecrets).toEqual([{ key: 'S1', value: 'base' }]); + }); + + it('readProjectEnvFiles targets scoped paths for non-default alias even before files exist', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); + + const env = await readProjectEnvFiles(tmpDir, 'uat', 'dev'); + expect(env.useScoped).toBe(true); + expect(env.configWriteFile).toBe('.env.config.uat'); + expect(env.secretsWriteFile).toBe('.env.secrets.uat'); + expect(env.envConfig).toEqual([]); + expect(env.envSecrets).toEqual([]); + expect(env.envConfigPresent).toBe(false); + expect(env.envSecretsPresent).toBe(false); + }); + + it('applyCloudEnvToFs creates scoped files for non-default alias without touching base', async () => { + await fs.writeFile(path.join(tmpDir, '.env.config'), 'E1=base\n', 'utf8'); + await fs.writeFile(path.join(tmpDir, '.env.secrets'), 'S1=base\n', 'utf8'); + await applyCloudEnvToFs( + tmpDir, + { + config: { + envVariables: { + assets: 'https://cdn.example.com/', + logo_png: 'logo.png', + E1: 'uat', + E2: 'new', + }, + }, + secrets: { secrets: { S1: 'sk' } }, + }, + ['logo.png'], + 'uat', + 'dev' + ); + + const baseConfig = await fs.readFile(path.join(tmpDir, '.env.config'), 'utf8'); + const baseSecrets = await fs.readFile(path.join(tmpDir, '.env.secrets'), 'utf8'); + const scoped = await fs.readFile(path.join(tmpDir, envConfigScopedFile('uat')), 'utf8'); + const secrets = await fs.readFile(path.join(tmpDir, envSecretsScopedFile('uat')), 'utf8'); + expect(baseConfig).toContain('E1=base'); + expect(baseSecrets).toContain('S1=base'); + expect(scoped).toContain('E1=uat'); + expect(scoped).toContain('E2=new'); + expect(scoped).toContain('assets=https://cdn.example.com/'); + expect(scoped).toContain('logo_png=logo.png'); + expect(secrets).toContain('S1=sk'); + }); +}); diff --git a/tests/core/manifest.test.ts b/tests/core/manifest.test.ts index 5b2b93d..9ca6c37 100644 --- a/tests/core/manifest.test.ts +++ b/tests/core/manifest.test.ts @@ -1,29 +1,139 @@ -import { describe, it, expect } from 'vitest'; -import { buildManifestObject, type RootManifest } from '../../src/core/manifest.js'; -import type { CloudApp } from '../../src/cloud/firestoreClient.js'; -import { EnsembleDocumentType } from '../../src/core/dto.js'; - -describe('buildManifestObject manifest lists', () => { - it('preserves existing scripts order and only adds new ones', () => { - const existing: RootManifest = { - widgets: [], - scripts: [{ name: 'S1' }, { name: 'S2' }], - }; - const cloud: CloudApp = { - id: 'app1', - name: 'App', - screens: [], - widgets: [], - scripts: [ - { id: 's2', name: 'S2', content: '', type: EnsembleDocumentType.Script }, - { id: 's1', name: 'S1', content: '', type: EnsembleDocumentType.Script }, - { id: 's3', name: 'S3', content: '', type: EnsembleDocumentType.Script }, - ], - translations: [], - }; - - const merged = buildManifestObject(existing, cloud); - - expect(merged.scripts?.map((s) => s.name)).toEqual(['S1', 'S2', 'S3']); - }); -}); +import { describe, it, expect } from 'vitest'; +import { + buildManifestObject, + mergeManifestFromSnapshot, + orderByManifestNames, + type RootManifest, +} from '../../src/core/manifest.js'; +import type { CloudApp } from '../../src/cloud/firestoreClient.js'; +import { EnsembleDocumentType } from '../../src/core/dto.js'; + +describe('manifest', () => { + it('orderByManifestNames sorts items to match manifest list order', () => { + const items = [ + { name: 'Wid2', content: '' }, + { name: 'Wid1', content: '' }, + ]; + + const ordered = orderByManifestNames(items, ['Wid1', 'Wid2']); + + expect(ordered.map((item) => item.name)).toEqual(['Wid1', 'Wid2']); + }); + + it('mergeManifestFromSnapshot syncs lists from snapshot but keeps other manifest keys', () => { + const existing: RootManifest = { + screens: [{ name: 'Home' }], + studioVersion: 3, + widgets: [{ name: 'Wid1', customId: 'keep-me' } as unknown as { name: string }], + languages: ['ar', 'en'], + defaultLanguage: 'ar', + }; + const cloud: CloudApp = { + id: 'app1', + name: 'App', + screens: [], + widgets: [ + { id: 'w2', name: 'Wid2', content: '', type: EnsembleDocumentType.Widget }, + { id: 'w1', name: 'Wid1', content: '', type: EnsembleDocumentType.Widget }, + ], + scripts: [], + translations: [ + { + id: 't-en', + name: 'en', + content: '', + type: EnsembleDocumentType.I18n, + defaultLocale: true, + }, + { + id: 't-ar', + name: 'ar', + content: '', + type: EnsembleDocumentType.I18n, + }, + ], + }; + + const merged = mergeManifestFromSnapshot(existing, cloud); + + expect(merged.screens).toEqual([{ name: 'Home' }]); + expect(merged.studioVersion).toBe(3); + expect(merged.widgets).toEqual([{ name: 'Wid2' }, { name: 'Wid1', customId: 'keep-me' }]); + expect(merged.languages).toEqual(['en', 'ar']); + expect(merged.defaultLanguage).toBe('en'); + }); + + it('buildManifestObject preserves existing list order on pull', () => { + const existing: RootManifest = { + widgets: [], + scripts: [{ name: 'S1' }, { name: 'S2' }], + languages: ['ar', 'en', 'bn'], + defaultLanguage: 'ar', + }; + const cloud: CloudApp = { + id: 'app1', + name: 'App', + screens: [], + widgets: [], + scripts: [ + { id: 's2', name: 'S2', content: '', type: EnsembleDocumentType.Script }, + { id: 's1', name: 'S1', content: '', type: EnsembleDocumentType.Script }, + { id: 's3', name: 'S3', content: '', type: EnsembleDocumentType.Script }, + ], + translations: [ + { + id: 't-en', + name: 'en', + content: '', + type: EnsembleDocumentType.I18n, + defaultLocale: true, + }, + { + id: 't-ar', + name: 'ar', + content: '', + type: EnsembleDocumentType.I18n, + }, + ], + }; + + const merged = buildManifestObject(existing, cloud); + + expect(merged.scripts?.map((s) => s.name)).toEqual(['S1', 'S2', 'S3']); + expect(merged.languages).toEqual(['ar', 'en']); + expect(merged.defaultLanguage).toBe('en'); + }); + + it('mergeManifestFromSnapshot keeps empty list keys that already exist', () => { + const existing: RootManifest = { + actions: [], + defaultLanguage: 'nl', + languages: ['nl', 'en'], + }; + const cloud: CloudApp = { + id: 'app1', + name: 'App', + screens: [], + translations: [ + { + id: 't-nl', + name: 'nl', + content: '', + type: EnsembleDocumentType.I18n, + defaultLocale: true, + }, + { + id: 't-en', + name: 'en', + content: '', + type: EnsembleDocumentType.I18n, + }, + ], + }; + + const merged = mergeManifestFromSnapshot(existing, cloud); + + expect(merged.actions).toEqual([]); + expect(merged.defaultLanguage).toBe('nl'); + }); +}); diff --git a/tests/core/starterProject.test.ts b/tests/core/starterProject.test.ts index a5c5129..f271eb0 100644 --- a/tests/core/starterProject.test.ts +++ b/tests/core/starterProject.test.ts @@ -1,70 +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)); - }); -}); +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/lib/spinner.test.ts b/tests/lib/spinner.test.ts index 0cbfe26..e346be1 100644 --- a/tests/lib/spinner.test.ts +++ b/tests/lib/spinner.test.ts @@ -1,45 +1,61 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { withSpinner } from '../../src/lib/spinner.js'; - -describe('withSpinner', () => { - let writeSpy: ReturnType; - - beforeEach(() => { - writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) as ReturnType< - typeof vi.spyOn - >; - }); - - afterEach(() => { - writeSpy.mockRestore(); - }); - - it('returns the result of the async function', async () => { - const result = await withSpinner('Loading', async () => { - return 42; - }); - - expect(result).toBe(42); - }); - - it('calls the function and writes success message', async () => { - await withSpinner('Test', async () => 'done'); - - expect(writeSpy).toHaveBeenCalled(); - const calls = writeSpy.mock.calls.map((c) => c[0]); - expect(calls.some((s) => typeof s === 'string' && s.includes('✓') && s.includes('Test'))).toBe( - true - ); - }); - - it('rethrows when function throws', async () => { - await expect( - withSpinner('Fail', async () => { - throw new Error('oops'); - }) - ).rejects.toThrow('oops'); - - const calls = writeSpy.mock.calls.map((c) => c[0]); - expect(calls.some((s) => typeof s === 'string' && s.includes('✗'))).toBe(true); - }); -}); +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { withSpinner } from '../../src/lib/spinner.js'; + +describe('withSpinner', () => { + let writeSpy: ReturnType; + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) as ReturnType< + typeof vi.spyOn + >; + }); + + afterEach(() => { + writeSpy.mockRestore(); + }); + + it('returns the result of the async function', async () => { + const result = await withSpinner('Loading', async () => { + return 42; + }); + + expect(result).toBe(42); + }); + + it('calls the function and writes success message', async () => { + await withSpinner('Test', async () => 'done'); + + expect(writeSpy).toHaveBeenCalled(); + const calls = writeSpy.mock.calls.map((c) => c[0]); + expect(calls.some((s) => typeof s === 'string' && s.includes('✓') && s.includes('Test'))).toBe( + true + ); + }); + + it('rethrows when function throws', async () => { + await expect( + withSpinner('Fail', async () => { + throw new Error('oops'); + }) + ).rejects.toThrow('oops'); + + const calls = writeSpy.mock.calls.map((c) => c[0]); + expect(calls.some((s) => typeof s === 'string' && s.includes('✗'))).toBe(true); + }); + + it('skips spinner output when ENSEMBLE_NO_SPINNER is set', async () => { + const original = process.env.ENSEMBLE_NO_SPINNER; + process.env.ENSEMBLE_NO_SPINNER = '1'; + writeSpy.mockClear(); + + const result = await withSpinner('Quiet', async () => 'ok'); + + expect(result).toBe('ok'); + expect(writeSpy).not.toHaveBeenCalled(); + if (original === undefined) { + delete process.env.ENSEMBLE_NO_SPINNER; + } else { + process.env.ENSEMBLE_NO_SPINNER = original; + } + }); +});