From cc08b3d0e14b43f1c8f748f78dc5f2cce34b003d Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 27 May 2026 11:51:45 +0200 Subject: [PATCH 01/37] feat: scaffold voltra cli package --- PLAN.md | 904 +++++++++++++++++++++++++++ packages/cli/README.md | 3 + packages/cli/package.json | 48 ++ packages/cli/src/bin.ts | 14 + packages/cli/src/index.ts | 9 + packages/cli/tsconfig.base.json | 17 + packages/cli/tsconfig.cjs.json | 9 + packages/cli/tsconfig.esm.json | 9 + packages/cli/tsconfig.json | 8 + packages/cli/tsconfig.typecheck.json | 6 + packages/cli/tsconfig.types.json | 10 + 11 files changed, 1037 insertions(+) create mode 100644 PLAN.md create mode 100644 packages/cli/README.md create mode 100644 packages/cli/package.json create mode 100644 packages/cli/src/bin.ts create mode 100644 packages/cli/src/index.ts create mode 100644 packages/cli/tsconfig.base.json create mode 100644 packages/cli/tsconfig.cjs.json create mode 100644 packages/cli/tsconfig.esm.json create mode 100644 packages/cli/tsconfig.json create mode 100644 packages/cli/tsconfig.typecheck.json create mode 100644 packages/cli/tsconfig.types.json diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..db6c17de --- /dev/null +++ b/PLAN.md @@ -0,0 +1,904 @@ +## Voltra CLI Plan + +### Goal + +Ship a new published package `voltra` that applies Voltra native integration to standard non-Expo React Native projects. + +V1 should do one thing well: `voltra apply`. + +It should: +- load Voltra config +- discover the native project +- generate Voltra-owned files +- mutate required native project files +- clean up stale generated files from previous runs + +It should not try to solve everything else yet. + +### Scope Cuts + +These are intentional simplifications for v1: +- no large shared-core refactor first +- no reuse of Expo mod wrappers +- no generic mutation framework +- no plan/diff mode +- no rollback system +- no rich `.voltra/state.json` metadata +- no broad support beyond standard React Native layouts +- no attempt to undo shared-file mutations from history +- no test implementation yet + +### Core Decisions + +- package name: `voltra` +- binary name: `voltra` +- publish as a public npm package from day one +- use `cosmiconfig` to load config from multiple supported file formats +- `projectRoot` defaults to the config file directory +- `projectRoot` can be overridden in config +- all relative paths resolve from `projectRoot` +- default behavior should follow Expo plugin defaults unless config overrides it +- if git worktree is dirty, warn and ask before continuing +- if running non-interactively, fail on dirty worktree unless explicitly bypassed +- do full preflight before the first write +- reuse only pure helpers and generators where practical +- duplicate CLI-native mutation code where that keeps the package simpler and more independent + +### Config + +Use `cosmiconfig`. + +Support common config locations from day one: +- `package.json` under `voltra` +- `.voltrarc` +- `.voltrarc.json` +- `.voltrarc.yaml` +- `.voltrarc.yml` +- `.voltrarc.js` +- `.voltrarc.cjs` +- `.voltrarc.mjs` +- `.voltrarc.ts` +- `voltra.config.json` +- `voltra.config.yaml` +- `voltra.config.yml` +- `voltra.config.js` +- `voltra.config.cjs` +- `voltra.config.mjs` +- `voltra.config.ts` + +Rules: +- `configDir` is the directory containing the loaded config file +- `projectRoot` defaults to `configDir` +- `projectRoot` can be overridden in config +- all relative widget, asset, preview, and font paths resolve from `projectRoot` +- config shape should stay close to current Expo plugin props, with extra fields for native project discovery overrides + +### Command Surface + +V1 public interface: +- `voltra apply` +- optional `--platform ios|android` +- optional `--config ` + +No other public commands are required in v1. + +### Internal Apply Pipeline + +Even with one public command, keep the internals split into clear stages: + +1. load config +2. normalize config +3. detect git state +4. if dirty: + - interactive mode: warn and ask for confirmation + - non-interactive mode: fail unless explicitly bypassed +5. discover all required native project files for requested platforms +6. parse and preflight all of them +7. abort before writes if any required artifact is missing or ambiguous +8. load previous Voltra state +9. compute stale generated files +10. apply generated file writes and shared-file mutations +11. delete stale generated files +12. write new Voltra state only after success +13. print summary of changed files and warnings + +Important safety rule: +- no writes before all targeted platforms finish discovery and preflight + +### State Tracking + +Store CLI state in `${projectRoot}/.voltra/state.json`. + +Keep it minimal: + +```json +{ + "schemaVersion": 1, + "files": [ + "ios/MyWidget/Info.plist", + "ios/MyWidget/VoltraWidgetBundle.swift", + "android/app/src/main/res/xml/voltra_widget_score_info.xml" + ] +} +``` + +Rules: +- store only relative file paths +- track only fully generated Voltra-owned files +- load previous file list +- compute new file list +- remove stale files from `previous.files - next.files` +- write new state only after a successful run + +Do not store: +- file kind +- owning widget ID +- feature name +- file hashes +- directories +- Xcode UUIDs + +Do not use state to revert shared-file mutations in: +- `AndroidManifest.xml` +- `Info.plist` +- entitlements +- `Podfile` +- `project.pbxproj` + +Those files should always be reconciled from current desired config. + +### Reuse Strategy + +Reuse only the parts that are already simple and low-risk: +- validation helpers +- prerendering +- locale helpers +- font resolution +- pure generators for Android and iOS generated files +- small pure helper functions that compute plist or entitlement values if useful + +Do not reuse: +- Expo config plugin wrappers +- Expo mod orchestration +- current Xcode mutation implementation as-is + +For native mutation code, duplicating CLI-native logic is acceptable if it keeps the package simpler and avoids coupling the public CLI to Expo-specific internals. + +### Android Plan + +Android should ship first. + +V1 Android scope: +- manifest mutation +- receiver generation +- XML/widget info generation +- layouts +- drawable assets +- preview assets +- fonts +- initial state generation + +Implementation approach: +- reuse current Android generators where practical +- write a CLI-native Android manifest mutator +- use XML parsing instead of raw string replacement + +Discovery should be convention-first with overrides: +- default `android/` +- default app module `app` +- default manifest `android/app/src/main/AndroidManifest.xml` +- allow config overrides for nonstandard layouts + +Android mutation rules: +- ensure permissions by exact name +- ensure receivers by exact class name +- ensure metadata by exact resource reference +- never duplicate +- preserve unrelated manifest content +- write atomically where practical + +### iOS Plan + +iOS should ship second. + +Split iOS into two slices. + +Slice 1: +- widget extension file generation +- main app `Info.plist` mutation +- entitlements mutation +- Podfile mutation + +Slice 2: +- `project.pbxproj` mutation using `@bacons/xcode` + +Implementation approach: +- reuse current iOS generated-file logic where practical +- duplicate or extract small pure helpers for plist keys and entitlements +- use plist parsing/building instead of raw string replacement +- use a Voltra-managed block in Podfile +- port current Xcode behavior into CLI-native code + +Discovery should be convention-first but strict: +- default `ios/` +- discover `.xcodeproj` +- discover main app target +- discover main `Info.plist` +- discover Podfile +- allow explicit overrides when ambiguous + +iOS mutation rules: +- update if present +- insert if missing +- avoid duplicates +- preserve unrelated user content +- write atomically where practical + +### Mutation Style + +No sophisticated abstraction layer is needed in v1. + +Use focused functions per artifact, for example: +- `ensureAndroidManifest` +- `ensureInfoPlist` +- `ensureEntitlements` +- `ensurePodfileBlock` +- `ensureXcodeWidgetTarget` + +Each should: +- read current state +- update existing entries when found +- insert missing entries +- avoid duplicates +- preserve unrelated content + +### Build And Packaging + +`packages/cli` should use a Node CLI build path, not the React Native library packaging flow. + +Needs: +- `bin` entry for `voltra` +- shebang +- Node runtime target +- source maps +- compatibility with public npm publishing + +### Testability Constraint + +We are skipping tests for now, but the code should be structured so tests are easy to add later. + +That means: +- keep parsing, normalization, discovery, mutation planning, and filesystem writes in separate modules +- keep pure logic in small functions where practical +- avoid embedding filesystem reads and writes deep inside transformation logic +- pass filesystem operations through thin wrappers or modules so they can be replaced later in tests +- keep command handlers small and orchestration-focused +- avoid hidden global state + +This does not mean building a heavy abstraction layer. It just means keeping boundaries clean enough that fixture tests can be added later without major rewrites. + +### Recommended Package Shape + +Possible initial structure: + +- `packages/cli/src/bin.ts` +- `packages/cli/src/commands/apply.ts` +- `packages/cli/src/config/load.ts` +- `packages/cli/src/config/normalize.ts` +- `packages/cli/src/config/types.ts` +- `packages/cli/src/git/status.ts` +- `packages/cli/src/state/load.ts` +- `packages/cli/src/state/save.ts` +- `packages/cli/src/state/diff.ts` +- `packages/cli/src/discovery/android.ts` +- `packages/cli/src/discovery/ios.ts` +- `packages/cli/src/platforms/android/apply.ts` +- `packages/cli/src/platforms/android/manifest.ts` +- `packages/cli/src/platforms/ios/apply.ts` +- `packages/cli/src/platforms/ios/plist.ts` +- `packages/cli/src/platforms/ios/entitlements.ts` +- `packages/cli/src/platforms/ios/podfile.ts` +- `packages/cli/src/platforms/ios/xcode.ts` +- `packages/cli/src/fs/readWrite.ts` +- `packages/cli/src/reporting/summary.ts` + +### Execution Strategy + +Break the implementation into a small number of dependency-aware workstreams. + +Workstreams: +- Foundation: package scaffolding, command entrypoint, config, filesystem boundaries, reporting +- Shared Apply Flow: preflight, git checks, state tracking, orchestration +- Android: discovery, manifest mutation, generated files, Android apply flow +- iOS Core: discovery, plist, entitlements, Podfile, generated files, iOS apply flow +- iOS Xcode: `project.pbxproj` mutation and target wiring +- Docs and release prep + +Parallelism rules: +- Foundation tasks should land first because most later tasks depend on their module boundaries +- after Foundation is in place, Android and iOS Core can proceed mostly in parallel +- Shared Apply Flow can proceed in parallel with platform-specific mutation work once config types and filesystem boundaries exist +- iOS Xcode should start only after iOS discovery and iOS generated-file assumptions are stable +- Docs and release prep should happen after the CLI behavior is stable enough to describe accurately + +### Detailed Tasks + +Each task below is meant to be independently assignable. Dependencies are explicit so parallel work stays safe. + +#### Foundation + +**T1. Create `packages/cli` package scaffold** + +Status: +- completed + +Deliverables: +- create `packages/cli` +- add package manifest with `name: voltra` +- wire `bin` entry for `voltra` +- add CLI entrypoint with shebang +- wire build output for Node CLI usage + +Notes: +- keep packaging independent from the React Native library build flow +- keep exports and runtime assumptions simple + +Depends on: +- none + +Can run in parallel with: +- nothing; this is the base task + +**T2. Define config and normalized internal types** + +Status: +- pending + +Deliverables: +- define public config types +- define normalized config types used internally by apply logic +- define platform-specific normalized shapes for Android and iOS +- document which defaults mirror Expo behavior + +Notes: +- keep normalized types stable so later tasks can build against them +- separate public config shape from internal resolved shape + +Depends on: +- T1 + +Can run in parallel with: +- T3 + +**T3. Add filesystem boundary module** + +Status: +- pending + +Deliverables: +- add thin read/write helpers under `packages/cli/src/fs` +- add atomic-write helper where practical +- centralize path normalization helpers +- expose directory creation and delete helpers used by generated-file tasks + +Notes: +- keep this thin; do not build a heavy virtual filesystem abstraction +- the goal is easy future test replacement, not indirection for its own sake + +Depends on: +- T1 + +Can run in parallel with: +- T2 + +**T4. Add CLI reporting primitives** + +Status: +- pending + +Deliverables: +- summary formatter for created/updated/deleted files +- warning formatter for dirty git state and ambiguous discovery +- error formatter for preflight failures + +Notes: +- keep reporting decoupled from mutation logic + +Depends on: +- T1 + +Can run in parallel with: +- T2 +- T3 + +#### Config And Command Flow + +**T5. Implement config loading with `cosmiconfig`** + +Deliverables: +- support `package.json` `voltra` key +- support `.voltrarc*` +- support `voltra.config.*` +- support `--config ` override +- return loaded config plus `configDir` + +Notes: +- all relative paths must ultimately resolve from `projectRoot` +- fail clearly when no config is found + +Depends on: +- T1 +- T2 + +Can run in parallel with: +- T6 +- T7 + +**T6. Implement config normalization** + +Deliverables: +- resolve defaults from loaded config +- derive `projectRoot` +- resolve relative paths +- normalize per-platform config for downstream apply tasks + +Notes: +- normalization should be pure once raw config is loaded + +Depends on: +- T2 +- T5 + +Can run in parallel with: +- T7 + +**T7. Implement `voltra apply` command shell** + +Deliverables: +- parse flags +- route to `apply` +- support `--platform ios|android` +- support `--config` +- return structured exit codes for success vs failure + +Notes: +- keep command handler thin +- actual work should live in orchestration modules + +Depends on: +- T1 +- T4 + +Can run in parallel with: +- T5 + +#### Shared Apply Flow + +**T8. Implement git worktree checks** + +Deliverables: +- detect dirty worktree +- support interactive warn-and-confirm flow +- support non-interactive fail-fast behavior unless bypassed +- expose a clean API for apply orchestration + +Notes: +- interactive prompting should stay out of mutation code + +Depends on: +- T1 +- T4 + +Can run in parallel with: +- T5 +- T6 +- T7 + +**T9. Implement Voltra state load/save/diff** + +Deliverables: +- load `.voltra/state.json` if present +- validate minimal schema +- diff `previous.files` vs `next.files` +- save new state only after success + +Notes: +- state tracks only Voltra-owned generated files +- no shared-file mutation history + +Depends on: +- T2 +- T3 + +Can run in parallel with: +- T10 +- T11 + +**T10. Implement apply preflight orchestration** + +Deliverables: +- gather requested platforms +- run discovery for all requested platforms before writes +- collect missing/ambiguous artifact failures +- abort before writes if any preflight check fails + +Notes: +- this is the safety boundary that prevents partial writes across platforms + +Depends on: +- T6 +- T7 +- T8 + +Can run in parallel with: +- T9 +- T11 + +**T11. Implement top-level apply pipeline** + +Deliverables: +- sequence load config, normalize, git check, preflight, state load, platform apply, stale cleanup, state write, summary output +- ensure no state write happens on failure +- ensure stale files are deleted only after successful writes/mutations + +Notes: +- this task should wire existing modules together, not reimplement platform logic + +Depends on: +- T6 +- T7 +- T8 +- T9 +- T10 + +Can run in parallel with: +- platform implementation tasks, once interfaces are known + +#### Android Workstream + +**T12. Implement Android project discovery** + +Deliverables: +- discover Android root +- discover app module +- discover manifest path +- resolve configurable overrides for nonstandard layouts +- return a stable Android discovery result for downstream tasks + +Notes: +- fail clearly on missing expected structure + +Depends on: +- T2 +- T3 +- T6 + +Can run in parallel with: +- T13 +- T14 + +**T13. Adapt reusable Android generated-file logic for CLI use** + +Deliverables: +- wire current Android generators behind CLI-friendly inputs +- define generated file inventory output so state tracking can capture all owned files +- keep generator calls independent from CLI command concerns + +Notes: +- reuse only pure or low-risk generation code + +Depends on: +- T2 +- T3 +- T6 + +Can run in parallel with: +- T12 +- T14 + +**T14. Implement CLI-native Android manifest mutator** + +Deliverables: +- parse `AndroidManifest.xml` +- ensure required permissions +- ensure widget receivers +- ensure metadata/resource links +- preserve unrelated content and avoid duplicate insertions + +Notes: +- use XML parsing, not fragile string replacement + +Depends on: +- T2 +- T3 +- T6 + +Can run in parallel with: +- T12 +- T13 + +**T15. Implement Android apply flow** + +Deliverables: +- combine discovery, manifest mutation, generated-file writes, and generated-file list emission +- return created/updated file inventory for reporting and state tracking +- keep shared-file mutations and generated-file writes clearly separated in code + +Notes: +- this is the Android platform entrypoint used by top-level apply orchestration + +Depends on: +- T12 +- T13 +- T14 + +Can run in parallel with: +- iOS Core tasks + +#### iOS Core Workstream + +**T16. Implement iOS project discovery** + +Deliverables: +- discover iOS root +- discover `.xcodeproj` +- discover main target candidates +- discover main `Info.plist` +- discover Podfile +- support explicit overrides for ambiguous projects + +Notes: +- fail clearly when discovery is ambiguous instead of guessing + +Depends on: +- T2 +- T3 +- T6 + +Can run in parallel with: +- T17 +- T18 +- T19 + +**T17. Adapt reusable iOS generated-file logic for CLI use** + +Deliverables: +- wire widget extension file generation behind CLI-friendly inputs +- define generated file inventory output for state tracking +- keep pure file generation separate from Xcode mutation + +Notes: +- include Swift files, widget plist, entitlements, assets, and localized strings where applicable + +Depends on: +- T2 +- T3 +- T6 + +Can run in parallel with: +- T16 +- T18 +- T19 + +**T18. Implement CLI-native plist and entitlements mutators** + +Deliverables: +- parse and update main app `Info.plist` +- parse and update entitlements +- ensure required keys are inserted or updated without duplicates +- preserve unrelated content + +Notes: +- use plist parse/build APIs, not raw string mutation + +Depends on: +- T2 +- T3 +- T6 + +Can run in parallel with: +- T16 +- T17 +- T19 + +**T19. Implement Podfile managed block mutation** + +Deliverables: +- insert or update a Voltra-managed block for widget extension pods +- keep unrelated Podfile content intact +- make repeated runs idempotent + +Notes: +- keep the managed block narrow and obvious to users + +Depends on: +- T3 +- T6 + +Can run in parallel with: +- T16 +- T17 +- T18 + +**T20. Implement iOS Core apply flow** + +Deliverables: +- combine iOS discovery, generated-file writes, plist mutation, entitlements mutation, and Podfile mutation +- emit generated file inventory for state tracking +- keep Xcode mutation out of this task + +Notes: +- this task should produce a working iOS core path before `project.pbxproj` mutation exists + +Depends on: +- T16 +- T17 +- T18 +- T19 + +Can run in parallel with: +- T15 + +#### iOS Xcode Workstream + +**T21. Implement Xcode project parsing and target discovery helpers** + +Deliverables: +- parse `project.pbxproj` via `@bacons/xcode` +- identify main app target and required groups/build phases +- expose stable helper functions for downstream target mutation + +Notes: +- do not tie this to command orchestration + +Depends on: +- T16 + +Can run in parallel with: +- late Android polishing or docs work + +**T22. Implement widget target creation/update in Xcode project** + +Deliverables: +- ensure widget extension target exists +- ensure product file, build phases, groups, and dependencies are present +- make repeated runs idempotent +- preserve unrelated project structure + +Notes: +- this is the highest-risk mutation task and should stay narrowly scoped + +Depends on: +- T17 +- T21 + +Can run in parallel with: +- T23 + +**T23. Integrate Xcode mutation into iOS apply flow** + +Deliverables: +- add `project.pbxproj` mutation to iOS apply flow +- ensure generated-file paths and target references stay aligned +- include Xcode changes in final reporting + +Notes: +- this converts iOS Core into the full iOS path for v1 + +Depends on: +- T20 +- T22 + +Can run in parallel with: +- T24 + +#### Docs And Release Prep + +**T24. Write CLI docs and usage examples** + +Deliverables: +- document config file locations +- document `voltra apply` +- document `--platform` and `--config` +- document discovery conventions and override points +- document dirty-worktree behavior +- document generated-file ownership and `.voltra/state.json` + +Notes: +- docs should describe current behavior only; do not document speculative future commands + +Depends on: +- T11 +- T15 +- T20 + +Can run in parallel with: +- T23 + +### Suggested Phases + +If sequencing work across multiple people, use these phases. + +**Phase 1: Foundation** +- T1 +- T2 +- T3 +- T4 +- T5 +- T6 +- T7 +- T8 +- T9 + +**Phase 2: Shared And Platform Parallel Work** +- T10 +- T11 +- T12 +- T13 +- T14 +- T16 +- T17 +- T18 +- T19 + +**Phase 3: First End-To-End Paths** +- T15 +- T20 + +**Phase 4: iOS Xcode Completion** +- T21 +- T22 +- T23 + +**Phase 5: Docs And Release Prep** +- T24 + +### Critical Path + +The minimum dependency path to a shippable CLI is: + +1. T1 +2. T2 +3. T5 +4. T6 +5. T7 +6. T8 +7. T10 +8. T9 +9. T11 +10. T12 +11. T13 +12. T14 +13. T15 +14. T16 +15. T17 +16. T18 +17. T19 +18. T20 +19. T21 +20. T22 +21. T23 +22. T24 + +Android can ship earlier internally, but v1 is not complete until iOS Xcode mutation is integrated. + +### Main Risks + +- Xcode mutation remains the hardest part +- Podfile mutation can still be brittle +- CLI defaults can drift from Expo behavior if not kept aligned +- iOS project discovery can be ambiguous in nontrivial apps +- config loading across many file formats increases public support surface + +### Guiding Principle + +Prefer the smaller solution unless extra complexity clearly improves safety. + +In practice, that means: +- minimal state file +- one public command in v1 +- duplicated CLI-native mutators where needed +- reuse only pure helpers and generators +- no generalized mutation engine +- no rollback system +- no extra state metadata unless it becomes clearly necessary diff --git a/packages/cli/README.md b/packages/cli/README.md new file mode 100644 index 00000000..6695b26f --- /dev/null +++ b/packages/cli/README.md @@ -0,0 +1,3 @@ +# voltra + +CLI for applying Voltra to native React Native projects. diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 00000000..89ff994f --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,48 @@ +{ + "name": "voltra", + "version": "1.4.1", + "description": "CLI for applying Voltra to native React Native projects", + "main": "build/cjs/index.js", + "module": "build/esm/index.js", + "types": "build/types/index.d.ts", + "bin": { + "voltra": "./build/cjs/bin.js" + }, + "exports": { + ".": { + "types": "./build/types/index.d.ts", + "require": "./build/cjs/index.js", + "import": "./build/esm/index.js", + "default": "./build/esm/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "build", + "README.md" + ], + "scripts": { + "build": "node ../../scripts/build-package.mjs packages/cli", + "clean": "rm -rf build", + "lint": "oxlint src", + "typecheck": "tsc -p tsconfig.typecheck.json --noEmit", + "test": "node --test" + }, + "keywords": [ + "react-native", + "voltra", + "cli", + "widget" + ], + "author": "Sa\u00fal Sharma (https://x.com/saul_sharma), Szymon Chmal (https://x.com/chmalszymon)", + "repository": { + "type": "git", + "url": "git+https://github.com/callstackincubator/voltra.git", + "directory": "packages/cli" + }, + "bugs": { + "url": "https://github.com/callstackincubator/voltra/issues" + }, + "license": "MIT", + "homepage": "https://use-voltra.dev" +} diff --git a/packages/cli/src/bin.ts b/packages/cli/src/bin.ts new file mode 100644 index 00000000..392a34f5 --- /dev/null +++ b/packages/cli/src/bin.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +import { runCli } from './index' + +void runCli(process.argv.slice(2)) + .then((exitCode) => { + process.exitCode = exitCode + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error) + + process.stderr.write(`${message}\n`) + process.exitCode = 1 + }) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts new file mode 100644 index 00000000..bd19579d --- /dev/null +++ b/packages/cli/src/index.ts @@ -0,0 +1,9 @@ +export async function runCli(argv: string[]): Promise { + if (argv.includes('--help') || argv.includes('-h')) { + process.stdout.write('voltra CLI scaffolding is ready.\n') + return 0 + } + + process.stderr.write('voltra CLI is not implemented yet.\n') + return 1 +} diff --git a/packages/cli/tsconfig.base.json b/packages/cli/tsconfig.base.json new file mode 100644 index 00000000..793e900a --- /dev/null +++ b/packages/cli/tsconfig.base.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020"], + "rootDir": "./src", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "types": ["node"] + }, + "include": ["./src"], + "exclude": ["**/__mocks__/*", "**/__tests__/*"] +} diff --git a/packages/cli/tsconfig.cjs.json b/packages/cli/tsconfig.cjs.json new file mode 100644 index 00000000..a6b3ca9c --- /dev/null +++ b/packages/cli/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "CommonJS", + "outDir": "./build/cjs", + "declaration": false, + "sourceMap": true + } +} diff --git a/packages/cli/tsconfig.esm.json b/packages/cli/tsconfig.esm.json new file mode 100644 index 00000000..2bb18d33 --- /dev/null +++ b/packages/cli/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2020", + "outDir": "./build/esm", + "declaration": false, + "sourceMap": true + } +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 00000000..09b0ecb8 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.esm.json" }, + { "path": "./tsconfig.cjs.json" }, + { "path": "./tsconfig.types.json" } + ] +} diff --git a/packages/cli/tsconfig.typecheck.json b/packages/cli/tsconfig.typecheck.json new file mode 100644 index 00000000..4979fccb --- /dev/null +++ b/packages/cli/tsconfig.typecheck.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "noEmit": true + } +} diff --git a/packages/cli/tsconfig.types.json b/packages/cli/tsconfig.types.json new file mode 100644 index 00000000..8ec821ee --- /dev/null +++ b/packages/cli/tsconfig.types.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2020", + "outDir": "./build/types", + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true + } +} From 456c1cd207fbd0e7ab560ff31016894183a3d983 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 27 May 2026 11:54:33 +0200 Subject: [PATCH 02/37] feat: define cli config types --- PLAN.md | 2 +- packages/cli/src/config/defaults.ts | 32 +++++ packages/cli/src/config/types.ts | 182 ++++++++++++++++++++++++++++ packages/cli/src/index.ts | 29 +++++ 4 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/config/defaults.ts create mode 100644 packages/cli/src/config/types.ts diff --git a/PLAN.md b/PLAN.md index db6c17de..ce63a372 100644 --- a/PLAN.md +++ b/PLAN.md @@ -352,7 +352,7 @@ Can run in parallel with: **T2. Define config and normalized internal types** Status: -- pending +- completed Deliverables: - define public config types diff --git a/packages/cli/src/config/defaults.ts b/packages/cli/src/config/defaults.ts new file mode 100644 index 00000000..daf9c2ca --- /dev/null +++ b/packages/cli/src/config/defaults.ts @@ -0,0 +1,32 @@ +import type { IOSWidgetFamily } from './types' + +export const DEFAULT_ANDROID_ENABLE_NOTIFICATIONS = false +export const DEFAULT_ANDROID_SERVER_UPDATE_INTERVAL_MINUTES = 60 +export const DEFAULT_ANDROID_SERVER_UPDATE_REFRESH = false +export const DEFAULT_ANDROID_USER_IMAGES_PATH = './assets/voltra-android' + +export const DEFAULT_IOS_ENABLE_PUSH_NOTIFICATIONS = false +export const DEFAULT_IOS_DEPLOYMENT_TARGET = '17.0' +export const DEFAULT_IOS_SERVER_UPDATE_INTERVAL_MINUTES = 15 +export const DEFAULT_IOS_SERVER_UPDATE_REFRESH = false +export const DEFAULT_IOS_WIDGET_FAMILIES: IOSWidgetFamily[] = ['systemSmall', 'systemMedium', 'systemLarge'] + +/** + * These defaults intentionally mirror the existing Expo plugin behavior where that behavior is already explicit. + * Some values, such as the default iOS widget target name, still depend on native project discovery and app naming. + */ +export const CLI_DEFAULTS = { + android: { + enableNotifications: DEFAULT_ANDROID_ENABLE_NOTIFICATIONS, + serverUpdateIntervalMinutes: DEFAULT_ANDROID_SERVER_UPDATE_INTERVAL_MINUTES, + serverUpdateRefresh: DEFAULT_ANDROID_SERVER_UPDATE_REFRESH, + userImagesPath: DEFAULT_ANDROID_USER_IMAGES_PATH, + }, + ios: { + deploymentTarget: DEFAULT_IOS_DEPLOYMENT_TARGET, + enablePushNotifications: DEFAULT_IOS_ENABLE_PUSH_NOTIFICATIONS, + serverUpdateIntervalMinutes: DEFAULT_IOS_SERVER_UPDATE_INTERVAL_MINUTES, + serverUpdateRefresh: DEFAULT_IOS_SERVER_UPDATE_REFRESH, + widgetFamilies: DEFAULT_IOS_WIDGET_FAMILIES, + }, +} as const diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts new file mode 100644 index 00000000..f89921ca --- /dev/null +++ b/packages/cli/src/config/types.ts @@ -0,0 +1,182 @@ +import type { CLI_DEFAULTS } from './defaults' + +export type VoltraPlatform = 'android' | 'ios' + +/** + * Per-locale widget copy or build-time initial state paths. + * This intentionally matches the existing Expo plugin contract. + */ +export type WidgetLocalizedValue = Record + +export type WidgetLabel = string | WidgetLocalizedValue + +export type WidgetInitialStatePath = string | WidgetLocalizedValue + +export interface AndroidWidgetServerUpdateConfig { + url: string + intervalMinutes?: number + refresh?: boolean +} + +export interface AndroidWidgetConfig { + id: string + displayName: WidgetLabel + description: WidgetLabel + minWidth?: number + minHeight?: number + minCellWidth?: number + minCellHeight?: number + targetCellWidth: number + targetCellHeight: number + resizeMode?: 'none' | 'horizontal' | 'vertical' | 'horizontal|vertical' + widgetCategory?: 'home_screen' | 'keyguard' | 'home_screen|keyguard' + initialStatePath?: WidgetInitialStatePath + serverUpdate?: AndroidWidgetServerUpdateConfig + previewImage?: string + previewLayout?: string +} + +export type IOSWidgetFamily = + | 'systemSmall' + | 'systemMedium' + | 'systemLarge' + | 'systemExtraLarge' + | 'accessoryCircular' + | 'accessoryRectangular' + | 'accessoryInline' + +export interface IOSWidgetServerUpdateConfig { + url: string + intervalMinutes?: number + refresh?: boolean +} + +export interface IOSWidgetConfig { + id: string + displayName: WidgetLabel + description: WidgetLabel + supportedFamilies?: IOSWidgetFamily[] + initialStatePath?: WidgetInitialStatePath + serverUpdate?: IOSWidgetServerUpdateConfig +} + +export interface AndroidProjectOverrides { + rootDir?: string + appModuleName?: string + manifestPath?: string + packageName?: string +} + +export interface IOSProjectOverrides { + rootDir?: string + xcodeprojPath?: string + mainTargetName?: string + infoPlistPath?: string + entitlementsPath?: string + podfilePath?: string +} + +/** + * Public CLI config for Android. This stays close to the current Expo plugin props, + * with explicit project discovery overrides added for native projects. + */ +export interface VoltraAndroidConfig { + enableNotifications?: boolean + widgets?: AndroidWidgetConfig[] + fonts?: string[] + userImagesPath?: string + project?: AndroidProjectOverrides +} + +/** + * Public CLI config for iOS. This stays close to the current Expo plugin props, + * with explicit project discovery overrides added for native projects. + */ +export interface VoltraIOSConfig { + enablePushNotifications?: boolean + groupIdentifier?: string + widgets?: IOSWidgetConfig[] + deploymentTarget?: string + targetName?: string + fonts?: string[] + keychainGroup?: string + project?: IOSProjectOverrides +} + +export interface VoltraConfig { + projectRoot?: string + android?: VoltraAndroidConfig + ios?: VoltraIOSConfig +} + +export interface LoadedVoltraConfig { + config: VoltraConfig + configPath?: string + configDir: string +} + +export interface NormalizedAndroidWidgetServerUpdateConfig { + url: string + intervalMinutes: number + refresh: boolean +} + +export interface NormalizedAndroidWidgetConfig extends Omit { + serverUpdate?: NormalizedAndroidWidgetServerUpdateConfig +} + +export interface NormalizedIOSWidgetServerUpdateConfig { + url: string + intervalMinutes: number + refresh: boolean +} + +export interface NormalizedIOSWidgetConfig extends Omit { + supportedFamilies: IOSWidgetFamily[] + serverUpdate?: NormalizedIOSWidgetServerUpdateConfig +} + +export interface NormalizedAndroidProjectConfig { + rootDir?: string + appModuleName?: string + manifestPath?: string + packageName?: string +} + +export interface NormalizedIOSProjectConfig { + rootDir?: string + xcodeprojPath?: string + mainTargetName?: string + infoPlistPath?: string + entitlementsPath?: string + podfilePath?: string +} + +export interface NormalizedVoltraAndroidConfig { + enableNotifications: boolean + widgets: NormalizedAndroidWidgetConfig[] + fonts: string[] + userImagesPath: string + project: NormalizedAndroidProjectConfig +} + +export interface NormalizedVoltraIOSConfig { + enablePushNotifications: boolean + groupIdentifier?: string + widgets: NormalizedIOSWidgetConfig[] + deploymentTarget: string + targetName?: string + fonts: string[] + keychainGroup?: string + project: NormalizedIOSProjectConfig +} + +export interface NormalizedVoltraConfig { + configPath?: string + configDir: string + projectRoot: string + android?: NormalizedVoltraAndroidConfig + ios?: NormalizedVoltraIOSConfig +} + +export type CliDefaults = typeof CLI_DEFAULTS diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index bd19579d..c9852440 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,3 +7,32 @@ export async function runCli(argv: string[]): Promise { process.stderr.write('voltra CLI is not implemented yet.\n') return 1 } + +export { CLI_DEFAULTS } from './config/defaults' +export type { + AndroidProjectOverrides, + AndroidWidgetConfig, + AndroidWidgetServerUpdateConfig, + CliDefaults, + IOSProjectOverrides, + IOSWidgetConfig, + IOSWidgetFamily, + IOSWidgetServerUpdateConfig, + LoadedVoltraConfig, + NormalizedAndroidProjectConfig, + NormalizedAndroidWidgetConfig, + NormalizedAndroidWidgetServerUpdateConfig, + NormalizedIOSProjectConfig, + NormalizedIOSWidgetConfig, + NormalizedIOSWidgetServerUpdateConfig, + NormalizedVoltraAndroidConfig, + NormalizedVoltraConfig, + NormalizedVoltraIOSConfig, + VoltraAndroidConfig, + VoltraConfig, + VoltraIOSConfig, + VoltraPlatform, + WidgetInitialStatePath, + WidgetLabel, + WidgetLocalizedValue, +} from './config/types' From ba1ddcbe722f62f60fad5b131acce4056e658ef5 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 27 May 2026 11:55:39 +0200 Subject: [PATCH 03/37] feat: add cli filesystem helpers --- PLAN.md | 2 +- packages/cli/src/fs/path.ts | 25 +++++++++ packages/cli/src/fs/readWrite.ts | 93 ++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/fs/path.ts create mode 100644 packages/cli/src/fs/readWrite.ts diff --git a/PLAN.md b/PLAN.md index ce63a372..22604314 100644 --- a/PLAN.md +++ b/PLAN.md @@ -373,7 +373,7 @@ Can run in parallel with: **T3. Add filesystem boundary module** Status: -- pending +- completed Deliverables: - add thin read/write helpers under `packages/cli/src/fs` diff --git a/packages/cli/src/fs/path.ts b/packages/cli/src/fs/path.ts new file mode 100644 index 00000000..11977d32 --- /dev/null +++ b/packages/cli/src/fs/path.ts @@ -0,0 +1,25 @@ +import path from 'node:path' + +export function normalizeRelativePath(filePath: string): string { + return filePath.replace(/\\/g, '/') +} + +export function resolveFromRoot(rootDir: string, filePath: string): string { + return path.resolve(rootDir, filePath) +} + +export function resolveOptionalFromRoot(rootDir: string, filePath: string | undefined): string | undefined { + if (!filePath) { + return undefined + } + + return resolveFromRoot(rootDir, filePath) +} + +export function toRelativePath(rootDir: string, targetPath: string): string { + return normalizeRelativePath(path.relative(rootDir, targetPath)) +} + +export function dirname(filePath: string): string { + return path.dirname(filePath) +} diff --git a/packages/cli/src/fs/readWrite.ts b/packages/cli/src/fs/readWrite.ts new file mode 100644 index 00000000..edd01218 --- /dev/null +++ b/packages/cli/src/fs/readWrite.ts @@ -0,0 +1,93 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import crypto from 'node:crypto' + +const UTF8 = 'utf8' + +export async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +export async function ensureDirectory(dirPath: string): Promise { + await fs.mkdir(dirPath, { recursive: true }) +} + +export async function readTextFile(filePath: string): Promise { + return fs.readFile(filePath, UTF8) +} + +export async function readJsonFile(filePath: string): Promise { + const content = await readTextFile(filePath) + return JSON.parse(content) as T +} + +export async function writeTextFile(filePath: string, content: string): Promise { + await ensureDirectory(path.dirname(filePath)) + await writeTextFileAtomic(filePath, content) +} + +export async function writeJsonFile(filePath: string, value: unknown): Promise { + await writeTextFile(filePath, `${JSON.stringify(value, null, 2)}\n`) +} + +export async function removeFileIfExists(filePath: string): Promise { + try { + await fs.unlink(filePath) + return true + } catch (error: unknown) { + if (isNotFoundError(error)) { + return false + } + + throw error + } +} + +export async function removeDirectoryIfExists(dirPath: string): Promise { + try { + await fs.rm(dirPath, { recursive: true, force: false }) + return true + } catch (error: unknown) { + if (isNotFoundError(error)) { + return false + } + + throw error + } +} + +export async function removePathIfExists(targetPath: string): Promise { + try { + const stat = await fs.lstat(targetPath) + + if (stat.isDirectory()) { + await fs.rm(targetPath, { recursive: true, force: false }) + } else { + await fs.unlink(targetPath) + } + + return true + } catch (error: unknown) { + if (isNotFoundError(error)) { + return false + } + + throw error + } +} + +async function writeTextFileAtomic(filePath: string, content: string): Promise { + const tempPath = `${filePath}.${crypto.randomUUID()}.tmp` + + await fs.writeFile(tempPath, content, UTF8) + await fs.rename(tempPath, filePath) +} + +function isNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} From f7bce90f73b4a0623bb57927f148b1675515adcc Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 27 May 2026 11:57:26 +0200 Subject: [PATCH 04/37] feat: add cli reporting primitives --- PLAN.md | 2 +- packages/cli/src/index.ts | 11 +++ packages/cli/src/reporting/summary.ts | 115 ++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/reporting/summary.ts diff --git a/PLAN.md b/PLAN.md index 22604314..d9e516cd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -394,7 +394,7 @@ Can run in parallel with: **T4. Add CLI reporting primitives** Status: -- pending +- completed Deliverables: - summary formatter for created/updated/deleted files diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c9852440..0c7f26f4 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -9,6 +9,16 @@ export async function runCli(argv: string[]): Promise { } export { CLI_DEFAULTS } from './config/defaults' +export { + formatAmbiguousDiscoveryWarning, + formatApplySummary, + formatDirtyWorktreeWarning, + formatError, + formatPreflightFailure, + formatWarning, + PreflightError, + VoltraCliError, +} from './reporting/summary' export type { AndroidProjectOverrides, AndroidWidgetConfig, @@ -36,3 +46,4 @@ export type { WidgetLabel, WidgetLocalizedValue, } from './config/types' +export type { ApplySummary, PreflightFailureReport, PreflightIssue, ReportedChange, ReportedChangeKind } from './reporting/summary' diff --git a/packages/cli/src/reporting/summary.ts b/packages/cli/src/reporting/summary.ts new file mode 100644 index 00000000..167f539a --- /dev/null +++ b/packages/cli/src/reporting/summary.ts @@ -0,0 +1,115 @@ +const PREFIX = '[voltra]' + +export type ReportedChangeKind = 'created' | 'updated' | 'deleted' + +export interface ReportedChange { + kind: ReportedChangeKind + path: string +} + +export interface ApplySummary { + changes: ReportedChange[] + warnings?: string[] +} + +export interface PreflightIssue { + message: string + path?: string +} + +export interface PreflightFailureReport { + summary?: string + issues: PreflightIssue[] +} + +export class VoltraCliError extends Error { + readonly code: string + + constructor(message: string, code = 'VOLTRA_CLI_ERROR') { + super(message) + this.name = 'VoltraCliError' + this.code = code + } +} + +export class PreflightError extends VoltraCliError { + readonly report: PreflightFailureReport + + constructor(report: PreflightFailureReport) { + super(formatPreflightFailure(report), 'VOLTRA_PREFLIGHT_FAILED') + this.name = 'PreflightError' + this.report = report + } +} + +export function formatApplySummary(summary: ApplySummary): string { + const lines = [ + `${PREFIX} Apply summary`, + `${PREFIX} Created: ${countChanges(summary.changes, 'created')}`, + `${PREFIX} Updated: ${countChanges(summary.changes, 'updated')}`, + `${PREFIX} Deleted: ${countChanges(summary.changes, 'deleted')}`, + ] + + for (const kind of ['created', 'updated', 'deleted'] as const) { + const paths = summary.changes.filter((change) => change.kind === kind).map((change) => change.path) + + for (const filePath of paths) { + lines.push(`${PREFIX} ${capitalize(kind)} ${filePath}`) + } + } + + for (const warning of summary.warnings ?? []) { + lines.push(formatWarning(warning)) + } + + return lines.join('\n') +} + +export function formatDirtyWorktreeWarning(details?: string): string { + if (!details) { + return `${PREFIX} Warning: git worktree has uncommitted changes.` + } + + return `${PREFIX} Warning: git worktree has uncommitted changes. ${details}` +} + +export function formatAmbiguousDiscoveryWarning(subject: string, candidates: string[]): string { + if (candidates.length === 0) { + return `${PREFIX} Warning: ${subject} is ambiguous.` + } + + return `${PREFIX} Warning: ${subject} is ambiguous. Candidates: ${candidates.join(', ')}` +} + +export function formatWarning(message: string): string { + return `${PREFIX} Warning: ${message}` +} + +export function formatError(message: string): string { + return `${PREFIX} Error: ${message}` +} + +export function formatPreflightFailure(report: PreflightFailureReport): string { + const lines = [ + report.summary ? formatError(report.summary) : formatError('Preflight failed.'), + ...report.issues.map((issue) => formatPreflightIssue(issue)), + ] + + return lines.join('\n') +} + +function formatPreflightIssue(issue: PreflightIssue): string { + if (!issue.path) { + return `${PREFIX} Preflight: ${issue.message}` + } + + return `${PREFIX} Preflight: ${issue.path}: ${issue.message}` +} + +function countChanges(changes: ReportedChange[], kind: ReportedChangeKind): number { + return changes.filter((change) => change.kind === kind).length +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} From fe3fbe50c676ab12110f7d33692be3e09c120384 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 27 May 2026 12:10:10 +0200 Subject: [PATCH 05/37] fix: harden cli scaffold helpers --- PLAN.md | 6 ++++++ packages/cli/src/fs/readWrite.ts | 17 +++++++++++++---- packages/cli/src/index.ts | 14 ++++++++++++-- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/PLAN.md b/PLAN.md index d9e516cd..738f111c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -396,6 +396,12 @@ Can run in parallel with: Status: - completed +Review follow-up: +- completed two review passes after T1-T4 +- fixed `pathExists` to only swallow missing-path errors instead of hiding all filesystem failures +- cleaned up atomic write temp files on write or rename failure +- improved scaffolded CLI help and default error output so the published binary shows real usage shape + Deliverables: - summary formatter for created/updated/deleted files - warning formatter for dirty git state and ambiguous discovery diff --git a/packages/cli/src/fs/readWrite.ts b/packages/cli/src/fs/readWrite.ts index edd01218..97baf8de 100644 --- a/packages/cli/src/fs/readWrite.ts +++ b/packages/cli/src/fs/readWrite.ts @@ -8,8 +8,12 @@ export async function pathExists(filePath: string): Promise { try { await fs.access(filePath) return true - } catch { - return false + } catch (error: unknown) { + if (isNotFoundError(error)) { + return false + } + + throw error } } @@ -84,8 +88,13 @@ export async function removePathIfExists(targetPath: string): Promise { async function writeTextFileAtomic(filePath: string, content: string): Promise { const tempPath = `${filePath}.${crypto.randomUUID()}.tmp` - await fs.writeFile(tempPath, content, UTF8) - await fs.rename(tempPath, filePath) + try { + await fs.writeFile(tempPath, content, UTF8) + await fs.rename(tempPath, filePath) + } catch (error: unknown) { + await removeFileIfExists(tempPath).catch(() => undefined) + throw error + } } function isNotFoundError(error: unknown): error is NodeJS.ErrnoException { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0c7f26f4..d812c689 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,10 +1,20 @@ +const HELP_TEXT = [ + 'voltra', + '', + 'Usage:', + ' voltra apply [--platform ios|android] [--config ]', + '', + 'Status:', + ' CLI scaffolding is ready. The apply command is not implemented yet.', +].join('\n') + export async function runCli(argv: string[]): Promise { if (argv.includes('--help') || argv.includes('-h')) { - process.stdout.write('voltra CLI scaffolding is ready.\n') + process.stdout.write(`${HELP_TEXT}\n`) return 0 } - process.stderr.write('voltra CLI is not implemented yet.\n') + process.stderr.write('voltra apply is not implemented yet. Run `voltra --help` for usage.\n') return 1 } From b7fd323ccd0e13ebf555b830ac3c72e3a728e2bd Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 27 May 2026 12:18:39 +0200 Subject: [PATCH 06/37] feat: add cli config loader --- PLAN.md | 3 + package-lock.json | 61 ++++++++++++++++---- packages/cli/package.json | 5 +- packages/cli/src/config/load.ts | 95 +++++++++++++++++++++++++++++++ packages/cli/src/cosmiconfig.d.ts | 21 +++++++ packages/cli/src/index.ts | 1 + 6 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/config/load.ts create mode 100644 packages/cli/src/cosmiconfig.d.ts diff --git a/PLAN.md b/PLAN.md index 738f111c..77e87073 100644 --- a/PLAN.md +++ b/PLAN.md @@ -421,6 +421,9 @@ Can run in parallel with: **T5. Implement config loading with `cosmiconfig`** +Status: +- completed + Deliverables: - support `package.json` `voltra` key - support `.voltrarc*` diff --git a/package-lock.json b/package-lock.json index d61ae071..9ef3af02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9514,7 +9514,6 @@ }, "node_modules/callsites": { "version": "3.1.0", - "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -10550,9 +10549,17 @@ "node": ">=8" } }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/error-ex": { "version": "1.3.2", - "devOptional": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -12581,9 +12588,7 @@ }, "node_modules/import-fresh": { "version": "3.3.1", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" @@ -12597,9 +12602,7 @@ }, "node_modules/import-fresh/node_modules/resolve-from": { "version": "4.0.0", - "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=4" } @@ -12713,7 +12716,6 @@ }, "node_modules/is-arrayish": { "version": "0.2.1", - "devOptional": true, "license": "MIT" }, "node_modules/is-async-function": { @@ -14318,7 +14320,6 @@ }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", - "devOptional": true, "license": "MIT" }, "node_modules/json-schema-traverse": { @@ -16010,9 +16011,7 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "callsites": "^3.0.0" }, @@ -16022,7 +16021,6 @@ }, "node_modules/parse-json": { "version": "5.2.0", - "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -20959,6 +20957,10 @@ "version": "1.0.1", "license": "MIT" }, + "node_modules/voltra": { + "resolved": "packages/cli", + "link": true + }, "node_modules/voltra-example": { "resolved": "example", "link": true @@ -21685,6 +21687,43 @@ "react": "*" } }, + "packages/cli": { + "name": "voltra", + "version": "1.4.1", + "license": "MIT", + "dependencies": { + "cosmiconfig": "^9.0.0" + }, + "bin": { + "voltra": "build/cjs/bin.js" + } + }, + "packages/cli/node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "packages/core": { "name": "@use-voltra/core", "version": "1.4.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 89ff994f..0bacfed0 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -44,5 +44,8 @@ "url": "https://github.com/callstackincubator/voltra/issues" }, "license": "MIT", - "homepage": "https://use-voltra.dev" + "homepage": "https://use-voltra.dev", + "dependencies": { + "cosmiconfig": "^9.0.0" + } } diff --git a/packages/cli/src/config/load.ts b/packages/cli/src/config/load.ts new file mode 100644 index 00000000..e32f495a --- /dev/null +++ b/packages/cli/src/config/load.ts @@ -0,0 +1,95 @@ +import { dirname, resolve } from 'node:path' + +import { cosmiconfig } from 'cosmiconfig' + +import type { CosmiconfigResult } from 'cosmiconfig' + +import type { LoadedVoltraConfig, VoltraConfig } from './types' + +const MODULE_NAME = 'voltra' + +export class VoltraConfigLoadError extends Error { + cause?: Error + + constructor(message: string, cause?: Error) { + super(message) + this.name = 'VoltraConfigLoadError' + this.cause = cause + } +} + +export interface LoadVoltraConfigOptions { + configPath?: string + cwd?: string +} + +function getExplorer() { + return cosmiconfig(MODULE_NAME, { + searchPlaces: [ + 'package.json', + '.voltrarc', + '.voltrarc.json', + '.voltrarc.yaml', + '.voltrarc.yml', + '.voltrarc.js', + '.voltrarc.cjs', + '.voltrarc.mjs', + '.voltrarc.ts', + 'voltra.config.json', + 'voltra.config.yaml', + 'voltra.config.yml', + 'voltra.config.js', + 'voltra.config.cjs', + 'voltra.config.mjs', + 'voltra.config.ts', + ], + }) +} + +function toLoadedConfig(result: CosmiconfigResult): LoadedVoltraConfig { + if (result.isEmpty) { + throw new VoltraConfigLoadError(`Voltra config file is empty: ${result.filepath}`) + } + + if (!result.config || typeof result.config !== 'object' || Array.isArray(result.config)) { + throw new VoltraConfigLoadError(`Voltra config must be an object: ${result.filepath}`) + } + + return { + config: result.config as VoltraConfig, + configPath: result.filepath, + configDir: dirname(result.filepath), + } +} + +export async function loadVoltraConfig(options: LoadVoltraConfigOptions = {}): Promise { + const explorer = getExplorer() + + try { + if (options.configPath) { + const configPath = resolve(options.cwd ?? process.cwd(), options.configPath) + const result = await explorer.load(configPath) + + if (!result) { + throw new VoltraConfigLoadError(`Voltra config not found at ${configPath}`) + } + + return toLoadedConfig(result) + } + + const result = await explorer.search(options.cwd) + + if (!result) { + throw new VoltraConfigLoadError('No Voltra config found. Checked package.json, .voltrarc*, and voltra.config.* files.') + } + + return toLoadedConfig(result) + } catch (error) { + if (error instanceof VoltraConfigLoadError) { + throw error + } + + const message = error instanceof Error ? error.message : String(error) + throw new VoltraConfigLoadError(`Failed to load Voltra config: ${message}`, error instanceof Error ? error : undefined) + } +} diff --git a/packages/cli/src/cosmiconfig.d.ts b/packages/cli/src/cosmiconfig.d.ts new file mode 100644 index 00000000..d39b2943 --- /dev/null +++ b/packages/cli/src/cosmiconfig.d.ts @@ -0,0 +1,21 @@ +declare module 'cosmiconfig' { + export interface CosmiconfigResult { + config: unknown + filepath: string + isEmpty?: boolean + } + + export interface CosmiconfigExplorer { + search(searchFrom?: string): Promise + load(filepath: string): Promise + } + + export interface CosmiconfigOptions { + searchPlaces?: string[] + loaders?: Record + } + + export const defaultLoaders: Record + + export function cosmiconfig(moduleName: string, options?: CosmiconfigOptions): CosmiconfigExplorer +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d812c689..67458328 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -19,6 +19,7 @@ export async function runCli(argv: string[]): Promise { } export { CLI_DEFAULTS } from './config/defaults' +export { VoltraConfigLoadError, loadVoltraConfig } from './config/load' export { formatAmbiguousDiscoveryWarning, formatApplySummary, From 6386d211690292429a8a46948a08500aa8464bad Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 27 May 2026 12:29:57 +0200 Subject: [PATCH 07/37] feat: normalize cli config --- PLAN.md | 3 + packages/cli/src/config/normalize.ts | 352 +++++++++++++++++++++++++++ packages/cli/src/index.ts | 1 + 3 files changed, 356 insertions(+) create mode 100644 packages/cli/src/config/normalize.ts diff --git a/PLAN.md b/PLAN.md index 77e87073..3a4cc1bf 100644 --- a/PLAN.md +++ b/PLAN.md @@ -445,6 +445,9 @@ Can run in parallel with: **T6. Implement config normalization** +Status: +- completed + Deliverables: - resolve defaults from loaded config - derive `projectRoot` diff --git a/packages/cli/src/config/normalize.ts b/packages/cli/src/config/normalize.ts new file mode 100644 index 00000000..f7067010 --- /dev/null +++ b/packages/cli/src/config/normalize.ts @@ -0,0 +1,352 @@ +import path from 'node:path' + +import { resolveFromRoot } from '../fs/path' +import { CLI_DEFAULTS } from './defaults' + +import type { + AndroidWidgetConfig, + IOSWidgetConfig, + LoadedVoltraConfig, + NormalizedAndroidWidgetConfig, + NormalizedVoltraAndroidConfig, + NormalizedVoltraConfig, + NormalizedVoltraIOSConfig, + NormalizedIOSWidgetConfig, + WidgetInitialStatePath, + WidgetLabel, + WidgetLocalizedValue, +} from './types' + +const VALID_IOS_WIDGET_FAMILIES = new Set([ + 'systemSmall', + 'systemMedium', + 'systemLarge', + 'systemExtraLarge', + 'accessoryCircular', + 'accessoryRectangular', + 'accessoryInline', +]) + +export class VoltraConfigNormalizationError extends Error { + constructor(message: string) { + super(message) + this.name = 'VoltraConfigNormalizationError' + } +} + +function assertObject(value: unknown, context: string): void { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new VoltraConfigNormalizationError(`${context} must be an object`) + } +} + +function assertRecord(value: unknown, context: string): asserts value is Record { + assertObject(value, context) +} + +function assertOptionalString(value: unknown, context: string): asserts value is string | undefined { + if (value !== undefined && typeof value !== 'string') { + throw new VoltraConfigNormalizationError(`${context} must be a string`) + } +} + +function assertOptionalBoolean(value: unknown, context: string): asserts value is boolean | undefined { + if (value !== undefined && typeof value !== 'boolean') { + throw new VoltraConfigNormalizationError(`${context} must be a boolean`) + } +} + +function assertNonEmptyString(value: unknown, context: string): asserts value is string { + if (typeof value !== 'string' || !value.trim()) { + throw new VoltraConfigNormalizationError(`${context} must be a non-empty string`) + } +} + +function assertOptionalStringArray(value: unknown, context: string): asserts value is string[] | undefined { + if (value === undefined) { + return + } + + if (!Array.isArray(value)) { + throw new VoltraConfigNormalizationError(`${context} must be an array of strings`) + } + + for (const entry of value) { + if (typeof entry !== 'string' || !entry.trim()) { + throw new VoltraConfigNormalizationError(`${context} must contain only non-empty strings`) + } + } +} + +function resolvePathFromProjectRoot(projectRoot: string, filePath: string): string { + return path.isAbsolute(filePath) ? filePath : resolveFromRoot(projectRoot, filePath) +} + +function resolveOptionalPathFromProjectRoot(projectRoot: string, filePath: string | undefined): string | undefined { + if (!filePath) { + return undefined + } + + return resolvePathFromProjectRoot(projectRoot, filePath) +} + +function normalizeLocalizedPathMap(projectRoot: string, value: WidgetLocalizedValue, context: string): WidgetLocalizedValue { + const entries = Object.entries(value) + + if (entries.length === 0) { + throw new VoltraConfigNormalizationError(`${context} must not be empty`) + } + + return Object.fromEntries( + entries.map(([locale, localePath]) => { + assertNonEmptyString(locale, `${context} locale key`) + assertNonEmptyString(localePath, `${context}.${locale}`) + return [locale, resolvePathFromProjectRoot(projectRoot, localePath)] + }) + ) +} + +function normalizeLabel(value: WidgetLabel, context: string): WidgetLabel { + if (typeof value === 'string') { + assertNonEmptyString(value, context) + return value + } + + assertRecord(value, context) + + const entries = Object.entries(value) + if (entries.length === 0) { + throw new VoltraConfigNormalizationError(`${context} must not be empty`) + } + + return Object.fromEntries( + entries.map(([locale, label]) => { + assertNonEmptyString(locale, `${context} locale key`) + if (typeof label !== 'string') { + throw new VoltraConfigNormalizationError(`${context}.${locale} must be a string`) + } + assertNonEmptyString(label, `${context}.${locale}`) + return [locale, label] + }) + ) +} + +function normalizeInitialStatePath( + projectRoot: string, + value: WidgetInitialStatePath | undefined, + context: string +): WidgetInitialStatePath | undefined { + if (value === undefined) { + return undefined + } + + if (typeof value === 'string') { + assertNonEmptyString(value, context) + return resolvePathFromProjectRoot(projectRoot, value) + } + + assertRecord(value, context) + return normalizeLocalizedPathMap(projectRoot, value, context) +} + +function normalizeServerUpdate( + serverUpdate: { url: string; intervalMinutes?: number; refresh?: boolean }, + context: string, + defaultIntervalMinutes: number, + defaultRefresh: boolean +): { url: string; intervalMinutes: number; refresh: boolean } { + assertObject(serverUpdate, context) + assertNonEmptyString(serverUpdate.url, `${context}.url`) + + if (serverUpdate.intervalMinutes !== undefined) { + if (typeof serverUpdate.intervalMinutes !== 'number' || !Number.isFinite(serverUpdate.intervalMinutes)) { + throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be a number`) + } + } + + if (serverUpdate.refresh !== undefined && typeof serverUpdate.refresh !== 'boolean') { + throw new VoltraConfigNormalizationError(`${context}.refresh must be a boolean`) + } + + return { + url: serverUpdate.url, + intervalMinutes: serverUpdate.intervalMinutes ?? defaultIntervalMinutes, + refresh: serverUpdate.refresh ?? defaultRefresh, + } +} + +function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig): NormalizedAndroidWidgetConfig { + assertObject(widget, 'android.widgets[]') + assertNonEmptyString(widget.id, 'android.widgets[].id') + + return { + ...widget, + displayName: normalizeLabel(widget.displayName, `android.widgets[${widget.id}].displayName`), + description: normalizeLabel(widget.description, `android.widgets[${widget.id}].description`), + initialStatePath: normalizeInitialStatePath(projectRoot, widget.initialStatePath, `android.widgets[${widget.id}].initialStatePath`), + previewImage: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewImage), + previewLayout: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewLayout), + serverUpdate: widget.serverUpdate + ? normalizeServerUpdate( + widget.serverUpdate, + `android.widgets[${widget.id}].serverUpdate`, + CLI_DEFAULTS.android.serverUpdateIntervalMinutes, + CLI_DEFAULTS.android.serverUpdateRefresh + ) + : undefined, + } +} + +function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): NormalizedIOSWidgetConfig { + assertObject(widget, 'ios.widgets[]') + assertNonEmptyString(widget.id, 'ios.widgets[].id') + + if (widget.supportedFamilies !== undefined) { + if (!Array.isArray(widget.supportedFamilies)) { + throw new VoltraConfigNormalizationError(`ios.widgets[${widget.id}].supportedFamilies must be an array`) + } + + for (const family of widget.supportedFamilies) { + if (!VALID_IOS_WIDGET_FAMILIES.has(family)) { + throw new VoltraConfigNormalizationError(`ios.widgets[${widget.id}].supportedFamilies contains invalid family '${family}'`) + } + } + } + + return { + ...widget, + displayName: normalizeLabel(widget.displayName, `ios.widgets[${widget.id}].displayName`), + description: normalizeLabel(widget.description, `ios.widgets[${widget.id}].description`), + supportedFamilies: widget.supportedFamilies ?? [...CLI_DEFAULTS.ios.widgetFamilies], + initialStatePath: normalizeInitialStatePath(projectRoot, widget.initialStatePath, `ios.widgets[${widget.id}].initialStatePath`), + serverUpdate: widget.serverUpdate + ? normalizeServerUpdate( + widget.serverUpdate, + `ios.widgets[${widget.id}].serverUpdate`, + CLI_DEFAULTS.ios.serverUpdateIntervalMinutes, + CLI_DEFAULTS.ios.serverUpdateRefresh + ) + : undefined, + } +} + +function assertUniqueWidgetIds(widgetIds: string[], context: string): void { + const seen = new Set() + + for (const widgetId of widgetIds) { + if (seen.has(widgetId)) { + throw new VoltraConfigNormalizationError(`Duplicate ${context} widget ID '${widgetId}'`) + } + + seen.add(widgetId) + } +} + +function normalizeAndroidConfig(projectRoot: string, config: LoadedVoltraConfig['config']['android']): NormalizedVoltraAndroidConfig | undefined { + if (config === undefined) { + return undefined + } + + assertObject(config, 'android') + assertOptionalBoolean(config.enableNotifications, 'android.enableNotifications') + assertOptionalStringArray(config.fonts, 'android.fonts') + assertOptionalString(config.userImagesPath, 'android.userImagesPath') + + if (config.project !== undefined) { + assertObject(config.project, 'android.project') + assertOptionalString(config.project.rootDir, 'android.project.rootDir') + assertOptionalString(config.project.appModuleName, 'android.project.appModuleName') + assertOptionalString(config.project.manifestPath, 'android.project.manifestPath') + assertOptionalString(config.project.packageName, 'android.project.packageName') + } + + if (config.widgets !== undefined && !Array.isArray(config.widgets)) { + throw new VoltraConfigNormalizationError('android.widgets must be an array') + } + + const widgets = (config.widgets ?? []).map((widget) => normalizeAndroidWidget(projectRoot, widget)) + assertUniqueWidgetIds( + widgets.map((widget) => widget.id), + 'android' + ) + + return { + enableNotifications: config.enableNotifications ?? CLI_DEFAULTS.android.enableNotifications, + widgets, + fonts: (config.fonts ?? []).map((fontPath) => resolvePathFromProjectRoot(projectRoot, fontPath)), + userImagesPath: resolvePathFromProjectRoot(projectRoot, config.userImagesPath ?? CLI_DEFAULTS.android.userImagesPath), + project: { + rootDir: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.rootDir), + appModuleName: config.project?.appModuleName, + manifestPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.manifestPath), + packageName: config.project?.packageName, + }, + } +} + +function normalizeIOSConfig(projectRoot: string, config: LoadedVoltraConfig['config']['ios']): NormalizedVoltraIOSConfig | undefined { + if (config === undefined) { + return undefined + } + + assertObject(config, 'ios') + assertOptionalBoolean(config.enablePushNotifications, 'ios.enablePushNotifications') + assertOptionalString(config.groupIdentifier, 'ios.groupIdentifier') + assertOptionalString(config.deploymentTarget, 'ios.deploymentTarget') + assertOptionalString(config.targetName, 'ios.targetName') + assertOptionalStringArray(config.fonts, 'ios.fonts') + assertOptionalString(config.keychainGroup, 'ios.keychainGroup') + + if (config.project !== undefined) { + assertObject(config.project, 'ios.project') + assertOptionalString(config.project.rootDir, 'ios.project.rootDir') + assertOptionalString(config.project.xcodeprojPath, 'ios.project.xcodeprojPath') + assertOptionalString(config.project.mainTargetName, 'ios.project.mainTargetName') + assertOptionalString(config.project.infoPlistPath, 'ios.project.infoPlistPath') + assertOptionalString(config.project.entitlementsPath, 'ios.project.entitlementsPath') + assertOptionalString(config.project.podfilePath, 'ios.project.podfilePath') + } + + if (config.widgets !== undefined && !Array.isArray(config.widgets)) { + throw new VoltraConfigNormalizationError('ios.widgets must be an array') + } + + const widgets = (config.widgets ?? []).map((widget) => normalizeIOSWidget(projectRoot, widget)) + assertUniqueWidgetIds( + widgets.map((widget) => widget.id), + 'ios' + ) + + return { + enablePushNotifications: config.enablePushNotifications ?? CLI_DEFAULTS.ios.enablePushNotifications, + groupIdentifier: config.groupIdentifier, + widgets, + deploymentTarget: config.deploymentTarget ?? CLI_DEFAULTS.ios.deploymentTarget, + targetName: config.targetName, + fonts: (config.fonts ?? []).map((fontPath) => resolvePathFromProjectRoot(projectRoot, fontPath)), + keychainGroup: config.keychainGroup, + project: { + rootDir: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.rootDir), + xcodeprojPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.xcodeprojPath), + mainTargetName: config.project?.mainTargetName, + infoPlistPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.infoPlistPath), + entitlementsPath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.entitlementsPath), + podfilePath: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.podfilePath), + }, + } +} + +export function normalizeVoltraConfig(loadedConfig: LoadedVoltraConfig): NormalizedVoltraConfig { + assertObject(loadedConfig.config, 'config') + assertOptionalString(loadedConfig.config.projectRoot, 'projectRoot') + + const projectRoot = resolvePathFromProjectRoot(loadedConfig.configDir, loadedConfig.config.projectRoot ?? loadedConfig.configDir) + + return { + configPath: loadedConfig.configPath, + configDir: loadedConfig.configDir, + projectRoot, + android: normalizeAndroidConfig(projectRoot, loadedConfig.config.android), + ios: normalizeIOSConfig(projectRoot, loadedConfig.config.ios), + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 67458328..436abc98 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,6 +20,7 @@ export async function runCli(argv: string[]): Promise { export { CLI_DEFAULTS } from './config/defaults' export { VoltraConfigLoadError, loadVoltraConfig } from './config/load' +export { VoltraConfigNormalizationError, normalizeVoltraConfig } from './config/normalize' export { formatAmbiguousDiscoveryWarning, formatApplySummary, From 48c44697916edfee972d423bf33d94c014176206 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 27 May 2026 12:38:13 +0200 Subject: [PATCH 08/37] feat: add apply command shell --- PLAN.md | 3 + packages/cli/src/apply/index.ts | 18 +++++ packages/cli/src/commands/apply.ts | 117 +++++++++++++++++++++++++++++ packages/cli/src/index.ts | 32 ++++++-- 4 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/apply/index.ts create mode 100644 packages/cli/src/commands/apply.ts diff --git a/PLAN.md b/PLAN.md index 3a4cc1bf..e7ee23dc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -466,6 +466,9 @@ Can run in parallel with: **T7. Implement `voltra apply` command shell** +Status: +- completed + Deliverables: - parse flags - route to `apply` diff --git a/packages/cli/src/apply/index.ts b/packages/cli/src/apply/index.ts new file mode 100644 index 00000000..9613b7d3 --- /dev/null +++ b/packages/cli/src/apply/index.ts @@ -0,0 +1,18 @@ +import type { VoltraPlatform } from '../config/types' + +export interface ApplyOptions { + configPath?: string + platform?: VoltraPlatform +} + +export interface ApplyResult { + exitCode: number + errorMessage?: string +} + +export async function applyVoltra(_options: ApplyOptions): Promise { + return { + exitCode: 1, + errorMessage: 'voltra apply is not implemented yet.', + } +} diff --git a/packages/cli/src/commands/apply.ts b/packages/cli/src/commands/apply.ts new file mode 100644 index 00000000..5209d4ed --- /dev/null +++ b/packages/cli/src/commands/apply.ts @@ -0,0 +1,117 @@ +import { applyVoltra } from '../apply' +import { formatError } from '../reporting/summary' + +import type { ApplyOptions } from '../apply' +import type { VoltraPlatform } from '../config/types' + +export const CLI_EXIT_CODE_SUCCESS = 0 +export const CLI_EXIT_CODE_FAILURE = 1 + +const APPLY_HELP_TEXT = [ + 'Usage:', + ' voltra apply [--platform ios|android] [--config ]', + '', + 'Options:', + ' --platform Limit apply to a single platform.', + ' --config Load config from an explicit file path.', + ' -h, --help Show this help text.', +].join('\n') + +export async function runApplyCommand(argv: string[]): Promise { + const parsed = parseApplyCommandArgs(argv) + + if ('exitCode' in parsed) { + return parsed.exitCode + } + + const result = await applyVoltra(parsed.options) + + if (result.exitCode !== CLI_EXIT_CODE_SUCCESS && result.errorMessage) { + process.stderr.write(`${formatError(result.errorMessage)}\n`) + } + + return result.exitCode +} + +interface ParsedApplyCommandArgs { + options: ApplyOptions +} + +interface ParsedApplyCommandEarlyExit { + exitCode: number +} + +function parseApplyCommandArgs(argv: string[]): ParsedApplyCommandArgs | ParsedApplyCommandEarlyExit { + const options: ApplyOptions = {} + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index] + + if (arg === '--help' || arg === '-h') { + process.stdout.write(`${APPLY_HELP_TEXT}\n`) + return { exitCode: CLI_EXIT_CODE_SUCCESS } + } + + if (arg === '--platform' || arg.startsWith('--platform=')) { + const value = readFlagValue(arg, argv[index + 1], '--platform') + if (arg === '--platform') { + index += 1 + } + + options.platform = parsePlatform(value) + continue + } + + if (arg === '--config' || arg.startsWith('--config=')) { + const value = readFlagValue(arg, argv[index + 1], '--config') + if (arg === '--config') { + index += 1 + } + + if (!value.trim()) { + throw new Error('--config must be a non-empty path') + } + + options.configPath = value + continue + } + + if (arg.startsWith('-')) { + throw new Error(`Unknown option: ${arg}`) + } + + throw new Error(`Unexpected argument: ${arg}`) + } + + return { options } +} + +function readFlagValue(arg: string, nextArg: string | undefined, flagName: string): string { + const equalsIndex = arg.indexOf('=') + if (equalsIndex >= 0) { + return arg.slice(equalsIndex + 1) + } + + if (nextArg === undefined) { + throw new Error(`Missing value for ${flagName}`) + } + + return nextArg +} + +function parsePlatform(value: string): VoltraPlatform { + if (value === 'android' || value === 'ios') { + return value + } + + throw new Error(`Invalid platform '${value}'. Expected 'ios' or 'android'.`) +} + +export function formatCommandError(error: unknown): string { + const message = error instanceof Error ? error.message : String(error) + return formatError(message) +} + +export function getApplyHelpText(): string { + return APPLY_HELP_TEXT +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 436abc98..a94da396 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,23 +1,41 @@ +import { CLI_EXIT_CODE_FAILURE, CLI_EXIT_CODE_SUCCESS, formatCommandError, runApplyCommand } from './commands/apply' + const HELP_TEXT = [ 'voltra', '', 'Usage:', ' voltra apply [--platform ios|android] [--config ]', - '', - 'Status:', - ' CLI scaffolding is ready. The apply command is not implemented yet.', + ' voltra --help', ].join('\n') export async function runCli(argv: string[]): Promise { - if (argv.includes('--help') || argv.includes('-h')) { + if (argv.length === 0) { + process.stdout.write(`${HELP_TEXT}\n`) + return CLI_EXIT_CODE_SUCCESS + } + + if (argv[0] === '--help' || argv[0] === '-h') { process.stdout.write(`${HELP_TEXT}\n`) - return 0 + return CLI_EXIT_CODE_SUCCESS } - process.stderr.write('voltra apply is not implemented yet. Run `voltra --help` for usage.\n') - return 1 + const [command, ...commandArgs] = argv + + try { + if (command === 'apply') { + return await runApplyCommand(commandArgs) + } + + throw new Error(`Unknown command: ${command}`) + } catch (error) { + process.stderr.write(`${formatCommandError(error)}\n`) + return CLI_EXIT_CODE_FAILURE + } } +export { applyVoltra } from './apply' +export { CLI_EXIT_CODE_FAILURE, CLI_EXIT_CODE_SUCCESS, getApplyHelpText, runApplyCommand } from './commands/apply' + export { CLI_DEFAULTS } from './config/defaults' export { VoltraConfigLoadError, loadVoltraConfig } from './config/load' export { VoltraConfigNormalizationError, normalizeVoltraConfig } from './config/normalize' From 73319f980345b6304a43053801527ee0160828fa Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 27 May 2026 13:02:54 +0200 Subject: [PATCH 09/37] feat: add cli git worktree checks --- packages/cli/src/git/status.ts | 196 +++++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + 2 files changed, 198 insertions(+) create mode 100644 packages/cli/src/git/status.ts diff --git a/packages/cli/src/git/status.ts b/packages/cli/src/git/status.ts new file mode 100644 index 00000000..1bb15c8c --- /dev/null +++ b/packages/cli/src/git/status.ts @@ -0,0 +1,196 @@ +import { execFile } from 'node:child_process' +import type { Readable, Writable } from 'node:stream' +import { createInterface } from 'node:readline/promises' +import { promisify } from 'node:util' + +import { formatDirtyWorktreeWarning, VoltraCliError } from '../reporting/summary' + +const execFileAsync = promisify(execFile) + +const GIT_NOT_REPOSITORY_EXIT_CODE = 128 +const DIRTY_ENTRY_PREVIEW_LIMIT = 5 + +export interface GitWorktreeStatus { + isGitRepository: boolean + isDirty: boolean + repoRoot?: string + entries: string[] +} + +export interface EnsureGitWorktreeOptions { + cwd: string + interactive?: boolean + allowDirty?: boolean + stdin?: Readable & { isTTY?: boolean } + stdout?: Writable & { isTTY?: boolean } +} + +export interface EnsureGitWorktreeResult { + status: GitWorktreeStatus + warning?: string +} + +interface ExecGitOptions { + cwd: string +} + +class GitCommandError extends VoltraCliError { + readonly args: string[] + readonly exitCode?: number + readonly stderr?: string + + constructor(args: string[], cause: unknown) { + const message = cause instanceof Error ? cause.message : String(cause) + super(`Failed to run git ${args.join(' ')}: ${message}`) + this.name = 'GitCommandError' + this.args = args + this.exitCode = getExecExitCode(cause) + this.stderr = getExecStderr(cause) + } +} + +export async function getGitWorktreeStatus(cwd: string): Promise { + const insideWorktree = await runGitCommand(['rev-parse', '--is-inside-work-tree'], { cwd }).catch((error: unknown) => { + if (isNotGitRepositoryError(error)) { + return undefined + } + + throw error + }) + + if (!insideWorktree || insideWorktree.trim() !== 'true') { + return { + isGitRepository: false, + isDirty: false, + entries: [], + } + } + + const [repoRootOutput, statusOutput] = await Promise.all([ + runGitCommand(['rev-parse', '--show-toplevel'], { cwd }), + runGitCommand(['status', '--short', '--untracked-files=normal'], { cwd }), + ]) + + const entries = splitGitStatusEntries(statusOutput) + + return { + isGitRepository: true, + isDirty: entries.length > 0, + repoRoot: repoRootOutput.trim(), + entries, + } +} + +export async function ensureGitWorktreeIsReady(options: EnsureGitWorktreeOptions): Promise { + const status = await getGitWorktreeStatus(options.cwd) + + if (!status.isGitRepository || !status.isDirty) { + return { status } + } + + const warning = formatDirtyWorktreeWarning(formatDirtyEntrySummary(status.entries)) + + if (options.allowDirty) { + return { status, warning } + } + + if (!isInteractiveSession(options)) { + throw new VoltraCliError(`${warning} Re-run interactively to confirm before applying changes.`) + } + + const confirmed = await promptForDirtyWorktreeConfirmation(options, warning) + + if (!confirmed) { + throw new VoltraCliError('Aborted because the git worktree has uncommitted changes.') + } + + return { status, warning } +} + +async function runGitCommand(args: string[], options: ExecGitOptions): Promise { + try { + const result = await execFileAsync('git', args, { + cwd: options.cwd, + encoding: 'utf8', + }) + + return result.stdout + } catch (error: unknown) { + throw new GitCommandError(args, error) + } +} + +function isNotGitRepositoryError(error: unknown): boolean { + if (!(error instanceof GitCommandError)) { + return false + } + + return ( + error.args.join(' ') === 'rev-parse --is-inside-work-tree' && + error.exitCode === GIT_NOT_REPOSITORY_EXIT_CODE && + typeof error.stderr === 'string' && + error.stderr.toLowerCase().includes('not a git repository') + ) +} + +function getExecExitCode(error: unknown): number | undefined { + if (!(error instanceof Error) || !('code' in error)) { + return undefined + } + + const code = error.code + + return typeof code === 'number' ? code : undefined +} + +function getExecStderr(error: unknown): string | undefined { + if (!(error instanceof Error) || !('stderr' in error)) { + return undefined + } + + const stderr = error.stderr + + return typeof stderr === 'string' ? stderr : undefined +} + +function splitGitStatusEntries(output: string): string[] { + return output + .split(/\r?\n/) + .map((entry) => entry.trimEnd()) + .filter((entry) => entry.length > 0) +} + +function formatDirtyEntrySummary(entries: string[]): string { + const preview = entries.slice(0, DIRTY_ENTRY_PREVIEW_LIMIT) + const remaining = entries.length - preview.length + const suffix = remaining > 0 ? ` and ${remaining} more` : '' + + return `Pending changes: ${preview.join(', ')}${suffix}` +} + +function isInteractiveSession(options: EnsureGitWorktreeOptions): boolean { + if (options.interactive !== undefined) { + return options.interactive + } + + const stdin = options.stdin ?? process.stdin + const stdout = options.stdout ?? process.stdout + + return Boolean(stdin.isTTY && stdout.isTTY) +} + +async function promptForDirtyWorktreeConfirmation(options: EnsureGitWorktreeOptions, warning: string): Promise { + const stdin = options.stdin ?? process.stdin + const stdout = options.stdout ?? process.stdout + const readline = createInterface({ input: stdin, output: stdout }) + + try { + stdout.write(`${warning}\n`) + const answer = await readline.question('[voltra] Continue anyway? [y/N] ') + const normalizedAnswer = answer.trim().toLowerCase() + + return normalizedAnswer === 'y' || normalizedAnswer === 'yes' + } finally { + readline.close() + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a94da396..1eea6fed 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -39,6 +39,8 @@ export { CLI_EXIT_CODE_FAILURE, CLI_EXIT_CODE_SUCCESS, getApplyHelpText, runAppl export { CLI_DEFAULTS } from './config/defaults' export { VoltraConfigLoadError, loadVoltraConfig } from './config/load' export { VoltraConfigNormalizationError, normalizeVoltraConfig } from './config/normalize' +export { ensureGitWorktreeIsReady, getGitWorktreeStatus } from './git/status' +export type { EnsureGitWorktreeOptions, EnsureGitWorktreeResult, GitWorktreeStatus } from './git/status' export { formatAmbiguousDiscoveryWarning, formatApplySummary, From 2f921537375b0385a8a639b86f4823481f3807a4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Wed, 27 May 2026 13:11:36 +0200 Subject: [PATCH 10/37] feat: add cli state tracking helpers --- packages/cli/src/index.ts | 6 ++++ packages/cli/src/state/diff.ts | 21 +++++++++++ packages/cli/src/state/files.ts | 42 ++++++++++++++++++++++ packages/cli/src/state/load.ts | 64 +++++++++++++++++++++++++++++++++ packages/cli/src/state/save.ts | 31 ++++++++++++++++ 5 files changed, 164 insertions(+) create mode 100644 packages/cli/src/state/diff.ts create mode 100644 packages/cli/src/state/files.ts create mode 100644 packages/cli/src/state/load.ts create mode 100644 packages/cli/src/state/save.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1eea6fed..8b9a93af 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -41,6 +41,12 @@ export { VoltraConfigLoadError, loadVoltraConfig } from './config/load' export { VoltraConfigNormalizationError, normalizeVoltraConfig } from './config/normalize' export { ensureGitWorktreeIsReady, getGitWorktreeStatus } from './git/status' export type { EnsureGitWorktreeOptions, EnsureGitWorktreeResult, GitWorktreeStatus } from './git/status' +export { diffVoltraState } from './state/diff' +export { getVoltraStatePath, loadVoltraState } from './state/load' +export { saveVoltraState } from './state/save' +export type { VoltraStateDiff } from './state/diff' +export type { SaveVoltraStateInput } from './state/save' +export type { VoltraState } from './state/load' export { formatAmbiguousDiscoveryWarning, formatApplySummary, diff --git a/packages/cli/src/state/diff.ts b/packages/cli/src/state/diff.ts new file mode 100644 index 00000000..95f5e3d5 --- /dev/null +++ b/packages/cli/src/state/diff.ts @@ -0,0 +1,21 @@ +import { normalizeTrackedStateFiles, normalizeTrackedStateFilesForDiff } from './files' + +import type { VoltraState } from './load' + +export interface VoltraStateDiff { + previousFiles: string[] + nextFiles: string[] + staleFiles: string[] +} + +export function diffVoltraState(previousState: VoltraState | undefined, nextFiles: string[]): VoltraStateDiff { + const previousFiles = normalizeTrackedStateFilesForDiff(previousState?.files) + const normalizedNextFiles = normalizeTrackedStateFiles(nextFiles, 'Next Voltra state files') + const nextFileSet = new Set(normalizedNextFiles) + + return { + previousFiles, + nextFiles: normalizedNextFiles, + staleFiles: previousFiles.filter((filePath) => !nextFileSet.has(filePath)), + } +} diff --git a/packages/cli/src/state/files.ts b/packages/cli/src/state/files.ts new file mode 100644 index 00000000..443f0bff --- /dev/null +++ b/packages/cli/src/state/files.ts @@ -0,0 +1,42 @@ +import path from 'node:path' + +import { normalizeRelativePath } from '../fs/path' +import { VoltraCliError } from '../reporting/summary' + +export function normalizeTrackedStateFiles(files: string[] | unknown[], errorContext: string): string[] { + if (!Array.isArray(files)) { + throw new VoltraCliError(`${errorContext} must be an array.`) + } + + const seen = new Set() + const normalizedFiles: string[] = [] + + for (const filePath of files) { + if (typeof filePath !== 'string' || !filePath.trim()) { + throw new VoltraCliError(`${errorContext} must contain only non-empty relative paths.`) + } + + const normalizedFilePath = normalizeRelativePath(filePath) + + if (path.isAbsolute(normalizedFilePath) || normalizedFilePath.startsWith('../') || normalizedFilePath === '..') { + throw new VoltraCliError(`${errorContext} must contain only project-relative paths.`) + } + + if (seen.has(normalizedFilePath)) { + continue + } + + seen.add(normalizedFilePath) + normalizedFiles.push(normalizedFilePath) + } + + return normalizedFiles.sort((left, right) => left.localeCompare(right)) +} + +export function normalizeTrackedStateFilesForDiff(files: string[] | undefined): string[] { + if (!files || files.length === 0) { + return [] + } + + return normalizeTrackedStateFiles(files, 'Voltra state files') +} diff --git a/packages/cli/src/state/load.ts b/packages/cli/src/state/load.ts new file mode 100644 index 00000000..d2ff85f6 --- /dev/null +++ b/packages/cli/src/state/load.ts @@ -0,0 +1,64 @@ +import path from 'node:path' + +import { pathExists, readJsonFile } from '../fs/readWrite' +import { VoltraCliError } from '../reporting/summary' + +import { normalizeTrackedStateFiles } from './files' + +const STATE_SCHEMA_VERSION = 1 +const STATE_DIRECTORY_NAME = '.voltra' +const STATE_FILE_NAME = 'state.json' + +export interface VoltraState { + schemaVersion: 1 + files: string[] +} + +interface RawVoltraState { + schemaVersion?: unknown + files?: unknown +} + +export async function loadVoltraState(projectRoot: string): Promise { + const statePath = getVoltraStatePath(projectRoot) + + if (!(await pathExists(statePath))) { + return undefined + } + + let rawState: RawVoltraState + + try { + rawState = await readJsonFile(statePath) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new VoltraCliError(`Failed to read Voltra state at ${statePath}: ${message}`) + } + + return validateVoltraState(rawState, statePath) +} + +export function getVoltraStatePath(projectRoot: string): string { + return path.join(projectRoot, STATE_DIRECTORY_NAME, STATE_FILE_NAME) +} + +function validateVoltraState(rawState: RawVoltraState, statePath: string): VoltraState { + if (!rawState || typeof rawState !== 'object' || Array.isArray(rawState)) { + throw new VoltraCliError(`Voltra state at ${statePath} must be a JSON object.`) + } + + if (rawState.schemaVersion !== STATE_SCHEMA_VERSION) { + throw new VoltraCliError( + `Unsupported Voltra state schema at ${statePath}: expected ${STATE_SCHEMA_VERSION}, received ${String(rawState.schemaVersion)}.` + ) + } + + if (!Array.isArray(rawState.files)) { + throw new VoltraCliError(`Voltra state at ${statePath} must contain a files array.`) + } + + return { + schemaVersion: STATE_SCHEMA_VERSION, + files: normalizeTrackedStateFiles(rawState.files, `Voltra state at ${statePath} files`), + } +} diff --git a/packages/cli/src/state/save.ts b/packages/cli/src/state/save.ts new file mode 100644 index 00000000..dd956103 --- /dev/null +++ b/packages/cli/src/state/save.ts @@ -0,0 +1,31 @@ +import { writeJsonFile } from '../fs/readWrite' +import { VoltraCliError } from '../reporting/summary' + +import { normalizeTrackedStateFiles } from './files' +import { getVoltraStatePath } from './load' + +import type { VoltraState } from './load' + +const STATE_SCHEMA_VERSION = 1 + +export interface SaveVoltraStateInput { + files: string[] +} + +export async function saveVoltraState(projectRoot: string, input: SaveVoltraStateInput): Promise { + const state: VoltraState = { + schemaVersion: STATE_SCHEMA_VERSION, + files: normalizeTrackedStateFiles(input.files, 'Voltra state files'), + } + + const statePath = getVoltraStatePath(projectRoot) + + try { + await writeJsonFile(statePath, state) + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + throw new VoltraCliError(`Failed to write Voltra state at ${statePath}: ${message}`) + } + + return state +} From 077ac09ea1c874ec57d7a80771277d69bcc47c51 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 07:44:00 +0200 Subject: [PATCH 11/37] feat: add apply preflight orchestration --- packages/cli/src/apply/preflight.ts | 172 ++++++++++++++++++++++++++++ packages/cli/src/index.ts | 10 ++ 2 files changed, 182 insertions(+) create mode 100644 packages/cli/src/apply/preflight.ts diff --git a/packages/cli/src/apply/preflight.ts b/packages/cli/src/apply/preflight.ts new file mode 100644 index 00000000..a2ef6f71 --- /dev/null +++ b/packages/cli/src/apply/preflight.ts @@ -0,0 +1,172 @@ +import { PreflightError } from '../reporting/summary' + +import type { NormalizedVoltraConfig, VoltraPlatform } from '../config/types' +import type { PreflightFailureReport, PreflightIssue } from '../reporting/summary' + +export interface PlatformPreflightSuccess { + platform: VoltraPlatform + context: TContext +} + +export interface PlatformPreflightFailure { + platform: VoltraPlatform + issues: PreflightIssue[] +} + +export type PlatformPreflightResult = PlatformPreflightSuccess | PlatformPreflightFailure + +export interface ApplyPreflightContext { + requestedPlatforms: VoltraPlatform[] +} + +export type PlatformPreflightRunner = ( + context: ApplyPreflightContext +) => Promise> + +export interface ApplyPreflightRunners { + android?: PlatformPreflightRunner + ios?: PlatformPreflightRunner +} + +export interface ApplyPreflightResult { + requestedPlatforms: VoltraPlatform[] + platformResults: Partial> +} + +export function getRequestedPlatforms(config: NormalizedVoltraConfig, platform?: VoltraPlatform): VoltraPlatform[] { + if (platform) { + validateConfiguredPlatform(config, platform) + return [platform] + } + + const configuredPlatforms = getConfiguredPlatforms(config) + + if (configuredPlatforms.length === 0) { + throw new PreflightError({ + summary: 'No platforms are configured for Voltra apply.', + issues: [{ message: 'Add an android or ios config block before running apply.' }], + }) + } + + return configuredPlatforms +} + +export async function runApplyPreflight( + config: NormalizedVoltraConfig, + runners: ApplyPreflightRunners, + platform?: VoltraPlatform +): Promise { + const requestedPlatforms = getRequestedPlatforms(config, platform) + const preflightContext: ApplyPreflightContext = { requestedPlatforms } + const failures: PlatformPreflightFailure[] = [] + const platformResults: Partial> = {} + + await Promise.all( + requestedPlatforms.map(async (requestedPlatform) => { + const runner = runners[requestedPlatform] + + if (!runner) { + failures.push({ + platform: requestedPlatform, + issues: [{ message: `No preflight runner is registered for ${requestedPlatform}.` }], + }) + return + } + + let result: PlatformPreflightResult + + try { + result = await runner(preflightContext) + } catch (error: unknown) { + failures.push({ + platform: requestedPlatform, + issues: [{ message: getPreflightRunnerErrorMessage(error) }], + }) + return + } + + if ('issues' in result) { + failures.push(result) + return + } + + if (result.platform !== requestedPlatform) { + failures.push({ + platform: requestedPlatform, + issues: [{ message: `Preflight runner returned a mismatched platform result: ${result.platform}.` }], + }) + return + } + + platformResults[requestedPlatform] = result.context + }) + ) + + if (failures.length > 0) { + throw new PreflightError(buildPreflightFailureReport(failures)) + } + + return { + requestedPlatforms, + platformResults, + } +} + +function getConfiguredPlatforms(config: NormalizedVoltraConfig): VoltraPlatform[] { + const platforms: VoltraPlatform[] = [] + + if (config.android) { + platforms.push('android') + } + + if (config.ios) { + platforms.push('ios') + } + + return platforms +} + +function validateConfiguredPlatform(config: NormalizedVoltraConfig, platform: VoltraPlatform): void { + if ((platform === 'android' && config.android) || (platform === 'ios' && config.ios)) { + return + } + + throw new PreflightError({ + summary: `Requested platform '${platform}' is not configured.`, + issues: [{ message: `Add a ${platform} config block or remove --platform ${platform}.` }], + }) +} + +function buildPreflightFailureReport(failures: PlatformPreflightFailure[]): PreflightFailureReport { + const orderedFailures = [...failures].sort((left, right) => comparePlatforms(left.platform, right.platform)) + + return { + summary: 'Preflight failed before any files were written.', + issues: orderedFailures.flatMap((failure) => + failure.issues.map((issue) => ({ + ...issue, + message: `${failure.platform}: ${issue.message}`, + })) + ), + } +} + +function getPreflightRunnerErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message + } + + return String(error) +} + +function comparePlatforms(left: VoltraPlatform, right: VoltraPlatform): number { + return getPlatformSortOrder(left) - getPlatformSortOrder(right) +} + +function getPlatformSortOrder(platform: VoltraPlatform): number { + if (platform === 'android') { + return 0 + } + + return 1 +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8b9a93af..acc9e0ed 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -34,6 +34,16 @@ export async function runCli(argv: string[]): Promise { } export { applyVoltra } from './apply' +export { getRequestedPlatforms, runApplyPreflight } from './apply/preflight' +export type { + ApplyPreflightContext, + ApplyPreflightResult, + ApplyPreflightRunners, + PlatformPreflightFailure, + PlatformPreflightResult, + PlatformPreflightRunner, + PlatformPreflightSuccess, +} from './apply/preflight' export { CLI_EXIT_CODE_FAILURE, CLI_EXIT_CODE_SUCCESS, getApplyHelpText, runApplyCommand } from './commands/apply' export { CLI_DEFAULTS } from './config/defaults' From 85f64f826da7689bc1d9186cfd6b96eb9f923035 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 07:50:15 +0200 Subject: [PATCH 12/37] feat: add apply pipeline orchestration --- PLAN.md | 6 ++ packages/cli/src/apply/index.ts | 164 ++++++++++++++++++++++++++++- packages/cli/src/commands/apply.ts | 8 +- packages/cli/src/index.ts | 3 +- 4 files changed, 173 insertions(+), 8 deletions(-) diff --git a/PLAN.md b/PLAN.md index e7ee23dc..2f82faf7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -511,6 +511,9 @@ Can run in parallel with: **T9. Implement Voltra state load/save/diff** +Status: +- completed + Deliverables: - load `.voltra/state.json` if present - validate minimal schema @@ -531,6 +534,9 @@ Can run in parallel with: **T10. Implement apply preflight orchestration** +Status: +- completed + Deliverables: - gather requested platforms - run discovery for all requested platforms before writes diff --git a/packages/cli/src/apply/index.ts b/packages/cli/src/apply/index.ts index 9613b7d3..c45d88d4 100644 --- a/packages/cli/src/apply/index.ts +++ b/packages/cli/src/apply/index.ts @@ -1,4 +1,20 @@ -import type { VoltraPlatform } from '../config/types' +import path from 'node:path' + +import { loadVoltraConfig } from '../config/load' +import { normalizeVoltraConfig } from '../config/normalize' +import { removePathIfExists } from '../fs/readWrite' +import { ensureGitWorktreeIsReady } from '../git/status' +import { formatApplySummary, VoltraCliError } from '../reporting/summary' +import { diffVoltraState } from '../state/diff' +import { loadVoltraState } from '../state/load' +import { saveVoltraState } from '../state/save' + +import { runApplyPreflight } from './preflight' + +import type { NormalizedVoltraConfig, VoltraPlatform } from '../config/types' +import type { ApplyPreflightResult, ApplyPreflightRunners } from './preflight' +import type { ReportedChange } from '../reporting/summary' +import type { VoltraState } from '../state/load' export interface ApplyOptions { configPath?: string @@ -10,9 +26,147 @@ export interface ApplyResult { errorMessage?: string } -export async function applyVoltra(_options: ApplyOptions): Promise { - return { - exitCode: 1, - errorMessage: 'voltra apply is not implemented yet.', +export interface PlatformApplyContext { + config: NormalizedVoltraConfig + platform: VoltraPlatform + preflight: unknown + previousState?: VoltraState + requestedPlatforms: VoltraPlatform[] +} + +export interface PlatformApplyResult { + platform: VoltraPlatform + changes: ReportedChange[] + generatedFiles: string[] + warnings?: string[] +} + +export type PlatformApplyRunner = (context: PlatformApplyContext) => Promise + +export interface ApplyDependencies { + applyRunners: Partial> + preflightRunners: ApplyPreflightRunners + writeStdout(message: string): void +} + +const DEFAULT_DEPENDENCIES: ApplyDependencies = { + applyRunners: {}, + preflightRunners: {}, + writeStdout(message: string) { + process.stdout.write(message) + }, +} + +export async function applyVoltra(options: ApplyOptions): Promise { + try { + await runApplyPipeline(options, DEFAULT_DEPENDENCIES) + + return { + exitCode: 0, + } + } catch (error: unknown) { + return { + exitCode: 1, + errorMessage: error instanceof Error ? error.message : String(error), + } } } + +export async function runApplyPipeline(options: ApplyOptions, dependencies: ApplyDependencies): Promise { + const loadedConfig = await loadVoltraConfig({ configPath: options.configPath }) + const normalizedConfig = normalizeVoltraConfig(loadedConfig) + const gitStatus = await ensureGitWorktreeIsReady({ cwd: normalizedConfig.projectRoot }) + const preflight = await runApplyPreflight(normalizedConfig, dependencies.preflightRunners, options.platform) + const previousState = await loadVoltraState(normalizedConfig.projectRoot) + const platformResults = await runPlatformApply(normalizedConfig, preflight, previousState, dependencies.applyRunners) + const nextGeneratedFiles = platformResults.flatMap((result) => result.generatedFiles) + const stateDiff = diffVoltraState(previousState, nextGeneratedFiles) + const deletedChanges = await removeStaleGeneratedFiles(normalizedConfig.projectRoot, stateDiff.staleFiles) + await saveVoltraState(normalizedConfig.projectRoot, { files: stateDiff.nextFiles }) + + const summaryWarnings = [gitStatus.warning, ...platformResults.flatMap((result) => result.warnings ?? [])].filter(isDefined) + const summaryChanges = [...platformResults.flatMap((result) => result.changes), ...deletedChanges] + + dependencies.writeStdout(`${formatApplySummary({ changes: summaryChanges, warnings: summaryWarnings })}\n`) +} + +async function runPlatformApply( + config: NormalizedVoltraConfig, + preflight: ApplyPreflightResult, + previousState: VoltraState | undefined, + applyRunners: Partial> +): Promise { + const missingRunners = preflight.requestedPlatforms.filter((platform) => !applyRunners[platform]) + + if (missingRunners.length > 0) { + throw new VoltraCliError(`No apply runner is registered for ${missingRunners.join(', ')}.`) + } + + const results: PlatformApplyResult[] = [] + + for (const platform of preflight.requestedPlatforms) { + const applyRunner = applyRunners[platform] + + if (!applyRunner) { + throw new VoltraCliError(`No apply runner is registered for ${platform}.`) + } + + let result: PlatformApplyResult + + try { + result = await applyRunner({ + config, + platform, + preflight: preflight.platformResults[platform], + previousState, + requestedPlatforms: preflight.requestedPlatforms, + }) + } catch (error: unknown) { + throw new VoltraCliError(`Apply failed for ${platform}: ${getApplyRunnerErrorMessage(error)}`) + } + + if (result.platform !== platform) { + throw new VoltraCliError(`Apply runner returned a mismatched platform result: expected ${platform}, received ${result.platform}.`) + } + + results.push(result) + } + + return results +} + +async function removeStaleGeneratedFiles(projectRoot: string, staleFiles: string[]): Promise { + const deletedChanges: ReportedChange[] = [] + + for (const staleFile of staleFiles) { + const staleFilePath = path.join(projectRoot, staleFile) + let deleted: boolean + + try { + deleted = await removePathIfExists(staleFilePath) + } catch (error: unknown) { + throw new VoltraCliError(`Failed to remove stale generated file ${staleFile}: ${getApplyRunnerErrorMessage(error)}`) + } + + if (deleted) { + deletedChanges.push({ + kind: 'deleted', + path: staleFile, + }) + } + } + + return deletedChanges +} + +function isDefined(value: TValue | undefined): value is TValue { + return value !== undefined +} + +function getApplyRunnerErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message + } + + return String(error) +} diff --git a/packages/cli/src/commands/apply.ts b/packages/cli/src/commands/apply.ts index 5209d4ed..10e0cdfa 100644 --- a/packages/cli/src/commands/apply.ts +++ b/packages/cli/src/commands/apply.ts @@ -27,7 +27,7 @@ export async function runApplyCommand(argv: string[]): Promise { const result = await applyVoltra(parsed.options) if (result.exitCode !== CLI_EXIT_CODE_SUCCESS && result.errorMessage) { - process.stderr.write(`${formatError(result.errorMessage)}\n`) + process.stderr.write(`${formatCliMessage(result.errorMessage)}\n`) } return result.exitCode @@ -109,9 +109,13 @@ function parsePlatform(value: string): VoltraPlatform { export function formatCommandError(error: unknown): string { const message = error instanceof Error ? error.message : String(error) - return formatError(message) + return formatCliMessage(message) } export function getApplyHelpText(): string { return APPLY_HELP_TEXT } + +function formatCliMessage(message: string): string { + return message.startsWith('[voltra] ') ? message : formatError(message) +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index acc9e0ed..b06caddb 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -33,7 +33,8 @@ export async function runCli(argv: string[]): Promise { } } -export { applyVoltra } from './apply' +export { applyVoltra, runApplyPipeline } from './apply' +export type { ApplyDependencies, ApplyOptions, ApplyResult, PlatformApplyContext, PlatformApplyResult, PlatformApplyRunner } from './apply' export { getRequestedPlatforms, runApplyPreflight } from './apply/preflight' export type { ApplyPreflightContext, From f5473dfd95c66ecc1d9ccceac74811a13037e371 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 07:55:51 +0200 Subject: [PATCH 13/37] feat: add android project discovery --- PLAN.md | 3 + packages/cli/src/discovery/android.ts | 222 ++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + 3 files changed, 227 insertions(+) create mode 100644 packages/cli/src/discovery/android.ts diff --git a/PLAN.md b/PLAN.md index 2f82faf7..f87f61b9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -579,6 +579,9 @@ Can run in parallel with: **T12. Implement Android project discovery** +Status: +- completed + Deliverables: - discover Android root - discover app module diff --git a/packages/cli/src/discovery/android.ts b/packages/cli/src/discovery/android.ts new file mode 100644 index 00000000..b2a1bc5d --- /dev/null +++ b/packages/cli/src/discovery/android.ts @@ -0,0 +1,222 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import type { Stats } from 'node:fs' + +import { VoltraCliError } from '../reporting/summary' + +import type { NormalizedAndroidProjectConfig } from '../config/types' + +const ANDROID_MANIFEST_FILE_NAME = 'AndroidManifest.xml' + +export interface AndroidProjectDiscovery { + androidRoot: string + appModuleName: string + appModuleRoot: string + manifestPath: string + buildGradlePath: string + packageName: string +} + +export class AndroidProjectDiscoveryError extends VoltraCliError { + constructor(message: string) { + super(message, 'VOLTRA_ANDROID_DISCOVERY_FAILED') + this.name = 'AndroidProjectDiscoveryError' + } +} + +export async function discoverAndroidProject( + projectRoot: string, + config: NormalizedAndroidProjectConfig +): Promise { + const androidRoot = await resolveAndroidRoot(projectRoot, config) + const manifestPath = await resolveManifestPath(androidRoot, config) + const appModuleName = resolveAppModuleName(androidRoot, manifestPath, config.appModuleName) + const appModuleRoot = path.join(androidRoot, appModuleName) + + await ensureDirectory(appModuleRoot, `Android app module directory does not exist: ${appModuleRoot}`) + ensureManifestBelongsToAppModule(appModuleRoot, manifestPath) + + const buildGradlePath = await resolveBuildGradlePath(appModuleRoot) + const packageName = config.packageName ?? (await resolvePackageName(buildGradlePath, manifestPath)) + + return { + androidRoot, + appModuleName, + appModuleRoot, + manifestPath, + buildGradlePath, + packageName, + } +} + +async function resolveAndroidRoot(projectRoot: string, config: NormalizedAndroidProjectConfig): Promise { + const androidRoot = config.rootDir ?? path.join(projectRoot, 'android') + + await ensureDirectory( + androidRoot, + config.rootDir + ? `Configured Android root directory does not exist: ${androidRoot}` + : `Android root directory does not exist at ${androidRoot}. Set android.project.rootDir to override the default android/ layout.` + ) + + return androidRoot +} + +async function resolveManifestPath(androidRoot: string, config: NormalizedAndroidProjectConfig): Promise { + const manifestPath = config.manifestPath ?? path.join(androidRoot, config.appModuleName ?? 'app', 'src', 'main', ANDROID_MANIFEST_FILE_NAME) + + await ensureFile( + manifestPath, + config.manifestPath + ? `Configured Android manifest does not exist: ${manifestPath}` + : `Android manifest does not exist at ${manifestPath}. Set android.project.appModuleName or android.project.manifestPath to override the default app/src/main/AndroidManifest.xml layout.` + ) + + return manifestPath +} + +function resolveAppModuleName(androidRoot: string, manifestPath: string, configuredAppModuleName: string | undefined): string { + if (configuredAppModuleName) { + return configuredAppModuleName + } + + const relativeManifestPath = path.relative(androidRoot, manifestPath) + + if (relativeManifestPath.startsWith('..') || path.isAbsolute(relativeManifestPath)) { + throw new AndroidProjectDiscoveryError( + `Android manifest ${manifestPath} is outside Android root ${androidRoot}. Set android.project.appModuleName to identify the app module explicitly.` + ) + } + + const segments = relativeManifestPath.split(path.sep) + + if (segments.length >= 4 && segments[1] === 'src' && segments[2] === 'main' && segments[3] === ANDROID_MANIFEST_FILE_NAME) { + return segments[0] + } + + throw new AndroidProjectDiscoveryError( + `Could not derive Android app module from manifest path ${manifestPath}. Set android.project.appModuleName explicitly.` + ) +} + +async function resolveBuildGradlePath(appModuleRoot: string): Promise { + const buildGradlePath = path.join(appModuleRoot, 'build.gradle') + const buildGradleKtsPath = path.join(appModuleRoot, 'build.gradle.kts') + const hasBuildGradle = await pathExists(buildGradlePath) + const hasBuildGradleKts = await pathExists(buildGradleKtsPath) + + if (hasBuildGradle && hasBuildGradleKts) { + throw new AndroidProjectDiscoveryError( + `Android app module has both build.gradle and build.gradle.kts: ${appModuleRoot}. Remove the ambiguity before running voltra apply.` + ) + } + + if (hasBuildGradle) { + return buildGradlePath + } + + if (hasBuildGradleKts) { + return buildGradleKtsPath + } + + throw new AndroidProjectDiscoveryError( + `Android app module build file does not exist in ${appModuleRoot}. Expected build.gradle or build.gradle.kts.` + ) +} + +function ensureManifestBelongsToAppModule(appModuleRoot: string, manifestPath: string): void { + const relativeManifestPath = path.relative(appModuleRoot, manifestPath) + + if (relativeManifestPath.startsWith('..') || path.isAbsolute(relativeManifestPath)) { + throw new AndroidProjectDiscoveryError( + `Android manifest ${manifestPath} is outside app module ${appModuleRoot}. Align android.project.appModuleName and android.project.manifestPath so they point at the same module.` + ) + } +} + +async function resolvePackageName(buildGradlePath: string, manifestPath: string): Promise { + const buildGradle = stripGradleComments(await fs.readFile(buildGradlePath, 'utf8')) + const namespace = matchGradleStringLiteral(buildGradle, 'namespace') + + if (namespace) { + return namespace + } + + const applicationId = matchGradleStringLiteral(buildGradle, 'applicationId') + + if (applicationId) { + return applicationId + } + + const manifest = await fs.readFile(manifestPath, 'utf8') + const manifestPackage = matchManifestPackage(manifest) + + if (manifestPackage) { + return manifestPackage + } + + throw new AndroidProjectDiscoveryError( + `Could not determine Android package name from ${buildGradlePath} or ${manifestPath}. Set android.project.packageName explicitly or add namespace/applicationId to the app module build file.` + ) +} + +function matchGradleStringLiteral(content: string, propertyName: string): string | undefined { + const match = content.match(new RegExp(`\\b${propertyName}\\s*(?:=)?\\s*['"]([^'"]+)['"]`)) + + return match?.[1] +} + +function stripGradleComments(content: string): string { + return content.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '$1') +} + +function matchManifestPackage(content: string): string | undefined { + const match = content.match(/]*\bpackage\s*=\s*['"]([^'"]+)['"]/) + + return match?.[1] +} + +async function ensureDirectory(dirPath: string, message: string): Promise { + const stat = await readPathStat(dirPath) + + if (!stat) { + throw new AndroidProjectDiscoveryError(message) + } + + if (!stat.isDirectory()) { + throw new AndroidProjectDiscoveryError(`Expected a directory but found a file: ${dirPath}`) + } +} + +async function ensureFile(filePath: string, message: string): Promise { + const stat = await readPathStat(filePath) + + if (!stat) { + throw new AndroidProjectDiscoveryError(message) + } + + if (!stat.isFile()) { + throw new AndroidProjectDiscoveryError(`Expected a file but found a directory: ${filePath}`) + } +} + +async function pathExists(targetPath: string): Promise { + return (await readPathStat(targetPath)) !== undefined +} + +async function readPathStat(targetPath: string): Promise { + try { + return await fs.stat(targetPath) + } catch (error: unknown) { + if (isNotFoundError(error)) { + return undefined + } + + throw error + } +} + +function isNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b06caddb..cf395af8 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -50,6 +50,8 @@ export { CLI_EXIT_CODE_FAILURE, CLI_EXIT_CODE_SUCCESS, getApplyHelpText, runAppl export { CLI_DEFAULTS } from './config/defaults' export { VoltraConfigLoadError, loadVoltraConfig } from './config/load' export { VoltraConfigNormalizationError, normalizeVoltraConfig } from './config/normalize' +export { AndroidProjectDiscoveryError, discoverAndroidProject } from './discovery/android' +export type { AndroidProjectDiscovery } from './discovery/android' export { ensureGitWorktreeIsReady, getGitWorktreeStatus } from './git/status' export type { EnsureGitWorktreeOptions, EnsureGitWorktreeResult, GitWorktreeStatus } from './git/status' export { diffVoltraState } from './state/diff' From ae45b2037fe0b3ba3cedea341140de0b55f874be Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 08:08:28 +0200 Subject: [PATCH 14/37] feat: add android generated file helpers --- PLAN.md | 3 + packages/cli/package.json | 5 +- packages/cli/src/index.ts | 2 + .../cli/src/platforms/android/generated.ts | 1062 +++++++++++++++++ 4 files changed, 1071 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/platforms/android/generated.ts diff --git a/PLAN.md b/PLAN.md index f87f61b9..30d07496 100644 --- a/PLAN.md +++ b/PLAN.md @@ -603,6 +603,9 @@ Can run in parallel with: **T13. Adapt reusable Android generated-file logic for CLI use** +Status: +- completed + Deliverables: - wire current Android generators behind CLI-friendly inputs - define generated file inventory output so state tracking can capture all owned files diff --git a/packages/cli/package.json b/packages/cli/package.json index 0bacfed0..08cb25c8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -46,6 +46,9 @@ "license": "MIT", "homepage": "https://use-voltra.dev", "dependencies": { - "cosmiconfig": "^9.0.0" + "@babel/core": "^7.27.4", + "@use-voltra/android": "1.4.1", + "cosmiconfig": "^9.0.0", + "vd-tool": "^4.0.2" } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cf395af8..214b0812 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -54,6 +54,8 @@ export { AndroidProjectDiscoveryError, discoverAndroidProject } from './discover export type { AndroidProjectDiscovery } from './discovery/android' export { ensureGitWorktreeIsReady, getGitWorktreeStatus } from './git/status' export type { EnsureGitWorktreeOptions, EnsureGitWorktreeResult, GitWorktreeStatus } from './git/status' +export { AndroidGeneratedFilesError, generateAndroidFiles } from './platforms/android/generated' +export type { GenerateAndroidFilesOptions, GenerateAndroidFilesResult } from './platforms/android/generated' export { diffVoltraState } from './state/diff' export { getVoltraStatePath, loadVoltraState } from './state/load' export { saveVoltraState } from './state/save' diff --git a/packages/cli/src/platforms/android/generated.ts b/packages/cli/src/platforms/android/generated.ts new file mode 100644 index 00000000..e426728f --- /dev/null +++ b/packages/cli/src/platforms/android/generated.ts @@ -0,0 +1,1062 @@ +import fs from 'node:fs' +import fsPromises from 'node:fs/promises' +import path from 'node:path' +import vm from 'node:vm' +import { createRequire } from 'node:module' + +import * as babel from '@babel/core' +import { renderAndroidWidgetToString } from '@use-voltra/android' +import { vdConvert } from 'vd-tool' + +import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite' +import { normalizeRelativePath, toRelativePath } from '../../fs/path' +import { VoltraCliError } from '../../reporting/summary' + +import type { AndroidProjectDiscovery } from '../../discovery/android' +import type { NormalizedAndroidWidgetConfig, NormalizedVoltraAndroidConfig, WidgetLabel } from '../../config/types' +import type { ReportedChange } from '../../reporting/summary' +import type { AndroidWidgetVariants } from '@use-voltra/android' + +const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', ''] +const VALID_DRAWABLE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.xml', '.svg']) +const FONT_EXTENSIONS = new Set(['.ttf', '.otf', '.woff', '.woff2']) +const MAX_IMAGE_SIZE_BYTES = 4096 +const DEFAULT_INITIAL_STATE_LOCALE = '__default' +const LOCALIZED_INITIAL_STATE_KEY = '__voltraLocales' +const DEFAULT_WIDGET_LOCALE_QUALIFIER = 'en' + +export interface GenerateAndroidFilesOptions { + projectRoot: string + android: NormalizedVoltraAndroidConfig + discovery: AndroidProjectDiscovery +} + +export interface GenerateAndroidFilesResult { + changes: ReportedChange[] + files: string[] + warnings: string[] +} + +interface GeneratedFileResult { + change?: ReportedChange + relativePath: string +} + +export class AndroidGeneratedFilesError extends VoltraCliError { + constructor(message: string) { + super(message, 'VOLTRA_ANDROID_GENERATED_FILES_FAILED') + this.name = 'AndroidGeneratedFilesError' + } +} + +type AndroidWidgetRenderer = typeof renderAndroidWidgetToString + +type PrerenderedWidgetStates = Map> + +export async function generateAndroidFiles(options: GenerateAndroidFilesOptions): Promise { + const { projectRoot, android, discovery } = options + const resourceRoot = path.join(discovery.appModuleRoot, 'src', 'main') + const changes: ReportedChange[] = [] + const warnings: string[] = [] + const generatedFiles = new Set() + + const receiverFiles = await generateWidgetReceivers(projectRoot, discovery, android.widgets) + mergeResult(receiverFiles, changes, warnings, generatedFiles) + + const assetFiles = await generateAndroidAssets(projectRoot, resourceRoot, android) + mergeResult(assetFiles, changes, warnings, generatedFiles) + + const xmlFiles = await generateAndroidXmlFiles(projectRoot, resourceRoot, android.widgets) + mergeResult(xmlFiles, changes, warnings, generatedFiles) + + const fontFiles = await copyAndroidFonts(projectRoot, resourceRoot, android.fonts) + mergeResult(fontFiles, changes, warnings, generatedFiles) + + const initialStateFiles = await generateAndroidInitialStates(projectRoot, resourceRoot, android.widgets) + mergeResult(initialStateFiles, changes, warnings, generatedFiles) + + return { + changes, + files: [...generatedFiles].sort(), + warnings, + } +} + +async function generateWidgetReceivers( + projectRoot: string, + discovery: AndroidProjectDiscovery, + widgets: NormalizedAndroidWidgetConfig[] +): Promise { + const javaRoot = path.join(discovery.appModuleRoot, 'src', 'main', 'java') + const widgetDir = path.join(javaRoot, discovery.packageName.replace(/\./g, '/'), 'widget') + const changes: ReportedChange[] = [] + const generatedFiles = new Set() + + for (const widget of widgets) { + assertValidWidgetId(widget.id) + const receiverPath = path.join(widgetDir, `VoltraWidget_${widget.id}Receiver.kt`) + const receiverContent = generateWidgetReceiverContent(widget, discovery.packageName) + const result = await writeGeneratedTextFile(projectRoot, receiverPath, receiverContent) + + pushChange(changes, result.change) + generatedFiles.add(result.relativePath) + } + + return { + changes, + files: [...generatedFiles], + warnings: [], + } +} + +async function generateAndroidXmlFiles( + projectRoot: string, + resourceRoot: string, + widgets: NormalizedAndroidWidgetConfig[] +): Promise { + const changes: ReportedChange[] = [] + const generatedFiles = new Set() + const warnings: string[] = [] + const xmlDir = path.join(resourceRoot, 'res', 'xml') + const layoutDir = path.join(resourceRoot, 'res', 'layout') + const valuesDir = path.join(resourceRoot, 'res', 'values') + + if (widgets.length === 0) { + return { changes, files: [], warnings } + } + + const defaultStringsPath = path.join(valuesDir, 'voltra_widgets.xml') + const defaultStringsResult = await writeGeneratedTextFile(projectRoot, defaultStringsPath, generateWidgetStringsXml(widgets, null)) + pushChange(changes, defaultStringsResult.change) + generatedFiles.add(defaultStringsResult.relativePath) + + for (const localeKey of collectWidgetLocaleKeys(widgets)) { + const qualifier = localeKeyToAndroidValuesQualifier(localeKey) + + if (qualifier === DEFAULT_WIDGET_LOCALE_QUALIFIER) { + continue + } + + const localizedValuesPath = path.join(resourceRoot, 'res', `values-${qualifier}`, 'voltra_widgets.xml') + const localizedValuesResult = await writeGeneratedTextFile(projectRoot, localizedValuesPath, generateWidgetStringsXml(widgets, localeKey)) + pushChange(changes, localizedValuesResult.change) + generatedFiles.add(localizedValuesResult.relativePath) + } + + const placeholderLayoutPath = path.join(layoutDir, 'voltra_widget_placeholder.xml') + const placeholderLayoutResult = await writeGeneratedTextFile(projectRoot, placeholderLayoutPath, generatePlaceholderLayoutXml()) + pushChange(changes, placeholderLayoutResult.change) + generatedFiles.add(placeholderLayoutResult.relativePath) + + const previewLayoutMap = await generatePreviewLayouts(projectRoot, layoutDir, widgets, warnings, changes, generatedFiles) + + for (const widget of widgets) { + const widgetInfoPath = path.join(xmlDir, `voltra_widget_${widget.id}_info.xml`) + const widgetInfoContent = generateWidgetInfoXml( + widget, + widget.previewImage ? getPreviewImageResourceName(widget) : undefined, + previewLayoutMap.get(widget.id) + ) + const widgetInfoResult = await writeGeneratedTextFile(projectRoot, widgetInfoPath, widgetInfoContent) + pushChange(changes, widgetInfoResult.change) + generatedFiles.add(widgetInfoResult.relativePath) + } + + return { + changes, + files: [...generatedFiles], + warnings, + } +} + +async function generatePreviewLayouts( + projectRoot: string, + layoutDir: string, + widgets: NormalizedAndroidWidgetConfig[], + warnings: string[], + changes: ReportedChange[], + generatedFiles: Set +): Promise> { + const previewLayoutMap = new Map() + + for (const widget of widgets) { + const layoutResourceName = `voltra_widget_${widget.id}_preview` + const layoutFilePath = path.join(layoutDir, `${layoutResourceName}.xml`) + + if (widget.previewLayout) { + const sourceExists = await pathExists(widget.previewLayout) + + if (!sourceExists) { + throw new AndroidGeneratedFilesError(`Preview layout not found for widget '${widget.id}' at ${widget.previewLayout}`) + } + + const content = await readTextFile(widget.previewLayout) + const layoutResult = await writeGeneratedTextFile(projectRoot, layoutFilePath, content) + pushChange(changes, layoutResult.change) + generatedFiles.add(layoutResult.relativePath) + previewLayoutMap.set(widget.id, layoutResourceName) + continue + } + + if (!widget.previewImage) { + continue + } + + const content = generateAutoImagePreviewLayout(widget.id, getPreviewImageResourceName(widget)) + const layoutResult = await writeGeneratedTextFile(projectRoot, layoutFilePath, content) + pushChange(changes, layoutResult.change) + generatedFiles.add(layoutResult.relativePath) + previewLayoutMap.set(widget.id, layoutResourceName) + + const previewImageSizeWarning = await getLargeImageWarning(widget.previewImage, path.basename(widget.previewImage)) + if (previewImageSizeWarning) { + warnings.push(previewImageSizeWarning) + } + } + + return previewLayoutMap +} + +async function generateAndroidAssets( + projectRoot: string, + resourceRoot: string, + android: NormalizedVoltraAndroidConfig +): Promise { + const drawableDir = path.join(resourceRoot, 'res', 'drawable') + const changes: ReportedChange[] = [] + const warnings: string[] = [] + const generatedFiles = new Set() + + for (const assetPath of await collectUserAssetPaths(projectRoot, android.userImagesPath)) { + const extension = path.extname(assetPath).toLowerCase() + + if (!VALID_DRAWABLE_EXTENSIONS.has(extension)) { + throw new AndroidGeneratedFilesError( + `Unsupported Android drawable asset '${assetPath}'. Supported extensions: ${[...VALID_DRAWABLE_EXTENSIONS].sort().join(', ')}` + ) + } + + const resourceName = sanitizeDrawableName(path.relative(android.userImagesPath, assetPath)) + + if (extension === '.svg') { + const destinationPath = path.join(drawableDir, `${resourceName}.svg`) + const svgResult = await convertSvgToVectorDrawable(projectRoot, assetPath, destinationPath) + + pushChange(changes, svgResult.change) + generatedFiles.add(svgResult.relativePath) + continue + } + + const destinationPath = path.join(drawableDir, `${resourceName}${extension}`) + const imageWarning = extension === '.xml' ? undefined : await getLargeImageWarning(assetPath, path.basename(assetPath)) + + if (imageWarning) { + warnings.push(imageWarning) + } + + const assetResult = await copyGeneratedFile(projectRoot, assetPath, destinationPath) + pushChange(changes, assetResult.change) + generatedFiles.add(assetResult.relativePath) + } + + for (const widget of android.widgets) { + if (!widget.previewImage) { + continue + } + + if (!(await pathExists(widget.previewImage))) { + throw new AndroidGeneratedFilesError(`Preview image not found for widget '${widget.id}' at ${widget.previewImage}`) + } + + const extension = path.extname(widget.previewImage).toLowerCase() + + if (!VALID_DRAWABLE_EXTENSIONS.has(extension)) { + throw new AndroidGeneratedFilesError( + `Unsupported Android preview image '${widget.previewImage}' for widget '${widget.id}'. Supported extensions: ${[ + ...VALID_DRAWABLE_EXTENSIONS, + ] + .sort() + .join(', ')}` + ) + } + + const resourceName = getPreviewImageResourceName(widget) + if (extension === '.svg') { + const destinationPath = path.join(drawableDir, `${resourceName}.svg`) + const previewResult = await convertSvgToVectorDrawable(projectRoot, widget.previewImage, destinationPath) + + pushChange(changes, previewResult.change) + generatedFiles.add(previewResult.relativePath) + continue + } + + const destinationPath = path.join(drawableDir, `${resourceName}${extension}`) + const imageWarning = extension === '.xml' ? undefined : await getLargeImageWarning(widget.previewImage, path.basename(widget.previewImage)) + + if (imageWarning) { + warnings.push(imageWarning) + } + + const previewResult = await copyGeneratedFile(projectRoot, widget.previewImage, destinationPath) + pushChange(changes, previewResult.change) + generatedFiles.add(previewResult.relativePath) + } + + return { + changes, + files: [...generatedFiles], + warnings, + } +} + +async function copyAndroidFonts(projectRoot: string, resourceRoot: string, fonts: string[]): Promise { + const changes: ReportedChange[] = [] + const generatedFiles = new Set() + const warnings: string[] = [] + + if (fonts.length === 0) { + return { changes, files: [], warnings } + } + + const fontsDir = path.join(resourceRoot, 'assets', 'fonts') + const fontPaths = await resolveFontPaths(projectRoot, fonts) + + for (const fontPath of fontPaths) { + const destinationPath = path.join(fontsDir, path.basename(fontPath)) + const fontResult = await copyGeneratedFile(projectRoot, fontPath, destinationPath) + + pushChange(changes, fontResult.change) + generatedFiles.add(fontResult.relativePath) + } + + return { + changes, + files: [...generatedFiles], + warnings, + } +} + +async function generateAndroidInitialStates( + projectRoot: string, + resourceRoot: string, + widgets: NormalizedAndroidWidgetConfig[] +): Promise { + const prerenderableWidgets = widgets.filter((widget) => widget.initialStatePath) + + if (prerenderableWidgets.length === 0) { + return { + changes: [], + files: [], + warnings: [], + } + } + const prerenderedStates = await prerenderWidgetStates(projectRoot, prerenderableWidgets, renderAndroidWidgetToString) + + if (prerenderedStates.size === 0) { + return { + changes: [], + files: [], + warnings: [], + } + } + + const initialStatesPath = path.join(resourceRoot, 'assets', 'voltra_initial_states.json') + const initialStatesContent = JSON.stringify(convertPrerenderedStatesToObject(prerenderedStates), null, 2) + const result = await writeGeneratedTextFile(projectRoot, initialStatesPath, `${initialStatesContent}\n`) + + return { + changes: result.change ? [result.change] : [], + files: [result.relativePath], + warnings: [], + } +} + +async function prerenderWidgetStates( + projectRoot: string, + widgets: NormalizedAndroidWidgetConfig[], + renderer: AndroidWidgetRenderer +): Promise { + const prerenderedStates: PrerenderedWidgetStates = new Map() + + for (const widget of widgets) { + const initialStatePath = widget.initialStatePath + + if (!initialStatePath) { + continue + } + + const perLocalePaths = + typeof initialStatePath === 'string' + ? { [DEFAULT_INITIAL_STATE_LOCALE]: initialStatePath } + : Object.fromEntries(Object.entries(initialStatePath)) + + const localeStates = new Map() + + for (const [localeKey, modulePath] of Object.entries(perLocalePaths)) { + if (!(await pathExists(modulePath))) { + throw new AndroidGeneratedFilesError(`Initial state file not found for widget '${widget.id}' at ${modulePath}`) + } + + const widgetVariants = evaluateWidgetModule(projectRoot, modulePath) + localeStates.set(localeKey, renderer(widgetVariants)) + } + + prerenderedStates.set(widget.id, localeStates) + } + + return prerenderedStates +} + +function evaluateWidgetModule(projectRoot: string, filePath: string): AndroidWidgetVariants { + const projectRequire = createProjectRequire(projectRoot) + const moduleCache = new Map() + + const customRequire = (moduleSpecifier: string, currentDir: string): unknown => { + if (!isLocalModule(moduleSpecifier)) { + return projectRequire(moduleSpecifier) + } + + const resolvedModulePath = resolveModulePath(moduleSpecifier, currentDir) + + if (!resolvedModulePath) { + throw new AndroidGeneratedFilesError(`Cannot resolve module '${moduleSpecifier}' from '${currentDir}'`) + } + + const cachedModule = moduleCache.get(resolvedModulePath) + + if (cachedModule !== undefined) { + return cachedModule + } + + const transpiledCode = transpileWidgetModule(projectRoot, resolvedModulePath, projectRequire) + const moduleDir = path.dirname(resolvedModulePath) + const moduleRecord = { exports: {} as Record } + moduleCache.set(resolvedModulePath, moduleRecord.exports) + + const context = vm.createContext({ + __dirname: moduleDir, + __filename: resolvedModulePath, + console, + exports: moduleRecord.exports, + module: moduleRecord, + process, + require: (specifier: string) => customRequire(specifier, moduleDir), + }) + + const script = new vm.Script(transpiledCode, { filename: resolvedModulePath }) + script.runInContext(context) + + moduleCache.set(resolvedModulePath, moduleRecord.exports) + return moduleRecord.exports + } + + const exports = customRequire(filePath, path.dirname(filePath)) as { default?: unknown } + const widgetVariants = exports.default ?? exports + + if (!widgetVariants || typeof widgetVariants !== 'object') { + throw new AndroidGeneratedFilesError(`Widget file must export widget variants: ${filePath}`) + } + + return widgetVariants as AndroidWidgetVariants +} + +function transpileWidgetModule(projectRoot: string, filePath: string, projectRequire: NodeRequire): string { + const source = fs.readFileSync(filePath, 'utf8') + const projectBabelConfigPath = resolveProjectBabelConfig(projectRoot) + const result = babel.transformSync(source, { + babelrc: false, + configFile: projectBabelConfigPath, + cwd: projectRoot, + filename: filePath, + presets: projectBabelConfigPath ? undefined : [resolveFallbackBabelPreset(projectRequire)], + }) + + if (!result?.code) { + throw new AndroidGeneratedFilesError(`Babel transpilation failed for ${filePath}`) + } + + return result.code +} + +function resolveProjectBabelConfig(projectRoot: string): string | undefined { + const candidates = ['babel.config.js', 'babel.config.cjs', 'babel.config.mjs'] + + for (const candidate of candidates) { + const candidatePath = path.join(projectRoot, candidate) + + if (fs.existsSync(candidatePath)) { + return candidatePath + } + } + + return undefined +} + +function resolveFallbackBabelPreset(projectRequire: NodeRequire): string { + try { + return projectRequire.resolve('@react-native/babel-preset') + } catch { + try { + return projectRequire.resolve('babel-preset-expo') + } catch { + throw new AndroidGeneratedFilesError( + 'Could not resolve a Babel preset for Android initial state generation. Add a project babel.config.js or install @react-native/babel-preset.' + ) + } + } +} + +function createProjectRequire(projectRoot: string): NodeRequire { + return createRequire(path.join(projectRoot, 'package.json')) +} + +function isLocalModule(moduleSpecifier: string): boolean { + return moduleSpecifier.startsWith('.') || moduleSpecifier.startsWith('/') +} + +function resolveModulePath(moduleSpecifier: string, fromDir: string): string | null { + const basePath = path.resolve(fromDir, moduleSpecifier) + + for (const extension of MODULE_EXTENSIONS) { + const candidate = `${basePath}${extension}` + + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate + } + } + + if (!fs.existsSync(basePath) || !fs.statSync(basePath).isDirectory()) { + return null + } + + for (const extension of MODULE_EXTENSIONS) { + const indexCandidate = path.join(basePath, `index${extension}`) + + if (fs.existsSync(indexCandidate) && fs.statSync(indexCandidate).isFile()) { + return indexCandidate + } + } + + return null +} + +function convertPrerenderedStatesToObject(prerenderedStates: PrerenderedWidgetStates): Record { + const result: Record = {} + + for (const [widgetId, localeStates] of prerenderedStates.entries()) { + if (localeStates.size === 1 && localeStates.has(DEFAULT_INITIAL_STATE_LOCALE)) { + result[widgetId] = JSON.parse(localeStates.get(DEFAULT_INITIAL_STATE_LOCALE) ?? 'null') + continue + } + + const localizedStates: Record = {} + + for (const [localeKey, state] of localeStates.entries()) { + localizedStates[localeKey] = JSON.parse(state) + } + + result[widgetId] = { + [LOCALIZED_INITIAL_STATE_KEY]: localizedStates, + } + } + + return result +} + +async function resolveFontPaths(projectRoot: string, fonts: string[]): Promise { + const projectRequire = createProjectRequire(projectRoot) + const resolvedFontPaths = new Set() + + for (const font of fonts) { + const resolvedFontInput = await resolveFontInput(projectRoot, font, projectRequire) + + if (!resolvedFontInput) { + throw new AndroidGeneratedFilesError(`Could not resolve Android font path: ${font}`) + } + + const stat = await fsPromises.stat(resolvedFontInput) + + if (!stat.isDirectory()) { + if (FONT_EXTENSIONS.has(path.extname(resolvedFontInput).toLowerCase())) { + resolvedFontPaths.add(resolvedFontInput) + } + + continue + } + + for (const entry of await fsPromises.readdir(resolvedFontInput)) { + const candidatePath = path.join(resolvedFontInput, entry) + + if (FONT_EXTENSIONS.has(path.extname(candidatePath).toLowerCase())) { + resolvedFontPaths.add(candidatePath) + } + } + } + + return [...resolvedFontPaths].sort() +} + +async function resolveFontInput(projectRoot: string, input: string, projectRequire: NodeRequire): Promise { + const resolvedPath = path.isAbsolute(input) ? input : path.resolve(projectRoot, input) + + if (await pathExists(resolvedPath)) { + return resolvedPath + } + + try { + return projectRequire.resolve(input) + } catch { + return null + } +} + +async function collectUserAssetPaths(projectRoot: string, userImagesPath: string): Promise { + const resolvedUserImagesPath = path.isAbsolute(userImagesPath) ? userImagesPath : path.resolve(projectRoot, userImagesPath) + + if (!(await pathExists(resolvedUserImagesPath))) { + return [] + } + + const collectedPaths: string[] = [] + + await collectPathsRecursively(resolvedUserImagesPath, collectedPaths) + return collectedPaths.sort() +} + +async function collectPathsRecursively(currentPath: string, collectedPaths: string[]): Promise { + const stat = await fsPromises.lstat(currentPath) + + if (!stat.isDirectory()) { + if (!path.basename(currentPath).startsWith('.')) { + collectedPaths.push(currentPath) + } + return + } + + for (const entry of await fsPromises.readdir(currentPath)) { + await collectPathsRecursively(path.join(currentPath, entry), collectedPaths) + } +} + +async function convertSvgToVectorDrawable(projectRoot: string, sourcePath: string, destinationSvgPath: string): Promise { + await ensureDirectory(path.dirname(destinationSvgPath)) + const sourceContent = await fsPromises.readFile(sourcePath) + const existingSvgContent = (await pathExists(destinationSvgPath)) ? await fsPromises.readFile(destinationSvgPath) : undefined + const vectorDrawablePath = destinationSvgPath.replace(/\.svg$/i, '.xml') + const existingVectorDrawableContent = (await pathExists(vectorDrawablePath)) ? await fsPromises.readFile(vectorDrawablePath) : undefined + + try { + await fsPromises.writeFile(destinationSvgPath, sourceContent) + await vdConvert(destinationSvgPath) + } catch (error: unknown) { + if (existingSvgContent) { + await fsPromises.writeFile(destinationSvgPath, existingSvgContent) + } else { + await fsPromises.rm(destinationSvgPath, { force: true }) + } + + if (existingVectorDrawableContent) { + await fsPromises.writeFile(vectorDrawablePath, existingVectorDrawableContent) + } else { + await fsPromises.rm(vectorDrawablePath, { force: true }) + } + + throw new AndroidGeneratedFilesError( + `Failed to convert SVG asset ${sourcePath}: ${error instanceof Error ? error.message : String(error)}` + ) + } + + await fsPromises.rm(destinationSvgPath, { force: true }) + const nextVectorDrawableContent = await fsPromises.readFile(vectorDrawablePath) + + return { + change: + existingVectorDrawableContent && Buffer.compare(existingVectorDrawableContent, nextVectorDrawableContent) === 0 + ? undefined + : { + kind: existingVectorDrawableContent ? 'updated' : 'created', + path: toRelativePath(projectRoot, vectorDrawablePath), + }, + relativePath: toRelativePath(projectRoot, vectorDrawablePath), + } +} + +async function copyGeneratedFile(projectRoot: string, sourcePath: string, destinationPath: string): Promise { + await ensureDirectory(path.dirname(destinationPath)) + const sourceContent = await fsPromises.readFile(sourcePath) + const existingContent = (await pathExists(destinationPath)) ? await fsPromises.readFile(destinationPath) : undefined + const relativePath = toRelativePath(projectRoot, destinationPath) + + if (existingContent && Buffer.compare(existingContent, sourceContent) === 0) { + return { + relativePath, + } + } + + await fsPromises.writeFile(destinationPath, sourceContent) + + return { + change: { + kind: existingContent ? 'updated' : 'created', + path: relativePath, + }, + relativePath, + } +} + +async function writeGeneratedTextFile(projectRoot: string, destinationPath: string, content: string): Promise { + const existingContent = (await pathExists(destinationPath)) ? await readTextFile(destinationPath) : undefined + const relativePath = toRelativePath(projectRoot, destinationPath) + + if (existingContent === content) { + return { + relativePath, + } + } + + await writeTextFile(destinationPath, content) + + return { + change: { + kind: existingContent === undefined ? 'created' : 'updated', + path: relativePath, + }, + relativePath, + } +} + +async function getLargeImageWarning(imagePath: string, fileName: string): Promise { + const stat = await fsPromises.stat(imagePath) + + if (stat.size < MAX_IMAGE_SIZE_BYTES) { + return undefined + } + + return `Image '${fileName}' is ${stat.size} bytes. Large Android widget images may not display correctly.` +} + +function generateWidgetReceiverContent(widget: NormalizedAndroidWidgetConfig, packageName: string): string { + const className = `VoltraWidget_${widget.id}Receiver` + const labelForComment = widgetLabelEnglish(widget.displayName) + + if (widget.serverUpdate) { + const refreshEnabled = widget.serverUpdate.refresh === true + + return [ + `package ${packageName}.widget`, + '', + 'import android.appwidget.AppWidgetManager', + 'import android.content.Context', + 'import voltra.widget.VoltraWidgetReceiver', + 'import voltra.widget.VoltraWidgetUpdateScheduler', + '', + '/**', + ` * Auto-generated widget receiver for ${labelForComment}`, + ` * Widget ID: ${widget.id}`, + ` * Server Update: ${widget.serverUpdate.url} (every ${widget.serverUpdate.intervalMinutes} minutes)`, + ` * Refresh Button: ${String(refreshEnabled)}`, + ' */', + `class ${className} : VoltraWidgetReceiver() {`, + ` override val widgetId: String = "${widget.id}"`, + '', + ' override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {', + ' super.onUpdate(context, appWidgetManager, appWidgetIds)', + '', + ' VoltraWidgetUpdateScheduler.schedulePeriodicUpdate(', + ' context = context,', + ` widgetId = "${widget.id}",`, + ` serverUrl = "${widget.serverUpdate.url}",`, + ` intervalMinutes = ${widget.serverUpdate.intervalMinutes}L,`, + ` refreshEnabled = ${String(refreshEnabled)}`, + ' )', + ' }', + '', + ' override fun onDeleted(context: Context, appWidgetIds: IntArray) {', + ' super.onDeleted(context, appWidgetIds)', + '', + ' val remaining = appWidgetManager(context, appWidgetIds)', + ' if (remaining == 0) {', + ` VoltraWidgetUpdateScheduler.cancelPeriodicUpdate(context, "${widget.id}")`, + ' }', + ' }', + '', + ' private fun appWidgetManager(context: Context, deletedIds: IntArray): Int {', + ' val manager = AppWidgetManager.getInstance(context)', + ' val componentName = android.content.ComponentName(context, this::class.java)', + ' val allIds = manager.getAppWidgetIds(componentName)', + ' return allIds.count { it !in deletedIds }', + ' }', + '}', + '', + ].join('\n') + } + + return [ + `package ${packageName}.widget`, + '', + 'import voltra.widget.VoltraWidgetReceiver', + '', + '/**', + ` * Auto-generated widget receiver for ${labelForComment}`, + ` * Widget ID: ${widget.id}`, + ' */', + `class ${className} : VoltraWidgetReceiver() {`, + ` override val widgetId: String = "${widget.id}"`, + '}', + '', + ].join('\n') +} + +function generateWidgetInfoXml( + widget: NormalizedAndroidWidgetConfig, + previewImageResourceName?: string, + previewLayoutResourceName?: string +): string { + const minWidth = widget.minWidth ?? (widget.minCellWidth !== undefined ? widget.minCellWidth * 70 - 30 : undefined) + const minHeight = widget.minHeight ?? (widget.minCellHeight !== undefined ? widget.minCellHeight * 70 - 30 : undefined) + const resizeMode = widget.resizeMode ?? 'horizontal|vertical' + const widgetCategory = widget.widgetCategory ?? 'home_screen' + const lines = [ + '', + ``, + '', + '', + ] + + return lines.join('\n') +} + +function generatePlaceholderLayoutXml(): string { + return [ + '', + '', + ' ', + '', + '', + ].join('\n') +} + +function generateAutoImagePreviewLayout(widgetId: string, drawableResourceName: string): string { + return [ + '', + '', + ' `, + '', + '', + ].join('\n') +} + +function generateWidgetStringsXml(widgets: NormalizedAndroidWidgetConfig[], localeKey: string | null): string { + const localeComment = + localeKey === null ? 'default (values/)' : `locale ${localeKey} → values-${localeKeyToAndroidValuesQualifier(localeKey)}` + const entries = widgets + .map((widget) => { + const label = escapeAndroidString(resolveWidgetLabel(widget.displayName, localeKey)) + const description = escapeAndroidString(resolveWidgetLabel(widget.description, localeKey)) + return ` ${label}\n ${description}` + }) + .join('\n') + + return ['', '', ` `, entries, '', ''].join('\n') +} + +function collectWidgetLocaleKeys(widgets: NormalizedAndroidWidgetConfig[]): Set { + const localeKeys = new Set() + + for (const widget of widgets) { + for (const value of [widget.displayName, widget.description]) { + if (isWidgetLocalizedMap(value)) { + for (const [localeKey, text] of Object.entries(value)) { + if (text.trim()) { + localeKeys.add(localeKey) + } + } + } + } + } + + return localeKeys +} + +function resolveWidgetLabel(label: WidgetLabel, localeKey: string | null): string { + if (!isWidgetLocalizedMap(label)) { + return label + } + + if (localeKey !== null) { + const localizedValue = label[localeKey] + + if (typeof localizedValue === 'string' && localizedValue.trim()) { + return localizedValue + } + } + + return widgetLabelEnglish(label) +} + +function widgetLabelEnglish(label: WidgetLabel): string { + if (!isWidgetLocalizedMap(label)) { + return label + } + + const englishLabel = label.en + + if (typeof englishLabel === 'string' && englishLabel.trim()) { + return englishLabel + } + + for (const [localeKey, value] of Object.entries(label)) { + if ((localeKey.startsWith('en-') || localeKey.startsWith('en_')) && value.trim()) { + return value + } + } + + return Object.values(label).find((value) => value.trim()) ?? '' +} + +function isWidgetLocalizedMap(label: WidgetLabel): label is Record { + return typeof label === 'object' && label !== null && !Array.isArray(label) +} + +function localeKeyToAndroidValuesQualifier(localeKey: string): string { + const normalized = localeKey.trim().replace(/_/g, '-') + const segments = normalized.split('-').filter(Boolean) + const language = segments[0]?.toLowerCase() + + if (!language) { + return normalized.toLowerCase() + } + + const rest = segments.slice(1) + + if (rest.length === 0) { + return language + } + + const [first, ...tail] = rest + + if (first && isRegionSubtag(first) && tail.length === 0) { + return `${language}-r${first.toUpperCase()}` + } + + const bcp47Segments = [language] + + for (const segment of rest) { + if (isScriptSubtag(segment)) { + bcp47Segments.push(formatScriptSubtag(segment)) + continue + } + + if (isRegionSubtag(segment)) { + bcp47Segments.push(segment.toUpperCase()) + continue + } + + bcp47Segments.push(segment.toLowerCase()) + } + + return `b+${bcp47Segments.join('+')}` +} + +function escapeAndroidString(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/'/g, "\\'") + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/&/g, '&') + .replace(//g, '>') +} + +function sanitizeDrawableName(filePath: string): string { + const directoryName = path.dirname(filePath) + const fileName = path.parse(filePath).name + const nameParts: string[] = [] + + if (directoryName !== '.') { + nameParts.push( + ...directoryName + .split(path.sep) + .filter((segment) => segment !== '.' && segment !== 'assets' && segment !== 'voltra' && segment !== 'voltra-android') + ) + } + + nameParts.push(fileName) + + let sanitizedName = nameParts.join('_').toLowerCase().replace(/[^a-z0-9_]/g, '_') + + if (!/^[a-z]/.test(sanitizedName)) { + sanitizedName = `img_${sanitizedName}` + } + + return sanitizedName +} + +function getPreviewImageResourceName(widget: Pick): string { + return `voltra_widget_${widget.id}_preview` +} + +function assertValidWidgetId(widgetId: string): void { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(widgetId)) { + throw new AndroidGeneratedFilesError( + `Widget ID '${widgetId}' is invalid. Must start with a letter or underscore and contain only alphanumeric characters and underscores.` + ) + } +} + +function isScriptSubtag(value: string): boolean { + return value.length === 4 && /^[a-z]+$/i.test(value) +} + +function isRegionSubtag(value: string): boolean { + return (value.length === 2 && /^[a-z]+$/i.test(value)) || (value.length === 3 && /^\d+$/.test(value)) +} + +function formatScriptSubtag(value: string): string { + const lower = value.toLowerCase() + return `${lower[0]?.toUpperCase() ?? ''}${lower.slice(1)}` +} + +function mergeResult( + result: GenerateAndroidFilesResult, + changes: ReportedChange[], + warnings: string[], + generatedFiles: Set +): void { + changes.push(...result.changes) + warnings.push(...result.warnings) + + for (const filePath of result.files) { + generatedFiles.add(normalizeRelativePath(filePath)) + } +} + +function pushChange(changes: ReportedChange[], change: ReportedChange | undefined): void { + if (change) { + changes.push(change) + } +} From 50843607e492ac896ac23550fed651e63093f149 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 08:12:38 +0200 Subject: [PATCH 15/37] feat: add android manifest mutator --- PLAN.md | 3 + packages/cli/package.json | 1 + packages/cli/src/index.ts | 2 + .../cli/src/platforms/android/manifest.ts | 299 ++++++++++++++++++ packages/cli/src/xml2js.d.ts | 22 ++ 5 files changed, 327 insertions(+) create mode 100644 packages/cli/src/platforms/android/manifest.ts create mode 100644 packages/cli/src/xml2js.d.ts diff --git a/PLAN.md b/PLAN.md index 30d07496..68af3a7a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -625,6 +625,9 @@ Can run in parallel with: **T14. Implement CLI-native Android manifest mutator** +Status: +- completed + Deliverables: - parse `AndroidManifest.xml` - ensure required permissions diff --git a/packages/cli/package.json b/packages/cli/package.json index 08cb25c8..8f0ee6f5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,6 +49,7 @@ "@babel/core": "^7.27.4", "@use-voltra/android": "1.4.1", "cosmiconfig": "^9.0.0", + "xml2js": "^0.6.2", "vd-tool": "^4.0.2" } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 214b0812..587ba3c0 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -56,6 +56,8 @@ export { ensureGitWorktreeIsReady, getGitWorktreeStatus } from './git/status' export type { EnsureGitWorktreeOptions, EnsureGitWorktreeResult, GitWorktreeStatus } from './git/status' export { AndroidGeneratedFilesError, generateAndroidFiles } from './platforms/android/generated' export type { GenerateAndroidFilesOptions, GenerateAndroidFilesResult } from './platforms/android/generated' +export { AndroidManifestMutationError, ensureAndroidManifest } from './platforms/android/manifest' +export type { EnsureAndroidManifestOptions, EnsureAndroidManifestResult } from './platforms/android/manifest' export { diffVoltraState } from './state/diff' export { getVoltraStatePath, loadVoltraState } from './state/load' export { saveVoltraState } from './state/save' diff --git a/packages/cli/src/platforms/android/manifest.ts b/packages/cli/src/platforms/android/manifest.ts new file mode 100644 index 00000000..729d9987 --- /dev/null +++ b/packages/cli/src/platforms/android/manifest.ts @@ -0,0 +1,299 @@ +import { Builder, parseStringPromise } from 'xml2js' + +import { readTextFile, writeTextFile } from '../../fs/readWrite' +import { toRelativePath } from '../../fs/path' +import { VoltraCliError } from '../../reporting/summary' + +import type { NormalizedVoltraAndroidConfig } from '../../config/types' +import type { AndroidProjectDiscovery } from '../../discovery/android' +import type { ReportedChange } from '../../reporting/summary' + +const XML_BUILDER = new Builder({ + headless: false, + renderOpts: { + pretty: true, + indent: ' ', + newline: '\n', + }, +}) + +const APPWIDGET_UPDATE_ACTION = 'android.appwidget.action.APPWIDGET_UPDATE' +const APPWIDGET_PROVIDER_METADATA = 'android.appwidget.provider' +const ONGOING_NOTIFICATION_RECEIVER = 'voltra.VoltraOngoingNotificationDismissedReceiver' +const POST_NOTIFICATIONS_PERMISSION = 'android.permission.POST_NOTIFICATIONS' +const POST_PROMOTED_NOTIFICATIONS_PERMISSION = 'android.permission.POST_PROMOTED_NOTIFICATIONS' + +export interface EnsureAndroidManifestOptions { + projectRoot: string + android: NormalizedVoltraAndroidConfig + discovery: AndroidProjectDiscovery +} + +export interface EnsureAndroidManifestResult { + change?: ReportedChange +} + +interface AndroidManifestDocument { + manifest?: AndroidManifestRoot +} + +interface AndroidManifestRoot { + $?: Record + application?: AndroidManifestApplication[] + 'uses-permission'?: AndroidManifestPermission[] +} + +interface AndroidManifestPermission { + $?: Record +} + +interface AndroidManifestApplication { + $?: Record + receiver?: AndroidManifestReceiver[] +} + +interface AndroidManifestReceiver { + $?: Record + 'intent-filter'?: AndroidManifestIntentFilter[] + 'meta-data'?: AndroidManifestMetaData[] +} + +interface AndroidManifestIntentFilter { + action?: AndroidManifestAction[] +} + +interface AndroidManifestAction { + $?: Record +} + +interface AndroidManifestMetaData { + $?: Record +} + +export class AndroidManifestMutationError extends VoltraCliError { + constructor(message: string) { + super(message, 'VOLTRA_ANDROID_MANIFEST_MUTATION_FAILED') + this.name = 'AndroidManifestMutationError' + } +} + +export async function ensureAndroidManifest(options: EnsureAndroidManifestOptions): Promise { + const { projectRoot, android, discovery } = options + const manifestPath = discovery.manifestPath + const previousContent = await readTextFile(manifestPath) + const manifestDocument = await parseAndroidManifest(previousContent, manifestPath) + const manifest = manifestDocument.manifest + + if (!manifest) { + throw new AndroidManifestMutationError(`Android manifest root is missing in ${manifestPath}`) + } + + const application = getMainApplication(manifest, manifestPath) + const permissions = manifest['uses-permission'] ?? [] + manifest['uses-permission'] = permissions + + if (android.enableNotifications) { + ensurePermission(permissions, POST_NOTIFICATIONS_PERMISSION) + ensurePermission(permissions, POST_PROMOTED_NOTIFICATIONS_PERMISSION) + } + + const receivers = application.receiver ?? [] + application.receiver = receivers + + if (android.enableNotifications) { + ensureNotificationReceiver(receivers) + } + + for (const widget of android.widgets) { + ensureWidgetReceiver(receivers, widget.id) + } + + const nextContent = `${XML_BUILDER.buildObject(manifestDocument)}\n` + + if (nextContent === previousContent) { + return {} + } + + await writeTextFile(manifestPath, nextContent) + + return { + change: { + kind: 'updated', + path: toRelativePath(projectRoot, manifestPath), + }, + } +} + +async function parseAndroidManifest(content: string, manifestPath: string): Promise { + try { + return (await parseStringPromise(content)) as AndroidManifestDocument + } catch (error: unknown) { + throw new AndroidManifestMutationError( + `Failed to parse AndroidManifest.xml at ${manifestPath}: ${error instanceof Error ? error.message : String(error)}` + ) + } +} + +function getMainApplication(manifest: AndroidManifestRoot, manifestPath: string): AndroidManifestApplication { + const applications = manifest.application ?? [] + + if (applications.length === 0) { + throw new AndroidManifestMutationError(`Android manifest does not contain an element: ${manifestPath}`) + } + + if (applications.length > 1) { + throw new AndroidManifestMutationError(`Android manifest contains multiple elements: ${manifestPath}`) + } + + return applications[0] +} + +function ensurePermission(permissions: AndroidManifestPermission[], permissionName: string): void { + const matchingPermissions = permissions.filter((permission) => permission.$?.['android:name'] === permissionName) + + if (matchingPermissions.length === 0) { + permissions.push(createPermission(permissionName)) + return + } + + const primaryPermission = matchingPermissions[0] + primaryPermission.$ = { + ...primaryPermission.$, + 'android:name': permissionName, + } + + removeDuplicateEntries(permissions, (permission) => permission.$?.['android:name'] === permissionName) +} + +function ensureNotificationReceiver(receivers: AndroidManifestReceiver[]): void { + const receiver = findReceiverByName(receivers, ONGOING_NOTIFICATION_RECEIVER) + + if (receiver) { + receiver.$ = { + ...receiver.$, + 'android:name': ONGOING_NOTIFICATION_RECEIVER, + 'android:exported': 'false', + } + removeDuplicateEntries(receivers, (candidate) => candidate.$?.['android:name'] === ONGOING_NOTIFICATION_RECEIVER) + return + } + + receivers.push(createReceiver(ONGOING_NOTIFICATION_RECEIVER, 'false')) +} + +function ensureWidgetReceiver(receivers: AndroidManifestReceiver[], widgetId: string): void { + const receiverName = `.widget.VoltraWidget_${widgetId}Receiver` + const metadataResource = `@xml/voltra_widget_${widgetId}_info` + const labelResource = `@string/voltra_widget_${widgetId}_label` + const receiver = findReceiverByName(receivers, receiverName) + + if (receiver) { + receiver.$ = { + ...receiver.$, + 'android:name': receiverName, + 'android:exported': 'true', + 'android:label': labelResource, + } + ensureAppWidgetUpdateIntentFilter(receiver) + ensureReceiverMetadata(receiver, metadataResource) + removeDuplicateEntries(receivers, (candidate) => candidate.$?.['android:name'] === receiverName) + return + } + + const nextReceiver = createReceiver(receiverName, 'true', labelResource) + ensureAppWidgetUpdateIntentFilter(nextReceiver) + ensureReceiverMetadata(nextReceiver, metadataResource) + receivers.push(nextReceiver) +} + +function ensureAppWidgetUpdateIntentFilter(receiver: AndroidManifestReceiver): void { + const intentFilters = receiver['intent-filter'] ?? [] + receiver['intent-filter'] = intentFilters + + const existingFilter = intentFilters.find((intentFilter) => + (intentFilter.action ?? []).some((action) => action.$?.['android:name'] === APPWIDGET_UPDATE_ACTION) + ) + + if (existingFilter) { + existingFilter.action = [{ $: { 'android:name': APPWIDGET_UPDATE_ACTION } }] + removeDuplicateEntries(intentFilters, (intentFilter) => + (intentFilter.action ?? []).some((action) => action.$?.['android:name'] === APPWIDGET_UPDATE_ACTION) + ) + return + } + + intentFilters.push({ + action: [ + { + $: { + 'android:name': APPWIDGET_UPDATE_ACTION, + }, + }, + ], + }) +} + +function ensureReceiverMetadata(receiver: AndroidManifestReceiver, metadataResource: string): void { + const metadataEntries = receiver['meta-data'] ?? [] + receiver['meta-data'] = metadataEntries + + const providerMetadata = metadataEntries.find((metadata) => metadata.$?.['android:name'] === APPWIDGET_PROVIDER_METADATA) + + if (providerMetadata) { + providerMetadata.$ = { + ...providerMetadata.$, + 'android:name': APPWIDGET_PROVIDER_METADATA, + 'android:resource': metadataResource, + } + removeDuplicateEntries(metadataEntries, (metadata) => metadata.$?.['android:name'] === APPWIDGET_PROVIDER_METADATA) + return + } + + metadataEntries.push({ + $: { + 'android:name': APPWIDGET_PROVIDER_METADATA, + 'android:resource': metadataResource, + }, + }) +} + +function findReceiverByName(receivers: AndroidManifestReceiver[], receiverName: string): AndroidManifestReceiver | undefined { + return receivers.find((receiver) => receiver.$?.['android:name'] === receiverName) +} + +function createPermission(permissionName: string): AndroidManifestPermission { + return { + $: { + 'android:name': permissionName, + }, + } +} + +function createReceiver(receiverName: string, exported: 'true' | 'false', label?: string): AndroidManifestReceiver { + return { + $: { + 'android:name': receiverName, + 'android:exported': exported, + ...(label ? { 'android:label': label } : {}), + }, + } +} + +function removeDuplicateEntries(entries: TEntry[], isDuplicate: (entry: TEntry) => boolean): void { + let foundPrimary = false + + for (let index = 0; index < entries.length; ) { + if (!isDuplicate(entries[index])) { + index += 1 + continue + } + + if (!foundPrimary) { + foundPrimary = true + index += 1 + continue + } + + entries.splice(index, 1) + } +} diff --git a/packages/cli/src/xml2js.d.ts b/packages/cli/src/xml2js.d.ts new file mode 100644 index 00000000..3104aa3f --- /dev/null +++ b/packages/cli/src/xml2js.d.ts @@ -0,0 +1,22 @@ +declare module 'xml2js' { + export interface ParserOptions { + explicitArray?: boolean + preserveChildrenOrder?: boolean + } + + export interface BuilderOptions { + headless?: boolean + renderOpts?: { + pretty?: boolean + indent?: string + newline?: string + } + } + + export class Builder { + constructor(options?: BuilderOptions) + buildObject(rootObj: unknown): string + } + + export function parseStringPromise(value: string, options?: ParserOptions): Promise +} From b9e18c38f1b350c3e18324a12ef98af0384b90e0 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 08:16:26 +0200 Subject: [PATCH 16/37] feat: add android apply flow --- PLAN.md | 3 + packages/cli/src/apply/index.ts | 22 +++++- packages/cli/src/index.ts | 1 + packages/cli/src/platforms/android/apply.ts | 84 +++++++++++++++++++++ 4 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/platforms/android/apply.ts diff --git a/PLAN.md b/PLAN.md index 68af3a7a..b59b98a0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -649,6 +649,9 @@ Can run in parallel with: **T15. Implement Android apply flow** +Status: +- completed + Deliverables: - combine discovery, manifest mutation, generated-file writes, and generated-file list emission - return created/updated file inventory for reporting and state tracking diff --git a/packages/cli/src/apply/index.ts b/packages/cli/src/apply/index.ts index c45d88d4..44e69d5e 100644 --- a/packages/cli/src/apply/index.ts +++ b/packages/cli/src/apply/index.ts @@ -4,6 +4,7 @@ import { loadVoltraConfig } from '../config/load' import { normalizeVoltraConfig } from '../config/normalize' import { removePathIfExists } from '../fs/readWrite' import { ensureGitWorktreeIsReady } from '../git/status' +import { applyAndroidPlatform, createAndroidPreflightRunner } from '../platforms/android/apply' import { formatApplySummary, VoltraCliError } from '../reporting/summary' import { diffVoltraState } from '../state/diff' import { loadVoltraState } from '../state/load' @@ -75,10 +76,11 @@ export async function applyVoltra(options: ApplyOptions): Promise { export async function runApplyPipeline(options: ApplyOptions, dependencies: ApplyDependencies): Promise { const loadedConfig = await loadVoltraConfig({ configPath: options.configPath }) const normalizedConfig = normalizeVoltraConfig(loadedConfig) + const resolvedDependencies = resolveApplyDependencies(normalizedConfig, dependencies) const gitStatus = await ensureGitWorktreeIsReady({ cwd: normalizedConfig.projectRoot }) - const preflight = await runApplyPreflight(normalizedConfig, dependencies.preflightRunners, options.platform) + const preflight = await runApplyPreflight(normalizedConfig, resolvedDependencies.preflightRunners, options.platform) const previousState = await loadVoltraState(normalizedConfig.projectRoot) - const platformResults = await runPlatformApply(normalizedConfig, preflight, previousState, dependencies.applyRunners) + const platformResults = await runPlatformApply(normalizedConfig, preflight, previousState, resolvedDependencies.applyRunners) const nextGeneratedFiles = platformResults.flatMap((result) => result.generatedFiles) const stateDiff = diffVoltraState(previousState, nextGeneratedFiles) const deletedChanges = await removeStaleGeneratedFiles(normalizedConfig.projectRoot, stateDiff.staleFiles) @@ -87,7 +89,21 @@ export async function runApplyPipeline(options: ApplyOptions, dependencies: Appl const summaryWarnings = [gitStatus.warning, ...platformResults.flatMap((result) => result.warnings ?? [])].filter(isDefined) const summaryChanges = [...platformResults.flatMap((result) => result.changes), ...deletedChanges] - dependencies.writeStdout(`${formatApplySummary({ changes: summaryChanges, warnings: summaryWarnings })}\n`) + resolvedDependencies.writeStdout(`${formatApplySummary({ changes: summaryChanges, warnings: summaryWarnings })}\n`) +} + +function resolveApplyDependencies(config: NormalizedVoltraConfig, dependencies: ApplyDependencies): ApplyDependencies { + return { + applyRunners: { + android: dependencies.applyRunners.android ?? applyAndroidPlatform, + ios: dependencies.applyRunners.ios, + }, + preflightRunners: { + android: dependencies.preflightRunners.android ?? (config.android ? createAndroidPreflightRunner(config) : undefined), + ios: dependencies.preflightRunners.ios, + }, + writeStdout: dependencies.writeStdout, + } } async function runPlatformApply( diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 587ba3c0..f62764ba 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -56,6 +56,7 @@ export { ensureGitWorktreeIsReady, getGitWorktreeStatus } from './git/status' export type { EnsureGitWorktreeOptions, EnsureGitWorktreeResult, GitWorktreeStatus } from './git/status' export { AndroidGeneratedFilesError, generateAndroidFiles } from './platforms/android/generated' export type { GenerateAndroidFilesOptions, GenerateAndroidFilesResult } from './platforms/android/generated' +export { applyAndroidPlatform, createAndroidPreflightRunner } from './platforms/android/apply' export { AndroidManifestMutationError, ensureAndroidManifest } from './platforms/android/manifest' export type { EnsureAndroidManifestOptions, EnsureAndroidManifestResult } from './platforms/android/manifest' export { diffVoltraState } from './state/diff' diff --git a/packages/cli/src/platforms/android/apply.ts b/packages/cli/src/platforms/android/apply.ts new file mode 100644 index 00000000..03cc4525 --- /dev/null +++ b/packages/cli/src/platforms/android/apply.ts @@ -0,0 +1,84 @@ +import { discoverAndroidProject } from '../../discovery/android' +import { VoltraCliError } from '../../reporting/summary' + +import { ensureAndroidManifest } from './manifest' +import { generateAndroidFiles } from './generated' + +import type { NormalizedVoltraConfig } from '../../config/types' +import type { AndroidProjectDiscovery } from '../../discovery/android' +import type { PlatformApplyContext, PlatformApplyResult } from '../../apply' +import type { ApplyPreflightContext, PlatformPreflightResult, PlatformPreflightRunner } from '../../apply/preflight' + +export function createAndroidPreflightRunner(config: NormalizedVoltraConfig): PlatformPreflightRunner { + return async (_context: ApplyPreflightContext): Promise> => { + const androidConfig = config.android + + if (!androidConfig) { + return { + platform: 'android', + issues: [{ message: 'Android config is missing.' }], + } + } + + return { + platform: 'android', + context: await discoverAndroidProject(config.projectRoot, androidConfig.project), + } + } +} + +export async function applyAndroidPlatform(context: PlatformApplyContext): Promise { + if (context.platform !== 'android') { + throw new VoltraCliError(`Android apply runner received unexpected platform: ${context.platform}.`) + } + + const androidConfig = context.config.android + + if (!androidConfig) { + throw new VoltraCliError('Android config is missing.') + } + + const discovery = getAndroidDiscovery(context.preflight) + const generatedResult = await generateAndroidFiles({ + projectRoot: context.config.projectRoot, + android: androidConfig, + discovery, + }) + const manifestResult = await ensureAndroidManifest({ + projectRoot: context.config.projectRoot, + android: androidConfig, + discovery, + }) + + return { + platform: 'android', + changes: manifestResult.change ? [manifestResult.change, ...generatedResult.changes] : generatedResult.changes, + generatedFiles: generatedResult.files, + warnings: generatedResult.warnings, + } +} + +function getAndroidDiscovery(value: unknown): AndroidProjectDiscovery { + if (!isAndroidProjectDiscovery(value)) { + throw new VoltraCliError('Android preflight did not provide a valid discovery result.') + } + + return value +} + +function isAndroidProjectDiscovery(value: unknown): value is AndroidProjectDiscovery { + if (!value || typeof value !== 'object') { + return false + } + + const candidate = value as Partial + + return [ + candidate.androidRoot, + candidate.appModuleName, + candidate.appModuleRoot, + candidate.manifestPath, + candidate.buildGradlePath, + candidate.packageName, + ].every((entry) => typeof entry === 'string' && entry.length > 0) +} From b588e9cb05fd080602f0974c300e508cc2972c4a Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 08:22:30 +0200 Subject: [PATCH 17/37] feat: add ios project discovery --- PLAN.md | 3 + packages/cli/src/discovery/ios.ts | 554 ++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 2 + 3 files changed, 559 insertions(+) create mode 100644 packages/cli/src/discovery/ios.ts diff --git a/PLAN.md b/PLAN.md index b59b98a0..32a6535b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -672,6 +672,9 @@ Can run in parallel with: **T16. Implement iOS project discovery** +Status: +- completed + Deliverables: - discover iOS root - discover `.xcodeproj` diff --git a/packages/cli/src/discovery/ios.ts b/packages/cli/src/discovery/ios.ts new file mode 100644 index 00000000..c40119f8 --- /dev/null +++ b/packages/cli/src/discovery/ios.ts @@ -0,0 +1,554 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import type { Stats } from 'node:fs' + +import { VoltraCliError } from '../reporting/summary' + +import type { NormalizedIOSProjectConfig } from '../config/types' + +const PBX_NATIVE_TARGET_SECTION = 'PBXNativeTarget' +const XC_BUILD_CONFIGURATION_SECTION = 'XCBuildConfiguration' +const XC_CONFIGURATION_LIST_SECTION = 'XCConfigurationList' +const IOS_APPLICATION_PRODUCT_TYPE = 'com.apple.product-type.application' + +export interface IOSProjectDiscovery { + iosRoot: string + xcodeprojPath: string + pbxprojPath: string + podfilePath: string + mainTargetName: string + mainTargetCandidates: string[] + infoPlistPath: string + entitlementsPath?: string +} + +interface ParsedPbxNativeTarget { + id: string + name: string + productType: string + buildConfigurationListId: string +} + +interface ParsedXCConfigurationList { + id: string + buildConfigurationIds: string[] + defaultConfigurationName?: string +} + +interface ParsedXCBuildConfiguration { + id: string + name: string + buildSettings: { + codeSignEntitlements?: string + infoPlistFile?: string + productName?: string + } +} + +interface ParsedIOSProject { + targets: ParsedPbxNativeTarget[] + configurationLists: Map + buildConfigurations: Map +} + +export class IOSProjectDiscoveryError extends VoltraCliError { + constructor(message: string) { + super(message, 'VOLTRA_IOS_DISCOVERY_FAILED') + this.name = 'IOSProjectDiscoveryError' + } +} + +export async function discoverIOSProject(projectRoot: string, config: NormalizedIOSProjectConfig): Promise { + const iosRoot = await resolveIOSRoot(projectRoot, config) + const xcodeprojPath = await resolveXcodeprojPath(iosRoot, config) + const pbxprojPath = await resolvePbxprojPath(xcodeprojPath) + const podfilePath = await resolvePodfilePath(iosRoot, config) + const pbxprojContent = await fs.readFile(pbxprojPath, 'utf8') + const parsedProject = parseIOSProject(pbxprojContent, pbxprojPath) + const mainTargetCandidates = getMainTargetCandidates(parsedProject.targets) + const mainTarget = resolveMainTarget(mainTargetCandidates, config.mainTargetName, pbxprojPath) + const mainTargetBuildConfigurations = getTargetBuildConfigurations(parsedProject, mainTarget, pbxprojPath) + const infoPlistPath = await resolveInfoPlistPath(iosRoot, config, mainTarget, mainTargetBuildConfigurations) + const entitlementsPath = await resolveEntitlementsPath(iosRoot, config, mainTarget, mainTargetBuildConfigurations) + + return { + iosRoot, + xcodeprojPath, + pbxprojPath, + podfilePath, + mainTargetName: mainTarget.name, + mainTargetCandidates: mainTargetCandidates.map((target) => target.name).sort(), + infoPlistPath, + entitlementsPath, + } +} + +async function resolveIOSRoot(projectRoot: string, config: NormalizedIOSProjectConfig): Promise { + const iosRoot = config.rootDir ?? path.join(projectRoot, 'ios') + + await ensureDirectory( + iosRoot, + config.rootDir + ? `Configured iOS root directory does not exist: ${iosRoot}` + : `iOS root directory does not exist at ${iosRoot}. Set ios.project.rootDir to override the default ios/ layout.` + ) + + return iosRoot +} + +async function resolveXcodeprojPath(iosRoot: string, config: NormalizedIOSProjectConfig): Promise { + if (config.xcodeprojPath) { + await ensureDirectory(config.xcodeprojPath, `Configured Xcode project does not exist: ${config.xcodeprojPath}`) + + if (!config.xcodeprojPath.endsWith('.xcodeproj')) { + throw new IOSProjectDiscoveryError( + `Configured Xcode project must point to a .xcodeproj directory: ${config.xcodeprojPath}` + ) + } + + return config.xcodeprojPath + } + + const entries = await fs.readdir(iosRoot, { withFileTypes: true }) + const xcodeprojCandidates = entries + .filter((entry) => entry.isDirectory() && entry.name.endsWith('.xcodeproj')) + .map((entry) => path.join(iosRoot, entry.name)) + .sort() + + if (xcodeprojCandidates.length === 1) { + return xcodeprojCandidates[0] + } + + if (xcodeprojCandidates.length === 0) { + throw new IOSProjectDiscoveryError( + `No .xcodeproj was found in ${iosRoot}. Set ios.project.xcodeprojPath to override discovery.` + ) + } + + throw new IOSProjectDiscoveryError( + `Multiple .xcodeproj directories were found in ${iosRoot}: ${xcodeprojCandidates.join(', ')}. Set ios.project.xcodeprojPath explicitly.` + ) +} + +async function resolvePbxprojPath(xcodeprojPath: string): Promise { + const pbxprojPath = path.join(xcodeprojPath, 'project.pbxproj') + await ensureFile(pbxprojPath, `Xcode project file is missing at ${pbxprojPath}`) + return pbxprojPath +} + +async function resolvePodfilePath(iosRoot: string, config: NormalizedIOSProjectConfig): Promise { + const podfilePath = config.podfilePath ?? path.join(iosRoot, 'Podfile') + + await ensureFile( + podfilePath, + config.podfilePath + ? `Configured Podfile does not exist: ${podfilePath}` + : `Podfile does not exist at ${podfilePath}. Set ios.project.podfilePath to override the default ios/Podfile path.` + ) + + return podfilePath +} + +function parseIOSProject(content: string, pbxprojPath: string): ParsedIOSProject { + const nativeTargetSection = getPbxprojSection(content, PBX_NATIVE_TARGET_SECTION, pbxprojPath) + const configurationListSection = getPbxprojSection(content, XC_CONFIGURATION_LIST_SECTION, pbxprojPath) + const buildConfigurationSection = getPbxprojSection(content, XC_BUILD_CONFIGURATION_SECTION, pbxprojPath) + + return { + targets: parseNativeTargets(nativeTargetSection), + configurationLists: parseConfigurationLists(configurationListSection), + buildConfigurations: parseBuildConfigurations(buildConfigurationSection), + } +} + +function getPbxprojSection(content: string, sectionName: string, pbxprojPath: string): string { + const pattern = new RegExp(`/[*] Begin ${sectionName} section [*]/([\\s\\S]*?)/[*] End ${sectionName} section [*]/`) + const match = content.match(pattern) + + if (!match) { + throw new IOSProjectDiscoveryError(`Could not parse ${sectionName} section from ${pbxprojPath}`) + } + + return match[1] +} + +function parseNativeTargets(section: string): ParsedPbxNativeTarget[] { + const targets: ParsedPbxNativeTarget[] = [] + const entryPattern = /([A-Za-z0-9]+) \/\*[^*]+\*\/ = \{([\s\S]*?)\n\t\t\};/g + + for (const match of section.matchAll(entryPattern)) { + const [, id, body] = match + const name = matchPbxprojAssignment(body, 'name') + const productType = matchPbxprojAssignment(body, 'productType') + const buildConfigurationListId = matchPbxprojReference(body, 'buildConfigurationList') + + if (!name || !productType || !buildConfigurationListId) { + continue + } + + targets.push({ + id, + name: stripPbxprojValue(name), + productType: stripPbxprojValue(productType), + buildConfigurationListId, + }) + } + + return targets +} + +function parseConfigurationLists(section: string): Map { + const configurationLists = new Map() + const entryPattern = /([A-Za-z0-9]+) \/\*[^*]+\*\/ = \{([\s\S]*?)\n\t\t\};/g + + for (const match of section.matchAll(entryPattern)) { + const [, id, body] = match + const buildConfigurationIds = matchBuildConfigurationIds(body) + + if (buildConfigurationIds.length === 0) { + continue + } + + configurationLists.set(id, { + id, + buildConfigurationIds, + defaultConfigurationName: stripPbxprojValue(matchPbxprojAssignment(body, 'defaultConfigurationName') ?? '' ) || undefined, + }) + } + + return configurationLists +} + +function parseBuildConfigurations(section: string): Map { + const buildConfigurations = new Map() + const entryPattern = /([A-Za-z0-9]+) \/\*[^*]+\*\/ = \{([\s\S]*?)\n\t\t\};/g + + for (const match of section.matchAll(entryPattern)) { + const [, id, body] = match + const name = matchPbxprojAssignment(body, 'name') + const buildSettingsBlock = matchBuildSettingsBlock(body) + + if (!name || !buildSettingsBlock) { + continue + } + + buildConfigurations.set(id, { + id, + name: stripPbxprojValue(name), + buildSettings: { + codeSignEntitlements: matchBuildSetting(buildSettingsBlock, 'CODE_SIGN_ENTITLEMENTS'), + infoPlistFile: matchBuildSetting(buildSettingsBlock, 'INFOPLIST_FILE'), + productName: matchBuildSetting(buildSettingsBlock, 'PRODUCT_NAME'), + }, + }) + } + + return buildConfigurations +} + +function getMainTargetCandidates(targets: ParsedPbxNativeTarget[]): ParsedPbxNativeTarget[] { + return targets.filter((target) => target.productType === IOS_APPLICATION_PRODUCT_TYPE) +} + +function resolveMainTarget( + targets: ParsedPbxNativeTarget[], + configuredMainTargetName: string | undefined, + pbxprojPath: string +): ParsedPbxNativeTarget { + if (targets.length === 0) { + throw new IOSProjectDiscoveryError(`No iOS application targets were found in ${pbxprojPath}`) + } + + if (configuredMainTargetName) { + const configuredTarget = targets.find((target) => target.name === configuredMainTargetName) + + if (configuredTarget) { + return configuredTarget + } + + throw new IOSProjectDiscoveryError( + `Configured iOS main target '${configuredMainTargetName}' was not found. Available application targets: ${targets + .map((target) => target.name) + .sort() + .join(', ')}` + ) + } + + if (targets.length === 1) { + return targets[0] + } + + throw new IOSProjectDiscoveryError( + `Multiple iOS application targets were found: ${targets + .map((target) => target.name) + .sort() + .join(', ')}. Set ios.project.mainTargetName explicitly.` + ) +} + +function getTargetBuildConfigurations( + project: ParsedIOSProject, + target: ParsedPbxNativeTarget, + pbxprojPath: string +): ParsedXCBuildConfiguration[] { + const configurationList = project.configurationLists.get(target.buildConfigurationListId) + + if (!configurationList) { + throw new IOSProjectDiscoveryError( + `Build configuration list ${target.buildConfigurationListId} for target '${target.name}' was not found in ${pbxprojPath}` + ) + } + + const missingConfigurationIds = configurationList.buildConfigurationIds.filter( + (configurationId) => !project.buildConfigurations.has(configurationId) + ) + + if (missingConfigurationIds.length > 0) { + throw new IOSProjectDiscoveryError( + `Build configurations ${missingConfigurationIds.join(', ')} for target '${target.name}' were not found in ${pbxprojPath}` + ) + } + + const buildConfigurations = configurationList.buildConfigurationIds + .map((configurationId) => project.buildConfigurations.get(configurationId)) + .filter((configuration): configuration is ParsedXCBuildConfiguration => configuration !== undefined) + + if (buildConfigurations.length === 0) { + throw new IOSProjectDiscoveryError(`No build configurations were found for target '${target.name}' in ${pbxprojPath}`) + } + + const defaultConfigurationName = configurationList.defaultConfigurationName + + if (!defaultConfigurationName) { + return buildConfigurations + } + + const defaultConfiguration = buildConfigurations.find((configuration) => configuration.name === defaultConfigurationName) + + return defaultConfiguration ? [defaultConfiguration, ...buildConfigurations.filter((configuration) => configuration !== defaultConfiguration)] : buildConfigurations +} + +async function resolveInfoPlistPath( + iosRoot: string, + config: NormalizedIOSProjectConfig, + target: ParsedPbxNativeTarget, + buildConfigurations: ParsedXCBuildConfiguration[] +): Promise { + const infoPlistPath = + config.infoPlistPath ?? + resolveConsistentBuildSettingPath(iosRoot, target, buildConfigurations, 'INFOPLIST_FILE', (configuration) => configuration.buildSettings.infoPlistFile) + + if (!infoPlistPath) { + throw new IOSProjectDiscoveryError( + `Could not determine Info.plist for target '${target.name}'. Set ios.project.infoPlistPath explicitly.` + ) + } + + await ensureFile( + infoPlistPath, + config.infoPlistPath + ? `Configured Info.plist does not exist: ${infoPlistPath}` + : `Discovered Info.plist does not exist: ${infoPlistPath}` + ) + + return infoPlistPath +} + +async function resolveEntitlementsPath( + iosRoot: string, + config: NormalizedIOSProjectConfig, + target: ParsedPbxNativeTarget, + buildConfigurations: ParsedXCBuildConfiguration[] +): Promise { + const entitlementsPath = + config.entitlementsPath ?? + resolveConsistentBuildSettingPath( + iosRoot, + target, + buildConfigurations, + 'CODE_SIGN_ENTITLEMENTS', + (configuration) => configuration.buildSettings.codeSignEntitlements + ) + + if (!entitlementsPath) { + return undefined + } + + await ensureFile( + entitlementsPath, + config.entitlementsPath + ? `Configured entitlements file does not exist: ${entitlementsPath}` + : `Discovered entitlements file does not exist: ${entitlementsPath}` + ) + + return entitlementsPath +} + +function resolveConsistentBuildSettingPath( + iosRoot: string, + target: ParsedPbxNativeTarget, + buildConfigurations: ParsedXCBuildConfiguration[], + settingName: string, + getSettingValue: (configuration: ParsedXCBuildConfiguration) => string | undefined +): string | undefined { + const resolvedPaths = new Map() + + for (const configuration of buildConfigurations) { + const settingValue = getSettingValue(configuration) + + if (!settingValue) { + continue + } + + const resolvedPath = resolveXcodePathValue(settingValue, iosRoot, target, configuration) + const configurationNames = resolvedPaths.get(resolvedPath) ?? [] + configurationNames.push(configuration.name) + resolvedPaths.set(resolvedPath, configurationNames) + } + + if (resolvedPaths.size === 0) { + return undefined + } + + if (resolvedPaths.size > 1) { + throw new IOSProjectDiscoveryError( + `Target '${target.name}' resolves ${settingName} to multiple paths: ${[...resolvedPaths.entries()] + .map(([resolvedPath, configurationNames]) => `${resolvedPath} (${configurationNames.join(', ')})`) + .join('; ')}. Set ios.project.${settingName === 'INFOPLIST_FILE' ? 'infoPlistPath' : 'entitlementsPath'} explicitly.` + ) + } + + return [...resolvedPaths.keys()][0] +} + +function resolveXcodePathValue( + value: string, + iosRoot: string, + target: ParsedPbxNativeTarget, + configuration: ParsedXCBuildConfiguration +): string { + const substitutions = new Map([ + ['PROJECT_DIR', iosRoot], + ['SRCROOT', iosRoot], + ['TARGET_NAME', target.name], + ]) + + const productName = configuration.buildSettings.productName + + if (productName) { + substitutions.set('PRODUCT_NAME', substituteXcodeVariables(productName, substitutions)) + } + + const substitutedValue = substituteXcodeVariables(value, substitutions) + + if (/\$\(|\$\{/.test(substitutedValue)) { + throw new IOSProjectDiscoveryError( + `Could not resolve Xcode build setting path '${value}' for target '${target.name}'. Set an explicit ios.project override.` + ) + } + + return path.normalize(path.isAbsolute(substitutedValue) ? substitutedValue : path.join(iosRoot, substitutedValue)) +} + +function substituteXcodeVariables(value: string, substitutions: Map): string { + let nextValue = stripPbxprojValue(value) + + for (let iteration = 0; iteration < 5; iteration += 1) { + let didReplace = false + + nextValue = nextValue.replace(/\$\(([^)]+)\)|\$\{([^}]+)\}/g, (match, groupedName, bracedName) => { + const variableName = groupedName ?? bracedName + const substitution = substitutions.get(variableName) + + if (substitution === undefined) { + return match + } + + didReplace = true + return substitution + }) + + if (!didReplace) { + break + } + } + + return stripPbxprojValue(nextValue) +} + +function matchBuildConfigurationIds(body: string): string[] { + const match = body.match(/buildConfigurations = \(([\s\S]*?)\n\t\t\t\);/) + + if (!match) { + return [] + } + + return [...match[1].matchAll(/([A-Za-z0-9]+) \/\*[^*]+\*\//g)].map((configurationMatch) => configurationMatch[1]) +} + +function matchBuildSettingsBlock(body: string): string | undefined { + return body.match(/buildSettings = \{([\s\S]*?)\n\t\t\t\};/)?.[1] +} + +function matchBuildSetting(buildSettingsBlock: string, settingName: string): string | undefined { + return stripPbxprojValue(buildSettingsBlock.match(new RegExp(`\\b${settingName}\\s*=\\s*([^;]+);`))?.[1] ?? '') || undefined +} + +function matchPbxprojReference(body: string, fieldName: string): string | undefined { + return body.match(new RegExp(`\\b${fieldName}\\s*=\\s*([A-Za-z0-9]+)`))?.[1] +} + +function matchPbxprojAssignment(body: string, fieldName: string): string | undefined { + return body.match(new RegExp(`\\b${fieldName}\\s*=\\s*([^;]+);`))?.[1] +} + +function stripPbxprojValue(value: string): string { + const trimmedValue = value.trim() + + if (trimmedValue.startsWith('"') && trimmedValue.endsWith('"')) { + return trimmedValue.slice(1, -1) + } + + return trimmedValue +} + +async function ensureDirectory(dirPath: string, message: string): Promise { + const stat = await readPathStat(dirPath) + + if (!stat) { + throw new IOSProjectDiscoveryError(message) + } + + if (!stat.isDirectory()) { + throw new IOSProjectDiscoveryError(`Expected a directory but found a file: ${dirPath}`) + } +} + +async function ensureFile(filePath: string, message: string): Promise { + const stat = await readPathStat(filePath) + + if (!stat) { + throw new IOSProjectDiscoveryError(message) + } + + if (!stat.isFile()) { + throw new IOSProjectDiscoveryError(`Expected a file but found a directory: ${filePath}`) + } +} + +async function readPathStat(targetPath: string): Promise { + try { + return await fs.stat(targetPath) + } catch (error: unknown) { + if (isNotFoundError(error)) { + return undefined + } + + throw error + } +} + +function isNotFoundError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === 'ENOENT' +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f62764ba..16add76b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -52,6 +52,8 @@ export { VoltraConfigLoadError, loadVoltraConfig } from './config/load' export { VoltraConfigNormalizationError, normalizeVoltraConfig } from './config/normalize' export { AndroidProjectDiscoveryError, discoverAndroidProject } from './discovery/android' export type { AndroidProjectDiscovery } from './discovery/android' +export { IOSProjectDiscoveryError, discoverIOSProject } from './discovery/ios' +export type { IOSProjectDiscovery } from './discovery/ios' export { ensureGitWorktreeIsReady, getGitWorktreeStatus } from './git/status' export type { EnsureGitWorktreeOptions, EnsureGitWorktreeResult, GitWorktreeStatus } from './git/status' export { AndroidGeneratedFilesError, generateAndroidFiles } from './platforms/android/generated' From 5d391964b0df5247de8acb8e385d029be8027c58 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 08:36:51 +0200 Subject: [PATCH 18/37] feat: add ios generated file helpers --- PLAN.md | 3 + packages/cli/package.json | 1 + packages/cli/src/index.ts | 2 + packages/cli/src/platforms/ios/generated.ts | 1101 +++++++++++++++++++ 4 files changed, 1107 insertions(+) create mode 100644 packages/cli/src/platforms/ios/generated.ts diff --git a/PLAN.md b/PLAN.md index 32a6535b..682de86e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -698,6 +698,9 @@ Can run in parallel with: **T17. Adapt reusable iOS generated-file logic for CLI use** +Status: +- completed + Deliverables: - wire widget extension file generation behind CLI-friendly inputs - define generated file inventory output for state tracking diff --git a/packages/cli/package.json b/packages/cli/package.json index 8f0ee6f5..458e25d3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,6 +48,7 @@ "dependencies": { "@babel/core": "^7.27.4", "@use-voltra/android": "1.4.1", + "@use-voltra/ios": "1.4.1", "cosmiconfig": "^9.0.0", "xml2js": "^0.6.2", "vd-tool": "^4.0.2" diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 16add76b..3d495383 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -61,6 +61,8 @@ export type { GenerateAndroidFilesOptions, GenerateAndroidFilesResult } from './ export { applyAndroidPlatform, createAndroidPreflightRunner } from './platforms/android/apply' export { AndroidManifestMutationError, ensureAndroidManifest } from './platforms/android/manifest' export type { EnsureAndroidManifestOptions, EnsureAndroidManifestResult } from './platforms/android/manifest' +export { IOSGeneratedFilesError, generateIOSFiles } from './platforms/ios/generated' +export type { GenerateIOSFilesOptions, GenerateIOSFilesResult } from './platforms/ios/generated' export { diffVoltraState } from './state/diff' export { getVoltraStatePath, loadVoltraState } from './state/load' export { saveVoltraState } from './state/save' diff --git a/packages/cli/src/platforms/ios/generated.ts b/packages/cli/src/platforms/ios/generated.ts new file mode 100644 index 00000000..38847af4 --- /dev/null +++ b/packages/cli/src/platforms/ios/generated.ts @@ -0,0 +1,1101 @@ +import fs from 'node:fs' +import fsPromises from 'node:fs/promises' +import path from 'node:path' +import vm from 'node:vm' +import { createRequire } from 'node:module' + +import * as babel from '@babel/core' +import { renderWidgetToString } from '@use-voltra/ios' +import { parseStringPromise } from 'xml2js' + +import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite' +import { normalizeRelativePath, toRelativePath } from '../../fs/path' +import { VoltraCliError } from '../../reporting/summary' + +import type { IOSProjectDiscovery } from '../../discovery/ios' +import type { IOSWidgetFamily, NormalizedIOSWidgetConfig, NormalizedVoltraIOSConfig, WidgetLabel } from '../../config/types' +import type { ReportedChange } from '../../reporting/summary' +import type { WidgetVariants } from '@use-voltra/ios' + +const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', ''] +const VALID_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']) +const FONT_EXTENSIONS = new Set(['.ttf', '.otf', '.woff', '.woff2']) +const MAX_IMAGE_SIZE_BYTES = 4096 +const DEFAULT_USER_IMAGES_PATH = './assets/voltra' +const DEFAULT_INITIAL_STATE_LOCALE = '__default' +const VOLTRA_WIDGET_STRINGS_BASENAME = 'VoltraWidgets.strings' + +const IOS_WIDGET_FAMILY_MAP: Record = { + systemSmall: '.systemSmall', + systemMedium: '.systemMedium', + systemLarge: '.systemLarge', + systemExtraLarge: '.systemExtraLarge', + accessoryCircular: '.accessoryCircular', + accessoryRectangular: '.accessoryRectangular', + accessoryInline: '.accessoryInline', +} + +const GENERATED_INITIAL_STATE_LOCALE_HELPER = `private enum VoltraGeneratedInitialStateLocale { + static func pickJson(from perLocale: [String: String], preferredLanguages: [String]) -> String? { + let entries = perLocale.filter { !$0.value.isEmpty } + if entries.isEmpty { + return nil + } + + func normalize(_ tag: String) -> String { + tag.trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + .replacingOccurrences(of: "_", with: "-") + } + + var byNorm: [String: String] = [:] + for (k, v) in entries { + byNorm[normalize(k)] = v + } + + for pref in preferredLanguages { + let n = normalize(pref) + if let direct = byNorm[n] { + return direct + } + let lang = n.split(separator: "-").first.map(String.init) ?? n + for (key, val) in entries { + let kn = normalize(key) + let keyLang = kn.split(separator: "-").first.map(String.init) ?? kn + if keyLang == lang { + return val + } + } + } + + if let en = byNorm["en"] { + return en + } + if let englishFamily = entries.keys.sorted().first(where: { + let normalized = normalize($0) + return normalized == "en" || normalized.hasPrefix("en-") + }) { + return entries[englishFamily] + } + if let def = byNorm["__default"] { + return def + } + + let sorted = entries.keys.sorted() + guard let firstKey = sorted.first else { + return nil + } + return entries[firstKey] + } + + static func preferredLanguageTags() -> [String] { + Locale.preferredLanguages + } +} +` + +export interface GenerateIOSFilesOptions { + projectRoot: string + ios: NormalizedVoltraIOSConfig + discovery: IOSProjectDiscovery +} + +export interface GenerateIOSFilesResult { + changes: ReportedChange[] + files: string[] + warnings: string[] + targetName: string + targetPath: string +} + +interface GeneratedFileResult { + change?: ReportedChange + relativePath: string +} + +interface MainAppMetadata { + shortVersionString: string + buildNumber: string + urlTypes?: Array<{ CFBundleURLSchemes: string[] }> +} + +interface ParsedPlistNode { + key?: string | string[] + string?: string | string[] + integer?: string | string[] + real?: string | string[] + true?: unknown | unknown[] + false?: unknown | unknown[] + dict?: ParsedPlistNode | ParsedPlistNode[] + array?: ParsedPlistNode | ParsedPlistNode[] +} + +type IOSWidgetRenderer = typeof renderWidgetToString +type PrerenderedWidgetStates = Map> + +export class IOSGeneratedFilesError extends VoltraCliError { + constructor(message: string) { + super(message, 'VOLTRA_IOS_GENERATED_FILES_FAILED') + this.name = 'IOSGeneratedFilesError' + } +} + +export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promise { + const { projectRoot, ios, discovery } = options + const targetName = resolveIOSTargetName(ios, discovery) + const targetPath = path.join(discovery.iosRoot, targetName) + const mainAppMetadata = await readMainAppMetadata(discovery.infoPlistPath) + const changes: ReportedChange[] = [] + const warnings: string[] = [] + const generatedFiles = new Set() + + const infoPlistResult = await generateInfoPlistFile(projectRoot, targetPath, targetName, ios, mainAppMetadata) + mergeSingleResult(infoPlistResult, changes, generatedFiles) + + const entitlementsResult = await generateEntitlementsFile(projectRoot, targetPath, targetName, ios) + mergeSingleResult(entitlementsResult, changes, generatedFiles) + + const assetResult = await generateAssetsCatalog(projectRoot, targetPath) + mergeResult(assetResult, changes, warnings, generatedFiles) + + const fontsResult = await copyIOSFonts(projectRoot, targetPath, ios.fonts) + mergeResult(fontsResult, changes, warnings, generatedFiles) + + const initialStatesResult = await generateInitialStatesSwift(projectRoot, ios.widgets) + mergeSingleResult( + await writeGeneratedTextFile(projectRoot, path.join(targetPath, 'VoltraWidgetInitialStates.swift'), initialStatesResult), + changes, + generatedFiles + ) + + const widgetBundleResult = await writeGeneratedTextFile( + projectRoot, + path.join(targetPath, 'VoltraWidgetBundle.swift'), + generateWidgetBundleSwift(ios.widgets) + ) + mergeSingleResult(widgetBundleResult, changes, generatedFiles) + + const localizedStringResults = await generateLocalizedWidgetStrings(projectRoot, targetPath, ios.widgets) + mergeResult(localizedStringResults, changes, warnings, generatedFiles) + + return { + changes, + files: [...generatedFiles].sort(), + warnings, + targetName, + targetPath, + } +} + +async function generateInfoPlistFile( + projectRoot: string, + targetPath: string, + targetName: string, + ios: NormalizedVoltraIOSConfig, + mainAppMetadata: MainAppMetadata +): Promise { + const plistPath = path.join(targetPath, 'Info.plist') + const fontNames = ios.fonts.map((fontPath) => path.basename(fontPath)).sort() + const serverWidgets = ios.widgets.filter((widget) => widget.serverUpdate) + const serverUrls = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.url])) + const serverIntervals = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.intervalMinutes])) + const serverRefresh = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.refresh ?? false])) + const infoPlist = buildPlistXml({ + CFBundleDevelopmentRegion: '$(DEVELOPMENT_LANGUAGE)', + CFBundleDisplayName: targetName, + CFBundleExecutable: '$(EXECUTABLE_NAME)', + CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)', + CFBundleInfoDictionaryVersion: '6.0', + CFBundleName: '$(PRODUCT_NAME)', + CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)', + CFBundleShortVersionString: mainAppMetadata.shortVersionString, + CFBundleVersion: mainAppMetadata.buildNumber, + NSExtension: { + NSExtensionPointIdentifier: 'com.apple.widgetkit-extension', + }, + RCTNewArchEnabled: true, + CFBundleURLTypes: mainAppMetadata.urlTypes, + UIAppFonts: fontNames.length > 0 ? fontNames : undefined, + Voltra_AppGroupIdentifier: ios.groupIdentifier, + Voltra_KeychainGroup: ios.keychainGroup, + Voltra_WidgetServerIntervals: Object.keys(serverIntervals).length > 0 ? serverIntervals : undefined, + Voltra_WidgetServerRefresh: Object.keys(serverRefresh).length > 0 ? serverRefresh : undefined, + NSAppTransportSecurity: + serverWidgets.length > 0 + ? { + NSAllowsLocalNetworking: true, + NSAllowsArbitraryLoads: false, + } + : undefined, + Voltra_WidgetServerUrls: Object.keys(serverUrls).length > 0 ? serverUrls : undefined, + }) + + return writeGeneratedTextFile(projectRoot, plistPath, infoPlist) +} + +async function generateEntitlementsFile( + projectRoot: string, + targetPath: string, + targetName: string, + ios: NormalizedVoltraIOSConfig +): Promise { + const entitlementsPath = path.join(targetPath, `${targetName}.entitlements`) + const entitlements = buildPlistXml({ + 'com.apple.security.application-groups': ios.groupIdentifier ? [ios.groupIdentifier] : undefined, + 'keychain-access-groups': ios.keychainGroup ? [ios.keychainGroup] : undefined, + }) + + return writeGeneratedTextFile(projectRoot, entitlementsPath, entitlements) +} + +async function generateAssetsCatalog( + projectRoot: string, + targetPath: string +): Promise { + const changes: ReportedChange[] = [] + const warnings: string[] = [] + const generatedFiles = new Set() + const assetsCatalogPath = path.join(targetPath, 'Assets.xcassets') + const rootContentsResult = await writeGeneratedTextFile( + projectRoot, + path.join(assetsCatalogPath, 'Contents.json'), + `${JSON.stringify({ info: { author: 'xcode', version: 1 } }, null, 2)}\n` + ) + mergeSingleResult(rootContentsResult, changes, generatedFiles) + + const userImagesPath = path.resolve(projectRoot, DEFAULT_USER_IMAGES_PATH) + const userImages = await collectUserImages(userImagesPath) + + for (const imagePath of userImages) { + const extension = path.extname(imagePath).toLowerCase() + + if (!VALID_IMAGE_EXTENSIONS.has(extension)) { + continue + } + + const imageName = sanitizeAssetName(path.basename(imagePath, extension)) + const imagesetPath = path.join(assetsCatalogPath, `${imageName}.imageset`) + const imageFileName = `${imageName}${extension}` + const imageResult = await copyGeneratedFile(projectRoot, imagePath, path.join(imagesetPath, imageFileName)) + mergeSingleResult(imageResult, changes, generatedFiles) + + const contentsResult = await writeGeneratedTextFile( + projectRoot, + path.join(imagesetPath, 'Contents.json'), + `${JSON.stringify( + { + images: [{ filename: imageFileName, idiom: 'universal' }], + info: { author: 'xcode', version: 1 }, + }, + null, + 2 + )}\n` + ) + mergeSingleResult(contentsResult, changes, generatedFiles) + + const imageWarning = await getLargeImageWarning(imagePath, imageFileName) + if (imageWarning) { + warnings.push(imageWarning) + } + } + + return { + changes, + files: [...generatedFiles], + warnings, + targetName: '', + targetPath, + } +} + +async function copyIOSFonts(projectRoot: string, targetPath: string, fonts: string[]): Promise { + const changes: ReportedChange[] = [] + const generatedFiles = new Set() + const warnings: string[] = [] + const fontPaths = await resolveFontPaths(projectRoot, fonts) + + for (const fontPath of fontPaths) { + const result = await copyGeneratedFile(projectRoot, fontPath, path.join(targetPath, path.basename(fontPath))) + mergeSingleResult(result, changes, generatedFiles) + } + + return { + changes, + files: [...generatedFiles], + warnings, + targetName: '', + targetPath, + } +} + +async function generateLocalizedWidgetStrings( + projectRoot: string, + targetPath: string, + widgets: NormalizedIOSWidgetConfig[] +): Promise { + const changes: ReportedChange[] = [] + const generatedFiles = new Set() + const byLocale = collectGalleryStringsByLocale(widgets) + + for (const [locale, entries] of byLocale.entries()) { + const lprojPath = path.join(targetPath, `${locale}.lproj`) + const stringsPath = path.join(lprojPath, VOLTRA_WIDGET_STRINGS_BASENAME) + const stringsContent = formatStringsFile(entries) + const result = await writeGeneratedTextFile(projectRoot, stringsPath, stringsContent) + mergeSingleResult(result, changes, generatedFiles) + } + + return { + changes, + files: [...generatedFiles], + warnings: [], + targetName: '', + targetPath, + } +} + +async function readMainAppMetadata(infoPlistPath: string): Promise { + const content = await readTextFile(infoPlistPath) + const parsed = await parseStringPromise(content, { explicitArray: false }) + const dict = getParsedPlistDict(parsed) + const shortVersionString = readPlistString(dict, 'CFBundleShortVersionString') ?? '1.0.0' + const buildNumber = readPlistString(dict, 'CFBundleVersion') ?? '1' + const urlTypes = readUrlTypes(dict) + + return { + shortVersionString, + buildNumber, + urlTypes: urlTypes.length > 0 ? urlTypes : undefined, + } +} + +async function generateInitialStatesSwift(projectRoot: string, widgets: NormalizedIOSWidgetConfig[]): Promise { + const prerenderableWidgets = widgets.filter((widget) => widget.initialStatePath) + + if (prerenderableWidgets.length === 0) { + return [ + '//', + '// VoltraWidgetInitialStates.swift', + '//', + '// Auto-generated by Voltra.', + '// No widget initial states configured.', + '//', + '', + 'import Foundation', + '', + 'public enum VoltraWidgetInitialStates {', + ' public static func getInitialState(for widgetId: String) -> Data? {', + ' nil', + ' }', + '}', + '', + ].join('\n') + } + + const prerenderedStates = await prerenderWidgetStates(projectRoot, prerenderableWidgets, renderWidgetToString) + const widgetEntries = [...prerenderedStates.entries()] + .map(([widgetId, localeMap]) => { + const localeEntries = [...localeMap.entries()] + .map(([localeKey, json]) => { + const delimiter = getSwiftRawStringDelimiter(json) + return ` ${JSON.stringify(localeKey)}: ${delimiter}"${json}"${delimiter}` + }) + .join(',\n') + return ` ${JSON.stringify(widgetId)}: [\n${localeEntries}\n ]` + }) + .join(',\n') + + return [ + '//', + '// VoltraWidgetInitialStates.swift', + '//', + '// Auto-generated by Voltra.', + '// Contains pre-rendered initial states for home screen widgets.', + '//', + '', + 'import Foundation', + '', + GENERATED_INITIAL_STATE_LOCALE_HELPER, + '', + 'public enum VoltraWidgetInitialStates {', + ' private static let bundledLocalizedStates: [String: [String: String]] = [', + widgetEntries, + ' ]', + '', + ' public static func getInitialState(for widgetId: String) -> Data? {', + ' guard let perLocale = bundledLocalizedStates[widgetId] else { return nil }', + ' let tags = VoltraGeneratedInitialStateLocale.preferredLanguageTags()', + ' guard let jsonString = VoltraGeneratedInitialStateLocale.pickJson(from: perLocale, preferredLanguages: tags) else {', + ' return nil', + ' }', + ' return jsonString.data(using: .utf8)', + ' }', + '}', + '', + ].join('\n') +} + +function generateWidgetBundleSwift(widgets: NormalizedIOSWidgetConfig[]): string { + const needsFoundation = widgets.some((widget) => isWidgetLocalizedMap(widget.displayName) || isWidgetLocalizedMap(widget.description)) + const imports = [needsFoundation ? 'import Foundation' : undefined, 'import SwiftUI', 'import WidgetKit', 'import VoltraWidget'] + .filter((value): value is string => value !== undefined) + .join('\n') + + if (widgets.length === 0) { + return [ + '//', + '// VoltraWidgetBundle.swift', + '//', + '// Auto-generated by Voltra.', + '//', + '', + imports, + '', + '@main', + 'struct VoltraWidgetBundle: WidgetBundle {', + ' var body: some Widget {', + ' VoltraWidget()', + ' }', + '}', + '', + ].join('\n') + } + + const widgetStructs = widgets.map(generateWidgetStruct).join('\n\n') + const widgetInstances = widgets.map((widget) => ` VoltraWidget_${widget.id}()`).join('\n') + + return [ + '//', + '// VoltraWidgetBundle.swift', + '//', + '// Auto-generated by Voltra.', + '//', + '', + imports, + '', + '@main', + 'struct VoltraWidgetBundle: WidgetBundle {', + ' var body: some Widget {', + ' VoltraWidget()', + widgetInstances, + ' }', + '}', + '', + widgetStructs, + '', + ].join('\n') +} + +function generateWidgetStruct(widget: NormalizedIOSWidgetConfig): string { + const familiesSwift = widget.supportedFamilies.map((family) => IOS_WIDGET_FAMILY_MAP[family]).join(', ') + const displayNameExpr = createSwiftLabelExpression(widget.id, 'displayName', widget.displayName) + const descriptionExpr = createSwiftLabelExpression(widget.id, 'description', widget.description) + + return [ + `public struct VoltraWidget_${widget.id}: Widget {`, + ` private let widgetId = ${JSON.stringify(widget.id)}`, + '', + ' public init() {}', + '', + ' public var body: some WidgetConfiguration {', + ' StaticConfiguration(', + ` kind: ${JSON.stringify(`Voltra_Widget_${widget.id}`)},`, + ' provider: VoltraHomeWidgetProvider(', + ' widgetId: widgetId,', + ' initialState: VoltraWidgetInitialStates.getInitialState(for: widgetId)', + ' )', + ' ) { entry in', + ' VoltraHomeWidgetView(entry: entry)', + ' }', + ` .configurationDisplayName(${displayNameExpr})`, + ` .description(${descriptionExpr})`, + ` .supportedFamilies([${familiesSwift}])`, + ' .contentMarginsDisabled()', + ' }', + '}', + ].join('\n') +} + +function createSwiftLabelExpression(widgetId: string, field: 'displayName' | 'description', label: WidgetLabel): string { + if (!isWidgetLocalizedMap(label)) { + return `Text(${JSON.stringify(label)})` + } + + const key = `voltra_widget_${widgetId}_${field}` + const defaultEnglish = escapeSwiftString(widgetLabelEnglish(label)) + + return `Text(LocalizedStringResource(${JSON.stringify(key)}, defaultValue: String.LocalizationValue(${JSON.stringify(defaultEnglish)}), table: ${JSON.stringify('VoltraWidgets')}))` +} + +async function prerenderWidgetStates( + projectRoot: string, + widgets: NormalizedIOSWidgetConfig[], + renderer: IOSWidgetRenderer +): Promise { + const prerenderedStates: PrerenderedWidgetStates = new Map() + + for (const widget of widgets) { + const initialStatePath = widget.initialStatePath + + if (!initialStatePath) { + continue + } + + const perLocalePaths = typeof initialStatePath === 'string' ? { [DEFAULT_INITIAL_STATE_LOCALE]: initialStatePath } : initialStatePath + const localeStates = new Map() + + for (const [localeKey, modulePath] of Object.entries(perLocalePaths)) { + if (!(await pathExists(modulePath))) { + throw new IOSGeneratedFilesError(`Initial state file not found for widget '${widget.id}' at ${modulePath}`) + } + + const widgetVariants = evaluateWidgetModule(projectRoot, modulePath) + localeStates.set(localeKey, renderer(widgetVariants)) + } + + prerenderedStates.set(widget.id, localeStates) + } + + return prerenderedStates +} + +function evaluateWidgetModule(projectRoot: string, filePath: string): WidgetVariants { + const projectRequire = createProjectRequire(projectRoot) + const moduleCache = new Map() + + const customRequire = (moduleSpecifier: string, currentDir: string): unknown => { + if (!isLocalModule(moduleSpecifier)) { + return projectRequire(moduleSpecifier) + } + + const resolvedModulePath = resolveModulePath(moduleSpecifier, currentDir) + + if (!resolvedModulePath) { + throw new IOSGeneratedFilesError(`Cannot resolve module '${moduleSpecifier}' from '${currentDir}'`) + } + + const cachedModule = moduleCache.get(resolvedModulePath) + if (cachedModule !== undefined) { + return cachedModule + } + + const transpiledCode = transpileWidgetModule(projectRoot, resolvedModulePath, projectRequire) + const moduleDir = path.dirname(resolvedModulePath) + const moduleRecord = { exports: {} as Record } + moduleCache.set(resolvedModulePath, moduleRecord.exports) + + const context = vm.createContext({ + __dirname: moduleDir, + __filename: resolvedModulePath, + console, + exports: moduleRecord.exports, + module: moduleRecord, + process, + require: (specifier: string) => customRequire(specifier, moduleDir), + }) + + const script = new vm.Script(transpiledCode, { filename: resolvedModulePath }) + script.runInContext(context) + + moduleCache.set(resolvedModulePath, moduleRecord.exports) + return moduleRecord.exports + } + + const exports = customRequire(filePath, path.dirname(filePath)) as { default?: unknown } + const widgetVariants = exports.default ?? exports + + if (!widgetVariants || typeof widgetVariants !== 'object') { + throw new IOSGeneratedFilesError(`Widget file must export widget variants: ${filePath}`) + } + + return widgetVariants as WidgetVariants +} + +function transpileWidgetModule(projectRoot: string, filePath: string, projectRequire: NodeRequire): string { + const source = fs.readFileSync(filePath, 'utf8') + const projectBabelConfigPath = resolveProjectBabelConfig(projectRoot) + const result = babel.transformSync(source, { + babelrc: false, + configFile: projectBabelConfigPath, + cwd: projectRoot, + filename: filePath, + presets: projectBabelConfigPath ? undefined : [resolveFallbackBabelPreset(projectRequire)], + }) + + if (!result?.code) { + throw new IOSGeneratedFilesError(`Babel transpilation failed for ${filePath}`) + } + + return result.code +} + +function resolveProjectBabelConfig(projectRoot: string): string | undefined { + const candidates = ['babel.config.js', 'babel.config.cjs', 'babel.config.mjs'] + + for (const candidate of candidates) { + const candidatePath = path.join(projectRoot, candidate) + if (fs.existsSync(candidatePath)) { + return candidatePath + } + } + + return undefined +} + +function resolveFallbackBabelPreset(projectRequire: NodeRequire): string { + try { + return projectRequire.resolve('@react-native/babel-preset') + } catch { + try { + return projectRequire.resolve('babel-preset-expo') + } catch { + throw new IOSGeneratedFilesError( + 'Could not resolve a Babel preset for iOS initial state generation. Add a project babel.config.js or install @react-native/babel-preset.' + ) + } + } +} + +function createProjectRequire(projectRoot: string): NodeRequire { + return createRequire(path.join(projectRoot, 'package.json')) +} + +function isLocalModule(moduleSpecifier: string): boolean { + return moduleSpecifier.startsWith('.') || moduleSpecifier.startsWith('/') +} + +function resolveModulePath(moduleSpecifier: string, fromDir: string): string | null { + const basePath = path.resolve(fromDir, moduleSpecifier) + + for (const extension of MODULE_EXTENSIONS) { + const candidate = `${basePath}${extension}` + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) { + return candidate + } + } + + if (!fs.existsSync(basePath) || !fs.statSync(basePath).isDirectory()) { + return null + } + + for (const extension of MODULE_EXTENSIONS) { + const indexCandidate = path.join(basePath, `index${extension}`) + if (fs.existsSync(indexCandidate) && fs.statSync(indexCandidate).isFile()) { + return indexCandidate + } + } + + return null +} + +async function resolveFontPaths(projectRoot: string, fonts: string[]): Promise { + const projectRequire = createProjectRequire(projectRoot) + const resolvedFontPaths = new Set() + + for (const font of fonts) { + const resolvedFontInput = await resolveFontInput(projectRoot, font, projectRequire) + + if (!resolvedFontInput) { + throw new IOSGeneratedFilesError(`Could not resolve iOS font path: ${font}`) + } + + const stat = await fsPromises.stat(resolvedFontInput) + if (!stat.isDirectory()) { + if (FONT_EXTENSIONS.has(path.extname(resolvedFontInput).toLowerCase())) { + resolvedFontPaths.add(resolvedFontInput) + } + continue + } + + for (const entry of await fsPromises.readdir(resolvedFontInput)) { + const candidatePath = path.join(resolvedFontInput, entry) + if (FONT_EXTENSIONS.has(path.extname(candidatePath).toLowerCase())) { + resolvedFontPaths.add(candidatePath) + } + } + } + + return [...resolvedFontPaths].sort() +} + +async function resolveFontInput(projectRoot: string, input: string, projectRequire: NodeRequire): Promise { + const resolvedPath = path.isAbsolute(input) ? input : path.resolve(projectRoot, input) + if (await pathExists(resolvedPath)) { + return resolvedPath + } + + try { + return projectRequire.resolve(input) + } catch { + return null + } +} + +async function collectUserImages(userImagesPath: string): Promise { + if (!(await pathExists(userImagesPath))) { + return [] + } + + const collectedPaths: string[] = [] + await collectPathsRecursively(userImagesPath, collectedPaths) + return collectedPaths.sort() +} + +async function collectPathsRecursively(currentPath: string, collectedPaths: string[]): Promise { + const stat = await fsPromises.lstat(currentPath) + + if (!stat.isDirectory()) { + if (!path.basename(currentPath).startsWith('.')) { + collectedPaths.push(currentPath) + } + return + } + + for (const entry of await fsPromises.readdir(currentPath)) { + await collectPathsRecursively(path.join(currentPath, entry), collectedPaths) + } +} + +async function copyGeneratedFile(projectRoot: string, sourcePath: string, destinationPath: string): Promise { + await ensureDirectory(path.dirname(destinationPath)) + const sourceContent = await fsPromises.readFile(sourcePath) + const existingContent = (await pathExists(destinationPath)) ? await fsPromises.readFile(destinationPath) : undefined + const relativePath = toRelativePath(projectRoot, destinationPath) + + if (existingContent && Buffer.compare(existingContent, sourceContent) === 0) { + return { relativePath } + } + + await fsPromises.writeFile(destinationPath, sourceContent) + + return { + change: { + kind: existingContent ? 'updated' : 'created', + path: relativePath, + }, + relativePath, + } +} + +async function writeGeneratedTextFile(projectRoot: string, destinationPath: string, content: string): Promise { + const existingContent = (await pathExists(destinationPath)) ? await readTextFile(destinationPath) : undefined + const relativePath = toRelativePath(projectRoot, destinationPath) + + if (existingContent === content) { + return { relativePath } + } + + await writeTextFile(destinationPath, content) + + return { + change: { + kind: existingContent === undefined ? 'created' : 'updated', + path: relativePath, + }, + relativePath, + } +} + +async function getLargeImageWarning(imagePath: string, fileName: string): Promise { + const stat = await fsPromises.stat(imagePath) + + if (stat.size < MAX_IMAGE_SIZE_BYTES) { + return undefined + } + + return `Image '${fileName}' is ${stat.size} bytes. Large iOS widget images may not display correctly.` +} + +function resolveIOSTargetName(ios: NormalizedVoltraIOSConfig, discovery: IOSProjectDiscovery): string { + if (ios.targetName) { + return ios.targetName + } + + const sanitizedTargetName = discovery.mainTargetName.replace(/[^A-Za-z0-9_]/g, '') + return `${sanitizedTargetName}LiveActivity` +} + +function collectGalleryStringsByLocale(widgets: NormalizedIOSWidgetConfig[]): Map> { + const byLocale = new Map>() + + const add = (locale: string, key: string, value: string): void => { + const bucket = byLocale.get(locale) ?? {} + bucket[key] = value + byLocale.set(locale, bucket) + } + + for (const widget of widgets) { + if (isWidgetLocalizedMap(widget.displayName)) { + for (const [locale, value] of Object.entries(widget.displayName)) { + if (value.trim()) { + add(locale, `voltra_widget_${widget.id}_displayName`, value) + } + } + } + + if (isWidgetLocalizedMap(widget.description)) { + for (const [locale, value] of Object.entries(widget.description)) { + if (value.trim()) { + add(locale, `voltra_widget_${widget.id}_description`, value) + } + } + } + } + + return byLocale +} + +function formatStringsFile(entries: Record): string { + const sortedKeys = Object.keys(entries).sort() + const lines = sortedKeys.map((key) => `${JSON.stringify(key)} = ${JSON.stringify(entries[key])};`) + return `/* Voltra widget gallery strings (auto-generated) */\n${lines.join('\n')}\n` +} + +function widgetLabelEnglish(label: WidgetLabel): string { + if (!isWidgetLocalizedMap(label)) { + return label + } + + const englishLabel = label.en + if (typeof englishLabel === 'string' && englishLabel.trim()) { + return englishLabel + } + + for (const [localeKey, value] of Object.entries(label)) { + if ((localeKey.startsWith('en-') || localeKey.startsWith('en_')) && value.trim()) { + return value + } + } + + return Object.values(label).find((value) => value.trim()) ?? '' +} + +function isWidgetLocalizedMap(label: WidgetLabel): label is Record { + return typeof label === 'object' && label !== null && !Array.isArray(label) +} + +function sanitizeAssetName(value: string): string { + let sanitized = value.replace(/[^A-Za-z0-9_-]/g, '-') + if (!/^[A-Za-z]/.test(sanitized)) { + sanitized = `asset-${sanitized}` + } + return sanitized +} + +function getParsedPlistDict(value: unknown): Record { + if (!value || typeof value !== 'object' || !('plist' in value)) { + throw new IOSGeneratedFilesError('Could not parse main app Info.plist for widget file generation.') + } + + const plistValue = (value as { plist?: { dict?: unknown } }).plist + + if (!plistValue || typeof plistValue !== 'object' || !('dict' in plistValue)) { + throw new IOSGeneratedFilesError('Main app Info.plist is missing its root dict.') + } + + if (!plistValue.dict || typeof plistValue.dict !== 'object') { + throw new IOSGeneratedFilesError('Main app Info.plist dict could not be read.') + } + + return convertPlistDict(plistValue.dict as ParsedPlistNode) +} + +function readPlistString(dict: Record, key: string): string | undefined { + const value = dict[key] + return typeof value === 'string' && value.trim() ? value : undefined +} + +function readUrlTypes(dict: Record): Array<{ CFBundleURLSchemes: string[] }> { + const urlTypesValue = dict.CFBundleURLTypes + + if (!Array.isArray(urlTypesValue)) { + return [] + } + + return urlTypesValue + .map((entry): { CFBundleURLSchemes: string[] } | undefined => { + if (!entry || typeof entry !== 'object') { + return undefined + } + + const schemesValue = (entry as { CFBundleURLSchemes?: unknown }).CFBundleURLSchemes + + if (!Array.isArray(schemesValue)) { + return undefined + } + + const schemes = schemesValue.filter((scheme): scheme is string => typeof scheme === 'string' && scheme.trim().length > 0) + + return schemes.length > 0 ? { CFBundleURLSchemes: schemes } : undefined + }) + .filter((entry): entry is { CFBundleURLSchemes: string[] } => entry !== undefined) +} + +function convertPlistDict(node: ParsedPlistNode): Record { + const keys = toStringArray(node.key) + const values = collectPlistValues(node) + + if (keys.length !== values.length) { + throw new IOSGeneratedFilesError('Main app Info.plist dict keys do not match their values.') + } + + return Object.fromEntries(keys.map((key, index) => [key, values[index]])) +} + +function collectPlistValues(node: ParsedPlistNode): unknown[] { + const values: unknown[] = [] + const keyCount = toStringArray(node.key).length + const entries = Object.entries(node).filter(([entryKey]) => entryKey !== 'key') + + for (const [entryKey, entryValue] of entries) { + if (entryValue === undefined) { + continue + } + + const normalizedValues = Array.isArray(entryValue) ? entryValue : [entryValue] + + for (const normalizedValue of normalizedValues) { + values.push(convertPlistValue(entryKey, normalizedValue)) + } + } + + if (keyCount > 0 && values.length > keyCount) { + throw new IOSGeneratedFilesError('Main app Info.plist dict contains more values than keys.') + } + + return values +} + +function convertPlistValue(entryKey: string, value: unknown): unknown { + switch (entryKey) { + case 'string': + return expectPlistScalar(value, 'string') + case 'integer': + return expectPlistScalar(value, 'integer') + case 'real': + return expectPlistScalar(value, 'real') + case 'true': + return true + case 'false': + return false + case 'dict': + if (!value || typeof value !== 'object') { + throw new IOSGeneratedFilesError('Main app Info.plist contains an invalid dict value.') + } + return convertPlistDict(value as ParsedPlistNode) + case 'array': + if (!value || typeof value !== 'object') { + throw new IOSGeneratedFilesError('Main app Info.plist contains an invalid array value.') + } + return convertPlistArray(value as ParsedPlistNode) + default: + throw new IOSGeneratedFilesError(`Unsupported plist node '${entryKey}' in main app Info.plist.`) + } +} + +function convertPlistArray(node: ParsedPlistNode): unknown[] { + return collectPlistValues(node) +} + +function expectPlistScalar(value: unknown, kind: string): string { + if (typeof value !== 'string') { + throw new IOSGeneratedFilesError(`Main app Info.plist contains a non-string ${kind} value.`) + } + + return value +} + +function toStringArray(value: string | string[] | undefined): string[] { + if (value === undefined) { + return [] + } + + return Array.isArray(value) ? value : [value] +} + +function escapeSwiftString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r') +} + +function getSwiftRawStringDelimiter(value: string): string { + const matches = value.match(/"#+/g) + if (!matches) { + return '#' + } + + const maxHashes = Math.max(...matches.map((match) => match.length - 1)) + return '#'.repeat(maxHashes + 1) +} + +function buildPlistXml(value: unknown): string { + return [ + '', + '', + '', + renderPlistValue(value, 0), + '', + '', + ].join('\n') +} + +function renderPlistValue(value: unknown, indentLevel: number): string { + const indent = ' '.repeat(indentLevel) + + if (Array.isArray(value)) { + const items = value.map((item) => renderPlistValue(item, indentLevel + 1)).join('\n') + return `${indent}\n${items}\n${indent}` + } + + if (value && typeof value === 'object') { + const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined) + const lines = entries.flatMap(([key, entryValue]) => [`${indent} ${escapePlistText(key)}`, renderPlistValue(entryValue, indentLevel + 1)]) + return `${indent}\n${lines.join('\n')}\n${indent}` + } + + if (typeof value === 'string') { + return `${indent}${escapePlistText(value)}` + } + + if (typeof value === 'number') { + return Number.isInteger(value) ? `${indent}${value}` : `${indent}${value}` + } + + if (typeof value === 'boolean') { + return `${indent}<${value ? 'true' : 'false'}/>` + } + + if (value === null || value === undefined) { + throw new IOSGeneratedFilesError('Cannot encode null or undefined in generated plist output.') + } + + throw new IOSGeneratedFilesError(`Unsupported plist value type: ${typeof value}`) +} + +function escapePlistText(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') +} + +function mergeResult( + result: Pick, + changes: ReportedChange[], + warnings: string[], + generatedFiles: Set +): void { + changes.push(...result.changes) + warnings.push(...result.warnings) + + for (const filePath of result.files) { + generatedFiles.add(normalizeRelativePath(filePath)) + } +} + +function mergeSingleResult(result: GeneratedFileResult, changes: ReportedChange[], generatedFiles: Set): void { + if (result.change) { + changes.push(result.change) + } + + generatedFiles.add(normalizeRelativePath(result.relativePath)) +} From c3044f0ab61e1c86e7701715ca0f2b20649f11fa Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 08:49:11 +0200 Subject: [PATCH 19/37] feat: add ios plist and entitlements mutators --- PLAN.md | 3 + packages/cli/src/index.ts | 4 + .../cli/src/platforms/ios/entitlements.ts | 105 ++++++ packages/cli/src/platforms/ios/generated.ts | 176 +--------- packages/cli/src/platforms/ios/plist.ts | 318 ++++++++++++++++++ packages/cli/src/xml2js.d.ts | 1 + 6 files changed, 439 insertions(+), 168 deletions(-) create mode 100644 packages/cli/src/platforms/ios/entitlements.ts create mode 100644 packages/cli/src/platforms/ios/plist.ts diff --git a/PLAN.md b/PLAN.md index 682de86e..fdee5417 100644 --- a/PLAN.md +++ b/PLAN.md @@ -721,6 +721,9 @@ Can run in parallel with: **T18. Implement CLI-native plist and entitlements mutators** +Status: +- completed + Deliverables: - parse and update main app `Info.plist` - parse and update entitlements diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3d495383..cc2fb3df 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -63,6 +63,10 @@ export { AndroidManifestMutationError, ensureAndroidManifest } from './platforms export type { EnsureAndroidManifestOptions, EnsureAndroidManifestResult } from './platforms/android/manifest' export { IOSGeneratedFilesError, generateIOSFiles } from './platforms/ios/generated' export type { GenerateIOSFilesOptions, GenerateIOSFilesResult } from './platforms/ios/generated' +export { IOSEntitlementsMutationError, ensureEntitlements } from './platforms/ios/entitlements' +export type { EnsureEntitlementsOptions, EnsureEntitlementsResult } from './platforms/ios/entitlements' +export { IOSInfoPlistMutationError, ensureInfoPlist } from './platforms/ios/plist' +export type { EnsureInfoPlistOptions, EnsureInfoPlistResult } from './platforms/ios/plist' export { diffVoltraState } from './state/diff' export { getVoltraStatePath, loadVoltraState } from './state/load' export { saveVoltraState } from './state/save' diff --git a/packages/cli/src/platforms/ios/entitlements.ts b/packages/cli/src/platforms/ios/entitlements.ts new file mode 100644 index 00000000..7248f787 --- /dev/null +++ b/packages/cli/src/platforms/ios/entitlements.ts @@ -0,0 +1,105 @@ +import { readTextFile, writeTextFile } from '../../fs/readWrite' +import { toRelativePath } from '../../fs/path' +import { VoltraCliError } from '../../reporting/summary' + +import { buildPlistXml, parsePlistFile } from './plist' + +import type { IOSProjectDiscovery } from '../../discovery/ios' +import type { NormalizedVoltraIOSConfig } from '../../config/types' +import type { ReportedChange } from '../../reporting/summary' + +export interface EnsureEntitlementsOptions { + projectRoot: string + ios: NormalizedVoltraIOSConfig + discovery: IOSProjectDiscovery +} + +export interface EnsureEntitlementsResult { + change?: ReportedChange +} + +export class IOSEntitlementsMutationError extends VoltraCliError { + constructor(message: string) { + super(message, 'VOLTRA_IOS_ENTITLEMENTS_FAILED') + this.name = 'IOSEntitlementsMutationError' + } +} + +export async function ensureEntitlements(options: EnsureEntitlementsOptions): Promise { + const { projectRoot, ios, discovery } = options + + if (!discovery.entitlementsPath) { + if (!needsEntitlementsMutation(ios)) { + return {} + } + + throw new IOSEntitlementsMutationError( + `Could not determine the main app entitlements file. Set ios.project.entitlementsPath to update entitlements for target '${discovery.mainTargetName}'.` + ) + } + + const entitlements = await parsePlistFile( + discovery.entitlementsPath, + 'main app entitlements', + createEntitlementsError + ) + + ensureStringArrayValue(entitlements, 'com.apple.security.application-groups', ios.groupIdentifier) + ensureStringArrayValue(entitlements, 'keychain-access-groups', ios.keychainGroup) + + if (ios.enablePushNotifications) { + entitlements['aps-environment'] = 'development' + } + + const nextContent = buildPlistXml(entitlements, createEntitlementsError) + const change = await writeEntitlementsIfChanged(projectRoot, discovery.entitlementsPath, nextContent) + + return { change } +} + +function needsEntitlementsMutation(ios: NormalizedVoltraIOSConfig): boolean { + return ios.enablePushNotifications || ios.groupIdentifier !== undefined || ios.keychainGroup !== undefined +} + +function ensureStringArrayValue(target: Record, key: string, nextValue: string | undefined): void { + const existingValues = Array.isArray(target[key]) ? target[key].filter((value): value is string => typeof value === 'string' && value.length > 0) : [] + const dedupedValues = Array.from(new Set(existingValues)) + + if (nextValue === undefined) { + if (existingValues.length > 0 && dedupedValues.length !== existingValues.length) { + target[key] = dedupedValues + } + return + } + + // Preserve unrelated user-managed values in shared entitlements. + // V1 does not attempt to undo historical shared-file mutations. + if (!dedupedValues.includes(nextValue)) { + dedupedValues.push(nextValue) + } + + target[key] = dedupedValues +} + +async function writeEntitlementsIfChanged( + projectRoot: string, + entitlementsPath: string, + nextContent: string +): Promise { + const previousContent = await readTextFile(entitlementsPath) + + if (previousContent === nextContent) { + return undefined + } + + await writeTextFile(entitlementsPath, nextContent) + + return { + kind: 'updated', + path: toRelativePath(projectRoot, entitlementsPath), + } +} + +function createEntitlementsError(message: string): IOSEntitlementsMutationError { + return new IOSEntitlementsMutationError(message) +} diff --git a/packages/cli/src/platforms/ios/generated.ts b/packages/cli/src/platforms/ios/generated.ts index 38847af4..8835c2ac 100644 --- a/packages/cli/src/platforms/ios/generated.ts +++ b/packages/cli/src/platforms/ios/generated.ts @@ -6,11 +6,11 @@ import { createRequire } from 'node:module' import * as babel from '@babel/core' import { renderWidgetToString } from '@use-voltra/ios' -import { parseStringPromise } from 'xml2js' import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite' import { normalizeRelativePath, toRelativePath } from '../../fs/path' import { VoltraCliError } from '../../reporting/summary' +import { buildPlistXml, parsePlistFile } from './plist' import type { IOSProjectDiscovery } from '../../discovery/ios' import type { IOSWidgetFamily, NormalizedIOSWidgetConfig, NormalizedVoltraIOSConfig, WidgetLabel } from '../../config/types' @@ -119,17 +119,6 @@ interface MainAppMetadata { urlTypes?: Array<{ CFBundleURLSchemes: string[] }> } -interface ParsedPlistNode { - key?: string | string[] - string?: string | string[] - integer?: string | string[] - real?: string | string[] - true?: unknown | unknown[] - false?: unknown | unknown[] - dict?: ParsedPlistNode | ParsedPlistNode[] - array?: ParsedPlistNode | ParsedPlistNode[] -} - type IOSWidgetRenderer = typeof renderWidgetToString type PrerenderedWidgetStates = Map> @@ -140,6 +129,10 @@ export class IOSGeneratedFilesError extends VoltraCliError { } } +function createGeneratedFilesError(message: string): IOSGeneratedFilesError { + return new IOSGeneratedFilesError(message) +} + export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promise { const { projectRoot, ios, discovery } = options const targetName = resolveIOSTargetName(ios, discovery) @@ -228,7 +221,7 @@ async function generateInfoPlistFile( } : undefined, Voltra_WidgetServerUrls: Object.keys(serverUrls).length > 0 ? serverUrls : undefined, - }) + }, createGeneratedFilesError) return writeGeneratedTextFile(projectRoot, plistPath, infoPlist) } @@ -243,7 +236,7 @@ async function generateEntitlementsFile( const entitlements = buildPlistXml({ 'com.apple.security.application-groups': ios.groupIdentifier ? [ios.groupIdentifier] : undefined, 'keychain-access-groups': ios.keychainGroup ? [ios.keychainGroup] : undefined, - }) + }, createGeneratedFilesError) return writeGeneratedTextFile(projectRoot, entitlementsPath, entitlements) } @@ -355,9 +348,7 @@ async function generateLocalizedWidgetStrings( } async function readMainAppMetadata(infoPlistPath: string): Promise { - const content = await readTextFile(infoPlistPath) - const parsed = await parseStringPromise(content, { explicitArray: false }) - const dict = getParsedPlistDict(parsed) + const dict = await parsePlistFile(infoPlistPath, 'main app Info.plist', (message: string) => new IOSGeneratedFilesError(message)) const shortVersionString = readPlistString(dict, 'CFBundleShortVersionString') ?? '1.0.0' const buildNumber = readPlistString(dict, 'CFBundleVersion') ?? '1' const urlTypes = readUrlTypes(dict) @@ -882,24 +873,6 @@ function sanitizeAssetName(value: string): string { return sanitized } -function getParsedPlistDict(value: unknown): Record { - if (!value || typeof value !== 'object' || !('plist' in value)) { - throw new IOSGeneratedFilesError('Could not parse main app Info.plist for widget file generation.') - } - - const plistValue = (value as { plist?: { dict?: unknown } }).plist - - if (!plistValue || typeof plistValue !== 'object' || !('dict' in plistValue)) { - throw new IOSGeneratedFilesError('Main app Info.plist is missing its root dict.') - } - - if (!plistValue.dict || typeof plistValue.dict !== 'object') { - throw new IOSGeneratedFilesError('Main app Info.plist dict could not be read.') - } - - return convertPlistDict(plistValue.dict as ParsedPlistNode) -} - function readPlistString(dict: Record, key: string): string | undefined { const value = dict[key] return typeof value === 'string' && value.trim() ? value : undefined @@ -931,88 +904,6 @@ function readUrlTypes(dict: Record): Array<{ CFBundleURLSchemes .filter((entry): entry is { CFBundleURLSchemes: string[] } => entry !== undefined) } -function convertPlistDict(node: ParsedPlistNode): Record { - const keys = toStringArray(node.key) - const values = collectPlistValues(node) - - if (keys.length !== values.length) { - throw new IOSGeneratedFilesError('Main app Info.plist dict keys do not match their values.') - } - - return Object.fromEntries(keys.map((key, index) => [key, values[index]])) -} - -function collectPlistValues(node: ParsedPlistNode): unknown[] { - const values: unknown[] = [] - const keyCount = toStringArray(node.key).length - const entries = Object.entries(node).filter(([entryKey]) => entryKey !== 'key') - - for (const [entryKey, entryValue] of entries) { - if (entryValue === undefined) { - continue - } - - const normalizedValues = Array.isArray(entryValue) ? entryValue : [entryValue] - - for (const normalizedValue of normalizedValues) { - values.push(convertPlistValue(entryKey, normalizedValue)) - } - } - - if (keyCount > 0 && values.length > keyCount) { - throw new IOSGeneratedFilesError('Main app Info.plist dict contains more values than keys.') - } - - return values -} - -function convertPlistValue(entryKey: string, value: unknown): unknown { - switch (entryKey) { - case 'string': - return expectPlistScalar(value, 'string') - case 'integer': - return expectPlistScalar(value, 'integer') - case 'real': - return expectPlistScalar(value, 'real') - case 'true': - return true - case 'false': - return false - case 'dict': - if (!value || typeof value !== 'object') { - throw new IOSGeneratedFilesError('Main app Info.plist contains an invalid dict value.') - } - return convertPlistDict(value as ParsedPlistNode) - case 'array': - if (!value || typeof value !== 'object') { - throw new IOSGeneratedFilesError('Main app Info.plist contains an invalid array value.') - } - return convertPlistArray(value as ParsedPlistNode) - default: - throw new IOSGeneratedFilesError(`Unsupported plist node '${entryKey}' in main app Info.plist.`) - } -} - -function convertPlistArray(node: ParsedPlistNode): unknown[] { - return collectPlistValues(node) -} - -function expectPlistScalar(value: unknown, kind: string): string { - if (typeof value !== 'string') { - throw new IOSGeneratedFilesError(`Main app Info.plist contains a non-string ${kind} value.`) - } - - return value -} - -function toStringArray(value: string | string[] | undefined): string[] { - if (value === undefined) { - return [] - } - - return Array.isArray(value) ? value : [value] -} - function escapeSwiftString(value: string): string { return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r') } @@ -1027,57 +918,6 @@ function getSwiftRawStringDelimiter(value: string): string { return '#'.repeat(maxHashes + 1) } -function buildPlistXml(value: unknown): string { - return [ - '', - '', - '', - renderPlistValue(value, 0), - '', - '', - ].join('\n') -} - -function renderPlistValue(value: unknown, indentLevel: number): string { - const indent = ' '.repeat(indentLevel) - - if (Array.isArray(value)) { - const items = value.map((item) => renderPlistValue(item, indentLevel + 1)).join('\n') - return `${indent}\n${items}\n${indent}` - } - - if (value && typeof value === 'object') { - const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined) - const lines = entries.flatMap(([key, entryValue]) => [`${indent} ${escapePlistText(key)}`, renderPlistValue(entryValue, indentLevel + 1)]) - return `${indent}\n${lines.join('\n')}\n${indent}` - } - - if (typeof value === 'string') { - return `${indent}${escapePlistText(value)}` - } - - if (typeof value === 'number') { - return Number.isInteger(value) ? `${indent}${value}` : `${indent}${value}` - } - - if (typeof value === 'boolean') { - return `${indent}<${value ? 'true' : 'false'}/>` - } - - if (value === null || value === undefined) { - throw new IOSGeneratedFilesError('Cannot encode null or undefined in generated plist output.') - } - - throw new IOSGeneratedFilesError(`Unsupported plist value type: ${typeof value}`) -} - -function escapePlistText(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') -} - function mergeResult( result: Pick, changes: ReportedChange[], diff --git a/packages/cli/src/platforms/ios/plist.ts b/packages/cli/src/platforms/ios/plist.ts new file mode 100644 index 00000000..adf8d2e7 --- /dev/null +++ b/packages/cli/src/platforms/ios/plist.ts @@ -0,0 +1,318 @@ +import { parseStringPromise } from 'xml2js' + +import { readTextFile, writeTextFile } from '../../fs/readWrite' +import { toRelativePath } from '../../fs/path' +import { VoltraCliError } from '../../reporting/summary' + +import type { IOSProjectDiscovery } from '../../discovery/ios' +import type { NormalizedVoltraIOSConfig } from '../../config/types' +import type { ReportedChange } from '../../reporting/summary' + +interface OrderedPlistNode { + '#name'?: string + _?: string + $$?: OrderedPlistNode[] +} + +interface TaggedPlistScalar { + __voltraPlistScalarType: 'data' | 'date' + value: string +} + +type PlistErrorFactory = (message: string) => Error + +export interface EnsureInfoPlistOptions { + projectRoot: string + ios: NormalizedVoltraIOSConfig + discovery: IOSProjectDiscovery +} + +export interface EnsureInfoPlistResult { + change?: ReportedChange +} + +export class IOSInfoPlistMutationError extends VoltraCliError { + constructor(message: string) { + super(message, 'VOLTRA_IOS_INFO_PLIST_FAILED') + this.name = 'IOSInfoPlistMutationError' + } +} + +export async function ensureInfoPlist(options: EnsureInfoPlistOptions): Promise { + const { projectRoot, ios, discovery } = options + const infoPlist = await parsePlistFile( + discovery.infoPlistPath, + 'main app Info.plist', + createInfoPlistError + ) + + infoPlist.NSSupportsLiveActivities = true + infoPlist.NSSupportsLiveActivitiesFrequentUpdates = false + + setOrDeleteVoltraKey(infoPlist, 'Voltra_AppGroupIdentifier', ios.groupIdentifier) + setOrDeleteVoltraKey(infoPlist, 'Voltra_KeychainGroup', ios.keychainGroup) + setOrDeleteVoltraKey(infoPlist, 'Voltra_EnablePushNotifications', ios.enablePushNotifications ? true : undefined) + + const widgetIds = ios.widgets.map((widget) => widget.id) + setOrDeleteVoltraKey(infoPlist, 'Voltra_WidgetIds', widgetIds.length > 0 ? widgetIds : undefined) + + const serverWidgets = ios.widgets.filter((widget) => widget.serverUpdate) + const serverUrls = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.url])) + const serverIntervals = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.intervalMinutes])) + + setOrDeleteVoltraKey(infoPlist, 'Voltra_WidgetServerUrls', Object.keys(serverUrls).length > 0 ? serverUrls : undefined) + setOrDeleteVoltraKey( + infoPlist, + 'Voltra_WidgetServerIntervals', + Object.keys(serverIntervals).length > 0 ? serverIntervals : undefined + ) + + const nextContent = buildPlistXml(infoPlist, createInfoPlistError) + const change = await writePlistIfChanged(projectRoot, discovery.infoPlistPath, nextContent) + + return { change } +} + +export async function parsePlistFile( + filePath: string, + errorContext: string, + createError: PlistErrorFactory +): Promise> { + let content: string + + try { + content = await readTextFile(filePath) + } catch (error: unknown) { + throw createError(`Failed to read ${errorContext} at ${filePath}: ${getErrorMessage(error)}`) + } + + let parsed: unknown + + try { + parsed = await parseStringPromise(content, { + explicitArray: false, + explicitChildren: true, + preserveChildrenOrder: true, + }) + } catch (error: unknown) { + throw createError(`Failed to parse ${errorContext} at ${filePath}: ${getErrorMessage(error)}`) + } + + const plistRoot = getPlistRoot(parsed, errorContext, filePath, createError) + return parsePlistDict(plistRoot, errorContext, filePath, createError) +} + +export function buildPlistXml(value: unknown, createError: PlistErrorFactory): string { + try { + return [ + '', + '', + '', + renderPlistValue(value, 0, createError), + '', + '', + ].join('\n') + } catch (error: unknown) { + if (error instanceof Error) { + throw error + } + + throw createError(`Failed to build plist XML: ${String(error)}`) + } +} + +function getPlistRoot( + value: unknown, + errorContext: string, + filePath: string, + createError: PlistErrorFactory +): OrderedPlistNode { + if (!value || typeof value !== 'object' || !('plist' in value)) { + throw createError(`Parsed ${errorContext} at ${filePath} is missing the plist root.`) + } + + const plistValue = (value as { plist?: { dict?: unknown } }).plist + + if (!plistValue || typeof plistValue !== 'object' || !('dict' in plistValue)) { + throw createError(`Parsed ${errorContext} at ${filePath} is missing the root dict.`) + } + + if (!plistValue.dict || typeof plistValue.dict !== 'object') { + throw createError(`Parsed ${errorContext} at ${filePath} contains an invalid root dict.`) + } + + return plistValue.dict as OrderedPlistNode +} + +function parsePlistDict( + node: OrderedPlistNode, + errorContext: string, + filePath: string, + createError: PlistErrorFactory +): Record { + const children = getOrderedChildren(node) + const result: Record = {} + + for (let index = 0; index < children.length; index += 2) { + const keyNode = children[index] + const valueNode = children[index + 1] + + if (keyNode?.['#name'] !== 'key' || typeof keyNode._ !== 'string' || keyNode._.trim().length === 0) { + throw createError(`Parsed ${errorContext} at ${filePath} contains an invalid dict key.`) + } + + if (!valueNode) { + throw createError(`Parsed ${errorContext} at ${filePath} is missing a value for key '${keyNode._}'.`) + } + + result[keyNode._] = parsePlistValue(valueNode, errorContext, filePath, createError) + } + + return result +} + +function parsePlistArray( + node: OrderedPlistNode, + errorContext: string, + filePath: string, + createError: PlistErrorFactory +): unknown[] { + return getOrderedChildren(node).map((child) => parsePlistValue(child, errorContext, filePath, createError)) +} + +function parsePlistValue( + node: OrderedPlistNode, + errorContext: string, + filePath: string, + createError: PlistErrorFactory +): unknown { + switch (node['#name']) { + case 'string': + return node._ ?? '' + case 'integer': + case 'real': { + const rawValue = node._ ?? '' + const numericValue = Number(rawValue) + + if (!Number.isFinite(numericValue)) { + throw createError(`Parsed ${errorContext} at ${filePath} contains an invalid ${node['#name']} value '${rawValue}'.`) + } + + return numericValue + } + case 'true': + return true + case 'false': + return false + case 'dict': + return parsePlistDict(node, errorContext, filePath, createError) + case 'array': + return parsePlistArray(node, errorContext, filePath, createError) + case 'data': + return { __voltraPlistScalarType: 'data', value: node._ ?? '' } satisfies TaggedPlistScalar + case 'date': + return { __voltraPlistScalarType: 'date', value: node._ ?? '' } satisfies TaggedPlistScalar + default: + throw createError( + `Parsed ${errorContext} at ${filePath} contains unsupported plist node '${node['#name'] ?? 'unknown'}'.` + ) + } +} + +function getOrderedChildren(node: OrderedPlistNode): OrderedPlistNode[] { + return Array.isArray(node.$$) ? node.$$ : [] +} + +function renderPlistValue(value: unknown, indentLevel: number, createError: PlistErrorFactory): string { + const indent = ' '.repeat(indentLevel) + + if (Array.isArray(value)) { + const items = value.map((item) => renderPlistValue(item, indentLevel + 1, createError)).join('\n') + return `${indent}\n${items}\n${indent}` + } + + if (isTaggedPlistScalar(value)) { + return `${indent}<${value.__voltraPlistScalarType}>${escapePlistText(value.value)}` + } + + if (value && typeof value === 'object') { + const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined) + const lines = entries.flatMap(([key, entryValue]) => [ + `${indent} ${escapePlistText(key)}`, + renderPlistValue(entryValue, indentLevel + 1, createError), + ]) + return `${indent}\n${lines.join('\n')}\n${indent}` + } + + if (typeof value === 'string') { + return `${indent}${escapePlistText(value)}` + } + + if (typeof value === 'number') { + return Number.isInteger(value) ? `${indent}${value}` : `${indent}${value}` + } + + if (typeof value === 'boolean') { + return `${indent}<${value ? 'true' : 'false'}/>` + } + + if (value === null || value === undefined) { + throw createError('Cannot encode null or undefined in plist output.') + } + + throw createError(`Unsupported plist value type: ${typeof value}`) +} + +function isTaggedPlistScalar(value: unknown): value is TaggedPlistScalar { + return ( + !!value && + typeof value === 'object' && + '__voltraPlistScalarType' in value && + ((value as TaggedPlistScalar).__voltraPlistScalarType === 'data' || + (value as TaggedPlistScalar).__voltraPlistScalarType === 'date') && + typeof (value as TaggedPlistScalar).value === 'string' + ) +} + +function setOrDeleteVoltraKey(target: Record, key: string, value: unknown): void { + if (value === undefined) { + delete target[key] + return + } + + target[key] = value +} + +async function writePlistIfChanged( + projectRoot: string, + plistPath: string, + nextContent: string +): Promise { + const previousContent = await readTextFile(plistPath) + + if (previousContent === nextContent) { + return undefined + } + + await writeTextFile(plistPath, nextContent) + + return { + kind: 'updated', + path: toRelativePath(projectRoot, plistPath), + } +} + +function escapePlistText(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function createInfoPlistError(message: string): IOSInfoPlistMutationError { + return new IOSInfoPlistMutationError(message) +} diff --git a/packages/cli/src/xml2js.d.ts b/packages/cli/src/xml2js.d.ts index 3104aa3f..e4d2023d 100644 --- a/packages/cli/src/xml2js.d.ts +++ b/packages/cli/src/xml2js.d.ts @@ -1,6 +1,7 @@ declare module 'xml2js' { export interface ParserOptions { explicitArray?: boolean + explicitChildren?: boolean preserveChildrenOrder?: boolean } From e5460f04600d2f7cceeaaa0b84dad8db54c12ae1 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 08:51:56 +0200 Subject: [PATCH 20/37] feat: add ios podfile mutator --- PLAN.md | 3 + packages/cli/src/index.ts | 2 + packages/cli/src/platforms/ios/podfile.ts | 153 ++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 packages/cli/src/platforms/ios/podfile.ts diff --git a/PLAN.md b/PLAN.md index fdee5417..68ffe09c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -745,6 +745,9 @@ Can run in parallel with: **T19. Implement Podfile managed block mutation** +Status: +- completed + Deliverables: - insert or update a Voltra-managed block for widget extension pods - keep unrelated Podfile content intact diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index cc2fb3df..301c2498 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -67,6 +67,8 @@ export { IOSEntitlementsMutationError, ensureEntitlements } from './platforms/io export type { EnsureEntitlementsOptions, EnsureEntitlementsResult } from './platforms/ios/entitlements' export { IOSInfoPlistMutationError, ensureInfoPlist } from './platforms/ios/plist' export type { EnsureInfoPlistOptions, EnsureInfoPlistResult } from './platforms/ios/plist' +export { IOSPodfileMutationError, ensurePodfileBlock } from './platforms/ios/podfile' +export type { EnsurePodfileBlockOptions, EnsurePodfileBlockResult } from './platforms/ios/podfile' export { diffVoltraState } from './state/diff' export { getVoltraStatePath, loadVoltraState } from './state/load' export { saveVoltraState } from './state/save' diff --git a/packages/cli/src/platforms/ios/podfile.ts b/packages/cli/src/platforms/ios/podfile.ts new file mode 100644 index 00000000..1d79414e --- /dev/null +++ b/packages/cli/src/platforms/ios/podfile.ts @@ -0,0 +1,153 @@ +import { readTextFile, writeTextFile } from '../../fs/readWrite' +import { toRelativePath } from '../../fs/path' +import { VoltraCliError } from '../../reporting/summary' + +import type { IOSProjectDiscovery } from '../../discovery/ios' +import type { NormalizedVoltraIOSConfig } from '../../config/types' +import type { ReportedChange } from '../../reporting/summary' + +const VOLTRA_MANAGED_BLOCK_START = '# >>> VOLTRA MANAGED BLOCK: widget extension target' +const VOLTRA_MANAGED_BLOCK_END = '# <<< VOLTRA MANAGED BLOCK: widget extension target' +const LEGACY_BLOCK_HEADER = '# Voltra Widget Extension Target' +const LEGACY_BLOCK_NOTICE = '# DO NOT MODIFY THIS FILE - IT IS AUTO-GENERATED BY THE VOLTRA PLUGIN' + +export interface EnsurePodfileBlockOptions { + projectRoot: string + ios: NormalizedVoltraIOSConfig + discovery: IOSProjectDiscovery +} + +export interface EnsurePodfileBlockResult { + change?: ReportedChange + targetName: string +} + +export class IOSPodfileMutationError extends VoltraCliError { + constructor(message: string) { + super(message, 'VOLTRA_IOS_PODFILE_FAILED') + this.name = 'IOSPodfileMutationError' + } +} + +export async function ensurePodfileBlock(options: EnsurePodfileBlockOptions): Promise { + const { projectRoot, ios, discovery } = options + const targetName = resolveWidgetTargetName(ios, discovery) + const currentContent = await readPodfile(discovery.podfilePath) + const nextBlock = buildManagedPodfileBlock(targetName) + const nextContent = reconcilePodfile(currentContent, nextBlock, targetName) + + if (currentContent === nextContent) { + return { targetName } + } + + await writeTextFile(discovery.podfilePath, nextContent) + + return { + change: { + kind: 'updated', + path: toRelativePath(projectRoot, discovery.podfilePath), + }, + targetName, + } +} + +function buildManagedPodfileBlock(targetName: string): string { + const escapedTargetName = escapeRubySingleQuotedString(targetName) + + return [ + VOLTRA_MANAGED_BLOCK_START, + '# Voltra widget extension target managed by voltra apply.', + `target '${escapedTargetName}' do`, + " use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if defined?(podfile_properties) && podfile_properties['ios.useFrameworks']", + " use_frameworks! :linkage => ENV['USE_FRAMEWORKS'].to_sym if ENV['USE_FRAMEWORKS']", + '', + " require 'pathname'", + ' project_root = "#{Pod::Config.instance.installation_root}/.."', + " voltra_client_root = `node --print \"require.resolve('@use-voltra/ios-client/package.json', {paths: ['#{project_root}']})\" 2>/dev/null`.strip.gsub('/package.json', '')", + " voltra_widget_podspec_root = File.join(voltra_client_root, 'ios')", + ' podspec_dir_path = Pathname.new(voltra_widget_podspec_root).relative_path_from(Pathname.new(__dir__)).to_path', + '', + " pod 'VoltraWidget', :path => podspec_dir_path", + 'end', + VOLTRA_MANAGED_BLOCK_END, + ].join('\n') +} + +function reconcilePodfile(currentContent: string, nextBlock: string, targetName: string): string { + const normalizedContent = stripTrailingWhitespace(currentContent) + const managedBlockPattern = createManagedBlockPattern() + const legacyBlockPattern = createLegacyBlockPattern() + + if (managedBlockPattern.test(normalizedContent)) { + return ensureTrailingNewline(normalizedContent.replace(managedBlockPattern, nextBlock)) + } + + if (legacyBlockPattern.test(normalizedContent)) { + return ensureTrailingNewline(normalizedContent.replace(legacyBlockPattern, nextBlock)) + } + + if (hasUnmanagedTargetBlock(normalizedContent, targetName)) { + throw new IOSPodfileMutationError( + `Podfile already contains an unmanaged target '${targetName}'. Remove it or choose a different ios.targetName before running voltra apply.` + ) + } + + const separator = normalizedContent.length === 0 ? '' : '\n\n' + return ensureTrailingNewline(`${normalizedContent}${separator}${nextBlock}`) +} + +function createManagedBlockPattern(): RegExp { + return new RegExp( + `${escapeRegExp(VOLTRA_MANAGED_BLOCK_START)}[\\s\\S]*?${escapeRegExp(VOLTRA_MANAGED_BLOCK_END)}`, + 'm' + ) +} + +function createLegacyBlockPattern(): RegExp { + return new RegExp( + `${escapeRegExp(LEGACY_BLOCK_HEADER)}\\n${escapeRegExp(LEGACY_BLOCK_NOTICE)}\\ntarget '[^']+' do[\\s\\S]*?\\nend`, + 'm' + ) +} + +async function readPodfile(podfilePath: string): Promise { + try { + return await readTextFile(podfilePath) + } catch (error: unknown) { + throw new IOSPodfileMutationError(`Failed to read Podfile at ${podfilePath}: ${getErrorMessage(error)}`) + } +} + +function resolveWidgetTargetName(ios: NormalizedVoltraIOSConfig, discovery: IOSProjectDiscovery): string { + if (ios.targetName) { + return ios.targetName + } + + const sanitizedTargetName = discovery.mainTargetName.replace(/[^A-Za-z0-9_]/g, '') + return `${sanitizedTargetName}LiveActivity` +} + +function stripTrailingWhitespace(value: string): string { + return value.replace(/\s+$/, '') +} + +function ensureTrailingNewline(value: string): string { + return `${value}\n` +} + +function escapeRubySingleQuotedString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/'/g, "\\'") +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +function hasUnmanagedTargetBlock(content: string, targetName: string): boolean { + const targetPattern = new RegExp(`(^|\\n)target '${escapeRegExp(escapeRubySingleQuotedString(targetName))}' do(\\n|$)`, 'm') + return targetPattern.test(content) +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} From c9ac7e55cb09f8704ae9c60dcc2db4d2b23a41c4 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 08:57:42 +0200 Subject: [PATCH 21/37] feat: add ios core apply flow --- PLAN.md | 3 + packages/cli/src/apply/index.ts | 5 +- packages/cli/src/index.ts | 2 + packages/cli/src/platforms/ios/apply.ts | 103 +++++++++++++++++++ packages/cli/src/platforms/ios/generated.ts | 12 +-- packages/cli/src/platforms/ios/podfile.ts | 12 +-- packages/cli/src/platforms/ios/targetName.ts | 11 ++ 7 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 packages/cli/src/platforms/ios/apply.ts create mode 100644 packages/cli/src/platforms/ios/targetName.ts diff --git a/PLAN.md b/PLAN.md index 68ffe09c..c8433087 100644 --- a/PLAN.md +++ b/PLAN.md @@ -767,6 +767,9 @@ Can run in parallel with: **T20. Implement iOS Core apply flow** +Status: +- completed + Deliverables: - combine iOS discovery, generated-file writes, plist mutation, entitlements mutation, and Podfile mutation - emit generated file inventory for state tracking diff --git a/packages/cli/src/apply/index.ts b/packages/cli/src/apply/index.ts index 44e69d5e..700dc963 100644 --- a/packages/cli/src/apply/index.ts +++ b/packages/cli/src/apply/index.ts @@ -5,6 +5,7 @@ import { normalizeVoltraConfig } from '../config/normalize' import { removePathIfExists } from '../fs/readWrite' import { ensureGitWorktreeIsReady } from '../git/status' import { applyAndroidPlatform, createAndroidPreflightRunner } from '../platforms/android/apply' +import { applyIOSPlatform, createIOSPreflightRunner } from '../platforms/ios/apply' import { formatApplySummary, VoltraCliError } from '../reporting/summary' import { diffVoltraState } from '../state/diff' import { loadVoltraState } from '../state/load' @@ -96,11 +97,11 @@ function resolveApplyDependencies(config: NormalizedVoltraConfig, dependencies: return { applyRunners: { android: dependencies.applyRunners.android ?? applyAndroidPlatform, - ios: dependencies.applyRunners.ios, + ios: dependencies.applyRunners.ios ?? applyIOSPlatform, }, preflightRunners: { android: dependencies.preflightRunners.android ?? (config.android ? createAndroidPreflightRunner(config) : undefined), - ios: dependencies.preflightRunners.ios, + ios: dependencies.preflightRunners.ios ?? (config.ios ? createIOSPreflightRunner(config) : undefined), }, writeStdout: dependencies.writeStdout, } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 301c2498..b9cd5cc2 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -63,6 +63,8 @@ export { AndroidManifestMutationError, ensureAndroidManifest } from './platforms export type { EnsureAndroidManifestOptions, EnsureAndroidManifestResult } from './platforms/android/manifest' export { IOSGeneratedFilesError, generateIOSFiles } from './platforms/ios/generated' export type { GenerateIOSFilesOptions, GenerateIOSFilesResult } from './platforms/ios/generated' +export { resolveIOSWidgetTargetName } from './platforms/ios/targetName' +export { applyIOSPlatform, createIOSPreflightRunner } from './platforms/ios/apply' export { IOSEntitlementsMutationError, ensureEntitlements } from './platforms/ios/entitlements' export type { EnsureEntitlementsOptions, EnsureEntitlementsResult } from './platforms/ios/entitlements' export { IOSInfoPlistMutationError, ensureInfoPlist } from './platforms/ios/plist' diff --git a/packages/cli/src/platforms/ios/apply.ts b/packages/cli/src/platforms/ios/apply.ts new file mode 100644 index 00000000..0a61dab4 --- /dev/null +++ b/packages/cli/src/platforms/ios/apply.ts @@ -0,0 +1,103 @@ +import { discoverIOSProject } from '../../discovery/ios' +import { VoltraCliError } from '../../reporting/summary' + +import { ensureEntitlements } from './entitlements' +import { generateIOSFiles } from './generated' +import { ensureInfoPlist } from './plist' +import { ensurePodfileBlock } from './podfile' + +import type { NormalizedVoltraConfig } from '../../config/types' +import type { IOSProjectDiscovery } from '../../discovery/ios' +import type { PlatformApplyContext, PlatformApplyResult } from '../../apply' +import type { ApplyPreflightContext, PlatformPreflightResult, PlatformPreflightRunner } from '../../apply/preflight' + +export function createIOSPreflightRunner(config: NormalizedVoltraConfig): PlatformPreflightRunner { + return async (_context: ApplyPreflightContext): Promise> => { + const iosConfig = config.ios + + if (!iosConfig) { + return { + platform: 'ios', + issues: [{ message: 'iOS config is missing.' }], + } + } + + return { + platform: 'ios', + context: await discoverIOSProject(config.projectRoot, iosConfig.project), + } + } +} + +export async function applyIOSPlatform(context: PlatformApplyContext): Promise { + if (context.platform !== 'ios') { + throw new VoltraCliError(`iOS apply runner received unexpected platform: ${context.platform}.`) + } + + const iosConfig = context.config.ios + + if (!iosConfig) { + throw new VoltraCliError('iOS config is missing.') + } + + const discovery = getIOSDiscovery(context.preflight) + const generatedResult = await generateIOSFiles({ + projectRoot: context.config.projectRoot, + ios: iosConfig, + discovery, + }) + const infoPlistResult = await ensureInfoPlist({ + projectRoot: context.config.projectRoot, + ios: iosConfig, + discovery, + }) + const entitlementsResult = await ensureEntitlements({ + projectRoot: context.config.projectRoot, + ios: iosConfig, + discovery, + }) + const podfileResult = await ensurePodfileBlock({ + projectRoot: context.config.projectRoot, + ios: iosConfig, + discovery, + }) + + if (generatedResult.targetName !== podfileResult.targetName) { + throw new VoltraCliError( + `iOS generated files and Podfile block resolved different widget target names: ${generatedResult.targetName} vs ${podfileResult.targetName}.` + ) + } + + const changes = [infoPlistResult.change, entitlementsResult.change, podfileResult.change].filter(isDefined) + + return { + platform: 'ios', + changes: [...changes, ...generatedResult.changes], + generatedFiles: generatedResult.files, + warnings: generatedResult.warnings, + } +} + +function getIOSDiscovery(value: unknown): IOSProjectDiscovery { + if (!isIOSProjectDiscovery(value)) { + throw new VoltraCliError('iOS preflight did not provide a valid discovery result.') + } + + return value +} + +function isIOSProjectDiscovery(value: unknown): value is IOSProjectDiscovery { + if (!value || typeof value !== 'object') { + return false + } + + const candidate = value as Partial + + return [candidate.iosRoot, candidate.xcodeprojPath, candidate.pbxprojPath, candidate.podfilePath, candidate.mainTargetName, candidate.infoPlistPath].every( + (entry) => typeof entry === 'string' && entry.length > 0 + ) && Array.isArray(candidate.mainTargetCandidates) +} + +function isDefined(value: TValue | undefined): value is TValue { + return value !== undefined +} diff --git a/packages/cli/src/platforms/ios/generated.ts b/packages/cli/src/platforms/ios/generated.ts index 8835c2ac..1bf515cd 100644 --- a/packages/cli/src/platforms/ios/generated.ts +++ b/packages/cli/src/platforms/ios/generated.ts @@ -11,6 +11,7 @@ import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../ import { normalizeRelativePath, toRelativePath } from '../../fs/path' import { VoltraCliError } from '../../reporting/summary' import { buildPlistXml, parsePlistFile } from './plist' +import { resolveIOSWidgetTargetName } from './targetName' import type { IOSProjectDiscovery } from '../../discovery/ios' import type { IOSWidgetFamily, NormalizedIOSWidgetConfig, NormalizedVoltraIOSConfig, WidgetLabel } from '../../config/types' @@ -135,7 +136,7 @@ function createGeneratedFilesError(message: string): IOSGeneratedFilesError { export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promise { const { projectRoot, ios, discovery } = options - const targetName = resolveIOSTargetName(ios, discovery) + const targetName = resolveIOSWidgetTargetName(ios, discovery) const targetPath = path.join(discovery.iosRoot, targetName) const mainAppMetadata = await readMainAppMetadata(discovery.infoPlistPath) const changes: ReportedChange[] = [] @@ -797,15 +798,6 @@ async function getLargeImageWarning(imagePath: string, fileName: string): Promis return `Image '${fileName}' is ${stat.size} bytes. Large iOS widget images may not display correctly.` } -function resolveIOSTargetName(ios: NormalizedVoltraIOSConfig, discovery: IOSProjectDiscovery): string { - if (ios.targetName) { - return ios.targetName - } - - const sanitizedTargetName = discovery.mainTargetName.replace(/[^A-Za-z0-9_]/g, '') - return `${sanitizedTargetName}LiveActivity` -} - function collectGalleryStringsByLocale(widgets: NormalizedIOSWidgetConfig[]): Map> { const byLocale = new Map>() diff --git a/packages/cli/src/platforms/ios/podfile.ts b/packages/cli/src/platforms/ios/podfile.ts index 1d79414e..8280cee3 100644 --- a/packages/cli/src/platforms/ios/podfile.ts +++ b/packages/cli/src/platforms/ios/podfile.ts @@ -1,6 +1,7 @@ import { readTextFile, writeTextFile } from '../../fs/readWrite' import { toRelativePath } from '../../fs/path' import { VoltraCliError } from '../../reporting/summary' +import { resolveIOSWidgetTargetName } from './targetName' import type { IOSProjectDiscovery } from '../../discovery/ios' import type { NormalizedVoltraIOSConfig } from '../../config/types' @@ -31,7 +32,7 @@ export class IOSPodfileMutationError extends VoltraCliError { export async function ensurePodfileBlock(options: EnsurePodfileBlockOptions): Promise { const { projectRoot, ios, discovery } = options - const targetName = resolveWidgetTargetName(ios, discovery) + const targetName = resolveIOSWidgetTargetName(ios, discovery) const currentContent = await readPodfile(discovery.podfilePath) const nextBlock = buildManagedPodfileBlock(targetName) const nextContent = reconcilePodfile(currentContent, nextBlock, targetName) @@ -118,15 +119,6 @@ async function readPodfile(podfilePath: string): Promise { } } -function resolveWidgetTargetName(ios: NormalizedVoltraIOSConfig, discovery: IOSProjectDiscovery): string { - if (ios.targetName) { - return ios.targetName - } - - const sanitizedTargetName = discovery.mainTargetName.replace(/[^A-Za-z0-9_]/g, '') - return `${sanitizedTargetName}LiveActivity` -} - function stripTrailingWhitespace(value: string): string { return value.replace(/\s+$/, '') } diff --git a/packages/cli/src/platforms/ios/targetName.ts b/packages/cli/src/platforms/ios/targetName.ts new file mode 100644 index 00000000..a9163e1e --- /dev/null +++ b/packages/cli/src/platforms/ios/targetName.ts @@ -0,0 +1,11 @@ +import type { IOSProjectDiscovery } from '../../discovery/ios' +import type { NormalizedVoltraIOSConfig } from '../../config/types' + +export function resolveIOSWidgetTargetName(ios: NormalizedVoltraIOSConfig, discovery: IOSProjectDiscovery): string { + if (ios.targetName) { + return ios.targetName + } + + const sanitizedTargetName = discovery.mainTargetName.replace(/[^A-Za-z0-9_]/g, '') + return `${sanitizedTargetName}LiveActivity` +} From c583410a3674e7788dad6e659f19105e16038bba Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 09:29:07 +0200 Subject: [PATCH 22/37] feat: add ios xcode project helpers --- PLAN.md | 3 + package-lock.json | 81 +++++++++++- packages/cli/package.json | 7 +- packages/cli/src/index.ts | 17 +++ packages/cli/src/platforms/ios/xcode.ts | 168 ++++++++++++++++++++++++ 5 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 packages/cli/src/platforms/ios/xcode.ts diff --git a/PLAN.md b/PLAN.md index c8433087..38de4e3c 100644 --- a/PLAN.md +++ b/PLAN.md @@ -791,6 +791,9 @@ Can run in parallel with: **T21. Implement Xcode project parsing and target discovery helpers** +Status: +- completed + Deliverables: - parse `project.pbxproj` via `@bacons/xcode` - identify main app target and required groups/build phases diff --git a/package-lock.json b/package-lock.json index 9ef3af02..16684ed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3559,6 +3559,57 @@ "node": ">=6.9.0" } }, + "node_modules/@bacons/xcode": { + "version": "1.0.0-alpha.33", + "resolved": "https://registry.npmjs.org/@bacons/xcode/-/xcode-1.0.0-alpha.33.tgz", + "integrity": "sha512-+vwXK3mLnW8NOYSHNpCa7sAYR7qWmIHM3xBbBkLailN81F1CdSVsJBH1TAnTMHb+rDEh5ehsZ8AQRNVxvhWR/Q==", + "license": "MIT", + "dependencies": { + "@expo/plist": "^0.0.18", + "debug": "^4.3.4", + "uuid": "^8.3.2" + } + }, + "node_modules/@bacons/xcode/node_modules/@expo/plist": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.0.18.tgz", + "integrity": "sha512-+48gRqUiz65R21CZ/IXa7RNBXgAI/uPSdvJqoN9x1hfL44DNbUoWHgHiEXTx7XelcATpDwNTz6sHLfy0iNqf+w==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "~0.7.0", + "base64-js": "^1.2.3", + "xmlbuilder": "^14.0.0" + } + }, + "node_modules/@bacons/xcode/node_modules/@xmldom/xmldom": { + "version": "0.7.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", + "integrity": "sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==", + "deprecated": "this version has critical issues, please update to the latest version", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@bacons/xcode/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@bacons/xcode/node_modules/xmlbuilder": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-14.0.0.tgz", + "integrity": "sha512-ts+B2rSe4fIckR6iquDjsKbQFK2NlUk6iG5nf14mDEyldgoc2nEKZ3jZWMPTxGQwVgToSjt6VGIho1H8/fNFTg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, "node_modules/@bcoe/v8-coverage": { "version": "0.2.3", "devOptional": true, @@ -21692,7 +21743,13 @@ "version": "1.4.1", "license": "MIT", "dependencies": { - "cosmiconfig": "^9.0.0" + "@babel/core": "^7.27.4", + "@bacons/xcode": "^1.0.0-alpha.33", + "@use-voltra/android": "1.4.1", + "@use-voltra/ios": "1.4.1", + "cosmiconfig": "^9.0.0", + "vd-tool": "^4.0.2", + "xml2js": "^0.6.2" }, "bin": { "voltra": "build/cjs/bin.js" @@ -21724,6 +21781,28 @@ } } }, + "packages/cli/node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "packages/cli/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "packages/core": { "name": "@use-voltra/core", "version": "1.4.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 458e25d3..43a6a7d3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -34,7 +34,7 @@ "cli", "widget" ], - "author": "Sa\u00fal Sharma (https://x.com/saul_sharma), Szymon Chmal (https://x.com/chmalszymon)", + "author": "Saúl Sharma (https://x.com/saul_sharma), Szymon Chmal (https://x.com/chmalszymon)", "repository": { "type": "git", "url": "git+https://github.com/callstackincubator/voltra.git", @@ -47,10 +47,11 @@ "homepage": "https://use-voltra.dev", "dependencies": { "@babel/core": "^7.27.4", + "@bacons/xcode": "^1.0.0-alpha.33", "@use-voltra/android": "1.4.1", "@use-voltra/ios": "1.4.1", "cosmiconfig": "^9.0.0", - "xml2js": "^0.6.2", - "vd-tool": "^4.0.2" + "vd-tool": "^4.0.2", + "xml2js": "^0.6.2" } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b9cd5cc2..d0f12821 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -71,6 +71,23 @@ export { IOSInfoPlistMutationError, ensureInfoPlist } from './platforms/ios/plis export type { EnsureInfoPlistOptions, EnsureInfoPlistResult } from './platforms/ios/plist' export { IOSPodfileMutationError, ensurePodfileBlock } from './platforms/ios/podfile' export type { EnsurePodfileBlockOptions, EnsurePodfileBlockResult } from './platforms/ios/podfile' +export { + ensureFrameworksGroup, + ensureMainGroupChild, + ensureProductsGroup, + getApplicationTargets, + getTargetBuildConfigurations, + getTargetBuildPhases, + IOSXcodeProjectError, + openIOSXcodeProject, + saveIOSXcodeProject, +} from './platforms/ios/xcode' +export type { + IOSXcodeProjectContext, + IOSXcodeTargetBuildConfigurations, + IOSXcodeTargetBuildPhases, + IOSXcodeTargetContext, +} from './platforms/ios/xcode' export { diffVoltraState } from './state/diff' export { getVoltraStatePath, loadVoltraState } from './state/load' export { saveVoltraState } from './state/save' diff --git a/packages/cli/src/platforms/ios/xcode.ts b/packages/cli/src/platforms/ios/xcode.ts new file mode 100644 index 00000000..1d1c5fcc --- /dev/null +++ b/packages/cli/src/platforms/ios/xcode.ts @@ -0,0 +1,168 @@ +import fs from 'node:fs/promises' + +import { PBXNativeTarget, XcodeProject } from '@bacons/xcode' +import { Writer } from '@bacons/xcode/build/json/writer' + +import { VoltraCliError } from '../../reporting/summary' + +import type { PBXCopyFilesBuildPhase, PBXFrameworksBuildPhase, PBXGroup, PBXResourcesBuildPhase, PBXSourcesBuildPhase, XCBuildConfiguration } from '@bacons/xcode' +import type { IOSProjectDiscovery } from '../../discovery/ios' + +const IOS_APP_PRODUCT_TYPE = 'com.apple.product-type.application' + +export interface IOSXcodeTargetBuildConfigurations { + all: XCBuildConfiguration[] + default: XCBuildConfiguration +} + +export interface IOSXcodeTargetBuildPhases { + sources: PBXSourcesBuildPhase + resources: PBXResourcesBuildPhase + frameworks: PBXFrameworksBuildPhase +} + +export interface IOSXcodeTargetContext { + target: PBXNativeTarget + buildConfigurations: IOSXcodeTargetBuildConfigurations + buildPhases: IOSXcodeTargetBuildPhases + getCopyFilesBuildPhaseFor(target: PBXNativeTarget): PBXCopyFilesBuildPhase +} + +export interface IOSXcodeProjectContext { + project: XcodeProject + mainAppTarget: IOSXcodeTargetContext + mainGroup: PBXGroup + productsGroup: PBXGroup + frameworksGroup: PBXGroup +} + +export class IOSXcodeProjectError extends VoltraCliError { + constructor(message: string) { + super(message, 'VOLTRA_IOS_XCODE_FAILED') + this.name = 'IOSXcodeProjectError' + } +} + +export function openIOSXcodeProject(discovery: IOSProjectDiscovery): IOSXcodeProjectContext { + try { + const project = XcodeProject.open(discovery.pbxprojPath) + const mainGroup = project.rootObject.props.mainGroup + + if (!mainGroup) { + throw new IOSXcodeProjectError(`Xcode project is missing a main group: ${discovery.pbxprojPath}`) + } + + const mainAppTarget = resolveMainAppTarget(project, discovery) + + return { + project, + mainAppTarget, + mainGroup, + productsGroup: getExistingProductGroup(project, discovery), + frameworksGroup: getExistingFrameworksGroup(project, discovery), + } + } catch (error: unknown) { + if (error instanceof IOSXcodeProjectError) { + throw error + } + + throw new IOSXcodeProjectError(`Failed to open Xcode project ${discovery.pbxprojPath}: ${getErrorMessage(error)}`) + } +} + +export function getApplicationTargets(project: XcodeProject): PBXNativeTarget[] { + return project.rootObject.props.targets.filter((target): target is PBXNativeTarget => { + return PBXNativeTarget.is(target) && target.props.productType === IOS_APP_PRODUCT_TYPE + }) +} + +export function getTargetBuildConfigurations(target: PBXNativeTarget): IOSXcodeTargetBuildConfigurations { + const configurationList = target.props.buildConfigurationList + + if (!configurationList) { + throw new IOSXcodeProjectError(`Target '${target.props.name}' is missing a build configuration list.`) + } + + const all = configurationList.props.buildConfigurations + + if (all.length === 0) { + throw new IOSXcodeProjectError(`Target '${target.props.name}' does not define any build configurations.`) + } + + return { + all, + default: configurationList.getDefaultConfiguration(), + } +} + +export function getTargetBuildPhases(target: PBXNativeTarget): IOSXcodeTargetBuildPhases { + return { + sources: target.getSourcesBuildPhase(), + resources: target.getResourcesBuildPhase(), + frameworks: target.getFrameworksBuildPhase(), + } +} + +export function ensureMainGroupChild(context: IOSXcodeProjectContext, name: string): PBXGroup { + return context.project.rootObject.ensureMainGroupChild(name) +} + +export function ensureProductsGroup(context: IOSXcodeProjectContext): PBXGroup { + return context.project.rootObject.ensureProductGroup() +} + +export function ensureFrameworksGroup(context: IOSXcodeProjectContext): PBXGroup { + return context.project.rootObject.getFrameworksGroup() +} + +export function saveIOSXcodeProject(context: IOSXcodeProjectContext): Promise { + const contents = new Writer(context.project.toJSON()).getResults() + return fs.writeFile(context.project.filePath, contents, 'utf8') +} + +function resolveMainAppTarget(project: XcodeProject, discovery: IOSProjectDiscovery): IOSXcodeTargetContext { + const applicationTargets = getApplicationTargets(project) + const target = applicationTargets.find((candidate) => candidate.props.name === discovery.mainTargetName) + + if (!target) { + throw new IOSXcodeProjectError( + `Xcode project does not contain the discovered main app target '${discovery.mainTargetName}'. Available application targets: ${applicationTargets + .map((candidate) => candidate.props.name) + .sort() + .join(', ')}` + ) + } + + return { + target, + buildConfigurations: getTargetBuildConfigurations(target), + buildPhases: getTargetBuildPhases(target), + getCopyFilesBuildPhaseFor(dependencyTarget: PBXNativeTarget): PBXCopyFilesBuildPhase { + return target.getCopyBuildPhaseForTarget(dependencyTarget) + }, + } +} + +function getExistingProductGroup(project: XcodeProject, discovery: IOSProjectDiscovery): PBXGroup { + const productGroup = project.rootObject.props.productRefGroup + + if (!productGroup) { + throw new IOSXcodeProjectError(`Xcode project is missing the Products group: ${discovery.pbxprojPath}`) + } + + return productGroup +} + +function getExistingFrameworksGroup(project: XcodeProject, discovery: IOSProjectDiscovery): PBXGroup { + const frameworksGroup = project.rootObject.props.mainGroup?.getChildGroups().find((group) => group.getDisplayName() === 'Frameworks') + + if (!frameworksGroup) { + throw new IOSXcodeProjectError(`Xcode project is missing the Frameworks group: ${discovery.pbxprojPath}`) + } + + return frameworksGroup +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} From 605e81ade191a213592b27862035f01ba397a3e2 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 09:40:10 +0200 Subject: [PATCH 23/37] feat: add ios widget target mutation --- PLAN.md | 3 + packages/cli/src/index.ts | 2 + packages/cli/src/platforms/ios/xcodeTarget.ts | 473 ++++++++++++++++++ 3 files changed, 478 insertions(+) create mode 100644 packages/cli/src/platforms/ios/xcodeTarget.ts diff --git a/PLAN.md b/PLAN.md index 38de4e3c..8e3eae69 100644 --- a/PLAN.md +++ b/PLAN.md @@ -810,6 +810,9 @@ Can run in parallel with: **T22. Implement widget target creation/update in Xcode project** +Status: +- completed + Deliverables: - ensure widget extension target exists - ensure product file, build phases, groups, and dependencies are present diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index d0f12821..95e4facc 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -64,6 +64,7 @@ export type { EnsureAndroidManifestOptions, EnsureAndroidManifestResult } from ' export { IOSGeneratedFilesError, generateIOSFiles } from './platforms/ios/generated' export type { GenerateIOSFilesOptions, GenerateIOSFilesResult } from './platforms/ios/generated' export { resolveIOSWidgetTargetName } from './platforms/ios/targetName' +export { IOSWidgetTargetMutationError, ensureIOSWidgetTarget } from './platforms/ios/xcodeTarget' export { applyIOSPlatform, createIOSPreflightRunner } from './platforms/ios/apply' export { IOSEntitlementsMutationError, ensureEntitlements } from './platforms/ios/entitlements' export type { EnsureEntitlementsOptions, EnsureEntitlementsResult } from './platforms/ios/entitlements' @@ -71,6 +72,7 @@ export { IOSInfoPlistMutationError, ensureInfoPlist } from './platforms/ios/plis export type { EnsureInfoPlistOptions, EnsureInfoPlistResult } from './platforms/ios/plist' export { IOSPodfileMutationError, ensurePodfileBlock } from './platforms/ios/podfile' export type { EnsurePodfileBlockOptions, EnsurePodfileBlockResult } from './platforms/ios/podfile' +export type { EnsureIOSWidgetTargetOptions, EnsureIOSWidgetTargetResult } from './platforms/ios/xcodeTarget' export { ensureFrameworksGroup, ensureMainGroupChild, diff --git a/packages/cli/src/platforms/ios/xcodeTarget.ts b/packages/cli/src/platforms/ios/xcodeTarget.ts new file mode 100644 index 00000000..72bb3398 --- /dev/null +++ b/packages/cli/src/platforms/ios/xcodeTarget.ts @@ -0,0 +1,473 @@ +import path from 'node:path' + +import { PBXFileReference, PBXGroup, PBXNativeTarget, XCBuildConfiguration, XCConfigurationList } from '@bacons/xcode' + +import { normalizeRelativePath, toRelativePath } from '../../fs/path' +import { VoltraCliError } from '../../reporting/summary' +import { resolveIOSWidgetTargetName } from './targetName' +import { ensureMainGroupChild, openIOSXcodeProject, saveIOSXcodeProject } from './xcode' + +import type { IOSProjectDiscovery } from '../../discovery/ios' +import type { NormalizedVoltraIOSConfig } from '../../config/types' +import type { ReportedChange } from '../../reporting/summary' +import type { IOSXcodeProjectContext } from './xcode' +import type { BuildSettings } from '@bacons/xcode/build/json/types' + +const IOS_APP_EXTENSION_PRODUCT_TYPE = 'com.apple.product-type.app-extension' +const PRODUCT_FILE_TYPE = 'wrapper.app-extension' +const SWIFT_FILE_TYPE = 'sourcecode.swift' +const STRINGS_FILE_TYPE = 'text.plist.strings' +const PLIST_FILE_TYPE = 'text.plist.xml' +const ASSET_CATALOG_FILE_TYPE = 'folder.assetcatalog' +const COPY_FILES_PHASE_NAME = 'Embed Foundation Extensions' +const SOURCE_EXTENSIONS = new Set(['.swift']) +const RESOURCE_EXTENSIONS = new Set(['.xcassets', '.strings', '.ttf', '.otf', '.woff', '.woff2']) + +export interface EnsureIOSWidgetTargetOptions { + projectRoot: string + ios: NormalizedVoltraIOSConfig + discovery: IOSProjectDiscovery + generatedFiles: string[] +} + +export interface EnsureIOSWidgetTargetResult { + change?: ReportedChange + targetName: string +} + +export class IOSWidgetTargetMutationError extends VoltraCliError { + constructor(message: string) { + super(message, 'VOLTRA_IOS_XCODE_TARGET_FAILED') + this.name = 'IOSWidgetTargetMutationError' + } +} + +export async function ensureIOSWidgetTarget(options: EnsureIOSWidgetTargetOptions): Promise { + const { projectRoot, ios, discovery, generatedFiles } = options + const targetName = resolveIOSWidgetTargetName(ios, discovery) + const context = openIOSXcodeProject(discovery) + const beforeSerialized = JSON.stringify(context.project.toJSON()) + const productPath = `${targetName}.appex` + const nextGeneratedFiles = normalizeGeneratedFilePaths(generatedFiles, projectRoot, discovery) + const bundleIdentifier = resolveBundleIdentifier(context, discovery, targetName) + const codeSigning = getMainAppCodeSigningSettings(context) + + ensureWidgetTarget(context, targetName, bundleIdentifier, ios.deploymentTarget, codeSigning) + + const widgetTarget = getWidgetTarget(context, targetName) + const widgetGroup = ensureWidgetGroup(context, targetName) + const productFile = ensureProductFile(context, targetName, productPath) + + widgetTarget.props.productReference = productFile + widgetTarget.props.productType = IOS_APP_EXTENSION_PRODUCT_TYPE + widgetTarget.props.productName = targetName + + ensureTargetDependency(context, widgetTarget) + ensureTargetAttributes(context, widgetTarget) + ensureBuildPhases(context, widgetTarget, productFile, nextGeneratedFiles) + ensureWidgetGroupFiles(context, widgetGroup, targetName, nextGeneratedFiles) + + const changePath = toRelativePath(projectRoot, discovery.pbxprojPath) + const afterSerialized = JSON.stringify(context.project.toJSON()) + + if (beforeSerialized !== afterSerialized) { + await saveIOSXcodeProject(context) + } + + return { + change: beforeSerialized === afterSerialized ? undefined : { kind: 'updated', path: changePath }, + targetName, + } +} + +function ensureWidgetTarget( + context: IOSXcodeProjectContext, + targetName: string, + bundleIdentifier: string, + deploymentTarget: string, + codeSigning: MainAppCodeSigningSettings +): PBXNativeTarget { + const existingTarget = getWidgetTargetOptional(context, targetName) + + if (existingTarget) { + ensureBuildConfigurations(existingTarget, targetName, bundleIdentifier, deploymentTarget, codeSigning) + return existingTarget + } + + const buildConfigurationList = createBuildConfigurationList(context, targetName, bundleIdentifier, deploymentTarget, codeSigning) + const target = context.project.rootObject.createNativeTarget({ + buildConfigurationList, + name: targetName, + productType: IOS_APP_EXTENSION_PRODUCT_TYPE, + }) + + target.props.productName = targetName + target.getSourcesBuildPhase() + target.getResourcesBuildPhase() + target.getFrameworksBuildPhase() + return target +} + +function createBuildConfigurationList( + context: IOSXcodeProjectContext, + targetName: string, + bundleIdentifier: string, + deploymentTarget: string, + codeSigning: MainAppCodeSigningSettings +): XCConfigurationList { + const configs = context.mainAppTarget.buildConfigurations.all.map((config) => { + return XCBuildConfiguration.create(context.project, { + name: config.props.name, + buildSettings: buildWidgetBuildSettings(targetName, bundleIdentifier, deploymentTarget, codeSigning, config.props.name), + }) + }) + + return XCConfigurationList.create(context.project, { + buildConfigurations: configs, + defaultConfigurationName: context.mainAppTarget.buildConfigurations.default.props.name, + }) +} + +function ensureBuildConfigurations( + target: PBXNativeTarget, + targetName: string, + bundleIdentifier: string, + deploymentTarget: string, + codeSigning: MainAppCodeSigningSettings +): void { + const configurationList = target.props.buildConfigurationList + + if (!configurationList) { + throw new IOSWidgetTargetMutationError(`Widget target '${target.props.name}' is missing a build configuration list.`) + } + + for (const config of configurationList.props.buildConfigurations) { + Object.assign(config.props.buildSettings, buildWidgetBuildSettings(targetName, bundleIdentifier, deploymentTarget, codeSigning, config.props.name)) + } +} + +function buildWidgetBuildSettings( + targetName: string, + bundleIdentifier: string, + deploymentTarget: string, + codeSigning: MainAppCodeSigningSettings, + configurationName: string +): BuildSettings & Record { + const buildSettings: BuildSettings & Record = { + ASSETCATALOG_COMPILER_APPICON_NAME: '""', + CODE_SIGN_ENTITLEMENTS: `"${targetName}/${targetName}.entitlements"`, + CURRENT_PROJECT_VERSION: '1', + INFOPLIST_FILE: `${targetName}/Info.plist`, + INFOPLIST_OUTPUT_FORMAT: 'xml', + IPHONEOS_DEPLOYMENT_TARGET: `"${deploymentTarget}"`, + MARKETING_VERSION: '1.0', + OTHER_SWIFT_FLAGS: `"$(inherited) -D EXPO_CONFIGURATION_${configurationName.toUpperCase()}"`, + PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`, + PRODUCT_NAME: '"$(TARGET_NAME)"', + SWIFT_OPTIMIZATION_LEVEL: '"-Onone"', + SWIFT_VERSION: '5.0', + TARGETED_DEVICE_FAMILY: '"1,2"', + ...(codeSigning.codeSignStyle ? { CODE_SIGN_STYLE: `"${codeSigning.codeSignStyle}"` } : {}), + ...(codeSigning.developmentTeam ? { DEVELOPMENT_TEAM: `"${codeSigning.developmentTeam}"` } : {}), + } + + buildSettings.APPLICATION_EXTENSION_API_ONLY = 'YES' + buildSettings.INFOPLIST_OUTPUT_FORMAT = 'xml' + + if (codeSigning.provisioningProfileSpecifier) { + buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${codeSigning.provisioningProfileSpecifier}"` + } + + return buildSettings +} + +function getWidgetTarget(context: IOSXcodeProjectContext, targetName: string): PBXNativeTarget { + const target = getWidgetTargetOptional(context, targetName) + + if (!target) { + throw new IOSWidgetTargetMutationError(`Xcode project does not contain widget target '${targetName}' after mutation.`) + } + + return target +} + +function getWidgetTargetOptional(context: IOSXcodeProjectContext, targetName: string): PBXNativeTarget | undefined { + return context.project.rootObject.props.targets.find((target): target is PBXNativeTarget => { + return PBXNativeTarget.is(target) && target.props.name === targetName && target.props.productType === IOS_APP_EXTENSION_PRODUCT_TYPE + }) +} + +function ensureWidgetGroup(context: IOSXcodeProjectContext, targetName: string): PBXGroup { + const existingGroup = context.mainGroup.getChildGroups().find((group) => group.getDisplayName() === targetName) + if (existingGroup) { + existingGroup.props.path = targetName + return existingGroup + } + + return ensureMainGroupChild(context, targetName) +} + +function ensureProductFile(context: IOSXcodeProjectContext, targetName: string, productPath: string): PBXFileReference { + const existingProduct = [...context.project.values()].find((object): object is PBXFileReference => { + return PBXFileReference.is(object) && stripQuotes(object.props.path) === productPath && object.props.sourceTree === 'BUILT_PRODUCTS_DIR' + }) + + if (existingProduct) { + existingProduct.props.explicitFileType = PRODUCT_FILE_TYPE + existingProduct.props.path = productPath + existingProduct.props.sourceTree = 'BUILT_PRODUCTS_DIR' + return existingProduct + } + + return context.productsGroup.createNewProductRefForTarget(targetName, 'appExtension') +} + +function ensureTargetDependency(context: IOSXcodeProjectContext, widgetTarget: PBXNativeTarget): void { + context.mainAppTarget.target.addDependency(widgetTarget) + + const copyFilesPhase = context.mainAppTarget.getCopyFilesBuildPhaseFor(widgetTarget) + copyFilesPhase.ensureDefaultsForTarget(widgetTarget) + copyFilesPhase.props.name = COPY_FILES_PHASE_NAME + + const productReference = widgetTarget.props.productReference + if (!productReference) { + throw new IOSWidgetTargetMutationError(`Widget target '${widgetTarget.props.name}' is missing a product reference.`) + } + + copyFilesPhase.ensureFile({ fileRef: productReference }) +} + +function ensureTargetAttributes(context: IOSXcodeProjectContext, widgetTarget: PBXNativeTarget): void { + const attributes = context.project.rootObject.props.attributes + const targetAttributes = (attributes.TargetAttributes ??= {}) as Record + targetAttributes[widgetTarget.uuid] ??= { LastSwiftMigration: '1250' } +} + +function ensureBuildPhases( + context: IOSXcodeProjectContext, + widgetTarget: PBXNativeTarget, + productFile: PBXFileReference, + generatedFiles: string[] +): void { + const sources = widgetTarget.getSourcesBuildPhase() + const resources = widgetTarget.getResourcesBuildPhase() + widgetTarget.getFrameworksBuildPhase() + + const fileReferences = generatedFiles.map((file) => ensureGeneratedFileReference(context, file)) + + for (const fileReference of fileReferences) { + const relativePath = getReferenceRelativePath(context, fileReference) + + if (isSourceFile(relativePath)) { + sources.ensureFile({ fileRef: fileReference }) + continue + } + + if (isResourceFile(relativePath)) { + resources.ensureFile({ fileRef: fileReference }) + } + } + + const copyFilesPhase = context.mainAppTarget.getCopyFilesBuildPhaseFor(widgetTarget) + copyFilesPhase.ensureDefaultsForTarget(widgetTarget) + copyFilesPhase.props.name = COPY_FILES_PHASE_NAME + copyFilesPhase.ensureFile({ fileRef: productFile }) +} + +function ensureWidgetGroupFiles( + context: IOSXcodeProjectContext, + widgetGroup: PBXGroup, + targetName: string, + generatedFiles: string[] +): void { + const localizedGroups = new Map() + + for (const file of generatedFiles) { + const reference = ensureGeneratedFileReference(context, file) + const relativeToTarget = getPathRelativeToTarget(file, targetName) + + if (!relativeToTarget) { + continue + } + + if (relativeToTarget.includes('/')) { + const [groupName] = relativeToTarget.split('/', 1) + if (groupName.endsWith('.lproj')) { + const localeGroup = localizedGroups.get(groupName) ?? ensureChildGroup(widgetGroup, groupName, groupName) + localizedGroups.set(groupName, localeGroup) + ensureGroupContainsReference(localeGroup, reference) + continue + } + } + + ensureGroupContainsReference(widgetGroup, reference) + } +} + +function ensureGeneratedFileReference(context: IOSXcodeProjectContext, relativeFilePath: string): PBXFileReference { + const absolutePath = path.join(context.project.getProjectRoot(), relativeFilePath) + const existingReference = context.project.getReferenceForPath(absolutePath) + + if (existingReference) { + applyFileType(existingReference, relativeFilePath) + return existingReference + } + + const targetName = path.dirname(relativeFilePath).split(path.sep)[0] + const widgetGroup = ensureWidgetGroup(context, targetName) + const pathWithinGroup = getPathRelativeToTarget(relativeFilePath, targetName) + + if (!pathWithinGroup) { + throw new IOSWidgetTargetMutationError(`Generated iOS file is outside widget target directory: ${relativeFilePath}`) + } + + const parentGroup = ensureParentGroup(widgetGroup, pathWithinGroup) + const fileReference = parentGroup.createFile({ path: path.basename(pathWithinGroup) }) + applyFileType(fileReference, relativeFilePath) + return fileReference +} + +function ensureParentGroup(rootGroup: PBXGroup, relativePath: string): PBXGroup { + const directories = path.dirname(relativePath) + if (directories === '.' || directories === '') { + return rootGroup + } + + const group = rootGroup.mkdir(directories.split(path.sep), { recursive: true }) + if (!group) { + throw new IOSWidgetTargetMutationError(`Failed to create Xcode group path for ${relativePath}`) + } + + return group +} + +function ensureChildGroup(parent: PBXGroup, name: string, relativePath: string): PBXGroup { + const existingGroup = parent.getChildGroups().find((group) => group.getDisplayName() === name) + if (existingGroup) { + existingGroup.props.path = relativePath + return existingGroup + } + + return parent.createGroup({ name, path: relativePath, sourceTree: '' }) +} + +function ensureGroupContainsReference(group: PBXGroup, reference: PBXFileReference): void { + const alreadyPresent = group.props.children.some((child) => child.uuid === reference.uuid) + if (!alreadyPresent) { + group.props.children.push(reference) + } +} + +function applyFileType(reference: PBXFileReference, relativePath: string): void { + const extension = path.extname(relativePath) + + if (extension === '.swift') { + reference.setLastKnownFileType(SWIFT_FILE_TYPE) + return + } + + if (extension === '.plist') { + reference.setLastKnownFileType(PLIST_FILE_TYPE) + return + } + + if (extension === '.entitlements') { + reference.setLastKnownFileType('text.plist.entitlements') + return + } + + if (extension === '.strings') { + reference.setLastKnownFileType(STRINGS_FILE_TYPE) + return + } + + if (extension === '.xcassets') { + reference.setLastKnownFileType(ASSET_CATALOG_FILE_TYPE) + return + } +} + +function isSourceFile(relativePath: string): boolean { + return SOURCE_EXTENSIONS.has(path.extname(relativePath)) +} + +function isResourceFile(relativePath: string): boolean { + const extension = path.extname(relativePath) + return RESOURCE_EXTENSIONS.has(extension) || relativePath.endsWith('.xcassets') +} + +function getReferenceRelativePath(context: IOSXcodeProjectContext, reference: PBXFileReference): string { + return normalizeRelativePath(path.relative(context.project.getProjectRoot(), reference.getFullPath())) +} + +function getPathRelativeToTarget(relativePath: string, targetName: string): string | null { + const normalizedPath = normalizeRelativePath(relativePath) + if (!normalizedPath.startsWith(`${targetName}/`)) { + return null + } + + return normalizedPath.slice(targetName.length + 1) +} + +function normalizeGeneratedFilePaths(generatedFiles: string[], projectRoot: string, discovery: IOSProjectDiscovery): string[] { + const iosRootRelativePath = normalizeRelativePath(path.relative(projectRoot, discovery.iosRoot)) + const iosRootRelativePrefix = iosRootRelativePath === '.' ? '' : `${iosRootRelativePath}/` + + return [...new Set(generatedFiles.map((file) => toIOSProjectRelativePath(file, iosRootRelativePrefix, discovery)))].sort() +} + +function toIOSProjectRelativePath( + relativeFilePath: string, + iosRootRelativePrefix: string, + discovery: IOSProjectDiscovery +): string { + const normalizedPath = normalizeRelativePath(relativeFilePath) + + if (iosRootRelativePrefix.length === 0) { + return normalizedPath + } + + if (normalizedPath.startsWith(iosRootRelativePrefix)) { + return normalizedPath.slice(iosRootRelativePrefix.length) + } + + throw new IOSWidgetTargetMutationError( + `Generated iOS file is outside the discovered iOS root '${discovery.iosRoot}': ${relativeFilePath}` + ) +} + +function resolveBundleIdentifier(context: IOSXcodeProjectContext, discovery: IOSProjectDiscovery, targetName: string): string { + const mainTargetBundleIdentifier = context.mainAppTarget.buildConfigurations.default.resolveBuildSetting('PRODUCT_BUNDLE_IDENTIFIER') + + if (typeof mainTargetBundleIdentifier !== 'string' || mainTargetBundleIdentifier.length === 0) { + throw new IOSWidgetTargetMutationError( + `Main app target '${discovery.mainTargetName}' is missing PRODUCT_BUNDLE_IDENTIFIER in ${discovery.pbxprojPath}` + ) + } + + return `${stripQuotes(mainTargetBundleIdentifier)}.${targetName}` +} + +function getMainAppCodeSigningSettings(context: IOSXcodeProjectContext): MainAppCodeSigningSettings { + const buildSettings = context.mainAppTarget.buildConfigurations.default.props.buildSettings ?? {} + + return { + codeSignStyle: readBuildSettingString(buildSettings.CODE_SIGN_STYLE), + developmentTeam: readBuildSettingString(buildSettings.DEVELOPMENT_TEAM), + provisioningProfileSpecifier: readBuildSettingString((buildSettings as unknown as { PROVISIONING_PROFILE_SPECIFIER?: unknown }).PROVISIONING_PROFILE_SPECIFIER), + } +} + +interface MainAppCodeSigningSettings { + codeSignStyle?: string + developmentTeam?: string + provisioningProfileSpecifier?: string +} + +function readBuildSettingString(value: unknown): string | undefined { + return typeof value === 'string' ? stripQuotes(value) : undefined +} + +function stripQuotes(value: string | undefined): string { + return value?.replace(/^"|"$/g, '') ?? '' +} From b8e1516f787d0f9c84667941726bb8f052bde235 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 10:01:01 +0200 Subject: [PATCH 24/37] feat: integrate ios xcode mutation --- PLAN.md | 3 +++ packages/cli/src/platforms/ios/apply.ts | 15 ++++++++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index 8e3eae69..0f95b157 100644 --- a/PLAN.md +++ b/PLAN.md @@ -831,6 +831,9 @@ Can run in parallel with: **T23. Integrate Xcode mutation into iOS apply flow** +Status: +- completed + Deliverables: - add `project.pbxproj` mutation to iOS apply flow - ensure generated-file paths and target references stay aligned diff --git a/packages/cli/src/platforms/ios/apply.ts b/packages/cli/src/platforms/ios/apply.ts index 0a61dab4..40ff391f 100644 --- a/packages/cli/src/platforms/ios/apply.ts +++ b/packages/cli/src/platforms/ios/apply.ts @@ -5,6 +5,7 @@ import { ensureEntitlements } from './entitlements' import { generateIOSFiles } from './generated' import { ensureInfoPlist } from './plist' import { ensurePodfileBlock } from './podfile' +import { ensureIOSWidgetTarget } from './xcodeTarget' import type { NormalizedVoltraConfig } from '../../config/types' import type { IOSProjectDiscovery } from '../../discovery/ios' @@ -61,6 +62,18 @@ export async function applyIOSPlatform(context: PlatformApplyContext): Promise

Date: Thu, 28 May 2026 10:19:46 +0200 Subject: [PATCH 25/37] docs: add cli usage guide --- PLAN.md | 3 + packages/cli/README.md | 247 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 249 insertions(+), 1 deletion(-) diff --git a/PLAN.md b/PLAN.md index 0f95b157..778317f9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -853,6 +853,9 @@ Can run in parallel with: **T24. Write CLI docs and usage examples** +Status: +- completed + Deliverables: - document config file locations - document `voltra apply` diff --git a/packages/cli/README.md b/packages/cli/README.md index 6695b26f..16ca0f0b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,3 +1,248 @@ # voltra -CLI for applying Voltra to native React Native projects. +CLI for applying Voltra to standard native React Native projects. + +`voltra` is the non-Expo path for wiring Voltra into an existing native app. + +V1 exposes one public command: + +```sh +voltra apply +``` + +It loads Voltra config, discovers the native project, generates Voltra-owned files, mutates required native project files, removes stale generated files from previous runs, and writes `.voltra/state.json` after a successful apply. + +## Install + +```sh +npm install --save-dev voltra +``` + +## Command + +```sh +voltra apply [--platform ios|android] [--config ] +``` + +Options: + +- `--platform ios|android`: limit apply to one platform. +- `--config `: load config from an explicit file path. +- `-h`, `--help`: show command help. + +Examples: + +```sh +# Apply both configured platforms +npx voltra apply + +# Apply only iOS +npx voltra apply --platform ios + +# Apply using an explicit config file +npx voltra apply --config ./config/voltra.config.ts +``` + +## Config Files + +`voltra` uses `cosmiconfig` and searches these locations: + +- `package.json` under `voltra` +- `.voltrarc` +- `.voltrarc.json` +- `.voltrarc.yaml` +- `.voltrarc.yml` +- `.voltrarc.js` +- `.voltrarc.cjs` +- `.voltrarc.mjs` +- `.voltrarc.ts` +- `voltra.config.json` +- `voltra.config.yaml` +- `voltra.config.yml` +- `voltra.config.js` +- `voltra.config.cjs` +- `voltra.config.mjs` +- `voltra.config.ts` + +When `--config` is provided, that file is loaded directly instead of searching. + +## Path Resolution + +- `configDir` is the directory containing the loaded config file. +- `projectRoot` defaults to `configDir`. +- `projectRoot` can be overridden in config. +- Relative widget, preview, font, and project-override paths resolve from `projectRoot`. + +## Config Shape + +The CLI config stays close to the existing Expo plugin config, with extra project-discovery overrides for native apps. + +```ts +import type { VoltraConfig } from 'voltra' + +const config: VoltraConfig = { + projectRoot: '.', + android: { + enableNotifications: true, + fonts: ['./assets/fonts/Inter-Regular.ttf'], + userImagesPath: './assets/voltra-android', + project: { + rootDir: './android', + appModuleName: 'app', + manifestPath: './android/app/src/main/AndroidManifest.xml', + packageName: 'com.example.app', + }, + widgets: [ + { + id: 'scoreboard', + displayName: 'Scoreboard', + description: 'Live score widget', + targetCellWidth: 2, + targetCellHeight: 2, + previewImage: './assets/widgets/scoreboard-preview.png', + initialStatePath: './widgets/scoreboard.android.tsx', + }, + ], + }, + ios: { + enablePushNotifications: true, + groupIdentifier: 'group.com.example.app', + keychainGroup: '$(AppIdentifierPrefix)com.example.shared', + deploymentTarget: '16.0', + targetName: 'ExampleLiveActivity', + fonts: ['./assets/fonts/Inter-Regular.ttf'], + project: { + rootDir: './ios', + xcodeprojPath: './ios/Example.xcodeproj', + mainTargetName: 'Example', + infoPlistPath: './ios/Example/Info.plist', + entitlementsPath: './ios/Example/Example.entitlements', + podfilePath: './ios/Podfile', + }, + widgets: [ + { + id: 'portfolio', + displayName: { + en: 'Portfolio', + pl: 'Portfel', + }, + description: 'Track holdings', + supportedFamilies: ['systemSmall', 'systemMedium'], + initialStatePath: { + en: './widgets/portfolio.ios.en.tsx', + pl: './widgets/portfolio.ios.pl.tsx', + }, + serverUpdate: { + url: 'https://example.com/widgets/portfolio', + intervalMinutes: 30, + refresh: true, + }, + }, + ], + }, +} + +export default config +``` + +## Discovery Defaults + +`voltra apply` is convention-first and only needs overrides for non-standard layouts or ambiguous native projects. + +For generated assets, Android reads `android.userImagesPath` and iOS uses the current default widget asset location under `./assets/voltra`. + +### Android + +Default discovery: + +- Android root: `android/` +- app module: `app` +- manifest: `android/app/src/main/AndroidManifest.xml` +- package name: resolved from `android.project.packageName`, then app-module `namespace`, then `applicationId`, then manifest `package` + +Android override fields: + +- `android.project.rootDir` +- `android.project.appModuleName` +- `android.project.manifestPath` +- `android.project.packageName` + +### iOS + +Default discovery: + +- iOS root: `ios/` +- Podfile: `ios/Podfile` +- Xcode project: the only `.xcodeproj` under `ios/` +- main app target: the only application target in `project.pbxproj` +- main app `Info.plist` and entitlements: resolved from the selected target build settings + +iOS override fields: + +- `ios.project.rootDir` +- `ios.project.xcodeprojPath` +- `ios.project.mainTargetName` +- `ios.project.infoPlistPath` +- `ios.project.entitlementsPath` +- `ios.project.podfilePath` + +If discovery is missing or ambiguous, `voltra apply` fails during preflight before writing any files. + +## Dirty Git Worktree Behavior + +Before writing files, `voltra apply` checks the git worktree: + +- clean worktree: continue +- dirty worktree in an interactive terminal: print a warning and ask for confirmation +- dirty worktree in a non-interactive environment: fail before applying changes +- no git repository: continue without blocking apply + +## Generated Files And State Tracking + +Voltra tracks only fully generated, Voltra-owned files in: + +```text +.voltra/state.json +``` + +Example state file: + +```json +{ + "schemaVersion": 1, + "files": [ + "ios/ExampleLiveActivity/Info.plist", + "ios/ExampleLiveActivity/VoltraWidgetBundle.swift", + "android/app/src/main/res/xml/voltra_widget_scoreboard_info.xml" + ] +} +``` + +Rules: + +- paths are stored relative to `projectRoot` +- only generated Voltra-owned files are tracked +- stale generated files from previous runs are removed after a successful apply +- shared native files are not reverted from state history + +Shared files are always reconciled from current config instead of state history. That includes: + +- `AndroidManifest.xml` +- main app `Info.plist` +- entitlements +- `Podfile` +- `project.pbxproj` + +## Apply Summary + +After a successful run, `voltra apply` prints a summary of created, updated, and deleted files, followed by any warnings. + +## Scope Notes + +Current v1 scope is intentionally narrow: + +- one public command: `voltra apply` +- standard native React Native project layouts first +- no plan or diff mode +- no rollback system +- no broad mutation history beyond `.voltra/state.json` From ade95a7710976726d4e1108a9d5c0b2af56165ea Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 10:40:56 +0200 Subject: [PATCH 26/37] fix: address cli review findings --- packages/cli/README.md | 6 + packages/cli/src/apply/index.ts | 88 +++++++++++++- packages/cli/src/platforms/ios/podfile.ts | 1 + packages/cli/src/platforms/ios/xcodeTarget.ts | 112 +++++++++++++++++- 4 files changed, 205 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 16ca0f0b..8f3d63ba 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -16,8 +16,11 @@ It loads Voltra config, discovers the native project, generates Voltra-owned fil ```sh npm install --save-dev voltra +npm install @use-voltra/ios-client ``` +If you apply only Android, the iOS client package is not required. + ## Command ```sh @@ -41,6 +44,9 @@ npx voltra apply --platform ios # Apply using an explicit config file npx voltra apply --config ./config/voltra.config.ts + +# Re-apply only Android without removing tracked iOS files +npx voltra apply --platform android ``` ## Config Files diff --git a/packages/cli/src/apply/index.ts b/packages/cli/src/apply/index.ts index 700dc963..104f2078 100644 --- a/packages/cli/src/apply/index.ts +++ b/packages/cli/src/apply/index.ts @@ -1,5 +1,6 @@ import path from 'node:path' +import { normalizeRelativePath } from '../fs/path' import { loadVoltraConfig } from '../config/load' import { normalizeVoltraConfig } from '../config/normalize' import { removePathIfExists } from '../fs/readWrite' @@ -82,7 +83,7 @@ export async function runApplyPipeline(options: ApplyOptions, dependencies: Appl const preflight = await runApplyPreflight(normalizedConfig, resolvedDependencies.preflightRunners, options.platform) const previousState = await loadVoltraState(normalizedConfig.projectRoot) const platformResults = await runPlatformApply(normalizedConfig, preflight, previousState, resolvedDependencies.applyRunners) - const nextGeneratedFiles = platformResults.flatMap((result) => result.generatedFiles) + const nextGeneratedFiles = mergeGeneratedFiles(normalizedConfig, previousState, preflight.requestedPlatforms, platformResults) const stateDiff = diffVoltraState(previousState, nextGeneratedFiles) const deletedChanges = await removeStaleGeneratedFiles(normalizedConfig.projectRoot, stateDiff.staleFiles) await saveVoltraState(normalizedConfig.projectRoot, { files: stateDiff.nextFiles }) @@ -107,6 +108,91 @@ function resolveApplyDependencies(config: NormalizedVoltraConfig, dependencies: } } +function mergeGeneratedFiles( + config: NormalizedVoltraConfig, + previousState: VoltraState | undefined, + requestedPlatforms: VoltraPlatform[], + platformResults: PlatformApplyResult[] +): string[] { + const nextGeneratedFilesByPlatform = new Map(platformResults.map((result) => [result.platform, result.generatedFiles] as const)) + const mergedFiles = new Set() + const platformRoots = getTrackedPlatformRoots(config) + const configuredPlatforms = getConfiguredPlatforms(config) + const isPartialApply = requestedPlatforms.length < configuredPlatforms.length + + for (const previousFile of previousState?.files ?? []) { + const owningPlatform = getTrackedFilePlatform(previousFile, platformRoots) + + if (!owningPlatform) { + if (isPartialApply) { + mergedFiles.add(previousFile) + } + + continue + } + + if (requestedPlatforms.includes(owningPlatform)) { + continue + } + + mergedFiles.add(previousFile) + } + + for (const platform of requestedPlatforms) { + for (const filePath of nextGeneratedFilesByPlatform.get(platform) ?? []) { + mergedFiles.add(filePath) + } + } + + return [...mergedFiles] +} + +function getTrackedPlatformRoots(config: NormalizedVoltraConfig): Partial> { + const projectRoot = config.projectRoot + const androidRoot = config.android ? normalizeRelativePath(path.relative(projectRoot, config.android.project.rootDir ?? path.join(projectRoot, 'android'))) : undefined + const iosRoot = config.ios ? normalizeRelativePath(path.relative(projectRoot, config.ios.project.rootDir ?? path.join(projectRoot, 'ios'))) : undefined + + return { + ...(androidRoot ? { android: androidRoot } : {}), + ...(iosRoot ? { ios: iosRoot } : {}), + } +} + +function getConfiguredPlatforms(config: NormalizedVoltraConfig): VoltraPlatform[] { + const platforms: VoltraPlatform[] = [] + + if (config.android) { + platforms.push('android') + } + + if (config.ios) { + platforms.push('ios') + } + + return platforms +} + +function getTrackedFilePlatform( + filePath: string, + platformRoots: Partial> +): VoltraPlatform | undefined { + const normalizedFilePath = normalizeRelativePath(filePath) + + for (const platform of ['android', 'ios'] as const) { + const root = platformRoots[platform] + + if (!root) { + continue + } + + if (normalizedFilePath === root || normalizedFilePath.startsWith(`${root}/`)) { + return platform + } + } + + return undefined +} + async function runPlatformApply( config: NormalizedVoltraConfig, preflight: ApplyPreflightResult, diff --git a/packages/cli/src/platforms/ios/podfile.ts b/packages/cli/src/platforms/ios/podfile.ts index 8280cee3..a486a1bf 100644 --- a/packages/cli/src/platforms/ios/podfile.ts +++ b/packages/cli/src/platforms/ios/podfile.ts @@ -64,6 +64,7 @@ function buildManagedPodfileBlock(targetName: string): string { '', " require 'pathname'", ' project_root = "#{Pod::Config.instance.installation_root}/.."', + " abort('[voltra] Error: @use-voltra/ios-client must be installed in the app project before running pod install.') unless system(\"node --print \\\"require.resolve('@use-voltra/ios-client/package.json', {paths: ['#{project_root}']})\\\" >/dev/null 2>&1\")", " voltra_client_root = `node --print \"require.resolve('@use-voltra/ios-client/package.json', {paths: ['#{project_root}']})\" 2>/dev/null`.strip.gsub('/package.json', '')", " voltra_widget_podspec_root = File.join(voltra_client_root, 'ios')", ' podspec_dir_path = Pathname.new(voltra_widget_podspec_root).relative_path_from(Pathname.new(__dir__)).to_path', diff --git a/packages/cli/src/platforms/ios/xcodeTarget.ts b/packages/cli/src/platforms/ios/xcodeTarget.ts index 72bb3398..15cd7a43 100644 --- a/packages/cli/src/platforms/ios/xcodeTarget.ts +++ b/packages/cli/src/platforms/ios/xcodeTarget.ts @@ -1,6 +1,15 @@ import path from 'node:path' -import { PBXFileReference, PBXGroup, PBXNativeTarget, XCBuildConfiguration, XCConfigurationList } from '@bacons/xcode' +import { + PBXFileReference, + PBXFrameworksBuildPhase, + PBXGroup, + PBXNativeTarget, + PBXResourcesBuildPhase, + PBXSourcesBuildPhase, + XCBuildConfiguration, + XCConfigurationList, +} from '@bacons/xcode' import { normalizeRelativePath, toRelativePath } from '../../fs/path' import { VoltraCliError } from '../../reporting/summary' @@ -64,6 +73,7 @@ export async function ensureIOSWidgetTarget(options: EnsureIOSWidgetTargetOption ensureTargetDependency(context, widgetTarget) ensureTargetAttributes(context, widgetTarget) + removeStaleGeneratedFileReferences(context, widgetTarget, widgetGroup, nextGeneratedFiles) ensureBuildPhases(context, widgetTarget, productFile, nextGeneratedFiles) ensureWidgetGroupFiles(context, widgetGroup, targetName, nextGeneratedFiles) @@ -274,6 +284,34 @@ function ensureBuildPhases( copyFilesPhase.ensureFile({ fileRef: productFile }) } +function removeStaleGeneratedFileReferences( + context: IOSXcodeProjectContext, + widgetTarget: PBXNativeTarget, + widgetGroup: PBXGroup, + generatedFiles: string[] +): void { + const generatedFileSet = new Set(generatedFiles) + const staleReferences = [...context.project.values()].filter((object): object is PBXFileReference => { + if (!PBXFileReference.is(object)) { + return false + } + + const relativePath = getReferenceRelativePath(context, object) + + if (!isGeneratedWidgetFile(relativePath, widgetTarget.props.name, generatedFileSet)) { + return false + } + + return !generatedFileSet.has(relativePath) + }) + + for (const reference of staleReferences) { + removeFileReferenceFromTargetBuildPhases(widgetTarget, reference) + removeFileReferenceFromGroupTree(widgetGroup, reference) + reference.removeFromProject() + } +} + function ensureWidgetGroupFiles( context: IOSXcodeProjectContext, widgetGroup: PBXGroup, @@ -304,6 +342,21 @@ function ensureWidgetGroupFiles( } } +function removeFileReferenceFromTargetBuildPhases(target: PBXNativeTarget, reference: PBXFileReference): void { + for (const phase of [target.getSourcesBuildPhase(), target.getResourcesBuildPhase(), target.getFrameworksBuildPhase()]) { + removeBuildPhaseReference(phase, reference) + } +} + +function removeBuildPhaseReference( + phase: PBXSourcesBuildPhase | PBXResourcesBuildPhase | PBXFrameworksBuildPhase, + reference: PBXFileReference +): void { + if (phase.includesFile(reference)) { + phase.removeFileReference(reference) + } +} + function ensureGeneratedFileReference(context: IOSXcodeProjectContext, relativeFilePath: string): PBXFileReference { const absolutePath = path.join(context.project.getProjectRoot(), relativeFilePath) const existingReference = context.project.getReferenceForPath(absolutePath) @@ -400,6 +453,63 @@ function getReferenceRelativePath(context: IOSXcodeProjectContext, reference: PB return normalizeRelativePath(path.relative(context.project.getProjectRoot(), reference.getFullPath())) } +function isGeneratedWidgetFile( + relativePath: string, + targetName: string | undefined, + generatedFiles: Set +): boolean { + if (!targetName) { + return false + } + + if (!relativePath.startsWith(`${targetName}/`)) { + return false + } + + if (generatedFiles.has(relativePath)) { + return true + } + + const pathWithinTarget = getPathRelativeToTarget(relativePath, targetName) + + if (!pathWithinTarget) { + return false + } + + if (pathWithinTarget.startsWith('Assets.xcassets/')) { + return true + } + + if (pathWithinTarget.includes('.lproj/')) { + return true + } + + const extension = path.extname(relativePath) + return ( + extension === '.swift' || + extension === '.plist' || + extension === '.entitlements' || + extension === '.strings' || + extension === '.ttf' || + extension === '.otf' || + extension === '.woff' || + extension === '.woff2' || + extension === '.json' || + extension === '.png' || + extension === '.jpg' || + extension === '.jpeg' || + relativePath.endsWith('.xcassets') + ) +} + +function removeFileReferenceFromGroupTree(group: PBXGroup, reference: PBXFileReference): void { + group.props.children = group.props.children.filter((child) => child.uuid !== reference.uuid) + + for (const childGroup of group.getChildGroups()) { + removeFileReferenceFromGroupTree(childGroup, reference) + } +} + function getPathRelativeToTarget(relativePath: string, targetName: string): string | null { const normalizedPath = normalizeRelativePath(relativePath) if (!normalizedPath.startsWith(`${targetName}/`)) { From 7a0d820facd55e78ef45821c55f3d8ba99174774 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 12:48:34 +0200 Subject: [PATCH 27/37] fix: harden cli apply reconciliation --- packages/cli/README.md | 5 +- packages/cli/src/config/defaults.ts | 2 + packages/cli/src/config/normalize.ts | 41 +++- packages/cli/src/config/types.ts | 2 + .../cli/src/platforms/android/manifest.ts | 68 ++++++- packages/cli/src/platforms/ios/apply.ts | 5 +- .../cli/src/platforms/ios/entitlements.ts | 55 +++++- packages/cli/src/platforms/ios/generated.ts | 27 ++- packages/cli/src/platforms/ios/xcodeTarget.ts | 183 ++++++++++++------ packages/cli/src/state/files.ts | 14 +- 10 files changed, 312 insertions(+), 90 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 8f3d63ba..4692c29b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -77,7 +77,7 @@ When `--config` is provided, that file is loaded directly instead of searching. - `configDir` is the directory containing the loaded config file. - `projectRoot` defaults to `configDir`. - `projectRoot` can be overridden in config. -- Relative widget, preview, font, and project-override paths resolve from `projectRoot`. +- Relative widget, preview, font, asset, and project-override paths resolve from `projectRoot`. ## Config Shape @@ -117,6 +117,7 @@ const config: VoltraConfig = { deploymentTarget: '16.0', targetName: 'ExampleLiveActivity', fonts: ['./assets/fonts/Inter-Regular.ttf'], + userImagesPath: './assets/voltra', project: { rootDir: './ios', xcodeprojPath: './ios/Example.xcodeproj', @@ -155,7 +156,7 @@ export default config `voltra apply` is convention-first and only needs overrides for non-standard layouts or ambiguous native projects. -For generated assets, Android reads `android.userImagesPath` and iOS uses the current default widget asset location under `./assets/voltra`. +For generated assets, Android reads `android.userImagesPath` and iOS reads `ios.userImagesPath`. If `ios.userImagesPath` is omitted, it defaults to `./assets/voltra`. ### Android diff --git a/packages/cli/src/config/defaults.ts b/packages/cli/src/config/defaults.ts index daf9c2ca..ad4ff911 100644 --- a/packages/cli/src/config/defaults.ts +++ b/packages/cli/src/config/defaults.ts @@ -9,6 +9,7 @@ export const DEFAULT_IOS_ENABLE_PUSH_NOTIFICATIONS = false export const DEFAULT_IOS_DEPLOYMENT_TARGET = '17.0' export const DEFAULT_IOS_SERVER_UPDATE_INTERVAL_MINUTES = 15 export const DEFAULT_IOS_SERVER_UPDATE_REFRESH = false +export const DEFAULT_IOS_USER_IMAGES_PATH = './assets/voltra' export const DEFAULT_IOS_WIDGET_FAMILIES: IOSWidgetFamily[] = ['systemSmall', 'systemMedium', 'systemLarge'] /** @@ -27,6 +28,7 @@ export const CLI_DEFAULTS = { enablePushNotifications: DEFAULT_IOS_ENABLE_PUSH_NOTIFICATIONS, serverUpdateIntervalMinutes: DEFAULT_IOS_SERVER_UPDATE_INTERVAL_MINUTES, serverUpdateRefresh: DEFAULT_IOS_SERVER_UPDATE_REFRESH, + userImagesPath: DEFAULT_IOS_USER_IMAGES_PATH, widgetFamilies: DEFAULT_IOS_WIDGET_FAMILIES, }, } as const diff --git a/packages/cli/src/config/normalize.ts b/packages/cli/src/config/normalize.ts index f7067010..23cb9f85 100644 --- a/packages/cli/src/config/normalize.ts +++ b/packages/cli/src/config/normalize.ts @@ -153,7 +153,8 @@ function normalizeServerUpdate( serverUpdate: { url: string; intervalMinutes?: number; refresh?: boolean }, context: string, defaultIntervalMinutes: number, - defaultRefresh: boolean + defaultRefresh: boolean, + minimumIntervalMinutes: number ): { url: string; intervalMinutes: number; refresh: boolean } { assertObject(serverUpdate, context) assertNonEmptyString(serverUpdate.url, `${context}.url`) @@ -162,6 +163,14 @@ function normalizeServerUpdate( if (typeof serverUpdate.intervalMinutes !== 'number' || !Number.isFinite(serverUpdate.intervalMinutes)) { throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be a number`) } + + if (!Number.isInteger(serverUpdate.intervalMinutes)) { + throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be an integer`) + } + + if (serverUpdate.intervalMinutes < minimumIntervalMinutes) { + throw new VoltraConfigNormalizationError(`${context}.intervalMinutes must be at least ${minimumIntervalMinutes}`) + } } if (serverUpdate.refresh !== undefined && typeof serverUpdate.refresh !== 'boolean') { @@ -178,6 +187,7 @@ function normalizeServerUpdate( function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig): NormalizedAndroidWidgetConfig { assertObject(widget, 'android.widgets[]') assertNonEmptyString(widget.id, 'android.widgets[].id') + assertValidWidgetId(widget.id, 'android.widgets[].id') return { ...widget, @@ -191,7 +201,8 @@ function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig widget.serverUpdate, `android.widgets[${widget.id}].serverUpdate`, CLI_DEFAULTS.android.serverUpdateIntervalMinutes, - CLI_DEFAULTS.android.serverUpdateRefresh + CLI_DEFAULTS.android.serverUpdateRefresh, + 15 ) : undefined, } @@ -200,6 +211,7 @@ function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): NormalizedIOSWidgetConfig { assertObject(widget, 'ios.widgets[]') assertNonEmptyString(widget.id, 'ios.widgets[].id') + assertValidWidgetId(widget.id, 'ios.widgets[].id') if (widget.supportedFamilies !== undefined) { if (!Array.isArray(widget.supportedFamilies)) { @@ -224,7 +236,8 @@ function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): Norma widget.serverUpdate, `ios.widgets[${widget.id}].serverUpdate`, CLI_DEFAULTS.ios.serverUpdateIntervalMinutes, - CLI_DEFAULTS.ios.serverUpdateRefresh + CLI_DEFAULTS.ios.serverUpdateRefresh, + 1 ) : undefined, } @@ -242,6 +255,22 @@ function assertUniqueWidgetIds(widgetIds: string[], context: string): void { } } +function assertValidWidgetId(widgetId: string, context: string): void { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(widgetId)) { + throw new VoltraConfigNormalizationError( + `${context} must start with a letter or underscore and contain only alphanumeric characters and underscores` + ) + } +} + +function assertValidIOSTargetName(targetName: string, context: string): void { + if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(targetName)) { + throw new VoltraConfigNormalizationError( + `${context} must start with a letter and contain only letters, numbers, and underscores` + ) + } +} + function normalizeAndroidConfig(projectRoot: string, config: LoadedVoltraConfig['config']['android']): NormalizedVoltraAndroidConfig | undefined { if (config === undefined) { return undefined @@ -295,6 +324,7 @@ function normalizeIOSConfig(projectRoot: string, config: LoadedVoltraConfig['con assertOptionalString(config.deploymentTarget, 'ios.deploymentTarget') assertOptionalString(config.targetName, 'ios.targetName') assertOptionalStringArray(config.fonts, 'ios.fonts') + assertOptionalString(config.userImagesPath, 'ios.userImagesPath') assertOptionalString(config.keychainGroup, 'ios.keychainGroup') if (config.project !== undefined) { @@ -311,6 +341,10 @@ function normalizeIOSConfig(projectRoot: string, config: LoadedVoltraConfig['con throw new VoltraConfigNormalizationError('ios.widgets must be an array') } + if (config.targetName !== undefined) { + assertValidIOSTargetName(config.targetName, 'ios.targetName') + } + const widgets = (config.widgets ?? []).map((widget) => normalizeIOSWidget(projectRoot, widget)) assertUniqueWidgetIds( widgets.map((widget) => widget.id), @@ -324,6 +358,7 @@ function normalizeIOSConfig(projectRoot: string, config: LoadedVoltraConfig['con deploymentTarget: config.deploymentTarget ?? CLI_DEFAULTS.ios.deploymentTarget, targetName: config.targetName, fonts: (config.fonts ?? []).map((fontPath) => resolvePathFromProjectRoot(projectRoot, fontPath)), + userImagesPath: resolvePathFromProjectRoot(projectRoot, config.userImagesPath ?? CLI_DEFAULTS.ios.userImagesPath), keychainGroup: config.keychainGroup, project: { rootDir: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.rootDir), diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index f89921ca..994e36b8 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -99,6 +99,7 @@ export interface VoltraIOSConfig { deploymentTarget?: string targetName?: string fonts?: string[] + userImagesPath?: string keychainGroup?: string project?: IOSProjectOverrides } @@ -167,6 +168,7 @@ export interface NormalizedVoltraIOSConfig { deploymentTarget: string targetName?: string fonts: string[] + userImagesPath: string keychainGroup?: string project: NormalizedIOSProjectConfig } diff --git a/packages/cli/src/platforms/android/manifest.ts b/packages/cli/src/platforms/android/manifest.ts index 729d9987..232a1a03 100644 --- a/packages/cli/src/platforms/android/manifest.ts +++ b/packages/cli/src/platforms/android/manifest.ts @@ -22,6 +22,10 @@ const APPWIDGET_PROVIDER_METADATA = 'android.appwidget.provider' const ONGOING_NOTIFICATION_RECEIVER = 'voltra.VoltraOngoingNotificationDismissedReceiver' const POST_NOTIFICATIONS_PERMISSION = 'android.permission.POST_NOTIFICATIONS' const POST_PROMOTED_NOTIFICATIONS_PERMISSION = 'android.permission.POST_PROMOTED_NOTIFICATIONS' +const WIDGET_RECEIVER_NAME_PREFIX = '.widget.VoltraWidget_' +const WIDGET_RECEIVER_NAME_SUFFIX = 'Receiver' +const WIDGET_INFO_RESOURCE_PREFIX = '@xml/voltra_widget_' +const WIDGET_INFO_RESOURCE_SUFFIX = '_info' export interface EnsureAndroidManifestOptions { projectRoot: string @@ -92,14 +96,14 @@ export async function ensureAndroidManifest(options: EnsureAndroidManifestOption const permissions = manifest['uses-permission'] ?? [] manifest['uses-permission'] = permissions - if (android.enableNotifications) { - ensurePermission(permissions, POST_NOTIFICATIONS_PERMISSION) - ensurePermission(permissions, POST_PROMOTED_NOTIFICATIONS_PERMISSION) - } + reconcileNotificationPermissions(permissions, android.enableNotifications) const receivers = application.receiver ?? [] application.receiver = receivers + reconcileNotificationReceiver(receivers, android.enableNotifications) + removeStaleWidgetReceivers(receivers, android.widgets.map((widget) => widget.id)) + if (android.enableNotifications) { ensureNotificationReceiver(receivers) } @@ -165,6 +169,13 @@ function ensurePermission(permissions: AndroidManifestPermission[], permissionNa removeDuplicateEntries(permissions, (permission) => permission.$?.['android:name'] === permissionName) } +function reconcileNotificationPermissions(permissions: AndroidManifestPermission[], enabled: boolean): void { + if (enabled) { + ensurePermission(permissions, POST_NOTIFICATIONS_PERMISSION) + ensurePermission(permissions, POST_PROMOTED_NOTIFICATIONS_PERMISSION) + } +} + function ensureNotificationReceiver(receivers: AndroidManifestReceiver[]): void { const receiver = findReceiverByName(receivers, ONGOING_NOTIFICATION_RECEIVER) @@ -181,6 +192,15 @@ function ensureNotificationReceiver(receivers: AndroidManifestReceiver[]): void receivers.push(createReceiver(ONGOING_NOTIFICATION_RECEIVER, 'false')) } +function reconcileNotificationReceiver(receivers: AndroidManifestReceiver[], enabled: boolean): void { + if (enabled) { + ensureNotificationReceiver(receivers) + return + } + + removeEntries(receivers, (receiver) => receiver.$?.['android:name'] === ONGOING_NOTIFICATION_RECEIVER) +} + function ensureWidgetReceiver(receivers: AndroidManifestReceiver[], widgetId: string): void { const receiverName = `.widget.VoltraWidget_${widgetId}Receiver` const metadataResource = `@xml/voltra_widget_${widgetId}_info` @@ -206,6 +226,27 @@ function ensureWidgetReceiver(receivers: AndroidManifestReceiver[], widgetId: st receivers.push(nextReceiver) } +function removeStaleWidgetReceivers(receivers: AndroidManifestReceiver[], widgetIds: string[]): void { + const nextWidgetIds = new Set(widgetIds) + + removeEntries(receivers, (receiver) => { + const receiverName = receiver.$?.['android:name'] + + if (!receiverName || !receiverName.startsWith(WIDGET_RECEIVER_NAME_PREFIX) || !receiverName.endsWith(WIDGET_RECEIVER_NAME_SUFFIX)) { + return false + } + + const widgetId = receiverName.slice(WIDGET_RECEIVER_NAME_PREFIX.length, -WIDGET_RECEIVER_NAME_SUFFIX.length) + + if (!widgetId || nextWidgetIds.has(widgetId)) { + return false + } + + const metadataResource = getReceiverMetadataResource(receiver) + return metadataResource === undefined || isWidgetMetadataResource(metadataResource) + }) +} + function ensureAppWidgetUpdateIntentFilter(receiver: AndroidManifestReceiver): void { const intentFilters = receiver['intent-filter'] ?? [] receiver['intent-filter'] = intentFilters @@ -261,6 +302,14 @@ function findReceiverByName(receivers: AndroidManifestReceiver[], receiverName: return receivers.find((receiver) => receiver.$?.['android:name'] === receiverName) } +function getReceiverMetadataResource(receiver: AndroidManifestReceiver): string | undefined { + return receiver['meta-data']?.find((metadata) => metadata.$?.['android:name'] === APPWIDGET_PROVIDER_METADATA)?.$?.['android:resource'] +} + +function isWidgetMetadataResource(resource: string): boolean { + return resource.startsWith(WIDGET_INFO_RESOURCE_PREFIX) && resource.endsWith(WIDGET_INFO_RESOURCE_SUFFIX) +} + function createPermission(permissionName: string): AndroidManifestPermission { return { $: { @@ -297,3 +346,14 @@ function removeDuplicateEntries(entries: TEntry[], isDuplicate: (entry: entries.splice(index, 1) } } + +function removeEntries(entries: TEntry[], shouldRemove: (entry: TEntry) => boolean): void { + for (let index = 0; index < entries.length; ) { + if (!shouldRemove(entries[index])) { + index += 1 + continue + } + + entries.splice(index, 1) + } +} diff --git a/packages/cli/src/platforms/ios/apply.ts b/packages/cli/src/platforms/ios/apply.ts index 40ff391f..4e00879d 100644 --- a/packages/cli/src/platforms/ios/apply.ts +++ b/packages/cli/src/platforms/ios/apply.ts @@ -47,12 +47,12 @@ export async function applyIOSPlatform(context: PlatformApplyContext): Promise

, key: string, nextValue: string | undefined): void { +function ensureStringArrayValue( + target: Record, + key: string, + nextValue: string | undefined, + previousOwnedValue: string | undefined +): void { const existingValues = Array.isArray(target[key]) ? target[key].filter((value): value is string => typeof value === 'string' && value.length > 0) : [] const dedupedValues = Array.from(new Set(existingValues)) + const filteredValues = previousOwnedValue ? dedupedValues.filter((value) => value !== previousOwnedValue) : dedupedValues if (nextValue === undefined) { - if (existingValues.length > 0 && dedupedValues.length !== existingValues.length) { - target[key] = dedupedValues + if (filteredValues.length === 0) { + delete target[key] + return } + + if (filteredValues.length !== existingValues.length) { + target[key] = filteredValues + } + return } // Preserve unrelated user-managed values in shared entitlements. // V1 does not attempt to undo historical shared-file mutations. - if (!dedupedValues.includes(nextValue)) { - dedupedValues.push(nextValue) + if (!filteredValues.includes(nextValue)) { + filteredValues.push(nextValue) } - target[key] = dedupedValues + target[key] = filteredValues } async function writeEntitlementsIfChanged( @@ -103,3 +129,14 @@ async function writeEntitlementsIfChanged( function createEntitlementsError(message: string): IOSEntitlementsMutationError { return new IOSEntitlementsMutationError(message) } + +async function readPreviousVoltraEntitlementValues(infoPlistPath: string): Promise { + const infoPlist = await parsePlistFile(infoPlistPath, 'main app Info.plist', createEntitlementsError) + + return { + appGroupIdentifier: + typeof infoPlist.Voltra_AppGroupIdentifier === 'string' ? infoPlist.Voltra_AppGroupIdentifier : undefined, + keychainGroup: typeof infoPlist.Voltra_KeychainGroup === 'string' ? infoPlist.Voltra_KeychainGroup : undefined, + pushNotificationsEnabled: infoPlist.Voltra_EnablePushNotifications === true, + } +} diff --git a/packages/cli/src/platforms/ios/generated.ts b/packages/cli/src/platforms/ios/generated.ts index 1bf515cd..166953fa 100644 --- a/packages/cli/src/platforms/ios/generated.ts +++ b/packages/cli/src/platforms/ios/generated.ts @@ -22,7 +22,6 @@ const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', ''] const VALID_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']) const FONT_EXTENSIONS = new Set(['.ttf', '.otf', '.woff', '.woff2']) const MAX_IMAGE_SIZE_BYTES = 4096 -const DEFAULT_USER_IMAGES_PATH = './assets/voltra' const DEFAULT_INITIAL_STATE_LOCALE = '__default' const VOLTRA_WIDGET_STRINGS_BASENAME = 'VoltraWidgets.strings' @@ -149,7 +148,7 @@ export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promis const entitlementsResult = await generateEntitlementsFile(projectRoot, targetPath, targetName, ios) mergeSingleResult(entitlementsResult, changes, generatedFiles) - const assetResult = await generateAssetsCatalog(projectRoot, targetPath) + const assetResult = await generateAssetsCatalog(projectRoot, targetPath, ios.userImagesPath) mergeResult(assetResult, changes, warnings, generatedFiles) const fontsResult = await copyIOSFonts(projectRoot, targetPath, ios.fonts) @@ -244,7 +243,8 @@ async function generateEntitlementsFile( async function generateAssetsCatalog( projectRoot: string, - targetPath: string + targetPath: string, + userImagesPath: string ): Promise { const changes: ReportedChange[] = [] const warnings: string[] = [] @@ -257,8 +257,8 @@ async function generateAssetsCatalog( ) mergeSingleResult(rootContentsResult, changes, generatedFiles) - const userImagesPath = path.resolve(projectRoot, DEFAULT_USER_IMAGES_PATH) const userImages = await collectUserImages(userImagesPath) + const seenAssetNames = new Map() for (const imagePath of userImages) { const extension = path.extname(imagePath).toLowerCase() @@ -267,7 +267,8 @@ async function generateAssetsCatalog( continue } - const imageName = sanitizeAssetName(path.basename(imagePath, extension)) + const relativeAssetPath = path.relative(userImagesPath, imagePath).slice(0, -extension.length) + const imageName = getAssetName(relativeAssetPath, seenAssetNames) const imagesetPath = path.join(assetsCatalogPath, `${imageName}.imageset`) const imageFileName = `${imageName}${extension}` const imageResult = await copyGeneratedFile(projectRoot, imagePath, path.join(imagesetPath, imageFileName)) @@ -859,12 +860,28 @@ function isWidgetLocalizedMap(label: WidgetLabel): label is Record): string { + const assetName = sanitizeAssetName(path.basename(relativeAssetPath)) + const existingPath = seenAssetNames.get(assetName) + + if (existingPath && existingPath !== relativeAssetPath) { + throw new IOSGeneratedFilesError( + `iOS widget assets must have unique basenames. Found both '${existingPath}' and '${relativeAssetPath}' resolving to asset '${assetName}'.` + ) + } + + seenAssetNames.set(assetName, relativeAssetPath) + return assetName +} + function readPlistString(dict: Record, key: string): string | undefined { const value = dict[key] return typeof value === 'string' && value.trim() ? value : undefined diff --git a/packages/cli/src/platforms/ios/xcodeTarget.ts b/packages/cli/src/platforms/ios/xcodeTarget.ts index 15cd7a43..952e4d74 100644 --- a/packages/cli/src/platforms/ios/xcodeTarget.ts +++ b/packages/cli/src/platforms/ios/xcodeTarget.ts @@ -37,6 +37,7 @@ export interface EnsureIOSWidgetTargetOptions { ios: NormalizedVoltraIOSConfig discovery: IOSProjectDiscovery generatedFiles: string[] + previousGeneratedFiles?: string[] } export interface EnsureIOSWidgetTargetResult { @@ -52,15 +53,18 @@ export class IOSWidgetTargetMutationError extends VoltraCliError { } export async function ensureIOSWidgetTarget(options: EnsureIOSWidgetTargetOptions): Promise { - const { projectRoot, ios, discovery, generatedFiles } = options + const { projectRoot, ios, discovery, generatedFiles, previousGeneratedFiles } = options const targetName = resolveIOSWidgetTargetName(ios, discovery) const context = openIOSXcodeProject(discovery) const beforeSerialized = JSON.stringify(context.project.toJSON()) const productPath = `${targetName}.appex` const nextGeneratedFiles = normalizeGeneratedFilePaths(generatedFiles, projectRoot, discovery) + const previousWidgetFiles = normalizeGeneratedFilePaths(previousGeneratedFiles ?? [], projectRoot, discovery) + const staleTargetNames = getStaleWidgetTargetNames(previousWidgetFiles, targetName) const bundleIdentifier = resolveBundleIdentifier(context, discovery, targetName) const codeSigning = getMainAppCodeSigningSettings(context) + removeStaleWidgetTargets(context, staleTargetNames) ensureWidgetTarget(context, targetName, bundleIdentifier, ios.deploymentTarget, codeSigning) const widgetTarget = getWidgetTarget(context, targetName) @@ -73,9 +77,10 @@ export async function ensureIOSWidgetTarget(options: EnsureIOSWidgetTargetOption ensureTargetDependency(context, widgetTarget) ensureTargetAttributes(context, widgetTarget) - removeStaleGeneratedFileReferences(context, widgetTarget, widgetGroup, nextGeneratedFiles) + removeStaleGeneratedFileReferences(context, widgetTarget, widgetGroup, previousWidgetFiles, nextGeneratedFiles) ensureBuildPhases(context, widgetTarget, productFile, nextGeneratedFiles) ensureWidgetGroupFiles(context, widgetGroup, targetName, nextGeneratedFiles) + removeEmptyWidgetGroups(context, staleTargetNames) const changePath = toRelativePath(projectRoot, discovery.pbxprojPath) const afterSerialized = JSON.stringify(context.project.toJSON()) @@ -253,6 +258,17 @@ function ensureTargetAttributes(context: IOSXcodeProjectContext, widgetTarget: P targetAttributes[widgetTarget.uuid] ??= { LastSwiftMigration: '1250' } } +function removeStaleWidgetTargets(context: IOSXcodeProjectContext, staleTargetNames: string[]): void { + for (const staleTargetName of staleTargetNames) { + const staleTarget = getWidgetTargetOptional(context, staleTargetName) + if (!staleTarget) { + continue + } + + staleTarget.removeFromProject() + } +} + function ensureBuildPhases( context: IOSXcodeProjectContext, widgetTarget: PBXNativeTarget, @@ -263,7 +279,7 @@ function ensureBuildPhases( const resources = widgetTarget.getResourcesBuildPhase() widgetTarget.getFrameworksBuildPhase() - const fileReferences = generatedFiles.map((file) => ensureGeneratedFileReference(context, file)) + const fileReferences = getBuildPhaseFileReferences(context, generatedFiles) for (const fileReference of fileReferences) { const relativePath = getReferenceRelativePath(context, fileReference) @@ -284,25 +300,41 @@ function ensureBuildPhases( copyFilesPhase.ensureFile({ fileRef: productFile }) } +function getBuildPhaseFileReferences(context: IOSXcodeProjectContext, generatedFiles: string[]): PBXFileReference[] { + const references = new Map() + + for (const file of generatedFiles) { + const buildPhasePath = getBuildPhaseReferencePath(file) + if (!buildPhasePath || references.has(buildPhasePath)) { + continue + } + + references.set(buildPhasePath, ensureGeneratedFileReference(context, buildPhasePath)) + } + + return [...references.values()] +} + function removeStaleGeneratedFileReferences( context: IOSXcodeProjectContext, widgetTarget: PBXNativeTarget, widgetGroup: PBXGroup, + previousGeneratedFiles: string[], generatedFiles: string[] ): void { - const generatedFileSet = new Set(generatedFiles) + const staleReferencePaths = getStaleReferencePaths(previousGeneratedFiles, generatedFiles) + + if (staleReferencePaths.size === 0) { + return + } + const staleReferences = [...context.project.values()].filter((object): object is PBXFileReference => { if (!PBXFileReference.is(object)) { return false } const relativePath = getReferenceRelativePath(context, object) - - if (!isGeneratedWidgetFile(relativePath, widgetTarget.props.name, generatedFileSet)) { - return false - } - - return !generatedFileSet.has(relativePath) + return staleReferencePaths.has(relativePath) }) for (const reference of staleReferences) { @@ -318,11 +350,18 @@ function ensureWidgetGroupFiles( targetName: string, generatedFiles: string[] ): void { + const groupedReferencePaths = new Set() const localizedGroups = new Map() for (const file of generatedFiles) { - const reference = ensureGeneratedFileReference(context, file) - const relativeToTarget = getPathRelativeToTarget(file, targetName) + const groupReferencePath = getGroupReferencePath(file) + if (!groupReferencePath || groupedReferencePaths.has(groupReferencePath)) { + continue + } + + groupedReferencePaths.add(groupReferencePath) + const reference = ensureGeneratedFileReference(context, groupReferencePath) + const relativeToTarget = getPathRelativeToTarget(groupReferencePath, targetName) if (!relativeToTarget) { continue @@ -342,6 +381,18 @@ function ensureWidgetGroupFiles( } } +function removeEmptyWidgetGroups(context: IOSXcodeProjectContext, staleTargetNames: string[]): void { + for (const staleTargetName of staleTargetNames) { + const staleGroup = context.mainGroup.getChildGroups().find((group) => group.getDisplayName() === staleTargetName) + + if (!staleGroup || staleGroup.props.children.length > 0) { + continue + } + + staleGroup.removeFromProject() + } +} + function removeFileReferenceFromTargetBuildPhases(target: PBXNativeTarget, reference: PBXFileReference): void { for (const phase of [target.getSourcesBuildPhase(), target.getResourcesBuildPhase(), target.getFrameworksBuildPhase()]) { removeBuildPhaseReference(phase, reference) @@ -449,57 +500,43 @@ function isResourceFile(relativePath: string): boolean { return RESOURCE_EXTENSIONS.has(extension) || relativePath.endsWith('.xcassets') } -function getReferenceRelativePath(context: IOSXcodeProjectContext, reference: PBXFileReference): string { - return normalizeRelativePath(path.relative(context.project.getProjectRoot(), reference.getFullPath())) -} +function getBuildPhaseReferencePath(relativePath: string): string { + const normalizedPath = normalizeRelativePath(relativePath) + const assetCatalogIndex = normalizedPath.indexOf('/Assets.xcassets/') -function isGeneratedWidgetFile( - relativePath: string, - targetName: string | undefined, - generatedFiles: Set -): boolean { - if (!targetName) { - return false + if (assetCatalogIndex >= 0) { + return normalizedPath.slice(0, assetCatalogIndex + '/Assets.xcassets'.length) } - if (!relativePath.startsWith(`${targetName}/`)) { - return false - } + return normalizedPath +} - if (generatedFiles.has(relativePath)) { - return true - } +function getStaleReferencePaths(previousGeneratedFiles: string[], generatedFiles: string[]): Set { + const currentReferencePaths = new Set(generatedFiles.flatMap((file) => [getBuildPhaseReferencePath(file), getGroupReferencePath(file)])) + const previousReferencePaths = new Set(previousGeneratedFiles.flatMap((file) => [getBuildPhaseReferencePath(file), getGroupReferencePath(file)])) - const pathWithinTarget = getPathRelativeToTarget(relativePath, targetName) + return new Set([...previousReferencePaths].filter((referencePath) => !currentReferencePaths.has(referencePath))) +} - if (!pathWithinTarget) { - return false - } +function getGroupReferencePath(relativePath: string): string { + const normalizedPath = normalizeRelativePath(relativePath) + const assetCatalogIndex = normalizedPath.indexOf('/Assets.xcassets/') - if (pathWithinTarget.startsWith('Assets.xcassets/')) { - return true - } + if (assetCatalogIndex >= 0) { + const imagesetIndex = normalizedPath.indexOf('.imageset/', assetCatalogIndex) + + if (imagesetIndex >= 0) { + return normalizedPath.slice(0, imagesetIndex + '.imageset'.length) + } - if (pathWithinTarget.includes('.lproj/')) { - return true + return normalizedPath.slice(0, assetCatalogIndex + '/Assets.xcassets'.length) } - const extension = path.extname(relativePath) - return ( - extension === '.swift' || - extension === '.plist' || - extension === '.entitlements' || - extension === '.strings' || - extension === '.ttf' || - extension === '.otf' || - extension === '.woff' || - extension === '.woff2' || - extension === '.json' || - extension === '.png' || - extension === '.jpg' || - extension === '.jpeg' || - relativePath.endsWith('.xcassets') - ) + return normalizedPath +} + +function getReferenceRelativePath(context: IOSXcodeProjectContext, reference: PBXFileReference): string { + return normalizeRelativePath(path.relative(context.project.getProjectRoot(), reference.getFullPath())) } function removeFileReferenceFromGroupTree(group: PBXGroup, reference: PBXFileReference): void { @@ -519,18 +556,31 @@ function getPathRelativeToTarget(relativePath: string, targetName: string): stri return normalizedPath.slice(targetName.length + 1) } +function getStaleWidgetTargetNames(previousGeneratedFiles: string[], targetName: string): string[] { + return [ + ...new Set( + previousGeneratedFiles + .map(getWidgetTargetNameFromGeneratedPath) + .filter((candidate): candidate is string => candidate !== undefined && candidate !== targetName) + ), + ].sort() +} + +function getWidgetTargetNameFromGeneratedPath(relativePath: string): string | undefined { + const normalizedPath = normalizeRelativePath(relativePath) + const [targetName] = normalizedPath.split(path.sep, 1) + + return typeof targetName === 'string' && targetName.length > 0 ? targetName : undefined +} + function normalizeGeneratedFilePaths(generatedFiles: string[], projectRoot: string, discovery: IOSProjectDiscovery): string[] { const iosRootRelativePath = normalizeRelativePath(path.relative(projectRoot, discovery.iosRoot)) const iosRootRelativePrefix = iosRootRelativePath === '.' ? '' : `${iosRootRelativePath}/` - return [...new Set(generatedFiles.map((file) => toIOSProjectRelativePath(file, iosRootRelativePrefix, discovery)))].sort() + return [...new Set(generatedFiles.map((file) => toIOSProjectRelativePath(file, iosRootRelativePrefix)).filter(isDefined))].sort() } -function toIOSProjectRelativePath( - relativeFilePath: string, - iosRootRelativePrefix: string, - discovery: IOSProjectDiscovery -): string { +function toIOSProjectRelativePath(relativeFilePath: string, iosRootRelativePrefix: string): string | undefined { const normalizedPath = normalizeRelativePath(relativeFilePath) if (iosRootRelativePrefix.length === 0) { @@ -541,9 +591,7 @@ function toIOSProjectRelativePath( return normalizedPath.slice(iosRootRelativePrefix.length) } - throw new IOSWidgetTargetMutationError( - `Generated iOS file is outside the discovered iOS root '${discovery.iosRoot}': ${relativeFilePath}` - ) + return undefined } function resolveBundleIdentifier(context: IOSXcodeProjectContext, discovery: IOSProjectDiscovery, targetName: string): string { @@ -555,7 +603,12 @@ function resolveBundleIdentifier(context: IOSXcodeProjectContext, discovery: IOS ) } - return `${stripQuotes(mainTargetBundleIdentifier)}.${targetName}` + return `${stripQuotes(mainTargetBundleIdentifier)}.${sanitizeBundleIdentifierSegment(targetName)}` +} + +function sanitizeBundleIdentifierSegment(targetName: string): string { + const sanitized = targetName.replace(/[^A-Za-z0-9-]/g, '-') + return sanitized.replace(/-+/g, '-').replace(/^-+|-+$/g, '') } function getMainAppCodeSigningSettings(context: IOSXcodeProjectContext): MainAppCodeSigningSettings { @@ -581,3 +634,7 @@ function readBuildSettingString(value: unknown): string | undefined { function stripQuotes(value: string | undefined): string { return value?.replace(/^"|"$/g, '') ?? '' } + +function isDefined(value: TValue | undefined): value is TValue { + return value !== undefined +} diff --git a/packages/cli/src/state/files.ts b/packages/cli/src/state/files.ts index 443f0bff..6fcc8cf5 100644 --- a/packages/cli/src/state/files.ts +++ b/packages/cli/src/state/files.ts @@ -16,9 +16,9 @@ export function normalizeTrackedStateFiles(files: string[] | unknown[], errorCon throw new VoltraCliError(`${errorContext} must contain only non-empty relative paths.`) } - const normalizedFilePath = normalizeRelativePath(filePath) + const normalizedFilePath = normalizeTrackedStateFilePath(filePath) - if (path.isAbsolute(normalizedFilePath) || normalizedFilePath.startsWith('../') || normalizedFilePath === '..') { + if (path.isAbsolute(normalizedFilePath)) { throw new VoltraCliError(`${errorContext} must contain only project-relative paths.`) } @@ -33,6 +33,16 @@ export function normalizeTrackedStateFiles(files: string[] | unknown[], errorCon return normalizedFiles.sort((left, right) => left.localeCompare(right)) } +function normalizeTrackedStateFilePath(filePath: string): string { + const normalizedFilePath = normalizeRelativePath(path.posix.normalize(filePath)) + + if (normalizedFilePath === '..' || normalizedFilePath.startsWith('../')) { + throw new VoltraCliError('Voltra state files must contain only project-relative paths.') + } + + return normalizedFilePath +} + export function normalizeTrackedStateFilesForDiff(files: string[] | undefined): string[] { if (!files || files.length === 0) { return [] From f4fdb6376b07784768b0366d8fa05acefc8ee8db Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 15:05:44 +0200 Subject: [PATCH 28/37] feat: modernize cli apply ux --- package-lock.json | 4 + packages/cli/README.md | 9 +- packages/cli/package.json | 2 + packages/cli/src/apply/index.ts | 6 +- packages/cli/src/commands/apply.ts | 149 ++++++++++++-------------- packages/cli/src/git/status.ts | 47 ++++---- packages/cli/src/index.ts | 76 +++++++++---- packages/cli/src/reporting/summary.ts | 7 +- packages/cli/test/cli.test.js | 71 ++++++++++++ 9 files changed, 243 insertions(+), 128 deletions(-) create mode 100644 packages/cli/test/cli.test.js diff --git a/package-lock.json b/package-lock.json index 16684ed5..72e80076 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16503,6 +16503,8 @@ }, "node_modules/prompts": { "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", "license": "MIT", "dependencies": { "kleur": "^3.0.3", @@ -21745,8 +21747,10 @@ "dependencies": { "@babel/core": "^7.27.4", "@bacons/xcode": "^1.0.0-alpha.33", + "@clack/prompts": "^1.0.0-alpha.5", "@use-voltra/android": "1.4.1", "@use-voltra/ios": "1.4.1", + "commander": "^12.1.0", "cosmiconfig": "^9.0.0", "vd-tool": "^4.0.2", "xml2js": "^0.6.2" diff --git a/packages/cli/README.md b/packages/cli/README.md index 4692c29b..0303b453 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -24,13 +24,14 @@ If you apply only Android, the iOS client package is not required. ## Command ```sh -voltra apply [--platform ios|android] [--config ] +voltra apply [options] ``` Options: - `--platform ios|android`: limit apply to one platform. - `--config `: load config from an explicit file path. +- `-y`, `--yes`: skip the dirty git worktree confirmation prompt. - `-h`, `--help`: show command help. Examples: @@ -45,6 +46,9 @@ npx voltra apply --platform ios # Apply using an explicit config file npx voltra apply --config ./config/voltra.config.ts +# Skip the dirty-worktree confirmation +npx voltra apply --yes + # Re-apply only Android without removing tracked iOS files npx voltra apply --platform android ``` @@ -200,7 +204,8 @@ If discovery is missing or ambiguous, `voltra apply` fails during preflight befo Before writing files, `voltra apply` checks the git worktree: - clean worktree: continue -- dirty worktree in an interactive terminal: print a warning and ask for confirmation +- dirty worktree in an interactive terminal: print a warning and ask for confirmation without listing modified paths +- dirty worktree with `--yes`: continue without asking for confirmation - dirty worktree in a non-interactive environment: fail before applying changes - no git repository: continue without blocking apply diff --git a/packages/cli/package.json b/packages/cli/package.json index 43a6a7d3..787d55d1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -48,8 +48,10 @@ "dependencies": { "@babel/core": "^7.27.4", "@bacons/xcode": "^1.0.0-alpha.33", + "@clack/prompts": "^1.0.0-alpha.5", "@use-voltra/android": "1.4.1", "@use-voltra/ios": "1.4.1", + "commander": "^12.1.0", "cosmiconfig": "^9.0.0", "vd-tool": "^4.0.2", "xml2js": "^0.6.2" diff --git a/packages/cli/src/apply/index.ts b/packages/cli/src/apply/index.ts index 104f2078..071bede2 100644 --- a/packages/cli/src/apply/index.ts +++ b/packages/cli/src/apply/index.ts @@ -22,6 +22,7 @@ import type { VoltraState } from '../state/load' export interface ApplyOptions { configPath?: string platform?: VoltraPlatform + allowDirty?: boolean } export interface ApplyResult { @@ -79,7 +80,10 @@ export async function runApplyPipeline(options: ApplyOptions, dependencies: Appl const loadedConfig = await loadVoltraConfig({ configPath: options.configPath }) const normalizedConfig = normalizeVoltraConfig(loadedConfig) const resolvedDependencies = resolveApplyDependencies(normalizedConfig, dependencies) - const gitStatus = await ensureGitWorktreeIsReady({ cwd: normalizedConfig.projectRoot }) + const gitStatus = await ensureGitWorktreeIsReady({ + cwd: normalizedConfig.projectRoot, + allowDirty: options.allowDirty, + }) const preflight = await runApplyPreflight(normalizedConfig, resolvedDependencies.preflightRunners, options.platform) const previousState = await loadVoltraState(normalizedConfig.projectRoot) const platformResults = await runPlatformApply(normalizedConfig, preflight, previousState, resolvedDependencies.applyRunners) diff --git a/packages/cli/src/commands/apply.ts b/packages/cli/src/commands/apply.ts index 10e0cdfa..c4541338 100644 --- a/packages/cli/src/commands/apply.ts +++ b/packages/cli/src/commands/apply.ts @@ -1,3 +1,5 @@ +import { Command } from 'commander' + import { applyVoltra } from '../apply' import { formatError } from '../reporting/summary' @@ -7,96 +9,63 @@ import type { VoltraPlatform } from '../config/types' export const CLI_EXIT_CODE_SUCCESS = 0 export const CLI_EXIT_CODE_FAILURE = 1 -const APPLY_HELP_TEXT = [ - 'Usage:', - ' voltra apply [--platform ios|android] [--config ]', - '', - 'Options:', - ' --platform Limit apply to a single platform.', - ' --config Load config from an explicit file path.', - ' -h, --help Show this help text.', -].join('\n') +interface ApplyCommandCliOptions { + platform?: VoltraPlatform + config?: string + yes?: boolean +} export async function runApplyCommand(argv: string[]): Promise { - const parsed = parseApplyCommandArgs(argv) - - if ('exitCode' in parsed) { - return parsed.exitCode - } - - const result = await applyVoltra(parsed.options) - - if (result.exitCode !== CLI_EXIT_CODE_SUCCESS && result.errorMessage) { - process.stderr.write(`${formatCliMessage(result.errorMessage)}\n`) + let exitCode = CLI_EXIT_CODE_SUCCESS + const command = createApplyCommand((nextExitCode) => { + exitCode = nextExitCode + }) + + try { + await command.parseAsync(argv, { from: 'user' }) + return exitCode + } catch (error) { + return handleCommanderError(error) } - - return result.exitCode -} - -interface ParsedApplyCommandArgs { - options: ApplyOptions -} - -interface ParsedApplyCommandEarlyExit { - exitCode: number } -function parseApplyCommandArgs(argv: string[]): ParsedApplyCommandArgs | ParsedApplyCommandEarlyExit { - const options: ApplyOptions = {} - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index] - - if (arg === '--help' || arg === '-h') { - process.stdout.write(`${APPLY_HELP_TEXT}\n`) - return { exitCode: CLI_EXIT_CODE_SUCCESS } - } - - if (arg === '--platform' || arg.startsWith('--platform=')) { - const value = readFlagValue(arg, argv[index + 1], '--platform') - if (arg === '--platform') { - index += 1 - } - - options.platform = parsePlatform(value) - continue - } - - if (arg === '--config' || arg.startsWith('--config=')) { - const value = readFlagValue(arg, argv[index + 1], '--config') - if (arg === '--config') { - index += 1 - } - - if (!value.trim()) { - throw new Error('--config must be a non-empty path') +export function createApplyCommand(onComplete?: (exitCode: number) => void): Command { + const command = new Command('apply') + + command + .description('Apply Voltra changes to the native project') + .usage('[options]') + .exitOverride() + .showHelpAfterError() + .showSuggestionAfterError() + .option('-p, --platform ', 'limit apply to a single platform', parsePlatform) + .option('-c, --config ', 'load config from an explicit file path') + .option('-y, --yes', 'skip the dirty git worktree confirmation prompt') + .action(async () => { + const cliOptions = command.opts() + const options = toApplyOptions(cliOptions) + const result = await applyVoltra(options) + + onComplete?.(result.exitCode) + + if (result.exitCode !== CLI_EXIT_CODE_SUCCESS && result.errorMessage) { + process.stderr.write(`${formatCliMessage(result.errorMessage)}\n`) } + }) - options.configPath = value - continue - } - - if (arg.startsWith('-')) { - throw new Error(`Unknown option: ${arg}`) - } - - throw new Error(`Unexpected argument: ${arg}`) - } - - return { options } + return command } -function readFlagValue(arg: string, nextArg: string | undefined, flagName: string): string { - const equalsIndex = arg.indexOf('=') - if (equalsIndex >= 0) { - return arg.slice(equalsIndex + 1) +function toApplyOptions(cliOptions: ApplyCommandCliOptions): ApplyOptions { + if (cliOptions.config !== undefined && !cliOptions.config.trim()) { + throw new Error('--config must be a non-empty path') } - if (nextArg === undefined) { - throw new Error(`Missing value for ${flagName}`) + return { + platform: cliOptions.platform, + configPath: cliOptions.config, + allowDirty: cliOptions.yes, } - - return nextArg } function parsePlatform(value: string): VoltraPlatform { @@ -107,13 +76,35 @@ function parsePlatform(value: string): VoltraPlatform { throw new Error(`Invalid platform '${value}'. Expected 'ios' or 'android'.`) } +function handleCommanderError(error: unknown): number { + if (isCommanderError(error)) { + return isCommanderDisplayError(error) ? CLI_EXIT_CODE_SUCCESS : CLI_EXIT_CODE_FAILURE + } + + process.stderr.write(`${formatCommandError(error)}\n`) + return CLI_EXIT_CODE_FAILURE +} + +function isCommanderDisplayError(error: unknown): error is { code: string } { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') + ) +} + +function isCommanderError(error: unknown): error is { code: string } { + return Boolean(error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' && error.code.startsWith('commander.')) +} + export function formatCommandError(error: unknown): string { const message = error instanceof Error ? error.message : String(error) return formatCliMessage(message) } export function getApplyHelpText(): string { - return APPLY_HELP_TEXT + return createApplyCommand().helpInformation() } function formatCliMessage(message: string): string { diff --git a/packages/cli/src/git/status.ts b/packages/cli/src/git/status.ts index 1bb15c8c..a5af58e8 100644 --- a/packages/cli/src/git/status.ts +++ b/packages/cli/src/git/status.ts @@ -1,15 +1,14 @@ import { execFile } from 'node:child_process' import type { Readable, Writable } from 'node:stream' -import { createInterface } from 'node:readline/promises' import { promisify } from 'node:util' -import { formatDirtyWorktreeWarning, VoltraCliError } from '../reporting/summary' +import { formatDirtyWorktreeWarning, formatError, VoltraCliError } from '../reporting/summary' + +import type * as ClackPrompts from '@clack/prompts' const execFileAsync = promisify(execFile) const GIT_NOT_REPOSITORY_EXIT_CODE = 128 -const DIRTY_ENTRY_PREVIEW_LIMIT = 5 - export interface GitWorktreeStatus { isGitRepository: boolean isDirty: boolean @@ -88,7 +87,7 @@ export async function ensureGitWorktreeIsReady(options: EnsureGitWorktreeOptions return { status } } - const warning = formatDirtyWorktreeWarning(formatDirtyEntrySummary(status.entries)) + const warning = formatDirtyWorktreeWarning(status.entries.length) if (options.allowDirty) { return { status, warning } @@ -160,14 +159,6 @@ function splitGitStatusEntries(output: string): string[] { .filter((entry) => entry.length > 0) } -function formatDirtyEntrySummary(entries: string[]): string { - const preview = entries.slice(0, DIRTY_ENTRY_PREVIEW_LIMIT) - const remaining = entries.length - preview.length - const suffix = remaining > 0 ? ` and ${remaining} more` : '' - - return `Pending changes: ${preview.join(', ')}${suffix}` -} - function isInteractiveSession(options: EnsureGitWorktreeOptions): boolean { if (options.interactive !== undefined) { return options.interactive @@ -182,15 +173,29 @@ function isInteractiveSession(options: EnsureGitWorktreeOptions): boolean { async function promptForDirtyWorktreeConfirmation(options: EnsureGitWorktreeOptions, warning: string): Promise { const stdin = options.stdin ?? process.stdin const stdout = options.stdout ?? process.stdout - const readline = createInterface({ input: stdin, output: stdout }) + const { confirm, isCancel } = await loadClackPrompts() - try { - stdout.write(`${warning}\n`) - const answer = await readline.question('[voltra] Continue anyway? [y/N] ') - const normalizedAnswer = answer.trim().toLowerCase() + stdout.write(`${warning}\n`) - return normalizedAnswer === 'y' || normalizedAnswer === 'yes' - } finally { - readline.close() + const response = await confirm({ + message: 'Continue anyway?', + initialValue: false, + input: stdin, + output: stdout, + }) + + if (isCancel(response)) { + stdout.write(`${formatError('Cancelled.')}\n`) + return false } + + return response === true +} + +function loadClackPrompts() { + const dynamicImport = new Function('specifier', 'return import(specifier)') as ( + specifier: string + ) => Promise + + return dynamicImport('@clack/prompts') } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 95e4facc..7e95c184 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,38 +1,70 @@ -import { CLI_EXIT_CODE_FAILURE, CLI_EXIT_CODE_SUCCESS, formatCommandError, runApplyCommand } from './commands/apply' +import { Command } from 'commander' -const HELP_TEXT = [ - 'voltra', - '', - 'Usage:', - ' voltra apply [--platform ios|android] [--config ]', - ' voltra --help', -].join('\n') +import { CLI_EXIT_CODE_FAILURE, CLI_EXIT_CODE_SUCCESS, createApplyCommand, formatCommandError } from './commands/apply' -export async function runCli(argv: string[]): Promise { - if (argv.length === 0) { - process.stdout.write(`${HELP_TEXT}\n`) - return CLI_EXIT_CODE_SUCCESS - } +function createProgram(): Command { + const program = new Command() + let exitCode = CLI_EXIT_CODE_SUCCESS - if (argv[0] === '--help' || argv[0] === '-h') { - process.stdout.write(`${HELP_TEXT}\n`) - return CLI_EXIT_CODE_SUCCESS - } + program + .name('voltra') + .description('Apply Voltra to native React Native projects') + .exitOverride() + .showHelpAfterError() + .showSuggestionAfterError() + .addHelpText( + 'after', + [ + '', + 'Examples:', + ' voltra apply', + ' voltra apply --platform ios', + ' voltra apply --config ./voltra.config.ts --yes', + ].join('\n') + ) + + program.addCommand( + createApplyCommand((nextExitCode) => { + exitCode = nextExitCode + }) + ) + + program.hook('postAction', () => { + program.setOptionValue('exitCode', exitCode) + }) + + return program +} - const [command, ...commandArgs] = argv +export async function runCli(argv: string[]): Promise { + const program = createProgram() try { - if (command === 'apply') { - return await runApplyCommand(commandArgs) + await program.parseAsync(['node', 'voltra', ...argv], { from: 'node' }) + return program.getOptionValue('exitCode') ?? CLI_EXIT_CODE_SUCCESS + } catch (error) { + if (isCommanderError(error)) { + return isCommanderDisplayError(error) ? CLI_EXIT_CODE_SUCCESS : CLI_EXIT_CODE_FAILURE } - throw new Error(`Unknown command: ${command}`) - } catch (error) { process.stderr.write(`${formatCommandError(error)}\n`) return CLI_EXIT_CODE_FAILURE } } +function isCommanderDisplayError(error: unknown): error is { code: string } { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') + ) +} + +function isCommanderError(error: unknown): error is { code: string } { + return Boolean(error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' && error.code.startsWith('commander.')) +} + export { applyVoltra, runApplyPipeline } from './apply' export type { ApplyDependencies, ApplyOptions, ApplyResult, PlatformApplyContext, PlatformApplyResult, PlatformApplyRunner } from './apply' export { getRequestedPlatforms, runApplyPreflight } from './apply/preflight' diff --git a/packages/cli/src/reporting/summary.ts b/packages/cli/src/reporting/summary.ts index 167f539a..cce24039 100644 --- a/packages/cli/src/reporting/summary.ts +++ b/packages/cli/src/reporting/summary.ts @@ -65,12 +65,13 @@ export function formatApplySummary(summary: ApplySummary): string { return lines.join('\n') } -export function formatDirtyWorktreeWarning(details?: string): string { - if (!details) { +export function formatDirtyWorktreeWarning(changeCount?: number): string { + if (!changeCount || changeCount <= 0) { return `${PREFIX} Warning: git worktree has uncommitted changes.` } - return `${PREFIX} Warning: git worktree has uncommitted changes. ${details}` + const noun = changeCount === 1 ? 'change' : 'changes' + return `${PREFIX} Warning: git worktree has ${changeCount} uncommitted ${noun}.` } export function formatAmbiguousDiscoveryWarning(subject: string, candidates: string[]): string { diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js new file mode 100644 index 00000000..ee0875f5 --- /dev/null +++ b/packages/cli/test/cli.test.js @@ -0,0 +1,71 @@ +const assert = require('node:assert/strict') +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const test = require('node:test') + +const packageRoot = path.resolve(__dirname, '..') + +function loadCliModule() { + return require(path.join(packageRoot, 'build/cjs/index.js')) +} + +test('apply help documents the yes flag', () => { + const { getApplyHelpText } = loadCliModule() + const helpText = getApplyHelpText() + + assert.match(helpText, /-y, --yes/) + assert.match(helpText, /skip the dirty git worktree confirmation prompt/) +}) + +test('dirty worktree warning hides modified file paths', async () => { + const { ensureGitWorktreeIsReady } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + const trackedFilePath = path.join(tempDir, 'tracked.txt') + + execFileSync('git', ['init'], { cwd: tempDir, stdio: 'ignore' }) + execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tempDir, stdio: 'ignore' }) + execFileSync('git', ['config', 'user.name', 'Voltra Test'], { cwd: tempDir, stdio: 'ignore' }) + + fs.writeFileSync(trackedFilePath, 'before\n') + execFileSync('git', ['add', 'tracked.txt'], { cwd: tempDir, stdio: 'ignore' }) + execFileSync('git', ['commit', '-m', 'init'], { cwd: tempDir, stdio: 'ignore' }) + + fs.writeFileSync(trackedFilePath, 'after\n') + + const result = await ensureGitWorktreeIsReady({ + cwd: tempDir, + allowDirty: true, + interactive: false, + }) + + assert.equal(result.status.isDirty, true) + assert.equal(result.status.entries.length, 1) + assert.equal(result.warning, '[voltra] Warning: git worktree has 1 uncommitted change.') + assert.doesNotMatch(result.warning, /tracked\.txt/) +}) + +test('unknown commands are reported once', () => { + const cliPath = path.join(packageRoot, 'build/cjs/bin.js') + + assert.throws( + () => { + execFileSync('node', [cliPath, 'nope'], { + cwd: packageRoot, + stdio: 'pipe', + encoding: 'utf8', + }) + }, + (error) => { + assert.equal(error.status, 1) + assert.equal(error.stdout, '') + assert.match(error.stderr, /unknown command 'nope'/) + + const occurrences = error.stderr.split("unknown command 'nope'").length - 1 + assert.equal(occurrences, 1) + + return true + } + ) +}) From 30346117fb391db2eb8a13a29c1e2537fcade1f0 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 15:11:33 +0200 Subject: [PATCH 29/37] fix: repair ios xcode target wiring --- packages/cli/src/platforms/ios/xcode.ts | 6 +- packages/cli/src/platforms/ios/xcodeTarget.ts | 160 ++++++++++++++---- 2 files changed, 128 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/platforms/ios/xcode.ts b/packages/cli/src/platforms/ios/xcode.ts index 1d1c5fcc..20f26314 100644 --- a/packages/cli/src/platforms/ios/xcode.ts +++ b/packages/cli/src/platforms/ios/xcode.ts @@ -1,7 +1,6 @@ import fs from 'node:fs/promises' import { PBXNativeTarget, XcodeProject } from '@bacons/xcode' -import { Writer } from '@bacons/xcode/build/json/writer' import { VoltraCliError } from '../../reporting/summary' @@ -9,6 +8,9 @@ import type { PBXCopyFilesBuildPhase, PBXFrameworksBuildPhase, PBXGroup, PBXReso import type { IOSProjectDiscovery } from '../../discovery/ios' const IOS_APP_PRODUCT_TYPE = 'com.apple.product-type.application' +const { build: buildXcodeProjectJson } = require('@bacons/xcode/json') as { + build(project: ReturnType): string +} export interface IOSXcodeTargetBuildConfigurations { all: XCBuildConfiguration[] @@ -116,7 +118,7 @@ export function ensureFrameworksGroup(context: IOSXcodeProjectContext): PBXGroup } export function saveIOSXcodeProject(context: IOSXcodeProjectContext): Promise { - const contents = new Writer(context.project.toJSON()).getResults() + const contents = buildXcodeProjectJson(context.project.toJSON()) return fs.writeFile(context.project.filePath, contents, 'utf8') } diff --git a/packages/cli/src/platforms/ios/xcodeTarget.ts b/packages/cli/src/platforms/ios/xcodeTarget.ts index 952e4d74..a146f573 100644 --- a/packages/cli/src/platforms/ios/xcodeTarget.ts +++ b/packages/cli/src/platforms/ios/xcodeTarget.ts @@ -63,14 +63,18 @@ export async function ensureIOSWidgetTarget(options: EnsureIOSWidgetTargetOption const staleTargetNames = getStaleWidgetTargetNames(previousWidgetFiles, targetName) const bundleIdentifier = resolveBundleIdentifier(context, discovery, targetName) const codeSigning = getMainAppCodeSigningSettings(context) + const mainAppEntitlementsPath = getMainAppEntitlementsBuildSetting(projectRoot, discovery) removeStaleWidgetTargets(context, staleTargetNames) + ensureMainAppEntitlementsBuildSetting(context, mainAppEntitlementsPath) ensureWidgetTarget(context, targetName, bundleIdentifier, ios.deploymentTarget, codeSigning) const widgetTarget = getWidgetTarget(context, targetName) const widgetGroup = ensureWidgetGroup(context, targetName) const productFile = ensureProductFile(context, targetName, productPath) + sanitizeWidgetGroupChildren(widgetGroup) + widgetTarget.props.productReference = productFile widgetTarget.props.productType = IOS_APP_EXTENSION_PRODUCT_TYPE widgetTarget.props.productName = targetName @@ -169,28 +173,28 @@ function buildWidgetBuildSettings( configurationName: string ): BuildSettings & Record { const buildSettings: BuildSettings & Record = { - ASSETCATALOG_COMPILER_APPICON_NAME: '""', - CODE_SIGN_ENTITLEMENTS: `"${targetName}/${targetName}.entitlements"`, + ASSETCATALOG_COMPILER_APPICON_NAME: '', + CODE_SIGN_ENTITLEMENTS: `${targetName}/${targetName}.entitlements`, CURRENT_PROJECT_VERSION: '1', INFOPLIST_FILE: `${targetName}/Info.plist`, INFOPLIST_OUTPUT_FORMAT: 'xml', - IPHONEOS_DEPLOYMENT_TARGET: `"${deploymentTarget}"`, + IPHONEOS_DEPLOYMENT_TARGET: deploymentTarget, MARKETING_VERSION: '1.0', - OTHER_SWIFT_FLAGS: `"$(inherited) -D EXPO_CONFIGURATION_${configurationName.toUpperCase()}"`, - PRODUCT_BUNDLE_IDENTIFIER: `"${bundleIdentifier}"`, - PRODUCT_NAME: '"$(TARGET_NAME)"', - SWIFT_OPTIMIZATION_LEVEL: '"-Onone"', + OTHER_SWIFT_FLAGS: `$(inherited) -D EXPO_CONFIGURATION_${configurationName.toUpperCase()}`, + PRODUCT_BUNDLE_IDENTIFIER: bundleIdentifier, + PRODUCT_NAME: '$(TARGET_NAME)', + SWIFT_OPTIMIZATION_LEVEL: '-Onone', SWIFT_VERSION: '5.0', - TARGETED_DEVICE_FAMILY: '"1,2"', - ...(codeSigning.codeSignStyle ? { CODE_SIGN_STYLE: `"${codeSigning.codeSignStyle}"` } : {}), - ...(codeSigning.developmentTeam ? { DEVELOPMENT_TEAM: `"${codeSigning.developmentTeam}"` } : {}), + TARGETED_DEVICE_FAMILY: '1,2', + ...(codeSigning.codeSignStyle ? { CODE_SIGN_STYLE: codeSigning.codeSignStyle } : {}), + ...(codeSigning.developmentTeam ? { DEVELOPMENT_TEAM: codeSigning.developmentTeam } : {}), } buildSettings.APPLICATION_EXTENSION_API_ONLY = 'YES' buildSettings.INFOPLIST_OUTPUT_FORMAT = 'xml' if (codeSigning.provisioningProfileSpecifier) { - buildSettings.PROVISIONING_PROFILE_SPECIFIER = `"${codeSigning.provisioningProfileSpecifier}"` + buildSettings.PROVISIONING_PROFILE_SPECIFIER = codeSigning.provisioningProfileSpecifier } return buildSettings @@ -351,7 +355,6 @@ function ensureWidgetGroupFiles( generatedFiles: string[] ): void { const groupedReferencePaths = new Set() - const localizedGroups = new Map() for (const file of generatedFiles) { const groupReferencePath = getGroupReferencePath(file) @@ -368,13 +371,10 @@ function ensureWidgetGroupFiles( } if (relativeToTarget.includes('/')) { - const [groupName] = relativeToTarget.split('/', 1) - if (groupName.endsWith('.lproj')) { - const localeGroup = localizedGroups.get(groupName) ?? ensureChildGroup(widgetGroup, groupName, groupName) - localizedGroups.set(groupName, localeGroup) - ensureGroupContainsReference(localeGroup, reference) - continue - } + const parentGroup = ensureParentGroup(widgetGroup, relativeToTarget) + removeGroupReference(widgetGroup, reference) + ensureGroupContainsReference(parentGroup, reference) + continue } ensureGroupContainsReference(widgetGroup, reference) @@ -445,16 +445,6 @@ function ensureParentGroup(rootGroup: PBXGroup, relativePath: string): PBXGroup return group } -function ensureChildGroup(parent: PBXGroup, name: string, relativePath: string): PBXGroup { - const existingGroup = parent.getChildGroups().find((group) => group.getDisplayName() === name) - if (existingGroup) { - existingGroup.props.path = relativePath - return existingGroup - } - - return parent.createGroup({ name, path: relativePath, sourceTree: '' }) -} - function ensureGroupContainsReference(group: PBXGroup, reference: PBXFileReference): void { const alreadyPresent = group.props.children.some((child) => child.uuid === reference.uuid) if (!alreadyPresent) { @@ -462,6 +452,10 @@ function ensureGroupContainsReference(group: PBXGroup, reference: PBXFileReferen } } +function removeGroupReference(group: PBXGroup, reference: PBXFileReference): void { + group.props.children = group.props.children.filter((child) => child.uuid !== reference.uuid) +} + function applyFileType(reference: PBXFileReference, relativePath: string): void { const extension = path.extname(relativePath) @@ -523,12 +517,6 @@ function getGroupReferencePath(relativePath: string): string { const assetCatalogIndex = normalizedPath.indexOf('/Assets.xcassets/') if (assetCatalogIndex >= 0) { - const imagesetIndex = normalizedPath.indexOf('.imageset/', assetCatalogIndex) - - if (imagesetIndex >= 0) { - return normalizedPath.slice(0, imagesetIndex + '.imageset'.length) - } - return normalizedPath.slice(0, assetCatalogIndex + '/Assets.xcassets'.length) } @@ -536,7 +524,85 @@ function getGroupReferencePath(relativePath: string): string { } function getReferenceRelativePath(context: IOSXcodeProjectContext, reference: PBXFileReference): string { - return normalizeRelativePath(path.relative(context.project.getProjectRoot(), reference.getFullPath())) + const segments = [stripQuotes(reference.props.path ?? '')].filter((segment) => segment.length > 0) + let parent = getPreferredParentGroup(reference) + + while (parent && parent.uuid !== context.mainGroup.uuid) { + const parentPath = stripQuotes(parent.props.path ?? parent.props.name ?? '') + if (parentPath.length > 0) { + segments.unshift(parentPath) + } + + parent = getPreferredParentGroup(parent) + } + + if (segments.length === 0) { + throw new IOSWidgetTargetMutationError(`Unable to resolve Xcode file reference path for ${reference.uuid}`) + } + + return normalizeRelativePath(segments.join('/')) +} + +function getPreferredParentGroup(object: PBXFileReference | PBXGroup): PBXGroup | undefined { + const parentGroups = object.getReferrers().filter((referrer): referrer is PBXGroup => PBXGroup.is(referrer)) + + if (parentGroups.length <= 1) { + return parentGroups[0] + } + + return [...parentGroups].sort((left, right) => getGroupSpecificity(right) - getGroupSpecificity(left))[0] +} + +function getGroupSpecificity(group: PBXGroup): number { + const identifier = group.props.path ?? group.props.name ?? group.getDisplayName() + + if (identifier.endsWith('.imageset')) { + return 4 + } + + if (identifier.endsWith('.xcassets') || identifier.endsWith('.lproj')) { + return 3 + } + + return 1 +} + +function sanitizeWidgetGroupChildren(widgetGroup: PBXGroup): void { + const staleChildren = widgetGroup.props.children.filter((child) => { + const identifier = stripQuotes('path' in child && typeof child.props.path === 'string' ? child.props.path : child.getDisplayName()) + + if (identifier.endsWith('.imageset')) { + return true + } + + return PBXGroup.is(child) && identifier.endsWith('.xcassets') + }) + + widgetGroup.props.children = widgetGroup.props.children.filter((child) => !staleChildren.includes(child)) + + for (const child of staleChildren) { + if (PBXGroup.is(child)) { + removeGroupTree(child) + continue + } + + child.removeFromProject() + } +} + +function removeGroupTree(group: PBXGroup): void { + const childGroups = [...group.getChildGroups()] + const childFiles = group.props.children.filter((child): child is PBXFileReference => PBXFileReference.is(child)) + + for (const childGroup of childGroups) { + removeGroupTree(childGroup) + } + + for (const childFile of childFiles) { + childFile.removeFromProject() + } + + group.removeFromProject() } function removeFileReferenceFromGroupTree(group: PBXGroup, reference: PBXFileReference): void { @@ -621,6 +687,28 @@ function getMainAppCodeSigningSettings(context: IOSXcodeProjectContext): MainApp } } +function getMainAppEntitlementsBuildSetting(projectRoot: string, discovery: IOSProjectDiscovery): string | undefined { + if (!discovery.entitlementsPath) { + return undefined + } + + return normalizeRelativePath(path.relative(discovery.iosRoot, discovery.entitlementsPath)) +} + +function ensureMainAppEntitlementsBuildSetting( + context: IOSXcodeProjectContext, + entitlementsPath: string | undefined +): void { + for (const config of context.mainAppTarget.buildConfigurations.all) { + if (entitlementsPath) { + config.props.buildSettings.CODE_SIGN_ENTITLEMENTS = entitlementsPath + continue + } + + delete config.props.buildSettings.CODE_SIGN_ENTITLEMENTS + } +} + interface MainAppCodeSigningSettings { codeSignStyle?: string developmentTeam?: string From 3d133eb55deebe87ea1a2a08f085d8b361711c19 Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Thu, 28 May 2026 16:12:13 +0200 Subject: [PATCH 30/37] feat: modernize cli apply ux --- packages/cli/src/apply/index.ts | 23 ++++++---- packages/cli/src/commands/apply.ts | 17 +++----- packages/cli/src/git/status.ts | 14 +++--- packages/cli/src/index.ts | 4 +- packages/cli/src/reporting/clack.ts | 67 +++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 25 deletions(-) create mode 100644 packages/cli/src/reporting/clack.ts diff --git a/packages/cli/src/apply/index.ts b/packages/cli/src/apply/index.ts index 071bede2..937bcf20 100644 --- a/packages/cli/src/apply/index.ts +++ b/packages/cli/src/apply/index.ts @@ -7,7 +7,8 @@ import { removePathIfExists } from '../fs/readWrite' import { ensureGitWorktreeIsReady } from '../git/status' import { applyAndroidPlatform, createAndroidPreflightRunner } from '../platforms/android/apply' import { applyIOSPlatform, createIOSPreflightRunner } from '../platforms/ios/apply' -import { formatApplySummary, VoltraCliError } from '../reporting/summary' +import { renderApplySummary, renderIntro } from '../reporting/clack' +import { VoltraCliError } from '../reporting/summary' import { diffVoltraState } from '../state/diff' import { loadVoltraState } from '../state/load' import { saveVoltraState } from '../state/save' @@ -50,14 +51,18 @@ export type PlatformApplyRunner = (context: PlatformApplyContext) => Promise> preflightRunners: ApplyPreflightRunners - writeStdout(message: string): void + writeIntro(): Promise + writeSummary(summary: { changes: ReportedChange[]; warnings?: string[] }): Promise } const DEFAULT_DEPENDENCIES: ApplyDependencies = { applyRunners: {}, preflightRunners: {}, - writeStdout(message: string) { - process.stdout.write(message) + async writeIntro() { + await renderIntro() + }, + async writeSummary(summary) { + await renderApplySummary(summary) }, } @@ -80,7 +85,8 @@ export async function runApplyPipeline(options: ApplyOptions, dependencies: Appl const loadedConfig = await loadVoltraConfig({ configPath: options.configPath }) const normalizedConfig = normalizeVoltraConfig(loadedConfig) const resolvedDependencies = resolveApplyDependencies(normalizedConfig, dependencies) - const gitStatus = await ensureGitWorktreeIsReady({ + await resolvedDependencies.writeIntro() + await ensureGitWorktreeIsReady({ cwd: normalizedConfig.projectRoot, allowDirty: options.allowDirty, }) @@ -92,10 +98,10 @@ export async function runApplyPipeline(options: ApplyOptions, dependencies: Appl const deletedChanges = await removeStaleGeneratedFiles(normalizedConfig.projectRoot, stateDiff.staleFiles) await saveVoltraState(normalizedConfig.projectRoot, { files: stateDiff.nextFiles }) - const summaryWarnings = [gitStatus.warning, ...platformResults.flatMap((result) => result.warnings ?? [])].filter(isDefined) + const summaryWarnings = platformResults.flatMap((result) => result.warnings ?? []).filter(isDefined) const summaryChanges = [...platformResults.flatMap((result) => result.changes), ...deletedChanges] - resolvedDependencies.writeStdout(`${formatApplySummary({ changes: summaryChanges, warnings: summaryWarnings })}\n`) + await resolvedDependencies.writeSummary({ changes: summaryChanges, warnings: summaryWarnings }) } function resolveApplyDependencies(config: NormalizedVoltraConfig, dependencies: ApplyDependencies): ApplyDependencies { @@ -108,7 +114,8 @@ function resolveApplyDependencies(config: NormalizedVoltraConfig, dependencies: android: dependencies.preflightRunners.android ?? (config.android ? createAndroidPreflightRunner(config) : undefined), ios: dependencies.preflightRunners.ios ?? (config.ios ? createIOSPreflightRunner(config) : undefined), }, - writeStdout: dependencies.writeStdout, + writeIntro: dependencies.writeIntro, + writeSummary: dependencies.writeSummary, } } diff --git a/packages/cli/src/commands/apply.ts b/packages/cli/src/commands/apply.ts index c4541338..33161865 100644 --- a/packages/cli/src/commands/apply.ts +++ b/packages/cli/src/commands/apply.ts @@ -1,11 +1,12 @@ import { Command } from 'commander' import { applyVoltra } from '../apply' -import { formatError } from '../reporting/summary' +import { normalizeClackMessage, renderError } from '../reporting/clack' import type { ApplyOptions } from '../apply' import type { VoltraPlatform } from '../config/types' + export const CLI_EXIT_CODE_SUCCESS = 0 export const CLI_EXIT_CODE_FAILURE = 1 @@ -25,7 +26,7 @@ export async function runApplyCommand(argv: string[]): Promise { await command.parseAsync(argv, { from: 'user' }) return exitCode } catch (error) { - return handleCommanderError(error) + return await handleCommanderError(error) } } @@ -49,7 +50,7 @@ export function createApplyCommand(onComplete?: (exitCode: number) => void): Com onComplete?.(result.exitCode) if (result.exitCode !== CLI_EXIT_CODE_SUCCESS && result.errorMessage) { - process.stderr.write(`${formatCliMessage(result.errorMessage)}\n`) + await renderError(result.errorMessage, { output: process.stderr }) } }) @@ -76,12 +77,12 @@ function parsePlatform(value: string): VoltraPlatform { throw new Error(`Invalid platform '${value}'. Expected 'ios' or 'android'.`) } -function handleCommanderError(error: unknown): number { +async function handleCommanderError(error: unknown): Promise { if (isCommanderError(error)) { return isCommanderDisplayError(error) ? CLI_EXIT_CODE_SUCCESS : CLI_EXIT_CODE_FAILURE } - process.stderr.write(`${formatCommandError(error)}\n`) + await renderError(formatCommandError(error), { output: process.stderr }) return CLI_EXIT_CODE_FAILURE } @@ -100,13 +101,9 @@ function isCommanderError(error: unknown): error is { code: string } { export function formatCommandError(error: unknown): string { const message = error instanceof Error ? error.message : String(error) - return formatCliMessage(message) + return normalizeClackMessage(message) } export function getApplyHelpText(): string { return createApplyCommand().helpInformation() } - -function formatCliMessage(message: string): string { - return message.startsWith('[voltra] ') ? message : formatError(message) -} diff --git a/packages/cli/src/git/status.ts b/packages/cli/src/git/status.ts index a5af58e8..ef6a7db3 100644 --- a/packages/cli/src/git/status.ts +++ b/packages/cli/src/git/status.ts @@ -2,7 +2,8 @@ import { execFile } from 'node:child_process' import type { Readable, Writable } from 'node:stream' import { promisify } from 'node:util' -import { formatDirtyWorktreeWarning, formatError, VoltraCliError } from '../reporting/summary' +import { renderCancelled, renderWarning } from '../reporting/clack' +import { formatDirtyWorktreeWarning, VoltraCliError } from '../reporting/summary' import type * as ClackPrompts from '@clack/prompts' @@ -97,7 +98,7 @@ export async function ensureGitWorktreeIsReady(options: EnsureGitWorktreeOptions throw new VoltraCliError(`${warning} Re-run interactively to confirm before applying changes.`) } - const confirmed = await promptForDirtyWorktreeConfirmation(options, warning) + const confirmed = await promptForDirtyWorktreeConfirmation(options) if (!confirmed) { throw new VoltraCliError('Aborted because the git worktree has uncommitted changes.') @@ -170,22 +171,23 @@ function isInteractiveSession(options: EnsureGitWorktreeOptions): boolean { return Boolean(stdin.isTTY && stdout.isTTY) } -async function promptForDirtyWorktreeConfirmation(options: EnsureGitWorktreeOptions, warning: string): Promise { +async function promptForDirtyWorktreeConfirmation(options: EnsureGitWorktreeOptions): Promise { const stdin = options.stdin ?? process.stdin const stdout = options.stdout ?? process.stdout + const promptMessage = 'You have uncommitted changes. Are you sure you want to continue?' const { confirm, isCancel } = await loadClackPrompts() - stdout.write(`${warning}\n`) + await renderWarning(promptMessage, { input: stdin, output: stdout }) const response = await confirm({ - message: 'Continue anyway?', + message: 'Continue?', initialValue: false, input: stdin, output: stdout, }) if (isCancel(response)) { - stdout.write(`${formatError('Cancelled.')}\n`) + await renderCancelled({ input: stdin, output: stdout }) return false } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7e95c184..489018f9 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,6 +1,7 @@ import { Command } from 'commander' import { CLI_EXIT_CODE_FAILURE, CLI_EXIT_CODE_SUCCESS, createApplyCommand, formatCommandError } from './commands/apply' +import { renderError } from './reporting/clack' function createProgram(): Command { const program = new Command() @@ -47,7 +48,7 @@ export async function runCli(argv: string[]): Promise { return isCommanderDisplayError(error) ? CLI_EXIT_CODE_SUCCESS : CLI_EXIT_CODE_FAILURE } - process.stderr.write(`${formatCommandError(error)}\n`) + await renderError(formatCommandError(error), { output: process.stderr }) return CLI_EXIT_CODE_FAILURE } } @@ -125,6 +126,7 @@ export type { export { diffVoltraState } from './state/diff' export { getVoltraStatePath, loadVoltraState } from './state/load' export { saveVoltraState } from './state/save' +export { normalizeClackMessage, renderApplySummary, renderCancelled, renderError, renderWarning } from './reporting/clack' export type { VoltraStateDiff } from './state/diff' export type { SaveVoltraStateInput } from './state/save' export type { VoltraState } from './state/load' diff --git a/packages/cli/src/reporting/clack.ts b/packages/cli/src/reporting/clack.ts new file mode 100644 index 00000000..a4ed226c --- /dev/null +++ b/packages/cli/src/reporting/clack.ts @@ -0,0 +1,67 @@ +import type { Readable, Writable } from 'node:stream' + +import type * as ClackPrompts from '@clack/prompts' + +import type { ApplySummary } from './summary' + +interface CommonClackOptions { + input?: Readable + output?: Writable +} + +export async function renderApplySummary(summary: ApplySummary, options?: CommonClackOptions): Promise { + const { outro, log } = await loadClackPrompts() + + for (const kind of ['created', 'updated', 'deleted'] as const) { + const paths = summary.changes.filter((change) => change.kind === kind).map((change) => change.path) + + for (const filePath of paths) { + log.step(`${capitalize(kind)} ${filePath}`, options) + } + } + + for (const warning of summary.warnings ?? []) { + log.warn(normalizeClackMessage(warning), options) + } + + outro('Done', options) +} + +export async function renderError(message: string, options?: CommonClackOptions): Promise { + const { log } = await loadClackPrompts() + log.error(normalizeClackMessage(message), options) +} + +export async function renderWarning(message: string, options?: CommonClackOptions): Promise { + const { log } = await loadClackPrompts() + log.warn(normalizeClackMessage(message), options) +} + +export async function renderCancelled(options?: CommonClackOptions): Promise { + const { cancel } = await loadClackPrompts() + cancel('Cancelled.', options) +} + +export async function renderIntro(options?: CommonClackOptions): Promise { + const { intro } = await loadClackPrompts() + intro('Voltra', options) +} + +export function normalizeClackMessage(message: string): string { + return message + .split('\n') + .map((line) => line.replace(/^\[voltra\]\s*(Warning:\s*|Error:\s*|Preflight:\s*)?/, '')) + .join('\n') +} + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1) +} + +function loadClackPrompts() { + const dynamicImport = new Function('specifier', 'return import(specifier)') as ( + specifier: string + ) => Promise + + return dynamicImport('@clack/prompts') +} From 922e6bdce1275b3076d0cc3dda001695e818135d Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 08:42:55 +0200 Subject: [PATCH 31/37] docs: add react native cli guide --- website/docs/getting-started/_meta.json | 5 + website/docs/getting-started/installation.mdx | 1 + .../docs/getting-started/react-native-cli.mdx | 113 ++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 website/docs/getting-started/react-native-cli.mdx diff --git a/website/docs/getting-started/_meta.json b/website/docs/getting-started/_meta.json index 6b94a59e..d82f8c6d 100644 --- a/website/docs/getting-started/_meta.json +++ b/website/docs/getting-started/_meta.json @@ -9,6 +9,11 @@ "name": "installation", "label": "Installation" }, + { + "type": "file", + "name": "react-native-cli", + "label": "React Native CLI" + }, { "type": "file", "name": "migration-v2", diff --git a/website/docs/getting-started/installation.mdx b/website/docs/getting-started/installation.mdx index 10dea8ff..a5f5b66d 100644 --- a/website/docs/getting-started/installation.mdx +++ b/website/docs/getting-started/installation.mdx @@ -46,4 +46,5 @@ After installing packages, configure the Voltra Expo plugin for each platform yo - [Configure iOS Setup](../ios/setup) - [Configure Android Setup](../android/setup) +- [Use Voltra in React Native CLI projects](./react-native-cli) - [Migrate from v1 to v2](./migration-v2) diff --git a/website/docs/getting-started/react-native-cli.mdx b/website/docs/getting-started/react-native-cli.mdx new file mode 100644 index 00000000..9ef08be6 --- /dev/null +++ b/website/docs/getting-started/react-native-cli.mdx @@ -0,0 +1,113 @@ +import { PackageManagerTabs } from '@rspress/core/theme' + +# React Native CLI Projects + +:::warning Experimental +Voltra support for React Native CLI projects is experimental. Feedback is welcome in [GitHub issues](https://github.com/callstackincubator/voltra/issues). +::: + +Voltra fully supports React Native CLI projects through the `voltra` CLI. Instead of relying on Expo config plugins, `voltra apply` updates the native project for you: it modifies the files Voltra needs, generates new Voltra-owned files, and cleans up outdated generated files from previous runs. + +## Installation + +Install the same native Voltra packages you would use in Expo for the platforms you support, then add `voltra` as a dev dependency. + +### iOS + + + +### Android + + + +### Voltra CLI + + + +Then create a `voltra.config.ts` file. Most properties under `ios` and `android` are the same as the Expo config equivalents, so use the existing platform docs for the shared configuration: + +- [iOS Expo config options](/ios/api/plugin-configuration) +- [Android Expo config options](/android/api/plugin-configuration) + +Minimal example: + +```ts +import type { VoltraConfig } from 'voltra' + +const config: VoltraConfig = { + ios: { + // Commonly needed on iOS so the app and extension can share data. + groupIdentifier: 'group.com.example.app', + widgets: [ + { + // Basic widget identity fields. + id: 'portfolio', + displayName: 'Portfolio', + description: 'Track holdings', + // Optional, but commonly used for build-time initial widget content. + initialStatePath: './widgets/portfolio.ios.tsx', + }, + ], + }, + android: { + widgets: [ + { + id: 'portfolio', + displayName: 'Portfolio', + description: 'Track holdings', + // Required on Android. + targetCellWidth: 2, + targetCellHeight: 2, + initialStatePath: './widgets/portfolio.android.tsx', + }, + ], + }, +} + +export default config +``` + +Once the config is in place, apply the native project changes: + + + +## Configuration + +Most properties under `ios` and `android` use the same names as the Expo config equivalents: + +- [iOS Expo config options](/ios/api/plugin-configuration) +- [Android Expo config options](/android/api/plugin-configuration) + +The properties below are specific to the Voltra CLI: + +| Property | Purpose | +| --- | --- | +| `projectRoot` | Overrides the root directory Voltra should treat as the native project root. Useful when the config file does not live at the app root. | +| `ios.userImagesPath` | Overrides the directory for user-provided iOS widget images. | +| `ios.project` | Lets you override iOS project discovery when the app does not use the standard React Native layout. Supports fields such as `rootDir`, `xcodeprojPath`, `mainTargetName`, `infoPlistPath`, `entitlementsPath`, and `podfilePath`. | +| `android.userImagesPath` | Overrides the directory for user-provided Android widget images. | +| `android.project` | Lets you override Android project discovery when the app does not use the standard React Native layout. Supports fields such as `rootDir`, `appModuleName`, `manifestPath`, and `packageName`. | + +For the full config shape, see [`VoltraConfig` and related types in source](https://github.com/callstackincubator/voltra/blob/main/packages/cli/src/config/types.ts). + +## Using Voltra CLI + +Every time you change `voltra.config.ts`, reapply the native project changes: + + + +The JSX APIs and runtime APIs are the same as in the rest of the Voltra docs. Once `voltra apply` has set up your native project, continue with the platform guides for [iOS](/ios/introduction) and [Android](/android/introduction). From 0684cb947438dc9da2cac378c4655a38b891aa3c Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 09:25:29 +0200 Subject: [PATCH 32/37] chore: update comments --- packages/cli/CONTRIBUTING.md | 47 ++++++ packages/cli/README.md | 269 +++---------------------------- packages/cli/src/config/types.ts | 97 +++++++++++ 3 files changed, 170 insertions(+), 243 deletions(-) create mode 100644 packages/cli/CONTRIBUTING.md diff --git a/packages/cli/CONTRIBUTING.md b/packages/cli/CONTRIBUTING.md new file mode 100644 index 00000000..1c0940c9 --- /dev/null +++ b/packages/cli/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing to `voltra` + +This package contains the CLI used to apply Voltra to standard native React Native projects. + +For the repo-wide contribution process, see the root [CONTRIBUTING.md](../../CONTRIBUTING.md). + +## High-level overview + +The CLI is organized around one user-facing workflow: load Voltra config, validate the target native project, apply the requested platform changes, clean up stale generated files, then report the result. + +At a high level: + +- `src/bin.ts` is the executable entry point. +- `src/commands/apply.ts` defines the public CLI command and translates argv into apply options. +- `src/apply/` orchestrates the end-to-end apply pipeline and preflight checks. +- `src/config/` loads and normalizes Voltra config before any platform work starts. +- `src/discovery/` resolves the native project layout for Android and iOS. +- `src/platforms/android/` and `src/platforms/ios/` contain platform-specific preflight and apply logic. +- `src/state/` tracks Voltra-owned generated files so later runs can clean up stale output safely. +- `src/reporting/` owns terminal output, errors, and apply summaries. +- `src/git/` handles git worktree checks before writes begin. +- `src/fs/` contains shared filesystem helpers used across the pipeline. + +## Design intent + +The package is intentionally convention-first: + +- config loading happens up front +- discovery and validation fail before writes when the target project is missing or ambiguous +- platform-specific code owns native mutations +- only Voltra-owned generated files are tracked for cleanup +- terminal reporting stays separate from the mutation logic + +This separation keeps the apply pipeline straightforward to reason about and makes platform behavior easier to evolve independently. + +## Development notes + +Useful package-local commands: + +```sh +npm run build --workspace packages/cli +npm run lint --workspace packages/cli +npm run typecheck --workspace packages/cli +npm run test --workspace packages/cli +``` + +When changing behavior, prefer keeping orchestration in `src/apply/` and pushing platform-specific details into the relevant platform directory instead of adding cross-platform branching in many places. diff --git a/packages/cli/README.md b/packages/cli/README.md index 0303b453..82f8b57c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1,260 +1,43 @@ -# voltra +![voltra-banner](https://use-voltra.dev/voltra-baner.jpg) -CLI for applying Voltra to standard native React Native projects. +### Voltra CLI for native React Native projects -`voltra` is the non-Expo path for wiring Voltra into an existing native app. +[![mit licence][license-badge]][license] [![npm downloads][npm-downloads-badge]][npm-downloads] [![PRs Welcome][prs-welcome-badge]][prs-welcome] -V1 exposes one public command: +`voltra` is the CLI package for applying Voltra to standard native React Native apps. -```sh -voltra apply -``` +Use it when you are integrating Voltra into a project that owns its native iOS and Android folders directly, without relying on Expo config plugins to make those changes for you. -It loads Voltra config, discovers the native project, generates Voltra-owned files, mutates required native project files, removes stale generated files from previous runs, and writes `.voltra/state.json` after a successful apply. +For installation and setup instructions, see the Voltra documentation: [use-voltra.dev](https://use-voltra.dev). -## Install +## When to use this package -```sh -npm install --save-dev voltra -npm install @use-voltra/ios-client -``` +- Native React Native apps that manage `ios/` and `android/` directly. +- Projects that need Voltra to wire platform-specific files and native project changes outside Expo prebuild. -If you apply only Android, the iOS client package is not required. +If you are using Expo config plugins, start with: -## Command +- [`@use-voltra/ios-client`](../ios-client) +- [`@use-voltra/android-client`](../android-client) -```sh -voltra apply [options] -``` +## For contributors -Options: +See [CONTRIBUTING.md](./CONTRIBUTING.md) for a high-level overview of the CLI package and its internal structure. -- `--platform ios|android`: limit apply to one platform. -- `--config `: load config from an explicit file path. -- `-y`, `--yes`: skip the dirty git worktree confirmation prompt. -- `-h`, `--help`: show command help. +## Authors -Examples: +Voltra is an open source collaboration between [Saúl Sharma](https://github.com/saulsharma) and [Szymon Chmal](https://github.com/szymonchmal) at [Callstack][callstack-readme-with-love]. -```sh -# Apply both configured platforms -npx voltra apply +If you think it's cool, please star it 🌟. This project will always remain free to use. -# Apply only iOS -npx voltra apply --platform ios +[Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi! -# Apply using an explicit config file -npx voltra apply --config ./config/voltra.config.ts +Like the project? ⚛️ [Join the Callstack team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥 -# Skip the dirty-worktree confirmation -npx voltra apply --yes - -# Re-apply only Android without removing tracked iOS files -npx voltra apply --platform android -``` - -## Config Files - -`voltra` uses `cosmiconfig` and searches these locations: - -- `package.json` under `voltra` -- `.voltrarc` -- `.voltrarc.json` -- `.voltrarc.yaml` -- `.voltrarc.yml` -- `.voltrarc.js` -- `.voltrarc.cjs` -- `.voltrarc.mjs` -- `.voltrarc.ts` -- `voltra.config.json` -- `voltra.config.yaml` -- `voltra.config.yml` -- `voltra.config.js` -- `voltra.config.cjs` -- `voltra.config.mjs` -- `voltra.config.ts` - -When `--config` is provided, that file is loaded directly instead of searching. - -## Path Resolution - -- `configDir` is the directory containing the loaded config file. -- `projectRoot` defaults to `configDir`. -- `projectRoot` can be overridden in config. -- Relative widget, preview, font, asset, and project-override paths resolve from `projectRoot`. - -## Config Shape - -The CLI config stays close to the existing Expo plugin config, with extra project-discovery overrides for native apps. - -```ts -import type { VoltraConfig } from 'voltra' - -const config: VoltraConfig = { - projectRoot: '.', - android: { - enableNotifications: true, - fonts: ['./assets/fonts/Inter-Regular.ttf'], - userImagesPath: './assets/voltra-android', - project: { - rootDir: './android', - appModuleName: 'app', - manifestPath: './android/app/src/main/AndroidManifest.xml', - packageName: 'com.example.app', - }, - widgets: [ - { - id: 'scoreboard', - displayName: 'Scoreboard', - description: 'Live score widget', - targetCellWidth: 2, - targetCellHeight: 2, - previewImage: './assets/widgets/scoreboard-preview.png', - initialStatePath: './widgets/scoreboard.android.tsx', - }, - ], - }, - ios: { - enablePushNotifications: true, - groupIdentifier: 'group.com.example.app', - keychainGroup: '$(AppIdentifierPrefix)com.example.shared', - deploymentTarget: '16.0', - targetName: 'ExampleLiveActivity', - fonts: ['./assets/fonts/Inter-Regular.ttf'], - userImagesPath: './assets/voltra', - project: { - rootDir: './ios', - xcodeprojPath: './ios/Example.xcodeproj', - mainTargetName: 'Example', - infoPlistPath: './ios/Example/Info.plist', - entitlementsPath: './ios/Example/Example.entitlements', - podfilePath: './ios/Podfile', - }, - widgets: [ - { - id: 'portfolio', - displayName: { - en: 'Portfolio', - pl: 'Portfel', - }, - description: 'Track holdings', - supportedFamilies: ['systemSmall', 'systemMedium'], - initialStatePath: { - en: './widgets/portfolio.ios.en.tsx', - pl: './widgets/portfolio.ios.pl.tsx', - }, - serverUpdate: { - url: 'https://example.com/widgets/portfolio', - intervalMinutes: 30, - refresh: true, - }, - }, - ], - }, -} - -export default config -``` - -## Discovery Defaults - -`voltra apply` is convention-first and only needs overrides for non-standard layouts or ambiguous native projects. - -For generated assets, Android reads `android.userImagesPath` and iOS reads `ios.userImagesPath`. If `ios.userImagesPath` is omitted, it defaults to `./assets/voltra`. - -### Android - -Default discovery: - -- Android root: `android/` -- app module: `app` -- manifest: `android/app/src/main/AndroidManifest.xml` -- package name: resolved from `android.project.packageName`, then app-module `namespace`, then `applicationId`, then manifest `package` - -Android override fields: - -- `android.project.rootDir` -- `android.project.appModuleName` -- `android.project.manifestPath` -- `android.project.packageName` - -### iOS - -Default discovery: - -- iOS root: `ios/` -- Podfile: `ios/Podfile` -- Xcode project: the only `.xcodeproj` under `ios/` -- main app target: the only application target in `project.pbxproj` -- main app `Info.plist` and entitlements: resolved from the selected target build settings - -iOS override fields: - -- `ios.project.rootDir` -- `ios.project.xcodeprojPath` -- `ios.project.mainTargetName` -- `ios.project.infoPlistPath` -- `ios.project.entitlementsPath` -- `ios.project.podfilePath` - -If discovery is missing or ambiguous, `voltra apply` fails during preflight before writing any files. - -## Dirty Git Worktree Behavior - -Before writing files, `voltra apply` checks the git worktree: - -- clean worktree: continue -- dirty worktree in an interactive terminal: print a warning and ask for confirmation without listing modified paths -- dirty worktree with `--yes`: continue without asking for confirmation -- dirty worktree in a non-interactive environment: fail before applying changes -- no git repository: continue without blocking apply - -## Generated Files And State Tracking - -Voltra tracks only fully generated, Voltra-owned files in: - -```text -.voltra/state.json -``` - -Example state file: - -```json -{ - "schemaVersion": 1, - "files": [ - "ios/ExampleLiveActivity/Info.plist", - "ios/ExampleLiveActivity/VoltraWidgetBundle.swift", - "android/app/src/main/res/xml/voltra_widget_scoreboard_info.xml" - ] -} -``` - -Rules: - -- paths are stored relative to `projectRoot` -- only generated Voltra-owned files are tracked -- stale generated files from previous runs are removed after a successful apply -- shared native files are not reverted from state history - -Shared files are always reconciled from current config instead of state history. That includes: - -- `AndroidManifest.xml` -- main app `Info.plist` -- entitlements -- `Podfile` -- `project.pbxproj` - -## Apply Summary - -After a successful run, `voltra apply` prints a summary of created, updated, and deleted files, followed by any warnings. - -## Scope Notes - -Current v1 scope is intentionally narrow: - -- one public command: `voltra apply` -- standard native React Native project layouts first -- no plan or diff mode -- no rollback system -- no broad mutation history beyond `.voltra/state.json` +[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=voltra&utm_term=readme-with-love +[license-badge]: https://img.shields.io/npm/l/voltra?style=for-the-badge +[license]: https://github.com/callstackincubator/voltra/blob/main/LICENSE.txt +[npm-downloads-badge]: https://img.shields.io/npm/dm/voltra?style=for-the-badge +[npm-downloads]: https://www.npmjs.com/package/voltra +[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge +[prs-welcome]: ./CONTRIBUTING.md diff --git a/packages/cli/src/config/types.ts b/packages/cli/src/config/types.ts index 994e36b8..3a0549e6 100644 --- a/packages/cli/src/config/types.ts +++ b/packages/cli/src/config/types.ts @@ -8,31 +8,51 @@ export type VoltraPlatform = 'android' | 'ios' */ export type WidgetLocalizedValue = Record +/** Widget display text, either as a single string or localized by locale identifier. */ export type WidgetLabel = string | WidgetLocalizedValue +/** Path to a widget initial state module, either as a single path or localized by locale identifier. */ export type WidgetInitialStatePath = string | WidgetLocalizedValue export interface AndroidWidgetServerUpdateConfig { + /** Server endpoint that returns widget state updates. */ url: string + /** Refresh interval, in minutes, for fetching server updates. */ intervalMinutes?: number + /** Whether fetched updates should trigger an immediate widget refresh. */ refresh?: boolean } export interface AndroidWidgetConfig { + /** Stable widget identifier used in generated files and registrations. */ id: string + /** User-facing widget name shown by the launcher. */ displayName: WidgetLabel + /** User-facing widget description shown by the launcher. */ description: WidgetLabel + /** Minimum widget width in dp. */ minWidth?: number + /** Minimum widget height in dp. */ minHeight?: number + /** Minimum widget width in launcher grid cells. */ minCellWidth?: number + /** Minimum widget height in launcher grid cells. */ minCellHeight?: number + /** Default widget width in launcher grid cells. */ targetCellWidth: number + /** Default widget height in launcher grid cells. */ targetCellHeight: number + /** Supported resize directions for the Android widget. */ resizeMode?: 'none' | 'horizontal' | 'vertical' | 'horizontal|vertical' + /** Launcher surfaces where the widget can be placed. */ widgetCategory?: 'home_screen' | 'keyguard' | 'home_screen|keyguard' + /** Path to the build-time initial state module for this widget. */ initialStatePath?: WidgetInitialStatePath + /** Server-driven update settings for this widget. */ serverUpdate?: AndroidWidgetServerUpdateConfig + /** Path to the preview image shown in widget pickers. */ previewImage?: string + /** Path to a preview layout XML file shown in widget pickers. */ previewLayout?: string } @@ -46,33 +66,52 @@ export type IOSWidgetFamily = | 'accessoryInline' export interface IOSWidgetServerUpdateConfig { + /** Server endpoint that returns widget state updates. */ url: string + /** Refresh interval, in minutes, for fetching server updates. */ intervalMinutes?: number + /** Whether fetched updates should trigger an immediate widget refresh. */ refresh?: boolean } export interface IOSWidgetConfig { + /** Stable widget identifier used in generated files and registrations. */ id: string + /** User-facing widget name shown in iOS widget configuration UI. */ displayName: WidgetLabel + /** User-facing widget description shown in iOS widget configuration UI. */ description: WidgetLabel + /** Supported iOS widget families for this widget. */ supportedFamilies?: IOSWidgetFamily[] + /** Path to the build-time initial state module for this widget. */ initialStatePath?: WidgetInitialStatePath + /** Server-driven update settings for this widget. */ serverUpdate?: IOSWidgetServerUpdateConfig } export interface AndroidProjectOverrides { + /** Root directory of the Android native project. Defaults to `android/` under `projectRoot`. */ rootDir?: string + /** Android app module name. Defaults to `app` when it can be inferred. */ appModuleName?: string + /** Explicit path to the AndroidManifest.xml file for the app module. */ manifestPath?: string + /** Explicit Android package name if it cannot be derived from the project files. */ packageName?: string } export interface IOSProjectOverrides { + /** Root directory of the iOS native project. Defaults to `ios/` under `projectRoot`. */ rootDir?: string + /** Explicit path to the `.xcodeproj` directory to use for discovery. */ xcodeprojPath?: string + /** Main application target name when the Xcode project has multiple app targets. */ mainTargetName?: string + /** Explicit path to the app target Info.plist file. */ infoPlistPath?: string + /** Explicit path to the main app entitlements file. */ entitlementsPath?: string + /** Explicit path to the Podfile. */ podfilePath?: string } @@ -81,10 +120,15 @@ export interface IOSProjectOverrides { * with explicit project discovery overrides added for native projects. */ export interface VoltraAndroidConfig { + /** Whether to add the Android notification permission and related setup. */ enableNotifications?: boolean + /** Android widgets to generate and register. */ widgets?: AndroidWidgetConfig[] + /** Font files that should be bundled for Android widget rendering. */ fonts?: string[] + /** Directory containing user-provided images for Android widgets. */ userImagesPath?: string + /** Native Android project discovery overrides. */ project?: AndroidProjectOverrides } @@ -93,91 +137,144 @@ export interface VoltraAndroidConfig { * with explicit project discovery overrides added for native projects. */ export interface VoltraIOSConfig { + /** Whether to enable push-notification-related iOS setup for widgets and Live Activities. */ enablePushNotifications?: boolean + /** App Group identifier used to share data between the app and widget extension. */ groupIdentifier?: string + /** iOS widgets to generate and register. */ widgets?: IOSWidgetConfig[] + /** Minimum iOS deployment target for generated widget targets. */ deploymentTarget?: string + /** Override for the generated widget extension target name. */ targetName?: string + /** Font files that should be bundled for iOS widget rendering. */ fonts?: string[] + /** Directory containing user-provided images for iOS widgets. */ userImagesPath?: string + /** Keychain access group shared by the app and extension. */ keychainGroup?: string + /** Native iOS project discovery overrides. */ project?: IOSProjectOverrides } export interface VoltraConfig { + /** Root directory used to resolve relative paths in the Voltra config. Defaults to the config file directory. */ projectRoot?: string + /** Android-specific Voltra configuration. */ android?: VoltraAndroidConfig + /** iOS-specific Voltra configuration. */ ios?: VoltraIOSConfig } export interface LoadedVoltraConfig { + /** Parsed Voltra config object loaded from disk. */ config: VoltraConfig + /** Absolute path to the loaded config file, when the config came from a file. */ configPath?: string + /** Directory that contained the loaded config source. */ configDir: string } export interface NormalizedAndroidWidgetServerUpdateConfig { + /** Server endpoint that returns widget state updates. */ url: string + /** Refresh interval, in minutes, for fetching server updates. */ intervalMinutes: number + /** Whether fetched updates should trigger an immediate widget refresh. */ refresh: boolean } export interface NormalizedAndroidWidgetConfig extends Omit { + /** Server-driven update settings after defaults have been applied. */ serverUpdate?: NormalizedAndroidWidgetServerUpdateConfig } export interface NormalizedIOSWidgetServerUpdateConfig { + /** Server endpoint that returns widget state updates. */ url: string + /** Refresh interval, in minutes, for fetching server updates. */ intervalMinutes: number + /** Whether fetched updates should trigger an immediate widget refresh. */ refresh: boolean } export interface NormalizedIOSWidgetConfig extends Omit { + /** Supported iOS widget families after defaults have been applied. */ supportedFamilies: IOSWidgetFamily[] + /** Server-driven update settings after defaults have been applied. */ serverUpdate?: NormalizedIOSWidgetServerUpdateConfig } export interface NormalizedAndroidProjectConfig { + /** Absolute Android project root directory, if overridden. */ rootDir?: string + /** Resolved Android app module name. */ appModuleName?: string + /** Absolute path to AndroidManifest.xml, if overridden. */ manifestPath?: string + /** Explicit Android package name, if overridden. */ packageName?: string } export interface NormalizedIOSProjectConfig { + /** Absolute iOS project root directory, if overridden. */ rootDir?: string + /** Absolute path to the `.xcodeproj` directory, if overridden. */ xcodeprojPath?: string + /** Explicit main iOS application target name, if overridden. */ mainTargetName?: string + /** Absolute path to the Info.plist file, if overridden. */ infoPlistPath?: string + /** Absolute path to the entitlements file, if overridden. */ entitlementsPath?: string + /** Absolute path to the Podfile, if overridden. */ podfilePath?: string } export interface NormalizedVoltraAndroidConfig { + /** Whether Android notification setup should be applied. */ enableNotifications: boolean + /** Android widgets after validation and normalization. */ widgets: NormalizedAndroidWidgetConfig[] + /** Absolute font file paths for Android widgets. */ fonts: string[] + /** Absolute path to the Android user images directory. */ userImagesPath: string + /** Normalized Android native project discovery overrides. */ project: NormalizedAndroidProjectConfig } export interface NormalizedVoltraIOSConfig { + /** Whether iOS push-notification-related setup should be applied. */ enablePushNotifications: boolean + /** App Group identifier used to share data between the app and extension. */ groupIdentifier?: string + /** iOS widgets after validation and normalization. */ widgets: NormalizedIOSWidgetConfig[] + /** Effective iOS deployment target for generated widget targets. */ deploymentTarget: string + /** Effective widget extension target name override, if provided. */ targetName?: string + /** Absolute font file paths for iOS widgets. */ fonts: string[] + /** Absolute path to the iOS user images directory. */ userImagesPath: string + /** Keychain access group shared by the app and extension. */ keychainGroup?: string + /** Normalized iOS native project discovery overrides. */ project: NormalizedIOSProjectConfig } export interface NormalizedVoltraConfig { + /** Absolute path to the loaded config file, when the config came from a file. */ configPath?: string + /** Directory that contained the loaded config source. */ configDir: string + /** Absolute root directory used for resolving all project-relative paths. */ projectRoot: string + /** Normalized Android-specific Voltra configuration. */ android?: NormalizedVoltraAndroidConfig + /** Normalized iOS-specific Voltra configuration. */ ios?: NormalizedVoltraIOSConfig } From 0764d54c8a7c3a4bd769a52fed5e099346fffcad Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 10:53:06 +0200 Subject: [PATCH 33/37] fix: make Voltra platform packages optional for CLI Load platform render packages from the app project at runtime and preflight configured platforms with clear missing-package errors. --- package-lock.json | 14 ++++++- packages/cli/package.json | 14 ++++++- .../cli/src/dependencies/platformPackages.ts | 38 +++++++++++++++++++ packages/cli/src/platforms/android/apply.ts | 8 ++++ .../cli/src/platforms/android/generated.ts | 21 ++++++++-- packages/cli/src/platforms/ios/apply.ts | 8 ++++ packages/cli/src/platforms/ios/generated.ts | 21 ++++++++-- packages/cli/test/cli.test.js | 34 +++++++++++++++++ 8 files changed, 146 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/dependencies/platformPackages.ts diff --git a/package-lock.json b/package-lock.json index 72e80076..59b6218b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21748,8 +21748,6 @@ "@babel/core": "^7.27.4", "@bacons/xcode": "^1.0.0-alpha.33", "@clack/prompts": "^1.0.0-alpha.5", - "@use-voltra/android": "1.4.1", - "@use-voltra/ios": "1.4.1", "commander": "^12.1.0", "cosmiconfig": "^9.0.0", "vd-tool": "^4.0.2", @@ -21757,6 +21755,18 @@ }, "bin": { "voltra": "build/cjs/bin.js" + }, + "peerDependencies": { + "@use-voltra/android": "*", + "@use-voltra/ios": "*" + }, + "peerDependenciesMeta": { + "@use-voltra/android": { + "optional": true + }, + "@use-voltra/ios": { + "optional": true + } } }, "packages/cli/node_modules/cosmiconfig": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 787d55d1..21d2c3fe 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,11 +49,21 @@ "@babel/core": "^7.27.4", "@bacons/xcode": "^1.0.0-alpha.33", "@clack/prompts": "^1.0.0-alpha.5", - "@use-voltra/android": "1.4.1", - "@use-voltra/ios": "1.4.1", "commander": "^12.1.0", "cosmiconfig": "^9.0.0", "vd-tool": "^4.0.2", "xml2js": "^0.6.2" + }, + "peerDependencies": { + "@use-voltra/android": "*", + "@use-voltra/ios": "*" + }, + "peerDependenciesMeta": { + "@use-voltra/android": { + "optional": true + }, + "@use-voltra/ios": { + "optional": true + } } } diff --git a/packages/cli/src/dependencies/platformPackages.ts b/packages/cli/src/dependencies/platformPackages.ts new file mode 100644 index 00000000..17b61784 --- /dev/null +++ b/packages/cli/src/dependencies/platformPackages.ts @@ -0,0 +1,38 @@ +import path from 'node:path' +import { createRequire } from 'node:module' + +import type { VoltraPlatform } from '../config/types' + +const PLATFORM_PACKAGE_NAMES: Record = { + android: '@use-voltra/android', + ios: '@use-voltra/ios', +} + +export function getPlatformPackageName(platform: VoltraPlatform): string { + return PLATFORM_PACKAGE_NAMES[platform] +} + +export function isPlatformPackageInstalled(projectRoot: string, platform: VoltraPlatform): boolean { + const projectRequire = createProjectRequire(projectRoot) + const packageName = getPlatformPackageName(platform) + + try { + projectRequire.resolve(`${packageName}/package.json`) + return true + } catch { + return false + } +} + +export function requirePlatformPackage(projectRoot: string, platform: VoltraPlatform): TPackage { + return createProjectRequire(projectRoot)(getPlatformPackageName(platform)) as TPackage +} + +export function getMissingPlatformPackageMessage(platform: VoltraPlatform): string { + const packageName = getPlatformPackageName(platform) + return `Required package ${packageName} is not installed in the app project. Install ${packageName} because voltra.config includes a ${platform} config block.` +} + +function createProjectRequire(projectRoot: string): NodeRequire { + return createRequire(path.join(projectRoot, 'package.json')) +} diff --git a/packages/cli/src/platforms/android/apply.ts b/packages/cli/src/platforms/android/apply.ts index 03cc4525..4ee4b9f8 100644 --- a/packages/cli/src/platforms/android/apply.ts +++ b/packages/cli/src/platforms/android/apply.ts @@ -1,4 +1,5 @@ import { discoverAndroidProject } from '../../discovery/android' +import { getMissingPlatformPackageMessage, isPlatformPackageInstalled } from '../../dependencies/platformPackages' import { VoltraCliError } from '../../reporting/summary' import { ensureAndroidManifest } from './manifest' @@ -20,6 +21,13 @@ export function createAndroidPreflightRunner(config: NormalizedVoltraConfig): Pl } } + if (!isPlatformPackageInstalled(config.projectRoot, 'android')) { + return { + platform: 'android', + issues: [{ message: getMissingPlatformPackageMessage('android') }], + } + } + return { platform: 'android', context: await discoverAndroidProject(config.projectRoot, androidConfig.project), diff --git a/packages/cli/src/platforms/android/generated.ts b/packages/cli/src/platforms/android/generated.ts index e426728f..211c5ea2 100644 --- a/packages/cli/src/platforms/android/generated.ts +++ b/packages/cli/src/platforms/android/generated.ts @@ -5,9 +5,9 @@ import vm from 'node:vm' import { createRequire } from 'node:module' import * as babel from '@babel/core' -import { renderAndroidWidgetToString } from '@use-voltra/android' import { vdConvert } from 'vd-tool' +import { requirePlatformPackage } from '../../dependencies/platformPackages' import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite' import { normalizeRelativePath, toRelativePath } from '../../fs/path' import { VoltraCliError } from '../../reporting/summary' @@ -15,7 +15,6 @@ import { VoltraCliError } from '../../reporting/summary' import type { AndroidProjectDiscovery } from '../../discovery/android' import type { NormalizedAndroidWidgetConfig, NormalizedVoltraAndroidConfig, WidgetLabel } from '../../config/types' import type { ReportedChange } from '../../reporting/summary' -import type { AndroidWidgetVariants } from '@use-voltra/android' const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', ''] const VALID_DRAWABLE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.xml', '.svg']) @@ -49,7 +48,11 @@ export class AndroidGeneratedFilesError extends VoltraCliError { } } -type AndroidWidgetRenderer = typeof renderAndroidWidgetToString +type AndroidWidgetVariants = Record +type AndroidWidgetRenderer = (variants: AndroidWidgetVariants) => string +interface AndroidPlatformPackage { + renderAndroidWidgetToString?: unknown +} type PrerenderedWidgetStates = Map> @@ -350,7 +353,7 @@ async function generateAndroidInitialStates( warnings: [], } } - const prerenderedStates = await prerenderWidgetStates(projectRoot, prerenderableWidgets, renderAndroidWidgetToString) + const prerenderedStates = await prerenderWidgetStates(projectRoot, prerenderableWidgets, loadAndroidWidgetRenderer(projectRoot)) if (prerenderedStates.size === 0) { return { @@ -460,6 +463,16 @@ function evaluateWidgetModule(projectRoot: string, filePath: string): AndroidWid return widgetVariants as AndroidWidgetVariants } +function loadAndroidWidgetRenderer(projectRoot: string): AndroidWidgetRenderer { + const androidPackage = requirePlatformPackage(projectRoot, 'android') + + if (typeof androidPackage.renderAndroidWidgetToString !== 'function') { + throw new AndroidGeneratedFilesError('Installed @use-voltra/android package does not export renderAndroidWidgetToString.') + } + + return androidPackage.renderAndroidWidgetToString as AndroidWidgetRenderer +} + function transpileWidgetModule(projectRoot: string, filePath: string, projectRequire: NodeRequire): string { const source = fs.readFileSync(filePath, 'utf8') const projectBabelConfigPath = resolveProjectBabelConfig(projectRoot) diff --git a/packages/cli/src/platforms/ios/apply.ts b/packages/cli/src/platforms/ios/apply.ts index 4e00879d..91635cce 100644 --- a/packages/cli/src/platforms/ios/apply.ts +++ b/packages/cli/src/platforms/ios/apply.ts @@ -1,4 +1,5 @@ import { discoverIOSProject } from '../../discovery/ios' +import { getMissingPlatformPackageMessage, isPlatformPackageInstalled } from '../../dependencies/platformPackages' import { VoltraCliError } from '../../reporting/summary' import { ensureEntitlements } from './entitlements' @@ -23,6 +24,13 @@ export function createIOSPreflightRunner(config: NormalizedVoltraConfig): Platfo } } + if (!isPlatformPackageInstalled(config.projectRoot, 'ios')) { + return { + platform: 'ios', + issues: [{ message: getMissingPlatformPackageMessage('ios') }], + } + } + return { platform: 'ios', context: await discoverIOSProject(config.projectRoot, iosConfig.project), diff --git a/packages/cli/src/platforms/ios/generated.ts b/packages/cli/src/platforms/ios/generated.ts index 166953fa..479976ca 100644 --- a/packages/cli/src/platforms/ios/generated.ts +++ b/packages/cli/src/platforms/ios/generated.ts @@ -5,8 +5,8 @@ import vm from 'node:vm' import { createRequire } from 'node:module' import * as babel from '@babel/core' -import { renderWidgetToString } from '@use-voltra/ios' +import { requirePlatformPackage } from '../../dependencies/platformPackages' import { ensureDirectory, pathExists, readTextFile, writeTextFile } from '../../fs/readWrite' import { normalizeRelativePath, toRelativePath } from '../../fs/path' import { VoltraCliError } from '../../reporting/summary' @@ -16,7 +16,6 @@ import { resolveIOSWidgetTargetName } from './targetName' import type { IOSProjectDiscovery } from '../../discovery/ios' import type { IOSWidgetFamily, NormalizedIOSWidgetConfig, NormalizedVoltraIOSConfig, WidgetLabel } from '../../config/types' import type { ReportedChange } from '../../reporting/summary' -import type { WidgetVariants } from '@use-voltra/ios' const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', ''] const VALID_IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg']) @@ -119,7 +118,11 @@ interface MainAppMetadata { urlTypes?: Array<{ CFBundleURLSchemes: string[] }> } -type IOSWidgetRenderer = typeof renderWidgetToString +type WidgetVariants = Record +type IOSWidgetRenderer = (variants: WidgetVariants) => string +interface IOSPlatformPackage { + renderWidgetToString?: unknown +} type PrerenderedWidgetStates = Map> export class IOSGeneratedFilesError extends VoltraCliError { @@ -385,7 +388,7 @@ async function generateInitialStatesSwift(projectRoot: string, widgets: Normaliz ].join('\n') } - const prerenderedStates = await prerenderWidgetStates(projectRoot, prerenderableWidgets, renderWidgetToString) + const prerenderedStates = await prerenderWidgetStates(projectRoot, prerenderableWidgets, loadIOSWidgetRenderer(projectRoot)) const widgetEntries = [...prerenderedStates.entries()] .map(([widgetId, localeMap]) => { const localeEntries = [...localeMap.entries()] @@ -604,6 +607,16 @@ function evaluateWidgetModule(projectRoot: string, filePath: string): WidgetVari return widgetVariants as WidgetVariants } +function loadIOSWidgetRenderer(projectRoot: string): IOSWidgetRenderer { + const iosPackage = requirePlatformPackage(projectRoot, 'ios') + + if (typeof iosPackage.renderWidgetToString !== 'function') { + throw new IOSGeneratedFilesError('Installed @use-voltra/ios package does not export renderWidgetToString.') + } + + return iosPackage.renderWidgetToString as IOSWidgetRenderer +} + function transpileWidgetModule(projectRoot: string, filePath: string, projectRequire: NodeRequire): string { const source = fs.readFileSync(filePath, 'utf8') const projectBabelConfigPath = resolveProjectBabelConfig(projectRoot) diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js index ee0875f5..60f5de50 100644 --- a/packages/cli/test/cli.test.js +++ b/packages/cli/test/cli.test.js @@ -69,3 +69,37 @@ test('unknown commands are reported once', () => { } ) }) + +test('ios preflight reports missing optional platform package', async () => { + const { createIOSPreflightRunner } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + fs.writeFileSync(path.join(tempDir, 'package.json'), `${JSON.stringify({ private: true }, null, 2)}\n`) + + const result = await createIOSPreflightRunner({ + projectRoot: tempDir, + ios: { + project: {}, + }, + })({ requestedPlatforms: ['ios'] }) + + assert.equal(result.platform, 'ios') + assert.match(result.issues[0].message, /@use-voltra\/ios is not installed/) + assert.match(result.issues[0].message, /ios config block/) +}) + +test('android preflight reports missing optional platform package', async () => { + const { createAndroidPreflightRunner } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + fs.writeFileSync(path.join(tempDir, 'package.json'), `${JSON.stringify({ private: true }, null, 2)}\n`) + + const result = await createAndroidPreflightRunner({ + projectRoot: tempDir, + android: { + project: {}, + }, + })({ requestedPlatforms: ['android'] }) + + assert.equal(result.platform, 'android') + assert.match(result.issues[0].message, /@use-voltra\/android is not installed/) + assert.match(result.issues[0].message, /android config block/) +}) From 7f051a811e58221c64b64d774c22fef95b8531ea Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 10:53:34 +0200 Subject: [PATCH 34/37] chore: reformat --- packages/cli/src/apply/index.ts | 39 ++++- packages/cli/src/commands/apply.ts | 9 +- packages/cli/src/config/load.ts | 9 +- packages/cli/src/config/normalize.ts | 42 ++++- packages/cli/src/discovery/android.ts | 17 +- packages/cli/src/discovery/ios.ts | 45 ++++-- packages/cli/src/git/status.ts | 14 +- packages/cli/src/index.ts | 33 +++- packages/cli/src/platforms/android/apply.ts | 4 +- .../cli/src/platforms/android/generated.ts | 132 +++++++++++++--- .../cli/src/platforms/android/manifest.ts | 36 ++++- packages/cli/src/platforms/ios/apply.ts | 20 ++- .../cli/src/platforms/ios/entitlements.ts | 14 +- packages/cli/src/platforms/ios/generated.ts | 149 ++++++++++++------ packages/cli/src/platforms/ios/plist.ts | 29 ++-- packages/cli/src/platforms/ios/podfile.ts | 5 +- packages/cli/src/platforms/ios/xcode.ts | 17 +- packages/cli/src/platforms/ios/xcodeTarget.ts | 86 ++++++++-- packages/cli/src/state/load.ts | 4 +- 19 files changed, 537 insertions(+), 167 deletions(-) diff --git a/packages/cli/src/apply/index.ts b/packages/cli/src/apply/index.ts index 937bcf20..55085a86 100644 --- a/packages/cli/src/apply/index.ts +++ b/packages/cli/src/apply/index.ts @@ -92,8 +92,18 @@ export async function runApplyPipeline(options: ApplyOptions, dependencies: Appl }) const preflight = await runApplyPreflight(normalizedConfig, resolvedDependencies.preflightRunners, options.platform) const previousState = await loadVoltraState(normalizedConfig.projectRoot) - const platformResults = await runPlatformApply(normalizedConfig, preflight, previousState, resolvedDependencies.applyRunners) - const nextGeneratedFiles = mergeGeneratedFiles(normalizedConfig, previousState, preflight.requestedPlatforms, platformResults) + const platformResults = await runPlatformApply( + normalizedConfig, + preflight, + previousState, + resolvedDependencies.applyRunners + ) + const nextGeneratedFiles = mergeGeneratedFiles( + normalizedConfig, + previousState, + preflight.requestedPlatforms, + platformResults + ) const stateDiff = diffVoltraState(previousState, nextGeneratedFiles) const deletedChanges = await removeStaleGeneratedFiles(normalizedConfig.projectRoot, stateDiff.staleFiles) await saveVoltraState(normalizedConfig.projectRoot, { files: stateDiff.nextFiles }) @@ -111,7 +121,8 @@ function resolveApplyDependencies(config: NormalizedVoltraConfig, dependencies: ios: dependencies.applyRunners.ios ?? applyIOSPlatform, }, preflightRunners: { - android: dependencies.preflightRunners.android ?? (config.android ? createAndroidPreflightRunner(config) : undefined), + android: + dependencies.preflightRunners.android ?? (config.android ? createAndroidPreflightRunner(config) : undefined), ios: dependencies.preflightRunners.ios ?? (config.ios ? createIOSPreflightRunner(config) : undefined), }, writeIntro: dependencies.writeIntro, @@ -125,7 +136,9 @@ function mergeGeneratedFiles( requestedPlatforms: VoltraPlatform[], platformResults: PlatformApplyResult[] ): string[] { - const nextGeneratedFilesByPlatform = new Map(platformResults.map((result) => [result.platform, result.generatedFiles] as const)) + const nextGeneratedFilesByPlatform = new Map( + platformResults.map((result) => [result.platform, result.generatedFiles] as const) + ) const mergedFiles = new Set() const platformRoots = getTrackedPlatformRoots(config) const configuredPlatforms = getConfiguredPlatforms(config) @@ -160,8 +173,14 @@ function mergeGeneratedFiles( function getTrackedPlatformRoots(config: NormalizedVoltraConfig): Partial> { const projectRoot = config.projectRoot - const androidRoot = config.android ? normalizeRelativePath(path.relative(projectRoot, config.android.project.rootDir ?? path.join(projectRoot, 'android'))) : undefined - const iosRoot = config.ios ? normalizeRelativePath(path.relative(projectRoot, config.ios.project.rootDir ?? path.join(projectRoot, 'ios'))) : undefined + const androidRoot = config.android + ? normalizeRelativePath( + path.relative(projectRoot, config.android.project.rootDir ?? path.join(projectRoot, 'android')) + ) + : undefined + const iosRoot = config.ios + ? normalizeRelativePath(path.relative(projectRoot, config.ios.project.rootDir ?? path.join(projectRoot, 'ios'))) + : undefined return { ...(androidRoot ? { android: androidRoot } : {}), @@ -240,7 +259,9 @@ async function runPlatformApply( } if (result.platform !== platform) { - throw new VoltraCliError(`Apply runner returned a mismatched platform result: expected ${platform}, received ${result.platform}.`) + throw new VoltraCliError( + `Apply runner returned a mismatched platform result: expected ${platform}, received ${result.platform}.` + ) } results.push(result) @@ -259,7 +280,9 @@ async function removeStaleGeneratedFiles(projectRoot: string, staleFiles: string try { deleted = await removePathIfExists(staleFilePath) } catch (error: unknown) { - throw new VoltraCliError(`Failed to remove stale generated file ${staleFile}: ${getApplyRunnerErrorMessage(error)}`) + throw new VoltraCliError( + `Failed to remove stale generated file ${staleFile}: ${getApplyRunnerErrorMessage(error)}` + ) } if (deleted) { diff --git a/packages/cli/src/commands/apply.ts b/packages/cli/src/commands/apply.ts index 33161865..466a06c8 100644 --- a/packages/cli/src/commands/apply.ts +++ b/packages/cli/src/commands/apply.ts @@ -6,7 +6,6 @@ import { normalizeClackMessage, renderError } from '../reporting/clack' import type { ApplyOptions } from '../apply' import type { VoltraPlatform } from '../config/types' - export const CLI_EXIT_CODE_SUCCESS = 0 export const CLI_EXIT_CODE_FAILURE = 1 @@ -96,7 +95,13 @@ function isCommanderDisplayError(error: unknown): error is { code: string } { } function isCommanderError(error: unknown): error is { code: string } { - return Boolean(error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' && error.code.startsWith('commander.')) + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'string' && + error.code.startsWith('commander.') + ) } export function formatCommandError(error: unknown): string { diff --git a/packages/cli/src/config/load.ts b/packages/cli/src/config/load.ts index e32f495a..2d4cc847 100644 --- a/packages/cli/src/config/load.ts +++ b/packages/cli/src/config/load.ts @@ -80,7 +80,9 @@ export async function loadVoltraConfig(options: LoadVoltraConfigOptions = {}): P const result = await explorer.search(options.cwd) if (!result) { - throw new VoltraConfigLoadError('No Voltra config found. Checked package.json, .voltrarc*, and voltra.config.* files.') + throw new VoltraConfigLoadError( + 'No Voltra config found. Checked package.json, .voltrarc*, and voltra.config.* files.' + ) } return toLoadedConfig(result) @@ -90,6 +92,9 @@ export async function loadVoltraConfig(options: LoadVoltraConfigOptions = {}): P } const message = error instanceof Error ? error.message : String(error) - throw new VoltraConfigLoadError(`Failed to load Voltra config: ${message}`, error instanceof Error ? error : undefined) + throw new VoltraConfigLoadError( + `Failed to load Voltra config: ${message}`, + error instanceof Error ? error : undefined + ) } } diff --git a/packages/cli/src/config/normalize.ts b/packages/cli/src/config/normalize.ts index 23cb9f85..69f92cda 100644 --- a/packages/cli/src/config/normalize.ts +++ b/packages/cli/src/config/normalize.ts @@ -90,7 +90,11 @@ function resolveOptionalPathFromProjectRoot(projectRoot: string, filePath: strin return resolvePathFromProjectRoot(projectRoot, filePath) } -function normalizeLocalizedPathMap(projectRoot: string, value: WidgetLocalizedValue, context: string): WidgetLocalizedValue { +function normalizeLocalizedPathMap( + projectRoot: string, + value: WidgetLocalizedValue, + context: string +): WidgetLocalizedValue { const entries = Object.entries(value) if (entries.length === 0) { @@ -193,7 +197,11 @@ function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig ...widget, displayName: normalizeLabel(widget.displayName, `android.widgets[${widget.id}].displayName`), description: normalizeLabel(widget.description, `android.widgets[${widget.id}].description`), - initialStatePath: normalizeInitialStatePath(projectRoot, widget.initialStatePath, `android.widgets[${widget.id}].initialStatePath`), + initialStatePath: normalizeInitialStatePath( + projectRoot, + widget.initialStatePath, + `android.widgets[${widget.id}].initialStatePath` + ), previewImage: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewImage), previewLayout: resolveOptionalPathFromProjectRoot(projectRoot, widget.previewLayout), serverUpdate: widget.serverUpdate @@ -220,7 +228,9 @@ function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): Norma for (const family of widget.supportedFamilies) { if (!VALID_IOS_WIDGET_FAMILIES.has(family)) { - throw new VoltraConfigNormalizationError(`ios.widgets[${widget.id}].supportedFamilies contains invalid family '${family}'`) + throw new VoltraConfigNormalizationError( + `ios.widgets[${widget.id}].supportedFamilies contains invalid family '${family}'` + ) } } } @@ -230,7 +240,11 @@ function normalizeIOSWidget(projectRoot: string, widget: IOSWidgetConfig): Norma displayName: normalizeLabel(widget.displayName, `ios.widgets[${widget.id}].displayName`), description: normalizeLabel(widget.description, `ios.widgets[${widget.id}].description`), supportedFamilies: widget.supportedFamilies ?? [...CLI_DEFAULTS.ios.widgetFamilies], - initialStatePath: normalizeInitialStatePath(projectRoot, widget.initialStatePath, `ios.widgets[${widget.id}].initialStatePath`), + initialStatePath: normalizeInitialStatePath( + projectRoot, + widget.initialStatePath, + `ios.widgets[${widget.id}].initialStatePath` + ), serverUpdate: widget.serverUpdate ? normalizeServerUpdate( widget.serverUpdate, @@ -271,7 +285,10 @@ function assertValidIOSTargetName(targetName: string, context: string): void { } } -function normalizeAndroidConfig(projectRoot: string, config: LoadedVoltraConfig['config']['android']): NormalizedVoltraAndroidConfig | undefined { +function normalizeAndroidConfig( + projectRoot: string, + config: LoadedVoltraConfig['config']['android'] +): NormalizedVoltraAndroidConfig | undefined { if (config === undefined) { return undefined } @@ -303,7 +320,10 @@ function normalizeAndroidConfig(projectRoot: string, config: LoadedVoltraConfig[ enableNotifications: config.enableNotifications ?? CLI_DEFAULTS.android.enableNotifications, widgets, fonts: (config.fonts ?? []).map((fontPath) => resolvePathFromProjectRoot(projectRoot, fontPath)), - userImagesPath: resolvePathFromProjectRoot(projectRoot, config.userImagesPath ?? CLI_DEFAULTS.android.userImagesPath), + userImagesPath: resolvePathFromProjectRoot( + projectRoot, + config.userImagesPath ?? CLI_DEFAULTS.android.userImagesPath + ), project: { rootDir: resolveOptionalPathFromProjectRoot(projectRoot, config.project?.rootDir), appModuleName: config.project?.appModuleName, @@ -313,7 +333,10 @@ function normalizeAndroidConfig(projectRoot: string, config: LoadedVoltraConfig[ } } -function normalizeIOSConfig(projectRoot: string, config: LoadedVoltraConfig['config']['ios']): NormalizedVoltraIOSConfig | undefined { +function normalizeIOSConfig( + projectRoot: string, + config: LoadedVoltraConfig['config']['ios'] +): NormalizedVoltraIOSConfig | undefined { if (config === undefined) { return undefined } @@ -375,7 +398,10 @@ export function normalizeVoltraConfig(loadedConfig: LoadedVoltraConfig): Normali assertObject(loadedConfig.config, 'config') assertOptionalString(loadedConfig.config.projectRoot, 'projectRoot') - const projectRoot = resolvePathFromProjectRoot(loadedConfig.configDir, loadedConfig.config.projectRoot ?? loadedConfig.configDir) + const projectRoot = resolvePathFromProjectRoot( + loadedConfig.configDir, + loadedConfig.config.projectRoot ?? loadedConfig.configDir + ) return { configPath: loadedConfig.configPath, diff --git a/packages/cli/src/discovery/android.ts b/packages/cli/src/discovery/android.ts index b2a1bc5d..5d416fa2 100644 --- a/packages/cli/src/discovery/android.ts +++ b/packages/cli/src/discovery/android.ts @@ -64,7 +64,9 @@ async function resolveAndroidRoot(projectRoot: string, config: NormalizedAndroid } async function resolveManifestPath(androidRoot: string, config: NormalizedAndroidProjectConfig): Promise { - const manifestPath = config.manifestPath ?? path.join(androidRoot, config.appModuleName ?? 'app', 'src', 'main', ANDROID_MANIFEST_FILE_NAME) + const manifestPath = + config.manifestPath ?? + path.join(androidRoot, config.appModuleName ?? 'app', 'src', 'main', ANDROID_MANIFEST_FILE_NAME) await ensureFile( manifestPath, @@ -76,7 +78,11 @@ async function resolveManifestPath(androidRoot: string, config: NormalizedAndroi return manifestPath } -function resolveAppModuleName(androidRoot: string, manifestPath: string, configuredAppModuleName: string | undefined): string { +function resolveAppModuleName( + androidRoot: string, + manifestPath: string, + configuredAppModuleName: string | undefined +): string { if (configuredAppModuleName) { return configuredAppModuleName } @@ -91,7 +97,12 @@ function resolveAppModuleName(androidRoot: string, manifestPath: string, configu const segments = relativeManifestPath.split(path.sep) - if (segments.length >= 4 && segments[1] === 'src' && segments[2] === 'main' && segments[3] === ANDROID_MANIFEST_FILE_NAME) { + if ( + segments.length >= 4 && + segments[1] === 'src' && + segments[2] === 'main' && + segments[3] === ANDROID_MANIFEST_FILE_NAME + ) { return segments[0] } diff --git a/packages/cli/src/discovery/ios.ts b/packages/cli/src/discovery/ios.ts index c40119f8..be8aadad 100644 --- a/packages/cli/src/discovery/ios.ts +++ b/packages/cli/src/discovery/ios.ts @@ -59,7 +59,10 @@ export class IOSProjectDiscoveryError extends VoltraCliError { } } -export async function discoverIOSProject(projectRoot: string, config: NormalizedIOSProjectConfig): Promise { +export async function discoverIOSProject( + projectRoot: string, + config: NormalizedIOSProjectConfig +): Promise { const iosRoot = await resolveIOSRoot(projectRoot, config) const xcodeprojPath = await resolveXcodeprojPath(iosRoot, config) const pbxprojPath = await resolvePbxprojPath(xcodeprojPath) @@ -127,7 +130,9 @@ async function resolveXcodeprojPath(iosRoot: string, config: NormalizedIOSProjec } throw new IOSProjectDiscoveryError( - `Multiple .xcodeproj directories were found in ${iosRoot}: ${xcodeprojCandidates.join(', ')}. Set ios.project.xcodeprojPath explicitly.` + `Multiple .xcodeproj directories were found in ${iosRoot}: ${xcodeprojCandidates.join( + ', ' + )}. Set ios.project.xcodeprojPath explicitly.` ) } @@ -213,7 +218,8 @@ function parseConfigurationLists(section: string): Map 0) { throw new IOSProjectDiscoveryError( - `Build configurations ${missingConfigurationIds.join(', ')} for target '${target.name}' were not found in ${pbxprojPath}` + `Build configurations ${missingConfigurationIds.join(', ')} for target '${ + target.name + }' were not found in ${pbxprojPath}` ) } @@ -315,7 +323,9 @@ function getTargetBuildConfigurations( .filter((configuration): configuration is ParsedXCBuildConfiguration => configuration !== undefined) if (buildConfigurations.length === 0) { - throw new IOSProjectDiscoveryError(`No build configurations were found for target '${target.name}' in ${pbxprojPath}`) + throw new IOSProjectDiscoveryError( + `No build configurations were found for target '${target.name}' in ${pbxprojPath}` + ) } const defaultConfigurationName = configurationList.defaultConfigurationName @@ -324,9 +334,13 @@ function getTargetBuildConfigurations( return buildConfigurations } - const defaultConfiguration = buildConfigurations.find((configuration) => configuration.name === defaultConfigurationName) + const defaultConfiguration = buildConfigurations.find( + (configuration) => configuration.name === defaultConfigurationName + ) - return defaultConfiguration ? [defaultConfiguration, ...buildConfigurations.filter((configuration) => configuration !== defaultConfiguration)] : buildConfigurations + return defaultConfiguration + ? [defaultConfiguration, ...buildConfigurations.filter((configuration) => configuration !== defaultConfiguration)] + : buildConfigurations } async function resolveInfoPlistPath( @@ -337,7 +351,13 @@ async function resolveInfoPlistPath( ): Promise { const infoPlistPath = config.infoPlistPath ?? - resolveConsistentBuildSettingPath(iosRoot, target, buildConfigurations, 'INFOPLIST_FILE', (configuration) => configuration.buildSettings.infoPlistFile) + resolveConsistentBuildSettingPath( + iosRoot, + target, + buildConfigurations, + 'INFOPLIST_FILE', + (configuration) => configuration.buildSettings.infoPlistFile + ) if (!infoPlistPath) { throw new IOSProjectDiscoveryError( @@ -415,7 +435,9 @@ function resolveConsistentBuildSettingPath( throw new IOSProjectDiscoveryError( `Target '${target.name}' resolves ${settingName} to multiple paths: ${[...resolvedPaths.entries()] .map(([resolvedPath, configurationNames]) => `${resolvedPath} (${configurationNames.join(', ')})`) - .join('; ')}. Set ios.project.${settingName === 'INFOPLIST_FILE' ? 'infoPlistPath' : 'entitlementsPath'} explicitly.` + .join('; ')}. Set ios.project.${ + settingName === 'INFOPLIST_FILE' ? 'infoPlistPath' : 'entitlementsPath' + } explicitly.` ) } @@ -492,7 +514,10 @@ function matchBuildSettingsBlock(body: string): string | undefined { } function matchBuildSetting(buildSettingsBlock: string, settingName: string): string | undefined { - return stripPbxprojValue(buildSettingsBlock.match(new RegExp(`\\b${settingName}\\s*=\\s*([^;]+);`))?.[1] ?? '') || undefined + return ( + stripPbxprojValue(buildSettingsBlock.match(new RegExp(`\\b${settingName}\\s*=\\s*([^;]+);`))?.[1] ?? '') || + undefined + ) } function matchPbxprojReference(body: string, fieldName: string): string | undefined { diff --git a/packages/cli/src/git/status.ts b/packages/cli/src/git/status.ts index ef6a7db3..3b75e722 100644 --- a/packages/cli/src/git/status.ts +++ b/packages/cli/src/git/status.ts @@ -50,13 +50,15 @@ class GitCommandError extends VoltraCliError { } export async function getGitWorktreeStatus(cwd: string): Promise { - const insideWorktree = await runGitCommand(['rev-parse', '--is-inside-work-tree'], { cwd }).catch((error: unknown) => { - if (isNotGitRepositoryError(error)) { - return undefined - } + const insideWorktree = await runGitCommand(['rev-parse', '--is-inside-work-tree'], { cwd }).catch( + (error: unknown) => { + if (isNotGitRepositoryError(error)) { + return undefined + } - throw error - }) + throw error + } + ) if (!insideWorktree || insideWorktree.trim() !== 'true') { return { diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 489018f9..becdf028 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -63,11 +63,24 @@ function isCommanderDisplayError(error: unknown): error is { code: string } { } function isCommanderError(error: unknown): error is { code: string } { - return Boolean(error && typeof error === 'object' && 'code' in error && typeof error.code === 'string' && error.code.startsWith('commander.')) + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'string' && + error.code.startsWith('commander.') + ) } export { applyVoltra, runApplyPipeline } from './apply' -export type { ApplyDependencies, ApplyOptions, ApplyResult, PlatformApplyContext, PlatformApplyResult, PlatformApplyRunner } from './apply' +export type { + ApplyDependencies, + ApplyOptions, + ApplyResult, + PlatformApplyContext, + PlatformApplyResult, + PlatformApplyRunner, +} from './apply' export { getRequestedPlatforms, runApplyPreflight } from './apply/preflight' export type { ApplyPreflightContext, @@ -126,7 +139,13 @@ export type { export { diffVoltraState } from './state/diff' export { getVoltraStatePath, loadVoltraState } from './state/load' export { saveVoltraState } from './state/save' -export { normalizeClackMessage, renderApplySummary, renderCancelled, renderError, renderWarning } from './reporting/clack' +export { + normalizeClackMessage, + renderApplySummary, + renderCancelled, + renderError, + renderWarning, +} from './reporting/clack' export type { VoltraStateDiff } from './state/diff' export type { SaveVoltraStateInput } from './state/save' export type { VoltraState } from './state/load' @@ -167,4 +186,10 @@ export type { WidgetLabel, WidgetLocalizedValue, } from './config/types' -export type { ApplySummary, PreflightFailureReport, PreflightIssue, ReportedChange, ReportedChangeKind } from './reporting/summary' +export type { + ApplySummary, + PreflightFailureReport, + PreflightIssue, + ReportedChange, + ReportedChangeKind, +} from './reporting/summary' diff --git a/packages/cli/src/platforms/android/apply.ts b/packages/cli/src/platforms/android/apply.ts index 4ee4b9f8..9397b0c7 100644 --- a/packages/cli/src/platforms/android/apply.ts +++ b/packages/cli/src/platforms/android/apply.ts @@ -10,7 +10,9 @@ import type { AndroidProjectDiscovery } from '../../discovery/android' import type { PlatformApplyContext, PlatformApplyResult } from '../../apply' import type { ApplyPreflightContext, PlatformPreflightResult, PlatformPreflightRunner } from '../../apply/preflight' -export function createAndroidPreflightRunner(config: NormalizedVoltraConfig): PlatformPreflightRunner { +export function createAndroidPreflightRunner( + config: NormalizedVoltraConfig +): PlatformPreflightRunner { return async (_context: ApplyPreflightContext): Promise> => { const androidConfig = config.android diff --git a/packages/cli/src/platforms/android/generated.ts b/packages/cli/src/platforms/android/generated.ts index 211c5ea2..192d5b55 100644 --- a/packages/cli/src/platforms/android/generated.ts +++ b/packages/cli/src/platforms/android/generated.ts @@ -129,7 +129,11 @@ async function generateAndroidXmlFiles( } const defaultStringsPath = path.join(valuesDir, 'voltra_widgets.xml') - const defaultStringsResult = await writeGeneratedTextFile(projectRoot, defaultStringsPath, generateWidgetStringsXml(widgets, null)) + const defaultStringsResult = await writeGeneratedTextFile( + projectRoot, + defaultStringsPath, + generateWidgetStringsXml(widgets, null) + ) pushChange(changes, defaultStringsResult.change) generatedFiles.add(defaultStringsResult.relativePath) @@ -141,17 +145,32 @@ async function generateAndroidXmlFiles( } const localizedValuesPath = path.join(resourceRoot, 'res', `values-${qualifier}`, 'voltra_widgets.xml') - const localizedValuesResult = await writeGeneratedTextFile(projectRoot, localizedValuesPath, generateWidgetStringsXml(widgets, localeKey)) + const localizedValuesResult = await writeGeneratedTextFile( + projectRoot, + localizedValuesPath, + generateWidgetStringsXml(widgets, localeKey) + ) pushChange(changes, localizedValuesResult.change) generatedFiles.add(localizedValuesResult.relativePath) } const placeholderLayoutPath = path.join(layoutDir, 'voltra_widget_placeholder.xml') - const placeholderLayoutResult = await writeGeneratedTextFile(projectRoot, placeholderLayoutPath, generatePlaceholderLayoutXml()) + const placeholderLayoutResult = await writeGeneratedTextFile( + projectRoot, + placeholderLayoutPath, + generatePlaceholderLayoutXml() + ) pushChange(changes, placeholderLayoutResult.change) generatedFiles.add(placeholderLayoutResult.relativePath) - const previewLayoutMap = await generatePreviewLayouts(projectRoot, layoutDir, widgets, warnings, changes, generatedFiles) + const previewLayoutMap = await generatePreviewLayouts( + projectRoot, + layoutDir, + widgets, + warnings, + changes, + generatedFiles + ) for (const widget of widgets) { const widgetInfoPath = path.join(xmlDir, `voltra_widget_${widget.id}_info.xml`) @@ -190,7 +209,9 @@ async function generatePreviewLayouts( const sourceExists = await pathExists(widget.previewLayout) if (!sourceExists) { - throw new AndroidGeneratedFilesError(`Preview layout not found for widget '${widget.id}' at ${widget.previewLayout}`) + throw new AndroidGeneratedFilesError( + `Preview layout not found for widget '${widget.id}' at ${widget.previewLayout}` + ) } const content = await readTextFile(widget.previewLayout) @@ -235,7 +256,9 @@ async function generateAndroidAssets( if (!VALID_DRAWABLE_EXTENSIONS.has(extension)) { throw new AndroidGeneratedFilesError( - `Unsupported Android drawable asset '${assetPath}'. Supported extensions: ${[...VALID_DRAWABLE_EXTENSIONS].sort().join(', ')}` + `Unsupported Android drawable asset '${assetPath}'. Supported extensions: ${[...VALID_DRAWABLE_EXTENSIONS] + .sort() + .join(', ')}` ) } @@ -251,7 +274,8 @@ async function generateAndroidAssets( } const destinationPath = path.join(drawableDir, `${resourceName}${extension}`) - const imageWarning = extension === '.xml' ? undefined : await getLargeImageWarning(assetPath, path.basename(assetPath)) + const imageWarning = + extension === '.xml' ? undefined : await getLargeImageWarning(assetPath, path.basename(assetPath)) if (imageWarning) { warnings.push(imageWarning) @@ -268,7 +292,9 @@ async function generateAndroidAssets( } if (!(await pathExists(widget.previewImage))) { - throw new AndroidGeneratedFilesError(`Preview image not found for widget '${widget.id}' at ${widget.previewImage}`) + throw new AndroidGeneratedFilesError( + `Preview image not found for widget '${widget.id}' at ${widget.previewImage}` + ) } const extension = path.extname(widget.previewImage).toLowerCase() @@ -294,7 +320,10 @@ async function generateAndroidAssets( } const destinationPath = path.join(drawableDir, `${resourceName}${extension}`) - const imageWarning = extension === '.xml' ? undefined : await getLargeImageWarning(widget.previewImage, path.basename(widget.previewImage)) + const imageWarning = + extension === '.xml' + ? undefined + : await getLargeImageWarning(widget.previewImage, path.basename(widget.previewImage)) if (imageWarning) { warnings.push(imageWarning) @@ -312,7 +341,11 @@ async function generateAndroidAssets( } } -async function copyAndroidFonts(projectRoot: string, resourceRoot: string, fonts: string[]): Promise { +async function copyAndroidFonts( + projectRoot: string, + resourceRoot: string, + fonts: string[] +): Promise { const changes: ReportedChange[] = [] const generatedFiles = new Set() const warnings: string[] = [] @@ -353,7 +386,11 @@ async function generateAndroidInitialStates( warnings: [], } } - const prerenderedStates = await prerenderWidgetStates(projectRoot, prerenderableWidgets, loadAndroidWidgetRenderer(projectRoot)) + const prerenderedStates = await prerenderWidgetStates( + projectRoot, + prerenderableWidgets, + loadAndroidWidgetRenderer(projectRoot) + ) if (prerenderedStates.size === 0) { return { @@ -467,7 +504,9 @@ function loadAndroidWidgetRenderer(projectRoot: string): AndroidWidgetRenderer { const androidPackage = requirePlatformPackage(projectRoot, 'android') if (typeof androidPackage.renderAndroidWidgetToString !== 'function') { - throw new AndroidGeneratedFilesError('Installed @use-voltra/android package does not export renderAndroidWidgetToString.') + throw new AndroidGeneratedFilesError( + 'Installed @use-voltra/android package does not export renderAndroidWidgetToString.' + ) } return androidPackage.renderAndroidWidgetToString as AndroidWidgetRenderer @@ -609,7 +648,11 @@ async function resolveFontPaths(projectRoot: string, fonts: string[]): Promise { +async function resolveFontInput( + projectRoot: string, + input: string, + projectRequire: NodeRequire +): Promise { const resolvedPath = path.isAbsolute(input) ? input : path.resolve(projectRoot, input) if (await pathExists(resolvedPath)) { @@ -624,7 +667,9 @@ async function resolveFontInput(projectRoot: string, input: string, projectRequi } async function collectUserAssetPaths(projectRoot: string, userImagesPath: string): Promise { - const resolvedUserImagesPath = path.isAbsolute(userImagesPath) ? userImagesPath : path.resolve(projectRoot, userImagesPath) + const resolvedUserImagesPath = path.isAbsolute(userImagesPath) + ? userImagesPath + : path.resolve(projectRoot, userImagesPath) if (!(await pathExists(resolvedUserImagesPath))) { return [] @@ -651,12 +696,20 @@ async function collectPathsRecursively(currentPath: string, collectedPaths: stri } } -async function convertSvgToVectorDrawable(projectRoot: string, sourcePath: string, destinationSvgPath: string): Promise { +async function convertSvgToVectorDrawable( + projectRoot: string, + sourcePath: string, + destinationSvgPath: string +): Promise { await ensureDirectory(path.dirname(destinationSvgPath)) const sourceContent = await fsPromises.readFile(sourcePath) - const existingSvgContent = (await pathExists(destinationSvgPath)) ? await fsPromises.readFile(destinationSvgPath) : undefined + const existingSvgContent = (await pathExists(destinationSvgPath)) + ? await fsPromises.readFile(destinationSvgPath) + : undefined const vectorDrawablePath = destinationSvgPath.replace(/\.svg$/i, '.xml') - const existingVectorDrawableContent = (await pathExists(vectorDrawablePath)) ? await fsPromises.readFile(vectorDrawablePath) : undefined + const existingVectorDrawableContent = (await pathExists(vectorDrawablePath)) + ? await fsPromises.readFile(vectorDrawablePath) + : undefined try { await fsPromises.writeFile(destinationSvgPath, sourceContent) @@ -694,7 +747,11 @@ async function convertSvgToVectorDrawable(projectRoot: string, sourcePath: strin } } -async function copyGeneratedFile(projectRoot: string, sourcePath: string, destinationPath: string): Promise { +async function copyGeneratedFile( + projectRoot: string, + sourcePath: string, + destinationPath: string +): Promise { await ensureDirectory(path.dirname(destinationPath)) const sourceContent = await fsPromises.readFile(sourcePath) const existingContent = (await pathExists(destinationPath)) ? await fsPromises.readFile(destinationPath) : undefined @@ -717,7 +774,11 @@ async function copyGeneratedFile(projectRoot: string, sourcePath: string, destin } } -async function writeGeneratedTextFile(projectRoot: string, destinationPath: string, content: string): Promise { +async function writeGeneratedTextFile( + projectRoot: string, + destinationPath: string, + content: string +): Promise { const existingContent = (await pathExists(destinationPath)) ? await readTextFile(destinationPath) : undefined const relativePath = toRelativePath(projectRoot, destinationPath) @@ -826,19 +887,24 @@ function generateWidgetInfoXml( previewLayoutResourceName?: string ): string { const minWidth = widget.minWidth ?? (widget.minCellWidth !== undefined ? widget.minCellWidth * 70 - 30 : undefined) - const minHeight = widget.minHeight ?? (widget.minCellHeight !== undefined ? widget.minCellHeight * 70 - 30 : undefined) + const minHeight = + widget.minHeight ?? (widget.minCellHeight !== undefined ? widget.minCellHeight * 70 - 30 : undefined) const resizeMode = widget.resizeMode ?? 'horizontal|vertical' const widgetCategory = widget.widgetCategory ?? 'home_screen' const lines = [ '', - ``, + ` android:description="@string/voltra_widget_${widget.id}_description"${ + previewImageResourceName ? ` android:previewImage="@drawable/${previewImageResourceName}"` : '' + }${previewLayoutResourceName ? ` android:previewLayout="@layout/${previewLayoutResourceName}"` : ''}>`, '', '', ] @@ -883,7 +949,9 @@ function generateAutoImagePreviewLayout(widgetId: string, drawableResourceName: function generateWidgetStringsXml(widgets: NormalizedAndroidWidgetConfig[], localeKey: string | null): string { const localeComment = - localeKey === null ? 'default (values/)' : `locale ${localeKey} → values-${localeKeyToAndroidValuesQualifier(localeKey)}` + localeKey === null + ? 'default (values/)' + : `locale ${localeKey} → values-${localeKeyToAndroidValuesQualifier(localeKey)}` const entries = widgets .map((widget) => { const label = escapeAndroidString(resolveWidgetLabel(widget.displayName, localeKey)) @@ -892,7 +960,14 @@ function generateWidgetStringsXml(widgets: NormalizedAndroidWidgetConfig[], loca }) .join('\n') - return ['', '', ` `, entries, '', ''].join('\n') + return [ + '', + '', + ` `, + entries, + '', + '', + ].join('\n') } function collectWidgetLocaleKeys(widgets: NormalizedAndroidWidgetConfig[]): Set { @@ -1014,13 +1089,18 @@ function sanitizeDrawableName(filePath: string): string { nameParts.push( ...directoryName .split(path.sep) - .filter((segment) => segment !== '.' && segment !== 'assets' && segment !== 'voltra' && segment !== 'voltra-android') + .filter( + (segment) => segment !== '.' && segment !== 'assets' && segment !== 'voltra' && segment !== 'voltra-android' + ) ) } nameParts.push(fileName) - let sanitizedName = nameParts.join('_').toLowerCase().replace(/[^a-z0-9_]/g, '_') + let sanitizedName = nameParts + .join('_') + .toLowerCase() + .replace(/[^a-z0-9_]/g, '_') if (!/^[a-z]/.test(sanitizedName)) { sanitizedName = `img_${sanitizedName}` diff --git a/packages/cli/src/platforms/android/manifest.ts b/packages/cli/src/platforms/android/manifest.ts index 232a1a03..16292bcd 100644 --- a/packages/cli/src/platforms/android/manifest.ts +++ b/packages/cli/src/platforms/android/manifest.ts @@ -81,7 +81,9 @@ export class AndroidManifestMutationError extends VoltraCliError { } } -export async function ensureAndroidManifest(options: EnsureAndroidManifestOptions): Promise { +export async function ensureAndroidManifest( + options: EnsureAndroidManifestOptions +): Promise { const { projectRoot, android, discovery } = options const manifestPath = discovery.manifestPath const previousContent = await readTextFile(manifestPath) @@ -102,7 +104,10 @@ export async function ensureAndroidManifest(options: EnsureAndroidManifestOption application.receiver = receivers reconcileNotificationReceiver(receivers, android.enableNotifications) - removeStaleWidgetReceivers(receivers, android.widgets.map((widget) => widget.id)) + removeStaleWidgetReceivers( + receivers, + android.widgets.map((widget) => widget.id) + ) if (android.enableNotifications) { ensureNotificationReceiver(receivers) @@ -133,7 +138,9 @@ async function parseAndroidManifest(content: string, manifestPath: string): Prom return (await parseStringPromise(content)) as AndroidManifestDocument } catch (error: unknown) { throw new AndroidManifestMutationError( - `Failed to parse AndroidManifest.xml at ${manifestPath}: ${error instanceof Error ? error.message : String(error)}` + `Failed to parse AndroidManifest.xml at ${manifestPath}: ${ + error instanceof Error ? error.message : String(error) + }` ) } } @@ -142,7 +149,9 @@ function getMainApplication(manifest: AndroidManifestRoot, manifestPath: string) const applications = manifest.application ?? [] if (applications.length === 0) { - throw new AndroidManifestMutationError(`Android manifest does not contain an element: ${manifestPath}`) + throw new AndroidManifestMutationError( + `Android manifest does not contain an element: ${manifestPath}` + ) } if (applications.length > 1) { @@ -232,7 +241,11 @@ function removeStaleWidgetReceivers(receivers: AndroidManifestReceiver[], widget removeEntries(receivers, (receiver) => { const receiverName = receiver.$?.['android:name'] - if (!receiverName || !receiverName.startsWith(WIDGET_RECEIVER_NAME_PREFIX) || !receiverName.endsWith(WIDGET_RECEIVER_NAME_SUFFIX)) { + if ( + !receiverName || + !receiverName.startsWith(WIDGET_RECEIVER_NAME_PREFIX) || + !receiverName.endsWith(WIDGET_RECEIVER_NAME_SUFFIX) + ) { return false } @@ -278,7 +291,9 @@ function ensureReceiverMetadata(receiver: AndroidManifestReceiver, metadataResou const metadataEntries = receiver['meta-data'] ?? [] receiver['meta-data'] = metadataEntries - const providerMetadata = metadataEntries.find((metadata) => metadata.$?.['android:name'] === APPWIDGET_PROVIDER_METADATA) + const providerMetadata = metadataEntries.find( + (metadata) => metadata.$?.['android:name'] === APPWIDGET_PROVIDER_METADATA + ) if (providerMetadata) { providerMetadata.$ = { @@ -298,12 +313,17 @@ function ensureReceiverMetadata(receiver: AndroidManifestReceiver, metadataResou }) } -function findReceiverByName(receivers: AndroidManifestReceiver[], receiverName: string): AndroidManifestReceiver | undefined { +function findReceiverByName( + receivers: AndroidManifestReceiver[], + receiverName: string +): AndroidManifestReceiver | undefined { return receivers.find((receiver) => receiver.$?.['android:name'] === receiverName) } function getReceiverMetadataResource(receiver: AndroidManifestReceiver): string | undefined { - return receiver['meta-data']?.find((metadata) => metadata.$?.['android:name'] === APPWIDGET_PROVIDER_METADATA)?.$?.['android:resource'] + return receiver['meta-data']?.find((metadata) => metadata.$?.['android:name'] === APPWIDGET_PROVIDER_METADATA)?.$?.[ + 'android:resource' + ] } function isWidgetMetadataResource(resource: string): boolean { diff --git a/packages/cli/src/platforms/ios/apply.ts b/packages/cli/src/platforms/ios/apply.ts index 91635cce..d840d544 100644 --- a/packages/cli/src/platforms/ios/apply.ts +++ b/packages/cli/src/platforms/ios/apply.ts @@ -90,7 +90,12 @@ export async function applyIOSPlatform(context: PlatformApplyContext): Promise

- return [candidate.iosRoot, candidate.xcodeprojPath, candidate.pbxprojPath, candidate.podfilePath, candidate.mainTargetName, candidate.infoPlistPath].every( - (entry) => typeof entry === 'string' && entry.length > 0 - ) && Array.isArray(candidate.mainTargetCandidates) + return ( + [ + candidate.iosRoot, + candidate.xcodeprojPath, + candidate.pbxprojPath, + candidate.podfilePath, + candidate.mainTargetName, + candidate.infoPlistPath, + ].every((entry) => typeof entry === 'string' && entry.length > 0) && Array.isArray(candidate.mainTargetCandidates) + ) } function isDefined(value: TValue | undefined): value is TValue { diff --git a/packages/cli/src/platforms/ios/entitlements.ts b/packages/cli/src/platforms/ios/entitlements.ts index 0cb311a9..b465ddd9 100644 --- a/packages/cli/src/platforms/ios/entitlements.ts +++ b/packages/cli/src/platforms/ios/entitlements.ts @@ -61,7 +61,11 @@ export async function ensureEntitlements(options: EnsureEntitlementsOptions): Pr if (ios.enablePushNotifications && entitlements['aps-environment'] === undefined) { entitlements['aps-environment'] = 'development' - } else if (!ios.enablePushNotifications && previousVoltraValues.pushNotificationsEnabled && entitlements['aps-environment'] === 'development') { + } else if ( + !ios.enablePushNotifications && + previousVoltraValues.pushNotificationsEnabled && + entitlements['aps-environment'] === 'development' + ) { delete entitlements['aps-environment'] } @@ -81,9 +85,13 @@ function ensureStringArrayValue( nextValue: string | undefined, previousOwnedValue: string | undefined ): void { - const existingValues = Array.isArray(target[key]) ? target[key].filter((value): value is string => typeof value === 'string' && value.length > 0) : [] + const existingValues = Array.isArray(target[key]) + ? target[key].filter((value): value is string => typeof value === 'string' && value.length > 0) + : [] const dedupedValues = Array.from(new Set(existingValues)) - const filteredValues = previousOwnedValue ? dedupedValues.filter((value) => value !== previousOwnedValue) : dedupedValues + const filteredValues = previousOwnedValue + ? dedupedValues.filter((value) => value !== previousOwnedValue) + : dedupedValues if (nextValue === undefined) { if (filteredValues.length === 0) { diff --git a/packages/cli/src/platforms/ios/generated.ts b/packages/cli/src/platforms/ios/generated.ts index 479976ca..4157444e 100644 --- a/packages/cli/src/platforms/ios/generated.ts +++ b/packages/cli/src/platforms/ios/generated.ts @@ -14,7 +14,12 @@ import { buildPlistXml, parsePlistFile } from './plist' import { resolveIOSWidgetTargetName } from './targetName' import type { IOSProjectDiscovery } from '../../discovery/ios' -import type { IOSWidgetFamily, NormalizedIOSWidgetConfig, NormalizedVoltraIOSConfig, WidgetLabel } from '../../config/types' +import type { + IOSWidgetFamily, + NormalizedIOSWidgetConfig, + NormalizedVoltraIOSConfig, + WidgetLabel, +} from '../../config/types' import type { ReportedChange } from '../../reporting/summary' const MODULE_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', ''] @@ -159,7 +164,11 @@ export async function generateIOSFiles(options: GenerateIOSFilesOptions): Promis const initialStatesResult = await generateInitialStatesSwift(projectRoot, ios.widgets) mergeSingleResult( - await writeGeneratedTextFile(projectRoot, path.join(targetPath, 'VoltraWidgetInitialStates.swift'), initialStatesResult), + await writeGeneratedTextFile( + projectRoot, + path.join(targetPath, 'VoltraWidgetInitialStates.swift'), + initialStatesResult + ), changes, generatedFiles ) @@ -194,37 +203,44 @@ async function generateInfoPlistFile( const fontNames = ios.fonts.map((fontPath) => path.basename(fontPath)).sort() const serverWidgets = ios.widgets.filter((widget) => widget.serverUpdate) const serverUrls = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.url])) - const serverIntervals = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.intervalMinutes])) - const serverRefresh = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.refresh ?? false])) - const infoPlist = buildPlistXml({ - CFBundleDevelopmentRegion: '$(DEVELOPMENT_LANGUAGE)', - CFBundleDisplayName: targetName, - CFBundleExecutable: '$(EXECUTABLE_NAME)', - CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)', - CFBundleInfoDictionaryVersion: '6.0', - CFBundleName: '$(PRODUCT_NAME)', - CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)', - CFBundleShortVersionString: mainAppMetadata.shortVersionString, - CFBundleVersion: mainAppMetadata.buildNumber, - NSExtension: { - NSExtensionPointIdentifier: 'com.apple.widgetkit-extension', + const serverIntervals = Object.fromEntries( + serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.intervalMinutes]) + ) + const serverRefresh = Object.fromEntries( + serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.refresh ?? false]) + ) + const infoPlist = buildPlistXml( + { + CFBundleDevelopmentRegion: '$(DEVELOPMENT_LANGUAGE)', + CFBundleDisplayName: targetName, + CFBundleExecutable: '$(EXECUTABLE_NAME)', + CFBundleIdentifier: '$(PRODUCT_BUNDLE_IDENTIFIER)', + CFBundleInfoDictionaryVersion: '6.0', + CFBundleName: '$(PRODUCT_NAME)', + CFBundlePackageType: '$(PRODUCT_BUNDLE_PACKAGE_TYPE)', + CFBundleShortVersionString: mainAppMetadata.shortVersionString, + CFBundleVersion: mainAppMetadata.buildNumber, + NSExtension: { + NSExtensionPointIdentifier: 'com.apple.widgetkit-extension', + }, + RCTNewArchEnabled: true, + CFBundleURLTypes: mainAppMetadata.urlTypes, + UIAppFonts: fontNames.length > 0 ? fontNames : undefined, + Voltra_AppGroupIdentifier: ios.groupIdentifier, + Voltra_KeychainGroup: ios.keychainGroup, + Voltra_WidgetServerIntervals: Object.keys(serverIntervals).length > 0 ? serverIntervals : undefined, + Voltra_WidgetServerRefresh: Object.keys(serverRefresh).length > 0 ? serverRefresh : undefined, + NSAppTransportSecurity: + serverWidgets.length > 0 + ? { + NSAllowsLocalNetworking: true, + NSAllowsArbitraryLoads: false, + } + : undefined, + Voltra_WidgetServerUrls: Object.keys(serverUrls).length > 0 ? serverUrls : undefined, }, - RCTNewArchEnabled: true, - CFBundleURLTypes: mainAppMetadata.urlTypes, - UIAppFonts: fontNames.length > 0 ? fontNames : undefined, - Voltra_AppGroupIdentifier: ios.groupIdentifier, - Voltra_KeychainGroup: ios.keychainGroup, - Voltra_WidgetServerIntervals: Object.keys(serverIntervals).length > 0 ? serverIntervals : undefined, - Voltra_WidgetServerRefresh: Object.keys(serverRefresh).length > 0 ? serverRefresh : undefined, - NSAppTransportSecurity: - serverWidgets.length > 0 - ? { - NSAllowsLocalNetworking: true, - NSAllowsArbitraryLoads: false, - } - : undefined, - Voltra_WidgetServerUrls: Object.keys(serverUrls).length > 0 ? serverUrls : undefined, - }, createGeneratedFilesError) + createGeneratedFilesError + ) return writeGeneratedTextFile(projectRoot, plistPath, infoPlist) } @@ -236,10 +252,13 @@ async function generateEntitlementsFile( ios: NormalizedVoltraIOSConfig ): Promise { const entitlementsPath = path.join(targetPath, `${targetName}.entitlements`) - const entitlements = buildPlistXml({ - 'com.apple.security.application-groups': ios.groupIdentifier ? [ios.groupIdentifier] : undefined, - 'keychain-access-groups': ios.keychainGroup ? [ios.keychainGroup] : undefined, - }, createGeneratedFilesError) + const entitlements = buildPlistXml( + { + 'com.apple.security.application-groups': ios.groupIdentifier ? [ios.groupIdentifier] : undefined, + 'keychain-access-groups': ios.keychainGroup ? [ios.keychainGroup] : undefined, + }, + createGeneratedFilesError + ) return writeGeneratedTextFile(projectRoot, entitlementsPath, entitlements) } @@ -353,7 +372,11 @@ async function generateLocalizedWidgetStrings( } async function readMainAppMetadata(infoPlistPath: string): Promise { - const dict = await parsePlistFile(infoPlistPath, 'main app Info.plist', (message: string) => new IOSGeneratedFilesError(message)) + const dict = await parsePlistFile( + infoPlistPath, + 'main app Info.plist', + (message: string) => new IOSGeneratedFilesError(message) + ) const shortVersionString = readPlistString(dict, 'CFBundleShortVersionString') ?? '1.0.0' const buildNumber = readPlistString(dict, 'CFBundleVersion') ?? '1' const urlTypes = readUrlTypes(dict) @@ -388,7 +411,11 @@ async function generateInitialStatesSwift(projectRoot: string, widgets: Normaliz ].join('\n') } - const prerenderedStates = await prerenderWidgetStates(projectRoot, prerenderableWidgets, loadIOSWidgetRenderer(projectRoot)) + const prerenderedStates = await prerenderWidgetStates( + projectRoot, + prerenderableWidgets, + loadIOSWidgetRenderer(projectRoot) + ) const widgetEntries = [...prerenderedStates.entries()] .map(([widgetId, localeMap]) => { const localeEntries = [...localeMap.entries()] @@ -432,8 +459,15 @@ async function generateInitialStatesSwift(projectRoot: string, widgets: Normaliz } function generateWidgetBundleSwift(widgets: NormalizedIOSWidgetConfig[]): string { - const needsFoundation = widgets.some((widget) => isWidgetLocalizedMap(widget.displayName) || isWidgetLocalizedMap(widget.description)) - const imports = [needsFoundation ? 'import Foundation' : undefined, 'import SwiftUI', 'import WidgetKit', 'import VoltraWidget'] + const needsFoundation = widgets.some( + (widget) => isWidgetLocalizedMap(widget.displayName) || isWidgetLocalizedMap(widget.description) + ) + const imports = [ + needsFoundation ? 'import Foundation' : undefined, + 'import SwiftUI', + 'import WidgetKit', + 'import VoltraWidget', + ] .filter((value): value is string => value !== undefined) .join('\n') @@ -512,7 +546,11 @@ function generateWidgetStruct(widget: NormalizedIOSWidgetConfig): string { ].join('\n') } -function createSwiftLabelExpression(widgetId: string, field: 'displayName' | 'description', label: WidgetLabel): string { +function createSwiftLabelExpression( + widgetId: string, + field: 'displayName' | 'description', + label: WidgetLabel +): string { if (!isWidgetLocalizedMap(label)) { return `Text(${JSON.stringify(label)})` } @@ -520,7 +558,9 @@ function createSwiftLabelExpression(widgetId: string, field: 'displayName' | 'de const key = `voltra_widget_${widgetId}_${field}` const defaultEnglish = escapeSwiftString(widgetLabelEnglish(label)) - return `Text(LocalizedStringResource(${JSON.stringify(key)}, defaultValue: String.LocalizationValue(${JSON.stringify(defaultEnglish)}), table: ${JSON.stringify('VoltraWidgets')}))` + return `Text(LocalizedStringResource(${JSON.stringify(key)}, defaultValue: String.LocalizationValue(${JSON.stringify( + defaultEnglish + )}), table: ${JSON.stringify('VoltraWidgets')}))` } async function prerenderWidgetStates( @@ -537,7 +577,8 @@ async function prerenderWidgetStates( continue } - const perLocalePaths = typeof initialStatePath === 'string' ? { [DEFAULT_INITIAL_STATE_LOCALE]: initialStatePath } : initialStatePath + const perLocalePaths = + typeof initialStatePath === 'string' ? { [DEFAULT_INITIAL_STATE_LOCALE]: initialStatePath } : initialStatePath const localeStates = new Map() for (const [localeKey, modulePath] of Object.entries(perLocalePaths)) { @@ -724,7 +765,11 @@ async function resolveFontPaths(projectRoot: string, fonts: string[]): Promise { +async function resolveFontInput( + projectRoot: string, + input: string, + projectRequire: NodeRequire +): Promise { const resolvedPath = path.isAbsolute(input) ? input : path.resolve(projectRoot, input) if (await pathExists(resolvedPath)) { return resolvedPath @@ -762,7 +807,11 @@ async function collectPathsRecursively(currentPath: string, collectedPaths: stri } } -async function copyGeneratedFile(projectRoot: string, sourcePath: string, destinationPath: string): Promise { +async function copyGeneratedFile( + projectRoot: string, + sourcePath: string, + destinationPath: string +): Promise { await ensureDirectory(path.dirname(destinationPath)) const sourceContent = await fsPromises.readFile(sourcePath) const existingContent = (await pathExists(destinationPath)) ? await fsPromises.readFile(destinationPath) : undefined @@ -783,7 +832,11 @@ async function copyGeneratedFile(projectRoot: string, sourcePath: string, destin } } -async function writeGeneratedTextFile(projectRoot: string, destinationPath: string, content: string): Promise { +async function writeGeneratedTextFile( + projectRoot: string, + destinationPath: string, + content: string +): Promise { const existingContent = (await pathExists(destinationPath)) ? await readTextFile(destinationPath) : undefined const relativePath = toRelativePath(projectRoot, destinationPath) @@ -919,7 +972,9 @@ function readUrlTypes(dict: Record): Array<{ CFBundleURLSchemes return undefined } - const schemes = schemesValue.filter((scheme): scheme is string => typeof scheme === 'string' && scheme.trim().length > 0) + const schemes = schemesValue.filter( + (scheme): scheme is string => typeof scheme === 'string' && scheme.trim().length > 0 + ) return schemes.length > 0 ? { CFBundleURLSchemes: schemes } : undefined }) diff --git a/packages/cli/src/platforms/ios/plist.ts b/packages/cli/src/platforms/ios/plist.ts index adf8d2e7..85c9cbd5 100644 --- a/packages/cli/src/platforms/ios/plist.ts +++ b/packages/cli/src/platforms/ios/plist.ts @@ -40,11 +40,7 @@ export class IOSInfoPlistMutationError extends VoltraCliError { export async function ensureInfoPlist(options: EnsureInfoPlistOptions): Promise { const { projectRoot, ios, discovery } = options - const infoPlist = await parsePlistFile( - discovery.infoPlistPath, - 'main app Info.plist', - createInfoPlistError - ) + const infoPlist = await parsePlistFile(discovery.infoPlistPath, 'main app Info.plist', createInfoPlistError) infoPlist.NSSupportsLiveActivities = true infoPlist.NSSupportsLiveActivitiesFrequentUpdates = false @@ -58,9 +54,15 @@ export async function ensureInfoPlist(options: EnsureInfoPlistOptions): Promise< const serverWidgets = ios.widgets.filter((widget) => widget.serverUpdate) const serverUrls = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.url])) - const serverIntervals = Object.fromEntries(serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.intervalMinutes])) + const serverIntervals = Object.fromEntries( + serverWidgets.map((widget) => [widget.id, widget.serverUpdate?.intervalMinutes]) + ) - setOrDeleteVoltraKey(infoPlist, 'Voltra_WidgetServerUrls', Object.keys(serverUrls).length > 0 ? serverUrls : undefined) + setOrDeleteVoltraKey( + infoPlist, + 'Voltra_WidgetServerUrls', + Object.keys(serverUrls).length > 0 ? serverUrls : undefined + ) setOrDeleteVoltraKey( infoPlist, 'Voltra_WidgetServerIntervals', @@ -195,7 +197,9 @@ function parsePlistValue( const numericValue = Number(rawValue) if (!Number.isFinite(numericValue)) { - throw createError(`Parsed ${errorContext} at ${filePath} contains an invalid ${node['#name']} value '${rawValue}'.`) + throw createError( + `Parsed ${errorContext} at ${filePath} contains an invalid ${node['#name']} value '${rawValue}'.` + ) } return numericValue @@ -232,7 +236,9 @@ function renderPlistValue(value: unknown, indentLevel: number, createError: Plis } if (isTaggedPlistScalar(value)) { - return `${indent}<${value.__voltraPlistScalarType}>${escapePlistText(value.value)}` + return `${indent}<${value.__voltraPlistScalarType}>${escapePlistText(value.value)}` } if (value && typeof value === 'object') { @@ -303,10 +309,7 @@ async function writePlistIfChanged( } function escapePlistText(value: string): string { - return value - .replace(/&/g, '&') - .replace(//g, '>') + return value.replace(/&/g, '&').replace(//g, '>') } function getErrorMessage(error: unknown): string { diff --git a/packages/cli/src/platforms/ios/podfile.ts b/packages/cli/src/platforms/ios/podfile.ts index a486a1bf..85791244 100644 --- a/packages/cli/src/platforms/ios/podfile.ts +++ b/packages/cli/src/platforms/ios/podfile.ts @@ -137,7 +137,10 @@ function escapeRegExp(value: string): string { } function hasUnmanagedTargetBlock(content: string, targetName: string): boolean { - const targetPattern = new RegExp(`(^|\\n)target '${escapeRegExp(escapeRubySingleQuotedString(targetName))}' do(\\n|$)`, 'm') + const targetPattern = new RegExp( + `(^|\\n)target '${escapeRegExp(escapeRubySingleQuotedString(targetName))}' do(\\n|$)`, + 'm' + ) return targetPattern.test(content) } diff --git a/packages/cli/src/platforms/ios/xcode.ts b/packages/cli/src/platforms/ios/xcode.ts index 20f26314..132c9c77 100644 --- a/packages/cli/src/platforms/ios/xcode.ts +++ b/packages/cli/src/platforms/ios/xcode.ts @@ -4,7 +4,14 @@ import { PBXNativeTarget, XcodeProject } from '@bacons/xcode' import { VoltraCliError } from '../../reporting/summary' -import type { PBXCopyFilesBuildPhase, PBXFrameworksBuildPhase, PBXGroup, PBXResourcesBuildPhase, PBXSourcesBuildPhase, XCBuildConfiguration } from '@bacons/xcode' +import type { + PBXCopyFilesBuildPhase, + PBXFrameworksBuildPhase, + PBXGroup, + PBXResourcesBuildPhase, + PBXSourcesBuildPhase, + XCBuildConfiguration, +} from '@bacons/xcode' import type { IOSProjectDiscovery } from '../../discovery/ios' const IOS_APP_PRODUCT_TYPE = 'com.apple.product-type.application' @@ -128,7 +135,9 @@ function resolveMainAppTarget(project: XcodeProject, discovery: IOSProjectDiscov if (!target) { throw new IOSXcodeProjectError( - `Xcode project does not contain the discovered main app target '${discovery.mainTargetName}'. Available application targets: ${applicationTargets + `Xcode project does not contain the discovered main app target '${ + discovery.mainTargetName + }'. Available application targets: ${applicationTargets .map((candidate) => candidate.props.name) .sort() .join(', ')}` @@ -156,7 +165,9 @@ function getExistingProductGroup(project: XcodeProject, discovery: IOSProjectDis } function getExistingFrameworksGroup(project: XcodeProject, discovery: IOSProjectDiscovery): PBXGroup { - const frameworksGroup = project.rootObject.props.mainGroup?.getChildGroups().find((group) => group.getDisplayName() === 'Frameworks') + const frameworksGroup = project.rootObject.props.mainGroup + ?.getChildGroups() + .find((group) => group.getDisplayName() === 'Frameworks') if (!frameworksGroup) { throw new IOSXcodeProjectError(`Xcode project is missing the Frameworks group: ${discovery.pbxprojPath}`) diff --git a/packages/cli/src/platforms/ios/xcodeTarget.ts b/packages/cli/src/platforms/ios/xcodeTarget.ts index a146f573..1f22c651 100644 --- a/packages/cli/src/platforms/ios/xcodeTarget.ts +++ b/packages/cli/src/platforms/ios/xcodeTarget.ts @@ -52,7 +52,9 @@ export class IOSWidgetTargetMutationError extends VoltraCliError { } } -export async function ensureIOSWidgetTarget(options: EnsureIOSWidgetTargetOptions): Promise { +export async function ensureIOSWidgetTarget( + options: EnsureIOSWidgetTargetOptions +): Promise { const { projectRoot, ios, discovery, generatedFiles, previousGeneratedFiles } = options const targetName = resolveIOSWidgetTargetName(ios, discovery) const context = openIOSXcodeProject(discovery) @@ -113,7 +115,13 @@ function ensureWidgetTarget( return existingTarget } - const buildConfigurationList = createBuildConfigurationList(context, targetName, bundleIdentifier, deploymentTarget, codeSigning) + const buildConfigurationList = createBuildConfigurationList( + context, + targetName, + bundleIdentifier, + deploymentTarget, + codeSigning + ) const target = context.project.rootObject.createNativeTarget({ buildConfigurationList, name: targetName, @@ -137,7 +145,13 @@ function createBuildConfigurationList( const configs = context.mainAppTarget.buildConfigurations.all.map((config) => { return XCBuildConfiguration.create(context.project, { name: config.props.name, - buildSettings: buildWidgetBuildSettings(targetName, bundleIdentifier, deploymentTarget, codeSigning, config.props.name), + buildSettings: buildWidgetBuildSettings( + targetName, + bundleIdentifier, + deploymentTarget, + codeSigning, + config.props.name + ), }) }) @@ -157,11 +171,16 @@ function ensureBuildConfigurations( const configurationList = target.props.buildConfigurationList if (!configurationList) { - throw new IOSWidgetTargetMutationError(`Widget target '${target.props.name}' is missing a build configuration list.`) + throw new IOSWidgetTargetMutationError( + `Widget target '${target.props.name}' is missing a build configuration list.` + ) } for (const config of configurationList.props.buildConfigurations) { - Object.assign(config.props.buildSettings, buildWidgetBuildSettings(targetName, bundleIdentifier, deploymentTarget, codeSigning, config.props.name)) + Object.assign( + config.props.buildSettings, + buildWidgetBuildSettings(targetName, bundleIdentifier, deploymentTarget, codeSigning, config.props.name) + ) } } @@ -204,7 +223,9 @@ function getWidgetTarget(context: IOSXcodeProjectContext, targetName: string): P const target = getWidgetTargetOptional(context, targetName) if (!target) { - throw new IOSWidgetTargetMutationError(`Xcode project does not contain widget target '${targetName}' after mutation.`) + throw new IOSWidgetTargetMutationError( + `Xcode project does not contain widget target '${targetName}' after mutation.` + ) } return target @@ -212,7 +233,11 @@ function getWidgetTarget(context: IOSXcodeProjectContext, targetName: string): P function getWidgetTargetOptional(context: IOSXcodeProjectContext, targetName: string): PBXNativeTarget | undefined { return context.project.rootObject.props.targets.find((target): target is PBXNativeTarget => { - return PBXNativeTarget.is(target) && target.props.name === targetName && target.props.productType === IOS_APP_EXTENSION_PRODUCT_TYPE + return ( + PBXNativeTarget.is(target) && + target.props.name === targetName && + target.props.productType === IOS_APP_EXTENSION_PRODUCT_TYPE + ) }) } @@ -228,7 +253,11 @@ function ensureWidgetGroup(context: IOSXcodeProjectContext, targetName: string): function ensureProductFile(context: IOSXcodeProjectContext, targetName: string, productPath: string): PBXFileReference { const existingProduct = [...context.project.values()].find((object): object is PBXFileReference => { - return PBXFileReference.is(object) && stripQuotes(object.props.path) === productPath && object.props.sourceTree === 'BUILT_PRODUCTS_DIR' + return ( + PBXFileReference.is(object) && + stripQuotes(object.props.path) === productPath && + object.props.sourceTree === 'BUILT_PRODUCTS_DIR' + ) }) if (existingProduct) { @@ -394,7 +423,11 @@ function removeEmptyWidgetGroups(context: IOSXcodeProjectContext, staleTargetNam } function removeFileReferenceFromTargetBuildPhases(target: PBXNativeTarget, reference: PBXFileReference): void { - for (const phase of [target.getSourcesBuildPhase(), target.getResourcesBuildPhase(), target.getFrameworksBuildPhase()]) { + for (const phase of [ + target.getSourcesBuildPhase(), + target.getResourcesBuildPhase(), + target.getFrameworksBuildPhase(), + ]) { removeBuildPhaseReference(phase, reference) } } @@ -506,8 +539,12 @@ function getBuildPhaseReferencePath(relativePath: string): string { } function getStaleReferencePaths(previousGeneratedFiles: string[], generatedFiles: string[]): Set { - const currentReferencePaths = new Set(generatedFiles.flatMap((file) => [getBuildPhaseReferencePath(file), getGroupReferencePath(file)])) - const previousReferencePaths = new Set(previousGeneratedFiles.flatMap((file) => [getBuildPhaseReferencePath(file), getGroupReferencePath(file)])) + const currentReferencePaths = new Set( + generatedFiles.flatMap((file) => [getBuildPhaseReferencePath(file), getGroupReferencePath(file)]) + ) + const previousReferencePaths = new Set( + previousGeneratedFiles.flatMap((file) => [getBuildPhaseReferencePath(file), getGroupReferencePath(file)]) + ) return new Set([...previousReferencePaths].filter((referencePath) => !currentReferencePaths.has(referencePath))) } @@ -569,7 +606,9 @@ function getGroupSpecificity(group: PBXGroup): number { function sanitizeWidgetGroupChildren(widgetGroup: PBXGroup): void { const staleChildren = widgetGroup.props.children.filter((child) => { - const identifier = stripQuotes('path' in child && typeof child.props.path === 'string' ? child.props.path : child.getDisplayName()) + const identifier = stripQuotes( + 'path' in child && typeof child.props.path === 'string' ? child.props.path : child.getDisplayName() + ) if (identifier.endsWith('.imageset')) { return true @@ -639,11 +678,17 @@ function getWidgetTargetNameFromGeneratedPath(relativePath: string): string | un return typeof targetName === 'string' && targetName.length > 0 ? targetName : undefined } -function normalizeGeneratedFilePaths(generatedFiles: string[], projectRoot: string, discovery: IOSProjectDiscovery): string[] { +function normalizeGeneratedFilePaths( + generatedFiles: string[], + projectRoot: string, + discovery: IOSProjectDiscovery +): string[] { const iosRootRelativePath = normalizeRelativePath(path.relative(projectRoot, discovery.iosRoot)) const iosRootRelativePrefix = iosRootRelativePath === '.' ? '' : `${iosRootRelativePath}/` - return [...new Set(generatedFiles.map((file) => toIOSProjectRelativePath(file, iosRootRelativePrefix)).filter(isDefined))].sort() + return [ + ...new Set(generatedFiles.map((file) => toIOSProjectRelativePath(file, iosRootRelativePrefix)).filter(isDefined)), + ].sort() } function toIOSProjectRelativePath(relativeFilePath: string, iosRootRelativePrefix: string): string | undefined { @@ -660,8 +705,13 @@ function toIOSProjectRelativePath(relativeFilePath: string, iosRootRelativePrefi return undefined } -function resolveBundleIdentifier(context: IOSXcodeProjectContext, discovery: IOSProjectDiscovery, targetName: string): string { - const mainTargetBundleIdentifier = context.mainAppTarget.buildConfigurations.default.resolveBuildSetting('PRODUCT_BUNDLE_IDENTIFIER') +function resolveBundleIdentifier( + context: IOSXcodeProjectContext, + discovery: IOSProjectDiscovery, + targetName: string +): string { + const mainTargetBundleIdentifier = + context.mainAppTarget.buildConfigurations.default.resolveBuildSetting('PRODUCT_BUNDLE_IDENTIFIER') if (typeof mainTargetBundleIdentifier !== 'string' || mainTargetBundleIdentifier.length === 0) { throw new IOSWidgetTargetMutationError( @@ -683,7 +733,9 @@ function getMainAppCodeSigningSettings(context: IOSXcodeProjectContext): MainApp return { codeSignStyle: readBuildSettingString(buildSettings.CODE_SIGN_STYLE), developmentTeam: readBuildSettingString(buildSettings.DEVELOPMENT_TEAM), - provisioningProfileSpecifier: readBuildSettingString((buildSettings as unknown as { PROVISIONING_PROFILE_SPECIFIER?: unknown }).PROVISIONING_PROFILE_SPECIFIER), + provisioningProfileSpecifier: readBuildSettingString( + (buildSettings as unknown as { PROVISIONING_PROFILE_SPECIFIER?: unknown }).PROVISIONING_PROFILE_SPECIFIER + ), } } diff --git a/packages/cli/src/state/load.ts b/packages/cli/src/state/load.ts index d2ff85f6..49d72ba9 100644 --- a/packages/cli/src/state/load.ts +++ b/packages/cli/src/state/load.ts @@ -49,7 +49,9 @@ function validateVoltraState(rawState: RawVoltraState, statePath: string): Voltr if (rawState.schemaVersion !== STATE_SCHEMA_VERSION) { throw new VoltraCliError( - `Unsupported Voltra state schema at ${statePath}: expected ${STATE_SCHEMA_VERSION}, received ${String(rawState.schemaVersion)}.` + `Unsupported Voltra state schema at ${statePath}: expected ${STATE_SCHEMA_VERSION}, received ${String( + rawState.schemaVersion + )}.` ) } From 8ea072efb5e3ccefe0249f75e483dc840e553afe Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 11:16:46 +0200 Subject: [PATCH 35/37] chore: remove PLAN.md --- PLAN.md | 964 -------------------------------------------------------- 1 file changed, 964 deletions(-) delete mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 778317f9..00000000 --- a/PLAN.md +++ /dev/null @@ -1,964 +0,0 @@ -## Voltra CLI Plan - -### Goal - -Ship a new published package `voltra` that applies Voltra native integration to standard non-Expo React Native projects. - -V1 should do one thing well: `voltra apply`. - -It should: -- load Voltra config -- discover the native project -- generate Voltra-owned files -- mutate required native project files -- clean up stale generated files from previous runs - -It should not try to solve everything else yet. - -### Scope Cuts - -These are intentional simplifications for v1: -- no large shared-core refactor first -- no reuse of Expo mod wrappers -- no generic mutation framework -- no plan/diff mode -- no rollback system -- no rich `.voltra/state.json` metadata -- no broad support beyond standard React Native layouts -- no attempt to undo shared-file mutations from history -- no test implementation yet - -### Core Decisions - -- package name: `voltra` -- binary name: `voltra` -- publish as a public npm package from day one -- use `cosmiconfig` to load config from multiple supported file formats -- `projectRoot` defaults to the config file directory -- `projectRoot` can be overridden in config -- all relative paths resolve from `projectRoot` -- default behavior should follow Expo plugin defaults unless config overrides it -- if git worktree is dirty, warn and ask before continuing -- if running non-interactively, fail on dirty worktree unless explicitly bypassed -- do full preflight before the first write -- reuse only pure helpers and generators where practical -- duplicate CLI-native mutation code where that keeps the package simpler and more independent - -### Config - -Use `cosmiconfig`. - -Support common config locations from day one: -- `package.json` under `voltra` -- `.voltrarc` -- `.voltrarc.json` -- `.voltrarc.yaml` -- `.voltrarc.yml` -- `.voltrarc.js` -- `.voltrarc.cjs` -- `.voltrarc.mjs` -- `.voltrarc.ts` -- `voltra.config.json` -- `voltra.config.yaml` -- `voltra.config.yml` -- `voltra.config.js` -- `voltra.config.cjs` -- `voltra.config.mjs` -- `voltra.config.ts` - -Rules: -- `configDir` is the directory containing the loaded config file -- `projectRoot` defaults to `configDir` -- `projectRoot` can be overridden in config -- all relative widget, asset, preview, and font paths resolve from `projectRoot` -- config shape should stay close to current Expo plugin props, with extra fields for native project discovery overrides - -### Command Surface - -V1 public interface: -- `voltra apply` -- optional `--platform ios|android` -- optional `--config ` - -No other public commands are required in v1. - -### Internal Apply Pipeline - -Even with one public command, keep the internals split into clear stages: - -1. load config -2. normalize config -3. detect git state -4. if dirty: - - interactive mode: warn and ask for confirmation - - non-interactive mode: fail unless explicitly bypassed -5. discover all required native project files for requested platforms -6. parse and preflight all of them -7. abort before writes if any required artifact is missing or ambiguous -8. load previous Voltra state -9. compute stale generated files -10. apply generated file writes and shared-file mutations -11. delete stale generated files -12. write new Voltra state only after success -13. print summary of changed files and warnings - -Important safety rule: -- no writes before all targeted platforms finish discovery and preflight - -### State Tracking - -Store CLI state in `${projectRoot}/.voltra/state.json`. - -Keep it minimal: - -```json -{ - "schemaVersion": 1, - "files": [ - "ios/MyWidget/Info.plist", - "ios/MyWidget/VoltraWidgetBundle.swift", - "android/app/src/main/res/xml/voltra_widget_score_info.xml" - ] -} -``` - -Rules: -- store only relative file paths -- track only fully generated Voltra-owned files -- load previous file list -- compute new file list -- remove stale files from `previous.files - next.files` -- write new state only after a successful run - -Do not store: -- file kind -- owning widget ID -- feature name -- file hashes -- directories -- Xcode UUIDs - -Do not use state to revert shared-file mutations in: -- `AndroidManifest.xml` -- `Info.plist` -- entitlements -- `Podfile` -- `project.pbxproj` - -Those files should always be reconciled from current desired config. - -### Reuse Strategy - -Reuse only the parts that are already simple and low-risk: -- validation helpers -- prerendering -- locale helpers -- font resolution -- pure generators for Android and iOS generated files -- small pure helper functions that compute plist or entitlement values if useful - -Do not reuse: -- Expo config plugin wrappers -- Expo mod orchestration -- current Xcode mutation implementation as-is - -For native mutation code, duplicating CLI-native logic is acceptable if it keeps the package simpler and avoids coupling the public CLI to Expo-specific internals. - -### Android Plan - -Android should ship first. - -V1 Android scope: -- manifest mutation -- receiver generation -- XML/widget info generation -- layouts -- drawable assets -- preview assets -- fonts -- initial state generation - -Implementation approach: -- reuse current Android generators where practical -- write a CLI-native Android manifest mutator -- use XML parsing instead of raw string replacement - -Discovery should be convention-first with overrides: -- default `android/` -- default app module `app` -- default manifest `android/app/src/main/AndroidManifest.xml` -- allow config overrides for nonstandard layouts - -Android mutation rules: -- ensure permissions by exact name -- ensure receivers by exact class name -- ensure metadata by exact resource reference -- never duplicate -- preserve unrelated manifest content -- write atomically where practical - -### iOS Plan - -iOS should ship second. - -Split iOS into two slices. - -Slice 1: -- widget extension file generation -- main app `Info.plist` mutation -- entitlements mutation -- Podfile mutation - -Slice 2: -- `project.pbxproj` mutation using `@bacons/xcode` - -Implementation approach: -- reuse current iOS generated-file logic where practical -- duplicate or extract small pure helpers for plist keys and entitlements -- use plist parsing/building instead of raw string replacement -- use a Voltra-managed block in Podfile -- port current Xcode behavior into CLI-native code - -Discovery should be convention-first but strict: -- default `ios/` -- discover `.xcodeproj` -- discover main app target -- discover main `Info.plist` -- discover Podfile -- allow explicit overrides when ambiguous - -iOS mutation rules: -- update if present -- insert if missing -- avoid duplicates -- preserve unrelated user content -- write atomically where practical - -### Mutation Style - -No sophisticated abstraction layer is needed in v1. - -Use focused functions per artifact, for example: -- `ensureAndroidManifest` -- `ensureInfoPlist` -- `ensureEntitlements` -- `ensurePodfileBlock` -- `ensureXcodeWidgetTarget` - -Each should: -- read current state -- update existing entries when found -- insert missing entries -- avoid duplicates -- preserve unrelated content - -### Build And Packaging - -`packages/cli` should use a Node CLI build path, not the React Native library packaging flow. - -Needs: -- `bin` entry for `voltra` -- shebang -- Node runtime target -- source maps -- compatibility with public npm publishing - -### Testability Constraint - -We are skipping tests for now, but the code should be structured so tests are easy to add later. - -That means: -- keep parsing, normalization, discovery, mutation planning, and filesystem writes in separate modules -- keep pure logic in small functions where practical -- avoid embedding filesystem reads and writes deep inside transformation logic -- pass filesystem operations through thin wrappers or modules so they can be replaced later in tests -- keep command handlers small and orchestration-focused -- avoid hidden global state - -This does not mean building a heavy abstraction layer. It just means keeping boundaries clean enough that fixture tests can be added later without major rewrites. - -### Recommended Package Shape - -Possible initial structure: - -- `packages/cli/src/bin.ts` -- `packages/cli/src/commands/apply.ts` -- `packages/cli/src/config/load.ts` -- `packages/cli/src/config/normalize.ts` -- `packages/cli/src/config/types.ts` -- `packages/cli/src/git/status.ts` -- `packages/cli/src/state/load.ts` -- `packages/cli/src/state/save.ts` -- `packages/cli/src/state/diff.ts` -- `packages/cli/src/discovery/android.ts` -- `packages/cli/src/discovery/ios.ts` -- `packages/cli/src/platforms/android/apply.ts` -- `packages/cli/src/platforms/android/manifest.ts` -- `packages/cli/src/platforms/ios/apply.ts` -- `packages/cli/src/platforms/ios/plist.ts` -- `packages/cli/src/platforms/ios/entitlements.ts` -- `packages/cli/src/platforms/ios/podfile.ts` -- `packages/cli/src/platforms/ios/xcode.ts` -- `packages/cli/src/fs/readWrite.ts` -- `packages/cli/src/reporting/summary.ts` - -### Execution Strategy - -Break the implementation into a small number of dependency-aware workstreams. - -Workstreams: -- Foundation: package scaffolding, command entrypoint, config, filesystem boundaries, reporting -- Shared Apply Flow: preflight, git checks, state tracking, orchestration -- Android: discovery, manifest mutation, generated files, Android apply flow -- iOS Core: discovery, plist, entitlements, Podfile, generated files, iOS apply flow -- iOS Xcode: `project.pbxproj` mutation and target wiring -- Docs and release prep - -Parallelism rules: -- Foundation tasks should land first because most later tasks depend on their module boundaries -- after Foundation is in place, Android and iOS Core can proceed mostly in parallel -- Shared Apply Flow can proceed in parallel with platform-specific mutation work once config types and filesystem boundaries exist -- iOS Xcode should start only after iOS discovery and iOS generated-file assumptions are stable -- Docs and release prep should happen after the CLI behavior is stable enough to describe accurately - -### Detailed Tasks - -Each task below is meant to be independently assignable. Dependencies are explicit so parallel work stays safe. - -#### Foundation - -**T1. Create `packages/cli` package scaffold** - -Status: -- completed - -Deliverables: -- create `packages/cli` -- add package manifest with `name: voltra` -- wire `bin` entry for `voltra` -- add CLI entrypoint with shebang -- wire build output for Node CLI usage - -Notes: -- keep packaging independent from the React Native library build flow -- keep exports and runtime assumptions simple - -Depends on: -- none - -Can run in parallel with: -- nothing; this is the base task - -**T2. Define config and normalized internal types** - -Status: -- completed - -Deliverables: -- define public config types -- define normalized config types used internally by apply logic -- define platform-specific normalized shapes for Android and iOS -- document which defaults mirror Expo behavior - -Notes: -- keep normalized types stable so later tasks can build against them -- separate public config shape from internal resolved shape - -Depends on: -- T1 - -Can run in parallel with: -- T3 - -**T3. Add filesystem boundary module** - -Status: -- completed - -Deliverables: -- add thin read/write helpers under `packages/cli/src/fs` -- add atomic-write helper where practical -- centralize path normalization helpers -- expose directory creation and delete helpers used by generated-file tasks - -Notes: -- keep this thin; do not build a heavy virtual filesystem abstraction -- the goal is easy future test replacement, not indirection for its own sake - -Depends on: -- T1 - -Can run in parallel with: -- T2 - -**T4. Add CLI reporting primitives** - -Status: -- completed - -Review follow-up: -- completed two review passes after T1-T4 -- fixed `pathExists` to only swallow missing-path errors instead of hiding all filesystem failures -- cleaned up atomic write temp files on write or rename failure -- improved scaffolded CLI help and default error output so the published binary shows real usage shape - -Deliverables: -- summary formatter for created/updated/deleted files -- warning formatter for dirty git state and ambiguous discovery -- error formatter for preflight failures - -Notes: -- keep reporting decoupled from mutation logic - -Depends on: -- T1 - -Can run in parallel with: -- T2 -- T3 - -#### Config And Command Flow - -**T5. Implement config loading with `cosmiconfig`** - -Status: -- completed - -Deliverables: -- support `package.json` `voltra` key -- support `.voltrarc*` -- support `voltra.config.*` -- support `--config ` override -- return loaded config plus `configDir` - -Notes: -- all relative paths must ultimately resolve from `projectRoot` -- fail clearly when no config is found - -Depends on: -- T1 -- T2 - -Can run in parallel with: -- T6 -- T7 - -**T6. Implement config normalization** - -Status: -- completed - -Deliverables: -- resolve defaults from loaded config -- derive `projectRoot` -- resolve relative paths -- normalize per-platform config for downstream apply tasks - -Notes: -- normalization should be pure once raw config is loaded - -Depends on: -- T2 -- T5 - -Can run in parallel with: -- T7 - -**T7. Implement `voltra apply` command shell** - -Status: -- completed - -Deliverables: -- parse flags -- route to `apply` -- support `--platform ios|android` -- support `--config` -- return structured exit codes for success vs failure - -Notes: -- keep command handler thin -- actual work should live in orchestration modules - -Depends on: -- T1 -- T4 - -Can run in parallel with: -- T5 - -#### Shared Apply Flow - -**T8. Implement git worktree checks** - -Deliverables: -- detect dirty worktree -- support interactive warn-and-confirm flow -- support non-interactive fail-fast behavior unless bypassed -- expose a clean API for apply orchestration - -Notes: -- interactive prompting should stay out of mutation code - -Depends on: -- T1 -- T4 - -Can run in parallel with: -- T5 -- T6 -- T7 - -**T9. Implement Voltra state load/save/diff** - -Status: -- completed - -Deliverables: -- load `.voltra/state.json` if present -- validate minimal schema -- diff `previous.files` vs `next.files` -- save new state only after success - -Notes: -- state tracks only Voltra-owned generated files -- no shared-file mutation history - -Depends on: -- T2 -- T3 - -Can run in parallel with: -- T10 -- T11 - -**T10. Implement apply preflight orchestration** - -Status: -- completed - -Deliverables: -- gather requested platforms -- run discovery for all requested platforms before writes -- collect missing/ambiguous artifact failures -- abort before writes if any preflight check fails - -Notes: -- this is the safety boundary that prevents partial writes across platforms - -Depends on: -- T6 -- T7 -- T8 - -Can run in parallel with: -- T9 -- T11 - -**T11. Implement top-level apply pipeline** - -Deliverables: -- sequence load config, normalize, git check, preflight, state load, platform apply, stale cleanup, state write, summary output -- ensure no state write happens on failure -- ensure stale files are deleted only after successful writes/mutations - -Notes: -- this task should wire existing modules together, not reimplement platform logic - -Depends on: -- T6 -- T7 -- T8 -- T9 -- T10 - -Can run in parallel with: -- platform implementation tasks, once interfaces are known - -#### Android Workstream - -**T12. Implement Android project discovery** - -Status: -- completed - -Deliverables: -- discover Android root -- discover app module -- discover manifest path -- resolve configurable overrides for nonstandard layouts -- return a stable Android discovery result for downstream tasks - -Notes: -- fail clearly on missing expected structure - -Depends on: -- T2 -- T3 -- T6 - -Can run in parallel with: -- T13 -- T14 - -**T13. Adapt reusable Android generated-file logic for CLI use** - -Status: -- completed - -Deliverables: -- wire current Android generators behind CLI-friendly inputs -- define generated file inventory output so state tracking can capture all owned files -- keep generator calls independent from CLI command concerns - -Notes: -- reuse only pure or low-risk generation code - -Depends on: -- T2 -- T3 -- T6 - -Can run in parallel with: -- T12 -- T14 - -**T14. Implement CLI-native Android manifest mutator** - -Status: -- completed - -Deliverables: -- parse `AndroidManifest.xml` -- ensure required permissions -- ensure widget receivers -- ensure metadata/resource links -- preserve unrelated content and avoid duplicate insertions - -Notes: -- use XML parsing, not fragile string replacement - -Depends on: -- T2 -- T3 -- T6 - -Can run in parallel with: -- T12 -- T13 - -**T15. Implement Android apply flow** - -Status: -- completed - -Deliverables: -- combine discovery, manifest mutation, generated-file writes, and generated-file list emission -- return created/updated file inventory for reporting and state tracking -- keep shared-file mutations and generated-file writes clearly separated in code - -Notes: -- this is the Android platform entrypoint used by top-level apply orchestration - -Depends on: -- T12 -- T13 -- T14 - -Can run in parallel with: -- iOS Core tasks - -#### iOS Core Workstream - -**T16. Implement iOS project discovery** - -Status: -- completed - -Deliverables: -- discover iOS root -- discover `.xcodeproj` -- discover main target candidates -- discover main `Info.plist` -- discover Podfile -- support explicit overrides for ambiguous projects - -Notes: -- fail clearly when discovery is ambiguous instead of guessing - -Depends on: -- T2 -- T3 -- T6 - -Can run in parallel with: -- T17 -- T18 -- T19 - -**T17. Adapt reusable iOS generated-file logic for CLI use** - -Status: -- completed - -Deliverables: -- wire widget extension file generation behind CLI-friendly inputs -- define generated file inventory output for state tracking -- keep pure file generation separate from Xcode mutation - -Notes: -- include Swift files, widget plist, entitlements, assets, and localized strings where applicable - -Depends on: -- T2 -- T3 -- T6 - -Can run in parallel with: -- T16 -- T18 -- T19 - -**T18. Implement CLI-native plist and entitlements mutators** - -Status: -- completed - -Deliverables: -- parse and update main app `Info.plist` -- parse and update entitlements -- ensure required keys are inserted or updated without duplicates -- preserve unrelated content - -Notes: -- use plist parse/build APIs, not raw string mutation - -Depends on: -- T2 -- T3 -- T6 - -Can run in parallel with: -- T16 -- T17 -- T19 - -**T19. Implement Podfile managed block mutation** - -Status: -- completed - -Deliverables: -- insert or update a Voltra-managed block for widget extension pods -- keep unrelated Podfile content intact -- make repeated runs idempotent - -Notes: -- keep the managed block narrow and obvious to users - -Depends on: -- T3 -- T6 - -Can run in parallel with: -- T16 -- T17 -- T18 - -**T20. Implement iOS Core apply flow** - -Status: -- completed - -Deliverables: -- combine iOS discovery, generated-file writes, plist mutation, entitlements mutation, and Podfile mutation -- emit generated file inventory for state tracking -- keep Xcode mutation out of this task - -Notes: -- this task should produce a working iOS core path before `project.pbxproj` mutation exists - -Depends on: -- T16 -- T17 -- T18 -- T19 - -Can run in parallel with: -- T15 - -#### iOS Xcode Workstream - -**T21. Implement Xcode project parsing and target discovery helpers** - -Status: -- completed - -Deliverables: -- parse `project.pbxproj` via `@bacons/xcode` -- identify main app target and required groups/build phases -- expose stable helper functions for downstream target mutation - -Notes: -- do not tie this to command orchestration - -Depends on: -- T16 - -Can run in parallel with: -- late Android polishing or docs work - -**T22. Implement widget target creation/update in Xcode project** - -Status: -- completed - -Deliverables: -- ensure widget extension target exists -- ensure product file, build phases, groups, and dependencies are present -- make repeated runs idempotent -- preserve unrelated project structure - -Notes: -- this is the highest-risk mutation task and should stay narrowly scoped - -Depends on: -- T17 -- T21 - -Can run in parallel with: -- T23 - -**T23. Integrate Xcode mutation into iOS apply flow** - -Status: -- completed - -Deliverables: -- add `project.pbxproj` mutation to iOS apply flow -- ensure generated-file paths and target references stay aligned -- include Xcode changes in final reporting - -Notes: -- this converts iOS Core into the full iOS path for v1 - -Depends on: -- T20 -- T22 - -Can run in parallel with: -- T24 - -#### Docs And Release Prep - -**T24. Write CLI docs and usage examples** - -Status: -- completed - -Deliverables: -- document config file locations -- document `voltra apply` -- document `--platform` and `--config` -- document discovery conventions and override points -- document dirty-worktree behavior -- document generated-file ownership and `.voltra/state.json` - -Notes: -- docs should describe current behavior only; do not document speculative future commands - -Depends on: -- T11 -- T15 -- T20 - -Can run in parallel with: -- T23 - -### Suggested Phases - -If sequencing work across multiple people, use these phases. - -**Phase 1: Foundation** -- T1 -- T2 -- T3 -- T4 -- T5 -- T6 -- T7 -- T8 -- T9 - -**Phase 2: Shared And Platform Parallel Work** -- T10 -- T11 -- T12 -- T13 -- T14 -- T16 -- T17 -- T18 -- T19 - -**Phase 3: First End-To-End Paths** -- T15 -- T20 - -**Phase 4: iOS Xcode Completion** -- T21 -- T22 -- T23 - -**Phase 5: Docs And Release Prep** -- T24 - -### Critical Path - -The minimum dependency path to a shippable CLI is: - -1. T1 -2. T2 -3. T5 -4. T6 -5. T7 -6. T8 -7. T10 -8. T9 -9. T11 -10. T12 -11. T13 -12. T14 -13. T15 -14. T16 -15. T17 -16. T18 -17. T19 -18. T20 -19. T21 -20. T22 -21. T23 -22. T24 - -Android can ship earlier internally, but v1 is not complete until iOS Xcode mutation is integrated. - -### Main Risks - -- Xcode mutation remains the hardest part -- Podfile mutation can still be brittle -- CLI defaults can drift from Expo behavior if not kept aligned -- iOS project discovery can be ambiguous in nontrivial apps -- config loading across many file formats increases public support surface - -### Guiding Principle - -Prefer the smaller solution unless extra complexity clearly improves safety. - -In practice, that means: -- minimal state file -- one public command in v1 -- duplicated CLI-native mutators where needed -- reuse only pure helpers and generators -- no generalized mutation engine -- no rollback system -- no extra state metadata unless it becomes clearly necessary From f6e6b6901376db9d76187eca23119909ae9e020f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 11:59:17 +0200 Subject: [PATCH 36/37] fix: harden voltra cli preflight validation Validate Android widget dimensions before generating native XML, require client packages during platform preflight, and avoid double escaping iOS localized widget defaults. --- packages/cli/src/config/normalize.ts | 16 ++++ .../cli/src/dependencies/platformPackages.ts | 49 ++++++++++-- packages/cli/src/platforms/android/apply.ts | 8 +- packages/cli/src/platforms/ios/apply.ts | 8 +- packages/cli/src/platforms/ios/generated.ts | 6 +- packages/cli/test/cli.test.js | 79 ++++++++++++++++++- 6 files changed, 146 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/config/normalize.ts b/packages/cli/src/config/normalize.ts index 69f92cda..d7c31cf1 100644 --- a/packages/cli/src/config/normalize.ts +++ b/packages/cli/src/config/normalize.ts @@ -56,6 +56,18 @@ function assertOptionalBoolean(value: unknown, context: string): asserts value i } } +function assertPositiveInteger(value: unknown, context: string): asserts value is number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { + throw new VoltraConfigNormalizationError(`${context} must be a positive integer`) + } +} + +function assertOptionalPositiveInteger(value: unknown, context: string): asserts value is number | undefined { + if (value !== undefined) { + assertPositiveInteger(value, context) + } +} + function assertNonEmptyString(value: unknown, context: string): asserts value is string { if (typeof value !== 'string' || !value.trim()) { throw new VoltraConfigNormalizationError(`${context} must be a non-empty string`) @@ -192,6 +204,10 @@ function normalizeAndroidWidget(projectRoot: string, widget: AndroidWidgetConfig assertObject(widget, 'android.widgets[]') assertNonEmptyString(widget.id, 'android.widgets[].id') assertValidWidgetId(widget.id, 'android.widgets[].id') + assertPositiveInteger(widget.targetCellWidth, `android.widgets[${widget.id}].targetCellWidth`) + assertPositiveInteger(widget.targetCellHeight, `android.widgets[${widget.id}].targetCellHeight`) + assertOptionalPositiveInteger(widget.minCellWidth, `android.widgets[${widget.id}].minCellWidth`) + assertOptionalPositiveInteger(widget.minCellHeight, `android.widgets[${widget.id}].minCellHeight`) return { ...widget, diff --git a/packages/cli/src/dependencies/platformPackages.ts b/packages/cli/src/dependencies/platformPackages.ts index 17b61784..47cec4ec 100644 --- a/packages/cli/src/dependencies/platformPackages.ts +++ b/packages/cli/src/dependencies/platformPackages.ts @@ -8,14 +8,50 @@ const PLATFORM_PACKAGE_NAMES: Record = { ios: '@use-voltra/ios', } +const PLATFORM_CLIENT_PACKAGE_NAMES: Record = { + android: '@use-voltra/android-client', + ios: '@use-voltra/ios-client', +} + export function getPlatformPackageName(platform: VoltraPlatform): string { return PLATFORM_PACKAGE_NAMES[platform] } +export function getPlatformClientPackageName(platform: VoltraPlatform): string { + return PLATFORM_CLIENT_PACKAGE_NAMES[platform] +} + export function isPlatformPackageInstalled(projectRoot: string, platform: VoltraPlatform): boolean { + return getMissingPlatformPackages(projectRoot, platform).length === 0 +} + +export function getMissingPlatformPackages(projectRoot: string, platform: VoltraPlatform): string[] { const projectRequire = createProjectRequire(projectRoot) - const packageName = getPlatformPackageName(platform) + const packageNames = getRequiredPlatformPackageNames(platform) + return packageNames.filter((packageName) => !canResolvePackage(projectRequire, packageName)) +} + +export function requirePlatformPackage(projectRoot: string, platform: VoltraPlatform): TPackage { + return createProjectRequire(projectRoot)(getPlatformPackageName(platform)) as TPackage +} + +export function getMissingPlatformPackageMessage( + platform: VoltraPlatform, + packageNames = getRequiredPlatformPackageNames(platform) +): string { + const packageLabel = packageNames.length === 1 ? 'package' : 'packages' + const verb = packageNames.length === 1 ? 'is' : 'are' + const pronoun = packageNames.length === 1 ? 'it' : 'them' + + return `Required ${packageLabel} ${formatPackageList(packageNames)} ${verb} not installed in the app project. Install ${pronoun} because voltra.config includes a ${platform} config block.` +} + +function getRequiredPlatformPackageNames(platform: VoltraPlatform): string[] { + return [getPlatformPackageName(platform), getPlatformClientPackageName(platform)] +} + +function canResolvePackage(projectRequire: NodeRequire, packageName: string): boolean { try { projectRequire.resolve(`${packageName}/package.json`) return true @@ -24,13 +60,12 @@ export function isPlatformPackageInstalled(projectRoot: string, platform: Voltra } } -export function requirePlatformPackage(projectRoot: string, platform: VoltraPlatform): TPackage { - return createProjectRequire(projectRoot)(getPlatformPackageName(platform)) as TPackage -} +function formatPackageList(packageNames: string[]): string { + if (packageNames.length === 1) { + return packageNames[0] + } -export function getMissingPlatformPackageMessage(platform: VoltraPlatform): string { - const packageName = getPlatformPackageName(platform) - return `Required package ${packageName} is not installed in the app project. Install ${packageName} because voltra.config includes a ${platform} config block.` + return `${packageNames.slice(0, -1).join(', ')} and ${packageNames[packageNames.length - 1]}` } function createProjectRequire(projectRoot: string): NodeRequire { diff --git a/packages/cli/src/platforms/android/apply.ts b/packages/cli/src/platforms/android/apply.ts index 9397b0c7..ff0ccf9c 100644 --- a/packages/cli/src/platforms/android/apply.ts +++ b/packages/cli/src/platforms/android/apply.ts @@ -1,5 +1,5 @@ import { discoverAndroidProject } from '../../discovery/android' -import { getMissingPlatformPackageMessage, isPlatformPackageInstalled } from '../../dependencies/platformPackages' +import { getMissingPlatformPackageMessage, getMissingPlatformPackages } from '../../dependencies/platformPackages' import { VoltraCliError } from '../../reporting/summary' import { ensureAndroidManifest } from './manifest' @@ -23,10 +23,12 @@ export function createAndroidPreflightRunner( } } - if (!isPlatformPackageInstalled(config.projectRoot, 'android')) { + const missingPackages = getMissingPlatformPackages(config.projectRoot, 'android') + + if (missingPackages.length > 0) { return { platform: 'android', - issues: [{ message: getMissingPlatformPackageMessage('android') }], + issues: [{ message: getMissingPlatformPackageMessage('android', missingPackages) }], } } diff --git a/packages/cli/src/platforms/ios/apply.ts b/packages/cli/src/platforms/ios/apply.ts index d840d544..60832cd2 100644 --- a/packages/cli/src/platforms/ios/apply.ts +++ b/packages/cli/src/platforms/ios/apply.ts @@ -1,5 +1,5 @@ import { discoverIOSProject } from '../../discovery/ios' -import { getMissingPlatformPackageMessage, isPlatformPackageInstalled } from '../../dependencies/platformPackages' +import { getMissingPlatformPackageMessage, getMissingPlatformPackages } from '../../dependencies/platformPackages' import { VoltraCliError } from '../../reporting/summary' import { ensureEntitlements } from './entitlements' @@ -24,10 +24,12 @@ export function createIOSPreflightRunner(config: NormalizedVoltraConfig): Platfo } } - if (!isPlatformPackageInstalled(config.projectRoot, 'ios')) { + const missingPackages = getMissingPlatformPackages(config.projectRoot, 'ios') + + if (missingPackages.length > 0) { return { platform: 'ios', - issues: [{ message: getMissingPlatformPackageMessage('ios') }], + issues: [{ message: getMissingPlatformPackageMessage('ios', missingPackages) }], } } diff --git a/packages/cli/src/platforms/ios/generated.ts b/packages/cli/src/platforms/ios/generated.ts index 4157444e..df0e8f14 100644 --- a/packages/cli/src/platforms/ios/generated.ts +++ b/packages/cli/src/platforms/ios/generated.ts @@ -556,7 +556,7 @@ function createSwiftLabelExpression( } const key = `voltra_widget_${widgetId}_${field}` - const defaultEnglish = escapeSwiftString(widgetLabelEnglish(label)) + const defaultEnglish = widgetLabelEnglish(label) return `Text(LocalizedStringResource(${JSON.stringify(key)}, defaultValue: String.LocalizationValue(${JSON.stringify( defaultEnglish @@ -981,10 +981,6 @@ function readUrlTypes(dict: Record): Array<{ CFBundleURLSchemes .filter((entry): entry is { CFBundleURLSchemes: string[] } => entry !== undefined) } -function escapeSwiftString(value: string): string { - return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r') -} - function getSwiftRawStringDelimiter(value: string): string { const matches = value.match(/"#+/g) if (!matches) { diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js index 60f5de50..e2a1e50e 100644 --- a/packages/cli/test/cli.test.js +++ b/packages/cli/test/cli.test.js @@ -11,6 +11,12 @@ function loadCliModule() { return require(path.join(packageRoot, 'build/cjs/index.js')) } +function writeFakePackage(projectRoot, packageName) { + const packagePath = path.join(projectRoot, 'node_modules', ...packageName.split('/'), 'package.json') + fs.mkdirSync(path.dirname(packagePath), { recursive: true }) + fs.writeFileSync(packagePath, `${JSON.stringify({ name: packageName, version: '0.0.0' }, null, 2)}\n`) +} + test('apply help documents the yes flag', () => { const { getApplyHelpText } = loadCliModule() const helpText = getApplyHelpText() @@ -83,7 +89,8 @@ test('ios preflight reports missing optional platform package', async () => { })({ requestedPlatforms: ['ios'] }) assert.equal(result.platform, 'ios') - assert.match(result.issues[0].message, /@use-voltra\/ios is not installed/) + assert.match(result.issues[0].message, /@use-voltra\/ios/) + assert.match(result.issues[0].message, /@use-voltra\/ios-client/) assert.match(result.issues[0].message, /ios config block/) }) @@ -100,6 +107,74 @@ test('android preflight reports missing optional platform package', async () => })({ requestedPlatforms: ['android'] }) assert.equal(result.platform, 'android') - assert.match(result.issues[0].message, /@use-voltra\/android is not installed/) + assert.match(result.issues[0].message, /@use-voltra\/android/) + assert.match(result.issues[0].message, /@use-voltra\/android-client/) assert.match(result.issues[0].message, /android config block/) }) + +test('ios preflight reports missing client package when renderer is installed', async () => { + const { createIOSPreflightRunner } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + fs.writeFileSync(path.join(tempDir, 'package.json'), `${JSON.stringify({ private: true }, null, 2)}\n`) + writeFakePackage(tempDir, '@use-voltra/ios') + + const result = await createIOSPreflightRunner({ + projectRoot: tempDir, + ios: { + project: {}, + }, + })({ requestedPlatforms: ['ios'] }) + + assert.equal(result.platform, 'ios') + assert.match(result.issues[0].message, /@use-voltra\/ios-client/) + assert.doesNotMatch(result.issues[0].message, /@use-voltra\/ios and/) +}) + +test('android preflight reports missing client package when renderer is installed', async () => { + const { createAndroidPreflightRunner } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + fs.writeFileSync(path.join(tempDir, 'package.json'), `${JSON.stringify({ private: true }, null, 2)}\n`) + writeFakePackage(tempDir, '@use-voltra/android') + + const result = await createAndroidPreflightRunner({ + projectRoot: tempDir, + android: { + project: {}, + }, + })({ requestedPlatforms: ['android'] }) + + assert.equal(result.platform, 'android') + assert.match(result.issues[0].message, /@use-voltra\/android-client/) + assert.doesNotMatch(result.issues[0].message, /@use-voltra\/android and/) +}) + +test('android config normalization rejects missing widget dimensions', () => { + const { VoltraConfigNormalizationError, normalizeVoltraConfig } = loadCliModule() + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'voltra-cli-test-')) + + assert.throws( + () => { + normalizeVoltraConfig({ + configDir: tempDir, + configPath: path.join(tempDir, 'voltra.config.json'), + config: { + android: { + widgets: [ + { + id: 'portfolio', + displayName: 'Portfolio', + description: 'Track holdings', + targetCellHeight: 2, + }, + ], + }, + }, + }) + }, + (error) => { + assert.ok(error instanceof VoltraConfigNormalizationError) + assert.match(error.message, /android\.widgets\[portfolio\]\.targetCellWidth must be a positive integer/) + return true + } + ) +}) From 78f305b6ecc5613d67ab9cd0f531b6d9080de44f Mon Sep 17 00:00:00 2001 From: Szymon Chmal Date: Fri, 29 May 2026 12:06:42 +0200 Subject: [PATCH 37/37] chore: format --- packages/cli/src/dependencies/platformPackages.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/dependencies/platformPackages.ts b/packages/cli/src/dependencies/platformPackages.ts index 47cec4ec..6683e086 100644 --- a/packages/cli/src/dependencies/platformPackages.ts +++ b/packages/cli/src/dependencies/platformPackages.ts @@ -44,7 +44,9 @@ export function getMissingPlatformPackageMessage( const verb = packageNames.length === 1 ? 'is' : 'are' const pronoun = packageNames.length === 1 ? 'it' : 'them' - return `Required ${packageLabel} ${formatPackageList(packageNames)} ${verb} not installed in the app project. Install ${pronoun} because voltra.config includes a ${platform} config block.` + return `Required ${packageLabel} ${formatPackageList( + packageNames + )} ${verb} not installed in the app project. Install ${pronoun} because voltra.config includes a ${platform} config block.` } function getRequiredPlatformPackageNames(platform: VoltraPlatform): string[] {