diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 144b02d..2cf74c4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,7 +30,9 @@ jobs: cd ../starter npm install cd ../preview - npm install + npm install + cd ../cli + npm install npm install -g @vscode/vsce - name: Lint diff --git a/.oxlintrc.json b/.oxlintrc.json index 34299d2..f34864b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -4,6 +4,8 @@ "starter/**/*", "preview/**/*", "plugin-explorer/**/*", + "cli/dist/**/*", + "cli/node_modules/**/*", "out/**/*", "tests/**/*", "resources/**/*", diff --git a/.vscodeignore b/.vscodeignore index c48fe3a..5267f0d 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -13,6 +13,8 @@ starter/** !starter/build/** preview/** !preview/build/** +cli/** +!cli/dist/** plugin-explorer/*.json plugin-explorer/*.js plugin-explorer/*.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ba8ccd..d1c7556 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Changelog +### Version 2.2.16 + +- Fix monorepo project switching re-running `npm outdated` on every switch by resolving the selected project before package processing and reusing per-project workspace cache + ### Version 2.2.15 - Fix for package outdated refresh. Update dependencies. @@ -11,6 +15,10 @@ ### Version 2.2.13 +- Implement standalone `wn` CLI under `cli/` (fully independent of the VS Code extension): info, run, build, sync, open, stop, devices, debug, scripts, check, packages, plugins, native, assets, release, migrate, new, integrate, generate, and config +- Port extension logic into `cli/src` (project detection, command builders, error parsers, recommendation rules, native project editors, device listing, migrations, starter templates) +- Add CLI framework with JSON envelope, exit-code contract, `--dry-run`, config precedence (`wn.json` / `~/.config/wn`), and agent skills under `cli/skill/` +- Wire `cli` into `npm run build:all`, `install:all`, CI, and `.vscodeignore` - Angular optional migrations added to project menu - Migration for deprecated typescript settings diff --git a/cli/.gitignore b/cli/.gitignore new file mode 100644 index 0000000..62ccde4 --- /dev/null +++ b/cli/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000..ddeb94d --- /dev/null +++ b/cli/README.md @@ -0,0 +1,56 @@ +# WebNative CLI (`wn`) + +Command-line interface for web and mobile app development with Capacitor, Cordova, Ionic, Angular, React, Vue, and related toolchains. + +This package lives inside the [vscode-webnative](https://github.com/) repository today and is designed to be extracted to its own project later. It is **completely independent** of the VS Code extension — no shared runtime imports. + +## Install / run + +```bash +# From this repo during development +node cli/dist/bin.js [options] + +# After publish +npx wn [options] +npm install --save-dev wn +``` + +## Quick start + +```bash +wn info --json # detect project +wn check --json # recommendation engine +wn devices list --json # simulators / emulators / devices +wn run web --background --json # start dev server +wn build --prod --json +wn sync --json +wn stop --json +``` + +Always pass `--json` from coding agents. Stdout is a single envelope: + +```json +{ "ok": true, "command": "info", "data": {}, "warnings": [], "errors": [], "durationMs": 120 } +``` + +Exit codes: `0` ok, `1` command failed, `2` usage, `3` no project, `4` missing tool, `5` missing input (choices in `errors[0].choices`), `6` blocking checks, `7` timeout. + +## Documentation + +- Full specification: [docs.md](./docs.md) +- Agent skills: [skill/README.md](./skill/README.md) + +## Development + +```bash +cd cli +npm install +npm run build +npm test +``` + +From the repo root, `npm run build:all` includes `build:cli`. + +## Independence + +All logic under `cli/src` is self-contained. Extension sources were copied and adapted (no `vscode` API, no `exState` globals). A later extraction can move this folder to its own repository without changing the VS Code extension. diff --git a/cli/docs.md b/cli/docs.md new file mode 100644 index 0000000..4ab4673 --- /dev/null +++ b/cli/docs.md @@ -0,0 +1,908 @@ +# WebNative CLI (`wn`) + +> Status: **design document**. Nothing in this file is implemented yet. It is the specification for a CLI that exposes the features of the [WebNative VS Code extension](https://webnative.dev) to terminals, CI pipelines, and coding agents. + +## Contents + +- [Overview](#overview) +- [Installation and invocation](#installation-and-invocation) +- [Design principles](#design-principles) +- [Global options](#global-options) +- [Output modes](#output-modes) +- [Exit codes](#exit-codes) +- [Project detection](#project-detection) +- [Monorepos](#monorepos) +- [Command reference](#command-reference) + - [`wn info`](#wn-info) + - [`wn new`](#wn-new) + - [`wn run`](#wn-run) + - [`wn build`](#wn-build) + - [`wn sync`](#wn-sync) + - [`wn open`](#wn-open) + - [`wn stop`](#wn-stop) + - [`wn devices`](#wn-devices) + - [`wn debug`](#wn-debug) + - [`wn scripts`](#wn-scripts) + - [`wn check`](#wn-check) + - [`wn packages`](#wn-packages) + - [`wn plugins`](#wn-plugins) + - [`wn native`](#wn-native) + - [`wn assets`](#wn-assets) + - [`wn release`](#wn-release) + - [`wn migrate`](#wn-migrate) + - [`wn integrate`](#wn-integrate) + - [`wn generate`](#wn-generate) + - [`wn config`](#wn-config) +- [Configuration](#configuration) +- [Environment variables](#environment-variables) +- [Prompting and non-interactive use](#prompting-and-non-interactive-use) +- [Feature mapping from the extension](#feature-mapping-from-the-extension) +- [Out of scope](#out-of-scope) + +--- + +## Overview + +`wn` is a single command-line entry point for web and mobile app development tasks: serving and building a web app, running it on iOS and Android devices, keeping Capacitor projects in sync, editing native project settings, generating splash screens and icons, auditing and upgrading dependencies, and running the recommendation engine that the extension shows in its sidebar. + +The CLI targets three audiences, in this order of priority: + +1. **Coding agents** — every command has a machine-readable `--json` mode, a stable exit-code contract, and a fully non-interactive path. See the [agent skills](./skill/README.md). +2. **CI pipelines** — no TTY assumptions, no hidden state, no prompts unless asked for. +3. **Humans at a terminal** — readable output, interactive pickers when a required value is missing. + +It works with Angular, React, Vue, Svelte, Solid, Preact, Lit, Qwik, Astro, Next.js, Nuxt, Vite, Ionic, Capacitor, and Cordova projects, and with NX, npm/yarn/bun workspaces, pnpm workspaces, Lerna, and folder-based monorepos. + +## Installation and invocation + +```bash +# One-off, always latest +npx wn [options] + +# Project-local (recommended for CI so the version is pinned) +npm install --save-dev wn +npx wn + +# Global +npm install -g wn +wn +``` + +The binary is `wn`. The package is published as `wn`, with `webnative` as an alias package that depends on it. + +`wn` with no arguments prints a short summary of the detected project plus the most relevant next commands. `wn --help` prints the full command list; `wn --help` prints options for one command. + +## Design principles + +**Deterministic.** The same command with the same flags in the same project produces the same underlying shell command. `--dry-run` prints that command without running it, so a caller can inspect or log it. + +**No hidden interactivity.** A command either has everything it needs from flags, config, and project state, or it fails with exit code `5` and a JSON payload describing exactly which input is missing and what the valid choices are. Interactive prompts only happen when stdin is a TTY and `--no-input` was not passed. + +**Read-only by default where it matters.** Commands that inspect (`info`, `check`, `devices`, `packages list`, `plugins search`, `native get`) never modify the project. Commands that write say so in their help text and support `--dry-run`. + +**Composable.** Every list-producing command supports `--json`, so results can be piped into `jq` or parsed by an agent, then fed back as arguments to a follow-up command. + +**Same engine as the extension.** The CLI and the extension share the project analyzer, the recommendation rules, and the command builders. A recommendation id shown in the extension sidebar is the same id accepted by `wn check --fix `. + +## Global options + +Available on every command. + +| Option | Default | Description | +| ------------------------ | ----------------- | ----------------------------------------------------------------------------------------------------- | +| `--cwd ` | current directory | Directory to treat as the workspace root. | +| `--project ` | auto | Sub-project to act on in a monorepo. See [Monorepos](#monorepos). | +| `--json` | off | Emit a single JSON object on stdout, nothing else. Implies `--no-input` and `--no-color`. | +| `--yes`, `-y` | off | Accept the default answer for every confirmation. Does not invent values for missing required inputs. | +| `--no-input` | off when TTY | Never prompt. Missing required input becomes exit code `5`. | +| `--dry-run` | off | Print the shell command(s) that would run, then exit `0`. Makes no changes. | +| `--verbose`, `-v` | off | Include the underlying shell commands and their raw output. | +| `--quiet`, `-q` | off | Suppress progress output; only print errors and final results. | +| `--no-color` | auto | Disable ANSI color. Also honours `NO_COLOR`. | +| `--package-manager ` | detected | `npm`, `pnpm`, `yarn`, or `bun`. Overrides lockfile detection. | +| `--timeout ` | none | Abort the command after this many seconds with exit code `7`. | +| `--version` | — | Print the CLI version. | +| `--help`, `-h` | — | Print help for the CLI or the current command. | + +## Output modes + +### Human output + +Progress lines to stderr, results to stdout. Errors are prefixed and colorized; recommendations are grouped the way the extension groups them in the sidebar. + +### JSON output + +With `--json`, stdout contains exactly one JSON object and nothing else. Progress and log lines move to stderr. The envelope is stable across all commands: + +```json +{ + "ok": true, + "command": "devices list", + "data": {}, + "warnings": [], + "errors": [], + "durationMs": 1840 +} +``` + +On failure: + +```json +{ + "ok": false, + "command": "run ios", + "data": null, + "warnings": [], + "errors": [ + { + "code": "MISSING_INPUT", + "message": "No device selected and more than one device is available.", + "input": "device", + "choices": [ + { "id": "1B2C3D4E-...", "name": "iPhone 16 Pro", "type": "simulator" }, + { "id": "00008120-...", "name": "Damian's iPhone", "type": "device" } + ], + "hint": "Re-run with --device " + } + ], + "durationMs": 320 +} +``` + +`errors[].code` values are stable identifiers, listed per command. Agents should branch on `code`, never on `message`. + +### Streaming output + +Long-running commands (`run`, `build`, `sync`, `migrate`) stream the child process output to stderr as it happens. With `--json`, the final object is still the only thing on stdout; add `--stream-json` to also receive newline-delimited progress events on stdout before the final object: + +``` +{"event":"start","step":"build","command":"npm run build"} +{"event":"log","level":"info","message":"vite v5.4.0 building for production..."} +{"event":"error","file":"src/app.ts","line":42,"column":9,"message":"Type 'string' is not assignable to type 'number'."} +{"event":"end","step":"build","exitCode":0} +{"ok":true,"command":"build","data":{...}} +``` + +The `error` event uses the extension's build-output parsers, so ESLint, ESBuild, Vite, Next.js, TypeScript, Vue, Jest, Jasmine, Java, and Swift/Xcode errors all arrive as structured `file`/`line`/`column` records regardless of the toolchain that produced them. + +## Exit codes + +| Code | Meaning | +| ----- | ---------------------------------------------------------------------------------------------------------------- | +| `0` | Success. | +| `1` | The command ran but failed (build error, test failure, device launch failure). | +| `2` | Usage error: unknown command, bad flag, invalid argument value. | +| `3` | No supported project found at `--cwd`. | +| `4` | A prerequisite is missing (Xcode, Android SDK, JDK, CocoaPods, adb, `node_modules`). The error payload names it. | +| `5` | Required input is missing and prompting is not possible. `errors[0].choices` lists the valid values. | +| `6` | Checks found blocking issues (`wn check --error-on `). | +| `7` | Timed out. | +| `130` | Interrupted (SIGINT). | + +## Project detection + +`wn` inspects the workspace the same way the extension does, before running any command: + +- **Project type** — `Capacitor` when `@capacitor/core`, `@capacitor/ios`, or `@capacitor/android` is in `dependencies`; `Cordova` when `cordova-ios`/`cordova-android` is present or `package.json` has a `cordova` key; otherwise `Other`. +- **Framework** — from `ionic.config.json` `type` if present, otherwise inferred from `package.json`: `@vue/cli-service` → `vue`, `@angular/core` → `angular`, `@angular/cli` or `@ionic/angular` → `angular-standalone`, `react-scripts` → `react`, `vite` + `react` → `react-vite`, `vite` + `vue` → `vue-vite`. +- **Native platforms** — a platform counts as present when both the `@capacitor/` dependency and the `ios`/`android` folder exist. +- **Package manager** — from the lockfile (`yarn.lock`, `pnpm-lock.yaml`, `bun.lock*`, else npm), overridden by the `packageManager` field, the monorepo type, or `--package-manager`. +- **Monorepo** — NX (`nx.json`), npm/yarn/bun `workspaces`, `pnpm-workspace.yaml`, `lerna.json`, or sibling folders that each contain a web `package.json`. + +Run `wn info --json` to see everything that detection produced. + +## Monorepos + +When a monorepo is detected and it contains more than one runnable project, commands that act on a project require `--project `: + +```bash +wn info --json # lists projects[] with name, path, framework +wn run web --project storefront +wn build --project admin --prod +``` + +Without `--project`, `wn` uses the single project if there is only one, the project containing `--cwd` if `--cwd` points inside one, or the `defaultProject` from `ionic.config.json`. If none of those resolve, it exits `5` with the list of project names in `choices`. + +`--project` is ignored for workspace-wide commands (`wn packages audit`, `wn config`). + +--- + +# Command reference + +## `wn info` + +Inspect the project and print everything detection found. Read-only. This is the command an agent should run first. + +```bash +wn info [--json] [--check] +``` + +| Option | Description | +| ------------- | -------------------------------------------------------------------------- | +| `--check` | Also run the recommendation engine and include a `checks` summary. Slower. | +| `--platforms` | Only report native platform state. | + +```bash +$ wn info --json +``` + +```json +{ + "ok": true, + "command": "info", + "data": { + "root": "/Users/me/apps/storefront", + "name": "storefront", + "type": "Capacitor", + "framework": "angular-standalone", + "packageManager": "pnpm", + "monorepo": { "type": "nx", "projects": ["storefront", "admin"], "selected": "storefront" }, + "webDir": "dist/storefront/browser", + "devServer": { "script": "start", "defaultPort": 8100 }, + "buildScript": "build", + "capacitor": { + "cliVersion": "7.2.0", + "coreVersion": "7.2.0", + "configFile": "capacitor.config.ts", + "appId": "com.example.storefront", + "appName": "Storefront", + "platforms": [ + { "name": "ios", "installed": true, "version": "17.2", "buildNumber": "42" }, + { "name": "android", "installed": true, "version": "17.2", "buildNumber": "42" } + ] + }, + "plugins": [{ "name": "@capacitor/camera", "version": "7.0.1" }], + "scripts": ["start", "build", "test", "lint"], + "toolchain": { + "node": "22.11.0", + "xcode": "16.2", + "androidStudio": "2024.2", + "jdk": "21.0.4", + "cocoapods": "1.16.2", + "adb": "/Users/me/Library/Android/sdk/platform-tools/adb" + } + } +} +``` + +Error codes: `NO_PROJECT` (exit `3`). + +## `wn new` + +Create a new project from a starter template. Equivalent to the extension's New Project wizard. + +```bash +wn new [name] [options] +``` + +| Option | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | +| `--template ` | Template id, e.g. `ionic-angular-tabs`, `vite-react`, `nextjs`, `nuxt`, `astro`, `capacitor-plugin`. | +| `--framework ` | Filter templates by framework: `angular`, `react`, `vue`, `svelte`, `solid`, `lit`, `preact`, `qwik`, `astro`, `next`, `nuxt`. | +| `--targets ` | Comma-separated: `web`, `ios`, `android`. Adds and configures Capacitor platforms. | +| `--dir ` | Parent folder for the new project. Defaults to `--cwd`. | +| `--package-manager ` | Package manager for the new project. | +| `--no-install` | Scaffold without installing dependencies. | +| `--no-git` | Skip `git init`. | +| `--list-templates` | Print the template catalog and exit. Read-only. | + +```bash +wn new --list-templates --json +wn new storefront --template ionic-angular-tabs --targets web,ios,android --package-manager pnpm -y +``` + +Error codes: `UNKNOWN_TEMPLATE`, `DIR_NOT_EMPTY`, `MISSING_INPUT` (no template chosen). + +## `wn run` + +Serve the web app or launch it on a device or emulator. + +```bash +wn run web [options] +wn run ios [options] +wn run android [options] +``` + +The web command resolves the serve script in this order: `wn:serve`, `ionic:serve`, `serve`, `dev`, `start`, then the framework default (`ng serve`, `vite`, `react-scripts start`, `vue-cli-service serve`, `nx serve `). + +### Shared options + +| Option | Default | Description | +| ---------------------- | ----------- | ----------------------------------------------------------------------------------------------- | +| `--port ` | `8100` | Dev server port. If busy, the next free port is used unless `--strict-port`. | +| `--strict-port` | off | Fail instead of incrementing the port. | +| `--config ` | none | Run configuration: an `angular.json` configuration, or a `.env.` file for Vite/React/Vue. | +| `--prod` | off | Production mode. | +| `--background` | off | Start and return immediately, printing the pid and URL. Use `wn stop` to end it. | +| `--open` / `--no-open` | `--no-open` | Open a browser once the server is ready. | + +### `wn run web` options + +| Option | Description | +| ---------------------- | ----------------------------------------------------------------------------- | +| `--host ` | Bind address. `--host external` picks the machine's external IPv4 address. | +| `--https` | Serve over HTTPS using a locally generated certificate (see `wn config ssl`). | +| `--public-host ` | Host name to advertise to devices, used by live reload. | + +### `wn run ios` / `wn run android` options + +| Option | Description | +| ---------------------- | --------------------------------------------------------------------------------------------------- | +| `--device ` | Target device or simulator/emulator id from `wn devices list`. | +| `--device-name ` | Match by display name instead of id. Fails if ambiguous. | +| `--live-reload` | Point the native app at the dev server instead of the bundled web assets. Implies an external host. | +| `--external` | Use the external IP for live reload even when a public host is configured. | +| `--no-sync` | Skip `cap sync` before launching. | +| `--no-build` | Skip the web build even if the project changed since the last run. | +| `--flavor ` | Android product flavor, read from `android/app/build.gradle`. | +| `--scheme ` | iOS scheme. | +| `--ssl` | Serve the live-reload dev server over HTTPS. | + +```bash +# Serve on a fixed port, non-interactive, in the background +wn run web --port 4200 --background --json + +# Run on a specific simulator with live reload +wn devices list --platform ios --json +wn run ios --device 1B2C3D4E-5F60-4A1B-9C2D-3E4F5A6B7C8D --live-reload + +# CI: build and launch on the only connected Android device +wn run android --no-sync --config staging -y +``` + +Success payload: + +```json +{ + "ok": true, + "command": "run web", + "data": { + "url": "http://localhost:4200", + "externalUrl": "http://192.168.1.42:4200", + "pid": 48213, + "background": true, + "logFile": "/tmp/wn/storefront-web.log" + } +} +``` + +Error codes: `MISSING_INPUT` (device required, `choices` populated), `NO_DEVICES`, `PLATFORM_NOT_ADDED`, `MISSING_TOOL` (Xcode, Android SDK), `BUILD_FAILED`, `LAUNCH_FAILED`, `PORT_IN_USE`. + +## `wn build` + +Build the web app, and optionally copy the result into the native projects. + +```bash +wn build [platform] [options] +``` + +`platform` is `web` (default), `ios`, `android`, or `all`. Naming a native platform runs the web build then `cap copy `. + +| Option | Description | +| ----------------- | ----------------------------------------------------------------------------- | +| `--prod` | Production build. Equivalent to the `buildForProduction` extension setting. | +| `--config ` | Build configuration from `angular.json`, or `--mode` for Vite-based projects. | +| `--sourcemap` | Force source maps on. | +| `--no-copy` | Build only; do not `cap copy`. | +| `--clean` | Remove the output directory first. | + +Build script resolution: `wn:build`, `ionic:build`, `build`, then the framework default (`ng build`, `vite build`, `react-scripts build`, `vue-cli-service build`, `nx build `). + +```bash +wn build --prod --config production --json +wn build ios --prod +``` + +Success payload includes `outputDir`, `durationMs`, `sizeBytes`, and `warnings[]`. On failure, `errors[]` contains parsed compiler errors with `file`, `line`, `column`. + +Error codes: `BUILD_FAILED`, `NO_BUILD_SCRIPT`, `UNKNOWN_CONFIG`. + +## `wn sync` + +Run `cap sync` — copy web assets and update native dependencies. + +```bash +wn sync [ios|android] [--no-build] [--deployment] +``` + +| Option | Description | +| -------------- | ----------------------------------------------------------------------------- | +| `--no-build` | Sync the existing web output without rebuilding. | +| `--deployment` | Pass `--deployment` to CocoaPods (fails if `Podfile.lock` would change). | +| `--inline` | Inline the sync output rather than using a spinner. Default in `--json` mode. | + +Error codes: `SYNC_FAILED`, `PLATFORM_NOT_ADDED`, `MISSING_TOOL` (CocoaPods). + +## `wn open` + +Open the project in an external tool. + +```bash +wn open xcode +wn open android-studio +wn open browser [--url ] +wn open folder +``` + +Error codes: `MISSING_TOOL`, `PLATFORM_NOT_ADDED`. + +## `wn stop` + +Stop dev servers and native launches started by `wn run --background`. + +```bash +wn stop [--all] [--pid ] [--port ] +``` + +With no arguments, stops everything `wn` started for the current project. `wn stop --all` stops every `wn`-managed process on the machine. + +## `wn devices` + +List and inspect run targets. Read-only. + +```bash +wn devices list [--platform ios|android] [--json] +wn devices default [--platform

] [--set ] [--clear] +``` + +```json +{ + "ok": true, + "command": "devices list", + "data": { + "ios": [ + { "id": "00008120-000A1B2C3D4E002E", "name": "Damian's iPhone", "type": "device", "os": "18.3" }, + { "id": "1B2C3D4E-5F60-4A1B-9C2D-3E4F5A6B7C8D", "name": "iPhone 16 Pro", "type": "simulator", "os": "18.2" } + ], + "android": [{ "id": "Pixel_8_API_35", "name": "Pixel 8 API 35", "type": "emulator", "api": 35, "os": "15" }] + } +} +``` + +`wn devices default --set ` remembers a target for later `wn run` calls, mirroring the extension's device memory. + +Error codes: `MISSING_TOOL` (adb, xcrun), `NO_DEVICES`. + +## `wn debug` + +Attach a debugger. + +```bash +wn debug web [--browser chrome|edge] [--port ] +wn debug android [--list] [--device ] [--webview ] [--web-root workspace|www] +``` + +`wn debug android --list` enumerates debuggable WebViews on connected devices and is read-only. Without `--webview`, a single WebView is attached automatically; more than one produces exit `5` with the list in `choices`. + +The command prints a Chrome DevTools Protocol endpoint so external debuggers can attach: + +```json +{ "ok": true, "command": "debug android", "data": { "cdpUrl": "ws://localhost:9222/devtools/page/A1B2", "port": 9222 } } +``` + +Error codes: `MISSING_TOOL` (adb), `NO_WEBVIEWS`, `MISSING_INPUT`. + +## `wn scripts` + +Work with `package.json` scripts, including NX targets. + +```bash +wn scripts list [--json] +wn scripts run [-- ] +``` + +```bash +wn scripts list --json +wn scripts run test -- --coverage +``` + +Error codes: `UNKNOWN_SCRIPT`, `SCRIPT_FAILED`. + +## `wn check` + +Run the recommendation engine — the same rules that populate the extension's Recommendations section. Read-only unless `--fix` is used. + +```bash +wn check [options] +``` + +| Option | Description | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `--json` | Machine-readable findings. | +| `--severity ` | Filter: `error`, `warning`, `info`. Default all. | +| `--category ` | Filter: `packages`, `capacitor`, `cordova`, `angular`, `typescript`, `android`, `ios`, `browserslist`, `security`, `privacy`. | +| `--error-on ` | Exit `6` if any finding at or above this severity exists. Use in CI. | +| `--fix ` | Apply the fix for one finding. Modifies the project. | +| `--fix-all [severity]` | Apply every automatic fix at or above a severity. Modifies the project. | +| `--ignore ` | Add a finding to the project ignore list. | +| `--show-ignored` | Include ignored findings in the output. | + +```json +{ + "ok": true, + "command": "check", + "data": { + "findings": [ + { + "id": "capacitor-version-mismatch", + "severity": "error", + "category": "capacitor", + "title": "@capacitor/android is 6.2.0 but @capacitor/core is 7.2.0", + "detail": "All @capacitor/* packages must share a major version.", + "fixable": true, + "fix": { "id": "capacitor-version-mismatch", "command": "pnpm add @capacitor/android@7.2.0" } + }, + { + "id": "deprecated-plugin-cordova-plugin-camera", + "severity": "warning", + "category": "packages", + "title": "cordova-plugin-camera is deprecated", + "detail": "Replace with @capacitor/camera.", + "fixable": true + } + ], + "counts": { "error": 1, "warning": 1, "info": 4, "ignored": 2 } + } +} +``` + +Findings cover: Capacitor version consistency and plugin compatibility, deprecated and end-of-life packages, Cordova plugin issues, `config.xml` and `AndroidManifest.xml` settings, `angular.json` issues, deprecated TypeScript compiler options, browserslist, missing Capacitor platforms, missing privacy manifest entries, and Android `minifyEnabled`. + +```bash +# CI gate +wn check --error-on error --json + +# Fix one thing an agent decided to fix +wn check --fix capacitor-version-mismatch +``` + +Error codes: `UNKNOWN_FINDING`, `FIX_FAILED`, `NOT_FIXABLE`. + +## `wn packages` + +Dependency management across npm, pnpm, yarn, and bun. + +```bash +wn packages list [--outdated] [--plugins] [--json] +wn packages add [--dev] [--version ] [--no-sync] +wn packages remove [--no-sync] +wn packages upgrade [name] [--version ] [--latest] [--all] +wn packages minor [--apply] +wn packages audit [--fix] [--severity ] +wn packages peers [--fix] +wn packages install [--force] [--frozen-lockfile] +wn packages size [--json] +wn packages export [--out ] +``` + +| Subcommand | Description | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `list` | Installed packages with `current`, `wanted`, `latest`, and `deprecated`. `--plugins` restricts to Capacitor/Cordova plugins. | +| `add` / `remove` | Install or uninstall. In a Capacitor project a `cap sync` runs afterwards unless `--no-sync`. | +| `upgrade` | Upgrade one package (`--version` for an exact version, `--latest` for the newest release) or `--all` for everything outdated. | +| `minor` | Report dependencies with a minor/patch update available; `--apply` installs them in one step. | +| `audit` | `npm audit` filtered to direct dependencies, with severity counts; `--fix` runs the fix. | +| `peers` | Report unmet peer dependencies and propose compatible versions. | +| `install` | Install all dependencies. | +| `size` | Production build plus bundle analysis: per-module and per-asset sizes. | +| `export` | Write `project-summary.md`: every dependency with its version, last release date, and a maintenance rating. | + +```bash +wn packages list --outdated --json +wn packages upgrade @capacitor/core --version 7.2.0 +wn packages minor --apply -y +wn packages audit --severity high --json +``` + +```json +{ + "ok": true, + "command": "packages list", + "data": { + "packages": [ + { "name": "@angular/core", "current": "19.2.1", "wanted": "19.2.9", "latest": "20.1.0", "type": "dependency" }, + { "name": "moment", "current": "2.30.1", "latest": "2.30.1", "deprecated": true, "type": "dependency" } + ], + "plugins": [{ "name": "@capacitor/camera", "current": "7.0.1", "latest": "7.0.2", "ios": true, "android": true }] + } +} +``` + +Error codes: `UNKNOWN_PACKAGE`, `INSTALL_FAILED`, `AUDIT_FAILED`, `INCOMPATIBLE_VERSION`. + +## `wn plugins` + +Browse and install Capacitor and Cordova plugins from the plugin directory. `search` and `info` are read-only. + +```bash +wn plugins search [--platform ios|android] [--official] [--json] +wn plugins info [--json] +wn plugins add [--version ] +wn plugins remove +wn plugins permissions [--json] +``` + +`wn plugins info` returns the npm metadata, repository stats, latest version, supported platforms, and the version that is compatible with the project's Capacitor major version. `wn plugins add` resolves that compatible version automatically unless `--version` is given, then runs `cap sync`. + +`wn plugins permissions` lists the Android permissions and features each installed plugin contributes, parsed from its `plugin.xml`. + +Error codes: `UNKNOWN_PLUGIN`, `NO_COMPATIBLE_VERSION`, `INSTALL_FAILED`. + +## `wn native` + +Read and write native project settings. `get` is read-only. + +```bash +wn native get [--platform ios|android] [--json] +wn native set [--platform ios|android] +wn native add +wn native privacy check [--json] +wn native privacy add [--reason ...] +``` + +Properties: `app-id` (bundle identifier), `app-name` (display name), `version`, `build` (build number). + +Without `--platform`, `set` writes both platforms and the Capacitor config. With `--platform`, it writes only that platform, which is how the extension supports iOS and Android diverging. + +```bash +wn native get --json +wn native set app-id com.example.storefront +wn native set build 128 --platform ios +wn native set version 2.4.0 +wn native add android +``` + +`wn native privacy check` reports whether `PrivacyInfo.xcprivacy` exists and which Apple required-reason API categories the installed plugins need. `wn native privacy add` creates the file, registers it in the Xcode project, and adds the reason codes. + +```json +{ + "ok": true, + "command": "native get", + "data": { + "ios": { "appId": "com.example.storefront", "appName": "Storefront", "version": "2.4.0", "build": "128" }, + "android": { "appId": "com.example.storefront", "appName": "Storefront", "version": "2.4.0", "build": "128" } + } +} +``` + +Error codes: `PLATFORM_NOT_ADDED`, `INVALID_VALUE` (bundle id / version format), `WRITE_FAILED`. + +## `wn assets` + +Generate splash screens and app icons from source images in `resources/`. + +```bash +wn assets generate [--ios] [--android] [--pwa] +wn assets set +wn assets list [--json] +``` + +`type` is `splash`, `splash-dark`, `icon`, `icon-foreground`, or `icon-background`. `wn assets set` copies the file into `resources/` with the expected name; `generate` runs `@capacitor/assets` for the requested targets (all installed platforms when no target flag is given) and adds the generated output to `.gitignore`. + +```bash +wn assets set icon ./design/icon-1024.png +wn assets generate --ios --android +``` + +Error codes: `MISSING_SOURCE_ASSET`, `INVALID_IMAGE` (wrong dimensions), `GENERATE_FAILED`. + +## `wn release` + +Produce a signed, distributable native build. + +```bash +wn release ios [--type ipa] [--config ] +wn release android --type apk|aab [--keystore ] [--keystore-password

] [--keystore-alias ] [--keystore-alias-password

] [--save-config] +``` + +Android signing values may also come from `capacitor.config.*` (written by `--save-config`) or from environment variables (`WN_KEYSTORE_PATH`, `WN_KEYSTORE_PASSWORD`, `WN_KEYSTORE_ALIAS`, `WN_KEYSTORE_ALIAS_PASSWORD`), which is the recommended approach in CI. Passwords are never written to disk or echoed in `--dry-run` output. + +The success payload contains `artifactPath`. + +Error codes: `MISSING_INPUT` (signing values), `MISSING_TOOL`, `SIGNING_FAILED`, `BUILD_FAILED`. + +## `wn migrate` + +Run guided migrations. + +```bash +wn migrate list [--json] +wn migrate capacitor [--to ] +wn migrate angular [--to ] [--all] +wn migrate cordova [--remove] +wn migrate spm +wn migrate package-manager +``` + +| Subcommand | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| `list` | Which migrations apply to this project, with the current and target versions. Read-only. | +| `capacitor` | Migrate to the next Capacitor major (or `--to`), including native file changes and minimum plugin versions. | +| `angular` | Migrate Angular one major at a time. `--all` repeats until the latest supported major. Refuses to run on a dirty git tree unless `--yes`. | +| `cordova` | Migrate a Cordova project to Capacitor; `--remove` strips the Cordova project afterwards. | +| `spm` | Run the iOS CocoaPods to Swift Package Manager migration assistant. | +| `package-manager` | Convert the project to another package manager. | + +Every migration supports `--dry-run`, which reports the steps and file changes without applying them. + +```bash +wn migrate list --json +wn migrate capacitor --to 8 --dry-run +wn migrate angular --to 20 -y +``` + +Error codes: `DIRTY_GIT`, `PRECONDITION_FAILED` (JDK, Android Studio, or plugin versions too old), `MIGRATION_FAILED`, `NOT_APPLICABLE`. + +## `wn integrate` + +Add a capability to an existing project. + +```bash +wn integrate capacitor [--app-id ] [--app-name ] [--web-dir

] [--platforms ios,android] +wn integrate pwa +wn integrate prettier [--husky] +``` + +`wn integrate capacitor` infers `webDir` from `angular.json` `outputPath` or the conventional `www`/`dist`/`build` folder when `--web-dir` is omitted. + +Error codes: `ALREADY_INTEGRATED`, `MISSING_INPUT`, `INTEGRATE_FAILED`. + +## `wn generate` + +Framework code generation. Currently Angular, delegating to `ng generate` with the project's package manager. + +```bash +wn generate [-- ] +wn generate --list [--json] +``` + +Schematics include `component`, `service`, `module`, `class`, `directive`, `pipe`, `guard`, `interceptor`, and `page` when `@ionic/angular-toolkit` is installed. + +```bash +wn generate component checkout/payment-form +wn generate service data -- --skip-tests +``` + +Also runs the codemod-style schematics the extension exposes: + +```bash +wn generate migration --list --json +wn generate migration control-flow +wn generate migration inject +wn generate migration signal-inputs +wn generate migration standalone-ionic +wn generate migration karma-to-vitest +wn generate migration application-builder +``` + +Error codes: `UNKNOWN_SCHEMATIC`, `NOT_ANGULAR`, `GENERATE_FAILED`. + +## `wn config` + +Read and write CLI settings. Read-only for `list` and `get`. + +```bash +wn config list [--json] +wn config get +wn config set [--global] +wn config unset [--global] +wn config ssl create +``` + +| Key | Type | Default | Description | +| --------------------- | ------- | --------------- | -------------------------------------------- | +| `defaultPort` | number | `8100` | Base dev server port. | +| `buildForProduction` | boolean | `false` | Default `--prod` for builds. | +| `packageManager` | enum | detected | `npm`, `pnpm`, `yarn`, `bun`. | +| `javaHome` | path | `$JAVA_HOME` | JDK for Android builds. | +| `androidSdk` | path | `$ANDROID_HOME` | Android SDK location. | +| `adbPath` | path | on `PATH` | adb binary. | +| `shellPath` | path | auto | Shell used to run commands. | +| `internalAddress` | boolean | `false` | Prefer the internal address for live reload. | +| `debugBrowser` | enum | `chrome` | `chrome` or `edge`. | +| `androidDebugWebRoot` | enum | `www` | `workspace` or `www`. | +| `telemetry` | boolean | `true` | Anonymous usage reporting. | + +Project settings live in `wn.json` at the project root; `--global` writes to `~/.config/wn/config.json`. Project values win, and command-line flags win over both. + +`wn config ssl create` generates the local certificate authority and server certificate used by `wn run web --https` and `wn run --live-reload --ssl`, and prints the path to the CA certificate that devices need to trust. + +--- + +## Configuration + +Precedence, highest first: + +1. Command-line flags +2. Environment variables +3. `wn.json` in the project root (or the sub-project root in a monorepo) +4. `~/.config/wn/config.json` +5. Built-in defaults + +```json +{ + "$schema": "https://webnative.dev/schema/wn.json", + "defaultPort": 4200, + "packageManager": "pnpm", + "buildForProduction": false, + "run": { + "ios": { "device": "iPhone 16 Pro" }, + "android": { "flavor": "staging" } + }, + "check": { + "ignore": ["deprecated-plugin-cordova-plugin-camera"], + "errorOn": "error" + } +} +``` + +`wn.json` is safe to commit. Nothing secret belongs in it; signing credentials come from environment variables or flags. + +## Environment variables + +| Variable | Description | +| --------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `WN_JSON` | Set to `1` to default every command to `--json`. | +| `WN_NO_INPUT` | Set to `1` to default every command to `--no-input`. | +| `WN_PACKAGE_MANAGER` | Default package manager. | +| `WN_PROJECT` | Default `--project` for monorepos. | +| `WN_TELEMETRY` | `0` disables anonymous usage reporting. | +| `WN_KEYSTORE_PATH`, `WN_KEYSTORE_PASSWORD`, `WN_KEYSTORE_ALIAS`, `WN_KEYSTORE_ALIAS_PASSWORD` | Android signing credentials for `wn release android`. | +| `JAVA_HOME`, `ANDROID_HOME` | Used when the matching config keys are unset. | +| `NO_COLOR` | Disables color. | + +## Prompting and non-interactive use + +A command prompts only when all of these hold: stdin is a TTY, `--no-input` was not passed, `--json` was not passed, and a required value is genuinely missing. + +Otherwise the command exits `5` and the JSON payload names the missing input and enumerates the valid choices, so a caller can pick one and re-run: + +```bash +$ wn run ios --json +{"ok":false,"command":"run ios","errors":[{"code":"MISSING_INPUT","input":"device","choices":[...],"hint":"Re-run with --device "}]} +``` + +`--yes` answers confirmations ("install these packages?", "overwrite this file?") with the default. It never fabricates a value for a missing required input — an agent still has to choose the device, the version, or the template explicitly. + +## Feature mapping from the extension + +| Extension feature | CLI equivalent | +| ------------------------------------ | ---------------------------------------------------------------- | +| Sidebar → Run → Web / Android / iOS | `wn run web` / `wn run android` / `wn run ios` | +| Live Reload toggle | `wn run --live-reload` | +| Device to run | `wn devices list`, `wn run --device`, `wn devices default --set` | +| Build | `wn build` | +| Build / Run Configuration | `--config ` | +| Sync | `wn sync` | +| Open in Xcode / Android Studio | `wn open xcode` / `wn open android-studio` | +| Prepare Release | `wn release ios` / `wn release android` | +| Debug → Web | `wn debug web` | +| Debug → Android WebViews | `wn debug android --list`, `wn debug android --webview` | +| Scripts section | `wn scripts list`, `wn scripts run` | +| Configuration → Properties | `wn native get` / `wn native set` | +| Configuration → Splash Screen & Icon | `wn assets set`, `wn assets generate` | +| Check for Minor Updates | `wn packages minor` | +| Security Audit | `wn packages audit` | +| Statistics | `wn packages size` | +| Export | `wn packages export` | +| Recommendations | `wn check`, `wn check --fix ` | +| Packages / Plugins sections | `wn packages list`, `wn plugins search`, `wn plugins info` | +| Plugin Explorer | `wn plugins search`, `wn plugins add` | +| Upgrade All Packages | `wn packages upgrade --all` | +| New Project wizard | `wn new` | +| Angular New → Component/Service/… | `wn generate ` | +| Angular migrations and schematics | `wn migrate angular`, `wn generate migration` | +| Capacitor migrations | `wn migrate capacitor` | +| Capacitor Migration (from Cordova) | `wn migrate cordova` | +| SPM Migration | `wn migrate spm` | +| Integrate Capacitor / Add PWA | `wn integrate capacitor` / `wn integrate pwa` | +| Add Android/iOS Project | `wn native add android` / `wn native add ios` | +| Privacy manifest | `wn native privacy check` / `wn native privacy add` | +| Use HTTPS | `wn run web --https`, `wn config ssl create` | +| Settings | `wn config` | +| Show Logs | `--verbose`, `wn run --background` log file | +| Stop / Restart | `wn stop` | + +## Out of scope + +These extension features are UI-specific and have no CLI equivalent: the device preview webview, the Nexus Browser preview window, "Open in Editor", the What's New page, clipboard command detection, inline quick fixes and auto-import code actions, and the error-assistant "Fix this error" inline chat action. The underlying diagnostics are still available as structured `error` events from `--stream-json`, so an agent can act on them itself. diff --git a/cli/package-lock.json b/cli/package-lock.json new file mode 100644 index 0000000..be540c4 --- /dev/null +++ b/cli/package-lock.json @@ -0,0 +1,1930 @@ +{ + "name": "wn", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wn", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@webnativellc/simple-plist": "2.1.2", + "fast-xml-parser": "5.7.3", + "globule": "1.3.4", + "htmlparser2": "^9.1.0", + "netmask": "2.0.2", + "rimraf": "6.1.2", + "semver": "7.7.3", + "xcode": "3.0.1" + }, + "bin": { + "wn": "dist/bin.js" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "@types/semver": "^7.7.1", + "typescript": "^5.8.3", + "vitest": "4.1.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@emnapi/core": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-2.0.0-alpha.3.tgz", + "integrity": "sha512-AZypUeJ/yByuxyS7BlSNRDOMLMlROYtjYdIAuBmJssVz1UJDSeYxLrdizhXCFYhedC5bqd/ASy8EuNXbVVXp9g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "2.0.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-2.0.0-alpha.3.tgz", + "integrity": "sha512-hFPAhMUjJD9BSyCANEISPOogeXC9Zo9ZQl7L6vKnaVsMkCtzznaW/naYypeyl0Gv5rYfWYsZbpixTMpjDJzQeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-2.0.1.tgz", + "integrity": "sha512-9DsSk+o5NBX0CCJT8s0EROGSGxjR/tKu6aBTaVyq+SjAEQH4XcdcRxPBRzsBLizTTJ49MJjF+jgu3qnO9GLQcQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", + "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" + } + }, + "node_modules/@nodable/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", + "integrity": "sha512-02hOeOSryYxVrOIphmLAsqnCJWxwlzFk+pEt/N/i6OgT3lShHO7xGCU5cpgchRDHboAEbSjzgGh+O/u1GswQmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.1.tgz", + "integrity": "sha512-fMsTOnN0OjFm3CyppWPitKnc8UlliVARUULW6cfU6AIqjdtgmSFWSk9vecHzZduv/yMWIHDlRhM1e8Iff9uAfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.1.tgz", + "integrity": "sha512-1wjKdz/XLGKHaTNHjQveQ/B23TKx4ItAqm1JbyVuvNPc4Ze0Fb48s49TAd/2zcplPl8okE/UbTgmlVfwT7eFeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.1.tgz", + "integrity": "sha512-Fa0jHR07E7YBN4vOEsbVf2briYNsuOowfLJaXULZM0ldMlaCaj2LJgLMbMe4iacRyZmvR8efFhgR9wKuGclQUg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.1.tgz", + "integrity": "sha512-pzkgu1SSHGgRRyRZ4fbmSgmajbVt+epaLP99NDjFft69v/ypfTi6swBMiVdh2EkQ0OSnHE1lZDM7DRGkyAzUpA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.1.tgz", + "integrity": "sha512-QI5SEDY8cbiYWHx0VO4vIc3UlS6a32vXHjU8Qy/17adEmZIPuByJg13UEvo9c/UCiUkdcVWY83C+b+JrwnNyUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.1.tgz", + "integrity": "sha512-Sm41FyCeXqmYcERoYOCbGIL5hNfd8w9LQ7Y61Bev48HkcjaJqV/iiVOaiDxjVTRMS+QKrZmD8cfPt4uMVnvM+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.1.tgz", + "integrity": "sha512-2x+WhXTGl9yJYPbltW/BSEPTVz9OIWQyER4N+gJEDWkkn904eRcBzELqh/Hf7K0w/ubGbKNMv0ZC+94QK/IFEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.1.tgz", + "integrity": "sha512-eEjmQpuRQayHPWWnywaWHkFT3ToPbP3RYy42VVd/B9aBGDA+Ol25EIWHxKQST3IiWJjikCWUF7KtbfqwZrzVwQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.1.tgz", + "integrity": "sha512-/Orga1fZYkLc/56jBICcHrKchl8Z2UKdDSr3LG9ToWO1lQ6a4Livk9Xz+9WN91zsz5QR3XQz2NNoSDEvP6qadw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.1.tgz", + "integrity": "sha512-xxBJRL+0q0Kce7orznGWLuylHDY65vuARXZRpX+hPdv+DqK2c3NlCsVA98tlWzWNEE7yPqA/1NQ5nnCrj49Y5A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.1.tgz", + "integrity": "sha512-M6AdXIXw3s+/8XpKMzdGDEXGS1S7kwUsy+rcTIUIOx5Ge4nXKCtAFHFV9YKkXvGcC5WMoTjAteLzlsQROVI0Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.1.tgz", + "integrity": "sha512-/TX0SoRGojHzSAHpfVBbavRVSazg5U3h3Y3VXfcc0cdugq6kxdqw8LPGFiPr+/7gE/60zRcsOY2Vi9b9eT0jww==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "2.0.0-alpha.3", + "@emnapi/runtime": "2.0.0-alpha.3", + "@napi-rs/wasm-runtime": "^1.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.1.tgz", + "integrity": "sha512-EvRrivJieyHG+AO9lleZWgq+g0+S7oV2C51yuqlcyU/R9net+sI4Pj0F+lUoP2bEr6TWX3SqFaaS0SzfLxSzkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.1.tgz", + "integrity": "sha512-Z4eCmn5QJ/5+azF9knpLWKfVd9aidn0mAe9TpJgvBLId9Ax3t0+JVxBmT25Bv7NBbVW1TZyKjQjQReouMeH5UQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@webnativellc/simple-plist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@webnativellc/simple-plist/-/simple-plist-2.1.2.tgz", + "integrity": "sha512-LNMg6IkVbKLowOM4z5E8ZcMXQvC8qQMWFkD+Q1s8a/bzNMlnuD1dZZ5nVxs/atabRgNpnTdytq+FLwSFRaFSYg==", + "license": "MIT" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz", + "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globule": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.4.tgz", + "integrity": "sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg==", + "license": "MIT", + "dependencies": { + "glob": "~7.1.1", + "lodash": "^4.17.21", + "minimatch": "~3.0.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/htmlparser2": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.1.0", + "entities": "^4.5.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz", + "integrity": "sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/netmask": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", + "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rimraf": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.2.tgz", + "integrity": "sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==", + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.0", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rolldown": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.1.tgz", + "integrity": "sha512-4FKJhg8d3OiyQOA6Q1Q0hoFFpW9/OoX+VsHzpECsdsIZoOArrAK90gl59YK/Z+gnDel45bgJZK03ozH/9bCqEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.1", + "@rolldown/binding-darwin-arm64": "1.2.1", + "@rolldown/binding-darwin-x64": "1.2.1", + "@rolldown/binding-freebsd-x64": "1.2.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.1", + "@rolldown/binding-linux-arm64-gnu": "1.2.1", + "@rolldown/binding-linux-arm64-musl": "1.2.1", + "@rolldown/binding-linux-ppc64-gnu": "1.2.1", + "@rolldown/binding-linux-s390x-gnu": "1.2.1", + "@rolldown/binding-linux-x64-gnu": "1.2.1", + "@rolldown/binding-linux-x64-musl": "1.2.1", + "@rolldown/binding-openharmony-arm64": "1.2.1", + "@rolldown/binding-wasm32-wasi": "1.2.1", + "@rolldown/binding-win32-arm64-msvc": "1.2.1", + "@rolldown/binding-win32-x64-msvc": "1.2.1" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "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/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", + "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + } + } +} diff --git a/cli/package.json b/cli/package.json new file mode 100644 index 0000000..a367a0f --- /dev/null +++ b/cli/package.json @@ -0,0 +1,40 @@ +{ + "name": "wn", + "version": "0.1.0", + "description": "WebNative CLI — run, build, sync, and manage web and native apps", + "license": "MIT", + "bin": { + "wn": "dist/bin.js" + }, + "main": "dist/bin.js", + "files": [ + "dist", + "README.md", + "docs.md" + ], + "scripts": { + "build": "tsc -p ./ && node -e \"require('fs').chmodSync('dist/bin.js', 0o755)\"", + "watch": "tsc -p ./ --watch", + "test": "npx vitest run", + "clean": "rm -rf dist" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "@webnativellc/simple-plist": "2.1.2", + "fast-xml-parser": "5.7.3", + "globule": "1.3.4", + "htmlparser2": "^9.1.0", + "netmask": "2.0.2", + "rimraf": "6.1.2", + "semver": "7.7.3", + "xcode": "3.0.1" + }, + "devDependencies": { + "@types/node": "^22.13.10", + "@types/semver": "^7.7.1", + "typescript": "^5.8.3", + "vitest": "4.1.5" + } +} diff --git a/cli/skill/README.md b/cli/skill/README.md new file mode 100644 index 0000000..53c9588 --- /dev/null +++ b/cli/skill/README.md @@ -0,0 +1,21 @@ +# WebNative CLI agent skills + +Agent skills for the `npx wn` CLI, split by how often the commands get used. Each `SKILL.md` is self-contained — it repeats the CLI contract (JSON output, exit codes, non-interactive behaviour) so an agent that loads only one still behaves correctly. + +| Skill | Covers | Commands | +| ----------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------- | +| [web-native](./web-native/SKILL.md) | The daily loop | `info`, `run`, `build`, `sync`, `devices`, `debug`, `scripts`, `stop`, `release` | +| [web-native-config](./web-native-config/SKILL.md) | Project and native configuration | `native`, `assets`, `new`, `generate`, `integrate`, `config` | +| [web-native-packages](./web-native-packages/SKILL.md) | Dependencies and plugins | `packages`, `plugins` | +| [web-native-migrate](./web-native-migrate/SKILL.md) | Health checks and version moves | `check`, `migrate`, `generate migration` | + +`web-native` is the one to load by default. The other three are for less frequent, higher-risk work and carry the extra guardrails that work needs. + +Full CLI specification: [../docs.md](../docs.md). + +## Installing + +Copy a skill directory into either location: + +- `~/.cursor/skills//` — personal, available in every project +- `.cursor/skills//` — project-scoped, shared with the repository diff --git a/cli/skill/web-native-config/SKILL.md b/cli/skill/web-native-config/SKILL.md new file mode 100644 index 0000000..65c80ca --- /dev/null +++ b/cli/skill/web-native-config/SKILL.md @@ -0,0 +1,187 @@ +--- +name: web-native-config +description: Configure native mobile project settings and scaffold projects with the WebNative CLI (`npx wn`). Use when setting a Capacitor app's bundle id or app id, display name, version number, or build number, adding an iOS or Android platform, generating app icons and splash screens, handling the Apple privacy manifest, creating a new project from a starter template, generating Angular components, services, or modules, integrating Capacitor or a PWA into an existing web project, or changing CLI settings such as the default port, package manager, or JDK path. +--- + +# WebNative: configuration and scaffolding + +`npx wn` commands for native project settings, assets, scaffolding, and CLI configuration. For the run/build/release loop see [Related skills](#related-skills). + +## CLI contract + +- **Always pass `--json`.** Stdout is one object: `{ ok, command, data, errors[], warnings[], durationMs }`. Branch on `errors[].code`, never on message text. +- **`--json` implies no prompting.** Missing required input exits `5` with the valid values in `errors[0].choices` and the flag to use in `errors[0].hint`. Choose one and re-run — never guess an id. +- **Run `wn info --json` first** to learn the framework, package manager, and which Capacitor platforms exist. +- **In a monorepo pass `--project `**, from `wn info`'s `monorepo.projects`. +- **`--dry-run`** prints the shell commands without running them. **`--yes`** accepts confirmations but never supplies a missing required value. +- Exit codes: `0` ok, `1` ran but failed, `2` usage error, `3` no project found, `4` missing prerequisite, `5` missing input, `6` blocking check findings, `7` timeout. + +Full option lists and payload shapes: [docs.md](../../docs.md). + +## Native project settings + +```bash +wn native get --json # read-only +wn native set app-id com.example.storefront --json +wn native set app-name "Storefront" --json +wn native set version 2.4.0 --json +wn native set build 128 --json +``` + +Properties: `app-id` (bundle identifier), `app-name` (display name), `version`, `build` (build number). + +Without `--platform`, `set` writes both native projects and the Capacitor config, which is what you almost always want. Add `--platform ios` or `--platform android` only when the user explicitly wants the platforms to diverge: + +```bash +wn native set build 128 --platform ios --json +``` + +`wn native get --json` returns per-platform values, so mismatches between iOS and Android are visible before you write: + +```json +{ + "ios": { "appId": "com.example.storefront", "appName": "Storefront", "version": "2.4.0", "build": "128" }, + "android": { "appId": "com.example.storefront", "appName": "Storefront", "version": "2.4.0", "build": "127" } +} +``` + +Changing `app-id` renames Java package folders on Android and rewrites the Xcode project. Use `--dry-run` first and make sure the working tree is committed. + +Error codes: `PLATFORM_NOT_ADDED`, `INVALID_VALUE` (bundle id or version format), `WRITE_FAILED`. + +## Platforms and privacy manifest + +```bash +wn native add ios --json +wn native add android --json +wn native privacy check --json # read-only +wn native privacy add --json +``` + +`wn native privacy check` reports whether `PrivacyInfo.xcprivacy` exists and which Apple required-reason API categories the installed plugins need. Run it after adding an iOS plugin. `wn native privacy add` creates the file, registers it in the Xcode project, and adds the reason codes. + +## Icons and splash screens + +```bash +wn assets list --json # read-only: which source images exist +wn assets set icon ./design/icon-1024.png --json +wn assets set splash ./design/splash.png --json +wn assets generate --ios --android --json +``` + +Asset types: `splash`, `splash-dark`, `icon`, `icon-foreground`, `icon-background`. + +`wn assets set` copies a file into `resources/` under the expected name. `wn assets generate` produces every platform size and adds the generated output to `.gitignore`; with no target flag it generates for all installed platforms. Add `--pwa` for web manifest icons. + +Error codes: `MISSING_SOURCE_ASSET`, `INVALID_IMAGE` (wrong dimensions), `GENERATE_FAILED`. + +## New projects + +```bash +wn new --list-templates --json # read-only +wn new storefront --template ionic-angular-tabs --targets web,ios,android --json +``` + +List templates before choosing one — never invent a template id. Template ids cover Ionic Angular/React/Vue, Angular CLI, Vite (react, vue, svelte, solid, preact, lit, qwik), Next.js, Nuxt, Astro, SvelteKit, TanStack Start, Hydrogen, and Capacitor plugins. + +`--targets` adds and configures the named Capacitor platforms. Other flags: `--framework ` to filter the template list, `--dir `, `--package-manager `, `--no-install`, `--no-git`. + +The template choice determines the whole project — ask the user rather than picking for them. + +## Integrations + +```bash +wn integrate capacitor --app-id com.example.app --json +wn integrate pwa --json +wn integrate prettier --husky --json +``` + +`wn integrate capacitor` infers `webDir` from `angular.json` `outputPath` or the conventional `www`/`dist`/`build` folder. Pass `--web-dir ` when the project is unconventional, and `--platforms ios,android` to add platforms in the same step. + +Error codes: `ALREADY_INTEGRATED`, `MISSING_INPUT`, `INTEGRATE_FAILED`. + +## Angular code generation + +```bash +wn generate --list --json +wn generate component checkout/payment-form --json +wn generate service data -- --skip-tests --json +``` + +Schematics: `component`, `service`, `module`, `class`, `directive`, `pipe`, `guard`, `interceptor`, and `page` when `@ionic/angular-toolkit` is installed. Arguments after `--` pass through to `ng generate`. + +Prefer this over calling `ng generate` directly — `wn` runs it from the correct project folder with the project's package manager, which matters in a monorepo. + +## CLI settings + +```bash +wn config list --json +wn config get defaultPort --json +wn config set defaultPort 4200 --json +wn config set packageManager pnpm --global --json +wn config ssl create --json +``` + +| Key | Default | Purpose | +| -------------------- | --------------- | ------------------------------------------- | +| `defaultPort` | `8100` | Base dev server port | +| `buildForProduction` | `false` | Default `--prod` for builds | +| `packageManager` | detected | `npm`, `pnpm`, `yarn`, `bun` | +| `javaHome` | `$JAVA_HOME` | JDK for Android builds | +| `androidSdk` | `$ANDROID_HOME` | Android SDK location | +| `adbPath` | on `PATH` | adb binary | +| `internalAddress` | `false` | Prefer the internal address for live reload | +| `debugBrowser` | `chrome` | `chrome` or `edge` | +| `telemetry` | `true` | Anonymous usage reporting | + +Project settings live in `wn.json` at the project root; `--global` writes to `~/.config/wn/config.json`. Precedence is flags, then environment variables, then `wn.json`, then global config, then defaults. + +`wn.json` is safe to commit. Signing credentials never belong in it — they come from `WN_KEYSTORE_*` environment variables. + +`wn config ssl create` generates the local certificate used by `wn run web --https` and prints the CA certificate path that devices must trust. + +--- + +## Workflows + +### Set up a new app's identity + +``` +- [ ] wn native get --json -> see current values and any iOS/Android drift +- [ ] wn native set app-id --json +- [ ] wn native set app-name "" --json +- [ ] wn assets set icon --json / wn assets set splash --json +- [ ] wn assets generate --json +- [ ] wn sync --json +``` + +### Bump the version before a release + +``` +- [ ] wn native get --json -> read the current version and build +- [ ] wn native set version --json +- [ ] wn native set build --json +- [ ] wn native get --json -> confirm both platforms match +``` + +### Add a platform to an existing project + +``` +- [ ] wn info --json -> confirm it is a Capacitor project +- [ ] wn native add android --json +- [ ] wn assets generate --android --json +- [ ] wn sync android --json +``` + +## What not to do + +- Do not edit `capacitor.config.ts`, `Info.plist`, `build.gradle`, `strings.xml`, or the Xcode project by hand for these properties. `wn native set` keeps the Capacitor config and both native projects consistent; hand edits drift. +- Do not change `app-id` on a dirty working tree — it rewrites native files and renames folders. +- Do not invent a template id or an asset type. Run `wn new --list-templates` or `wn assets list` first. +- Do not put secrets in `wn.json`. + +## Related skills + +- **`web-native`** — run, build, sync, debug, and release. +- **`web-native-packages`** — dependencies, upgrades, security audit, Capacitor plugins. +- **`web-native-migrate`** — health checks and guided migrations. diff --git a/cli/skill/web-native-migrate/SKILL.md b/cli/skill/web-native-migrate/SKILL.md new file mode 100644 index 0000000..ce1e0f0 --- /dev/null +++ b/cli/skill/web-native-migrate/SKILL.md @@ -0,0 +1,177 @@ +--- +name: web-native-migrate +description: Run project health checks and guided migrations with the WebNative CLI (`npx wn`). Use when auditing a project for problems or recommendations, gating CI on project health, upgrading Capacitor across a major version, migrating Angular to a newer version, converting a Cordova project to Capacitor, migrating iOS from CocoaPods to Swift Package Manager, switching package manager to pnpm or bun, running Angular codemods such as built-in control flow, inject, or signal inputs, or fixing deprecated packages, plugins, and TypeScript compiler options. +--- + +# WebNative: health checks and migrations + +`npx wn` commands for auditing a project and moving it forward a version. These commands change many files at once — the workflows below exist to make that safe. For other tasks see [Related skills](#related-skills). + +## CLI contract + +- **Always pass `--json`.** Stdout is one object: `{ ok, command, data, errors[], warnings[], durationMs }`. Branch on `errors[].code`, never on message text. +- **`--json` implies no prompting.** Missing required input exits `5` with the valid values in `errors[0].choices` and the flag to use in `errors[0].hint`. Choose one and re-run — never guess a target version or a finding id. +- **Run `wn info --json` first** to learn the framework, package manager, Capacitor version, and monorepo layout. +- **In a monorepo pass `--project `**, from `wn info`'s `monorepo.projects`. +- **`--dry-run`** prints the steps and file changes without applying them. **`--yes`** accepts confirmations but never supplies a missing required value. +- Exit codes: `0` ok, `1` ran but failed, `2` usage error, `3` no project found, `4` missing prerequisite, `5` missing input, `6` blocking check findings, `7` timeout. + +Full option lists and payload shapes: [docs.md](../../docs.md). + +## Health checks + +```bash +wn check --json # all findings, read-only +wn check --severity error --json +wn check --category capacitor,packages --json +wn check --error-on error --json # CI gate: exits 6 on any error-level finding +wn check --fix --json # apply one fix +wn check --ignore --json +wn check --show-ignored --json +``` + +Findings carry a stable `id`, `severity` (`error`, `warning`, `info`), `category`, `title`, `detail`, and `fixable`: + +```json +{ + "findings": [ + { + "id": "capacitor-version-mismatch", + "severity": "error", + "category": "capacitor", + "title": "@capacitor/android is 6.2.0 but @capacitor/core is 7.2.0", + "detail": "All @capacitor/* packages must share a major version.", + "fixable": true + } + ], + "counts": { "error": 1, "warning": 3, "info": 4, "ignored": 2 } +} +``` + +Categories: `packages`, `capacitor`, `cordova`, `angular`, `typescript`, `android`, `ios`, `browserslist`, `security`, `privacy`. Findings cover Capacitor version consistency and plugin compatibility, deprecated and end-of-life packages, Cordova plugin problems, `config.xml` and `AndroidManifest.xml` settings, `angular.json` issues, deprecated TypeScript compiler options, browserslist, missing platforms, missing privacy manifest entries, and Android `minifyEnabled`. + +**Fix findings one at a time**, re-running `wn check` after each. Use `--fix-all` only when the user explicitly asks for a bulk cleanup on a clean git tree. + +Error codes: `UNKNOWN_FINDING`, `NOT_FIXABLE`, `FIX_FAILED`. + +## Migrations + +```bash +wn migrate list --json # what applies here, read-only +wn migrate capacitor --to 8 --json +wn migrate angular --to 20 --json +wn migrate cordova --json +wn migrate cordova --remove --json +wn migrate spm --json +wn migrate package-manager pnpm --json +``` + +| Command | What it does | +| ----------------- | ----------------------------------------------------------------------------------------------------------------- | +| `capacitor` | Moves to the next Capacitor major (or `--to `), including native file changes and minimum plugin versions. | +| `angular` | Migrates Angular one major at a time. `--all` repeats to the latest supported major. | +| `cordova` | Converts a Cordova project to Capacitor. `--remove` strips the Cordova project afterwards. | +| `spm` | Migrates iOS from CocoaPods to Swift Package Manager. | +| `package-manager` | Converts the project to `pnpm`, `bun`, `npm`, or `yarn`. | + +**Always `--dry-run` first and report the plan to the user before applying it.** + +Preconditions the CLI enforces, surfaced as exit `1` with a specific code: + +- `DIRTY_GIT` — commit or stash first. Do not pass `--yes` to bypass this; the guard is the only way to undo a bad migration. +- `PRECONDITION_FAILED` — a required tool is too old (JDK, Android Studio, CocoaPods) or an installed plugin has no version compatible with the target. `errors[0]` names it. +- `NOT_APPLICABLE` — the migration does not apply to this project. + +## Angular codemods + +```bash +wn generate migration --list --json +wn generate migration control-flow --json +wn generate migration inject --json +wn generate migration signal-inputs --json +wn generate migration standalone-ionic --json +wn generate migration karma-to-vitest --json +wn generate migration application-builder --json +``` + +These are optional Angular schematics, separate from a version migration. They rewrite source files across the project — commit first, run one at a time, and build after each. + +--- + +## Workflows + +### Diagnose a project + +``` +- [ ] wn info --json +- [ ] wn check --json +- [ ] Group findings by severity; report errors first with their ids +- [ ] Propose fixes and let the user choose +- [ ] wn check --fix --json, one at a time +- [ ] wn check --json to confirm, then wn build --json +``` + +Report findings by title and severity. Do not apply fixes the user did not ask for — some rewrite native files or change dependency versions. + +### Capacitor major upgrade + +``` +- [ ] git status -> must be clean +- [ ] wn info --json -> current Capacitor version +- [ ] wn migrate list --json -> confirm the target is available +- [ ] wn migrate capacitor --to --dry-run --json -> report the plan +- [ ] wn migrate capacitor --to --json +- [ ] wn check --json -> plugin compatibility fallout +- [ ] wn build --json && wn sync --json +- [ ] wn run ios --device --json -> verify on both platforms +``` + +Do one major at a time. If `PRECONDITION_FAILED` names an incompatible plugin, upgrade or replace that plugin first (see `web-native-packages`). + +### Angular version migration + +``` +- [ ] git status -> must be clean; do not override with --yes +- [ ] wn migrate list --json -> the next supported major +- [ ] wn migrate angular --to --dry-run --json +- [ ] wn migrate angular --to --json +- [ ] wn build --json +- [ ] wn scripts run test --json +- [ ] Commit before moving to the next major +``` + +Angular migrates one major at a time by design. `--all` chains them, but a failure mid-chain is much harder to unpick — prefer stepping manually and committing between steps. + +### Cordova to Capacitor + +``` +- [ ] wn check --category cordova --json -> plugins with no Capacitor equivalent +- [ ] Report unsupported plugins to the user and agree on replacements first +- [ ] wn migrate cordova --dry-run --json +- [ ] wn migrate cordova --json +- [ ] wn native add ios --json / wn native add android --json +- [ ] wn assets generate --json +- [ ] wn build --json && wn sync --json +- [ ] wn migrate cordova --remove --json -> only after the app runs +``` + +### CI health gate + +```bash +wn check --error-on error --json # exit 6 means blocking findings exist +``` + +## What not to do + +- Do not run a migration without `--dry-run` first. +- Do not bypass the dirty-git guard with `--yes`. Commit or stash instead. +- Do not chain multiple majors in one run when the user needs to review the result. +- Do not use `wn check --fix-all` as a default; fix findings individually so each change is attributable. +- Do not invent a finding id or a target version. Run `wn check --json` or `wn migrate list --json` first. +- Do not hand-edit native files that a migration owns (`Podfile`, `build.gradle`, `AndroidManifest.xml`, `project.pbxproj`) while a migration is pending — re-run the migration instead. + +## Related skills + +- **`web-native`** — run, build, sync, debug, and release. +- **`web-native-config`** — bundle id, app name, version and build number, icons and splash screens, new projects. +- **`web-native-packages`** — dependencies, upgrades, security audit, Capacitor plugins. diff --git a/cli/skill/web-native-packages/SKILL.md b/cli/skill/web-native-packages/SKILL.md new file mode 100644 index 0000000..541049b --- /dev/null +++ b/cli/skill/web-native-packages/SKILL.md @@ -0,0 +1,174 @@ +--- +name: web-native-packages +description: Manage dependencies and Capacitor plugins with the WebNative CLI (`npx wn`). Use when listing outdated packages, upgrading a dependency to a specific or latest version, applying minor and patch updates, running a security audit for vulnerabilities, resolving unmet peer dependencies, finding deprecated or end-of-life packages, searching for and installing a Capacitor or Cordova plugin, checking plugin platform compatibility or Android permissions, or analyzing bundle size across npm, pnpm, yarn, and bun projects. +--- + +# WebNative: dependencies and plugins + +`npx wn` commands for packages and Capacitor plugins. Works across npm, pnpm, yarn, and bun — the package manager is detected from the lockfile. For other tasks see [Related skills](#related-skills). + +## CLI contract + +- **Always pass `--json`.** Stdout is one object: `{ ok, command, data, errors[], warnings[], durationMs }`. Branch on `errors[].code`, never on message text. +- **`--json` implies no prompting.** Missing required input exits `5` with the valid values in `errors[0].choices` and the flag to use in `errors[0].hint`. Choose one and re-run — never guess a version or package name. +- **Run `wn info --json` first** to learn the package manager, framework, and Capacitor version. +- **In a monorepo pass `--project `**, from `wn info`'s `monorepo.projects`. `wn packages audit` is workspace-wide and ignores it. +- **`--dry-run`** prints the shell commands without running them. **`--yes`** accepts confirmations but never supplies a missing required value. +- Exit codes: `0` ok, `1` ran but failed, `2` usage error, `3` no project found, `4` missing prerequisite, `5` missing input, `6` blocking check findings, `7` timeout. + +Full option lists and payload shapes: [docs.md](../../docs.md). + +## Inspect + +```bash +wn packages list --json +wn packages list --outdated --json +wn packages list --plugins --json +wn packages peers --json +wn packages audit --json +``` + +All read-only. `wn packages list` reports `current`, `wanted`, `latest`, and `deprecated` per package: + +```json +{ + "packages": [ + { "name": "@angular/core", "current": "19.2.1", "wanted": "19.2.9", "latest": "20.1.0", "type": "dependency" }, + { "name": "moment", "current": "2.30.1", "latest": "2.30.1", "deprecated": true, "type": "dependency" } + ], + "plugins": [{ "name": "@capacitor/camera", "current": "7.0.1", "latest": "7.0.2", "ios": true, "android": true }] +} +``` + +`wanted` is the highest version satisfying the existing range — safe. `latest` may be a new major — needs review. + +## Install and remove + +```bash +wn packages add date-fns --json +wn packages add vitest --dev --json +wn packages remove moment --json +wn packages install --json # install all dependencies +wn packages install --frozen-lockfile --json +``` + +In a Capacitor project these run `cap sync` afterwards. Pass `--no-sync` to batch several changes and sync once at the end. + +## Upgrade + +```bash +wn packages upgrade @capacitor/core --version 7.2.0 --json +wn packages upgrade @angular/core --latest --json +wn packages minor --json # report available minor/patch updates +wn packages minor --apply --json # install them in one step +wn packages upgrade --all --json # everything outdated, including majors +``` + +Order of preference, safest first: `wn packages minor --apply` for routine maintenance, then single-package `wn packages upgrade --version` for majors, one at a time with a build in between. Reserve `wn packages upgrade --all` for a project the user has explicitly asked to bring fully up to date on a clean git tree. + +**All `@capacitor/*` packages must share a major version** — `@capacitor/core`, `@capacitor/ios`, `@capacitor/android`, `@capacitor/cli`. Upgrade them together in one step, or `wn check` will report a version mismatch. For a Capacitor major upgrade use `wn migrate capacitor` instead, which also handles the native file changes. + +Error codes: `UNKNOWN_PACKAGE`, `INSTALL_FAILED`, `INCOMPATIBLE_VERSION`. + +## Security and peer dependencies + +```bash +wn packages audit --json +wn packages audit --severity high --json +wn packages audit --fix --json +wn packages peers --json +wn packages peers --fix --json +``` + +`wn packages audit` filters `npm audit` to direct dependencies, so the findings are ones the project can actually act on. `--fix` may change major versions; review the output before applying it to a production project. + +`wn packages peers` reports unmet peer dependencies and proposes compatible versions. + +## Capacitor plugins + +```bash +wn plugins search camera --json +wn plugins search geolocation --platform ios --official --json +wn plugins info @capacitor/camera --json +wn plugins add @capacitor/camera --json +wn plugins remove @capacitor/camera --json +wn plugins permissions --json +``` + +`wn plugins info` returns npm metadata, repository stats, supported platforms, and — importantly — the version compatible with this project's Capacitor major. `wn plugins add` resolves that version automatically unless you pass `--version`, then runs `cap sync`. + +`wn plugins permissions` lists the Android permissions and features each installed plugin contributes, parsed from its `plugin.xml`. Run it before shipping to explain what the app requests. + +Error codes: `UNKNOWN_PLUGIN`, `NO_COMPATIBLE_VERSION`, `INSTALL_FAILED`. + +## Analysis + +```bash +wn packages size --json # production build plus per-module and per-asset bundle sizes +wn packages export --json # writes project-summary.md +``` + +`wn packages export` produces a per-dependency report with version, last release date, and a maintenance rating — useful for answering "what in here is unmaintained?". + +`wn packages size` runs a production build, so it is slow. Do not run it as a routine check. + +--- + +## Workflows + +### Routine dependency maintenance + +``` +- [ ] wn packages list --outdated --json +- [ ] wn packages minor --json -> review what would change +- [ ] wn packages minor --apply --json +- [ ] wn build --json -> confirm it still builds +- [ ] wn packages audit --json +``` + +### Upgrade one package across a major version + +``` +- [ ] wn packages list --outdated --json -> confirm current and latest +- [ ] Check the package's own migration notes if it is a framework package +- [ ] wn packages upgrade --version --json +- [ ] wn packages peers --json -> catch newly unmet peers +- [ ] wn build --json +``` + +Repeat per package. Never batch several majors into one step — a failure becomes impossible to attribute. + +### Add a native capability + +``` +- [ ] wn plugins search --json +- [ ] wn plugins info --json -> check platforms and compatible version +- [ ] wn plugins add --json +- [ ] wn native privacy check --json -> iOS may now need a reason code +- [ ] wn run ios --device --json -> verify on a device +``` + +### Respond to a vulnerability report + +``` +- [ ] wn packages audit --json -> severity counts and affected direct deps +- [ ] wn packages upgrade --version --json -> targeted fix, preferred +- [ ] wn build --json +- [ ] wn packages audit --json -> confirm resolved +``` + +Use `wn packages audit --fix` only when a targeted upgrade is not available, and tell the user it may introduce breaking major versions. + +## What not to do + +- Do not invent a version number or a plugin name. Run `wn packages list`, `wn plugins search`, or `wn plugins info` first. +- Do not run `wn packages upgrade --all` on a project with uncommitted changes. +- Do not upgrade `@capacitor/*` packages individually — they must stay on the same major. +- Do not edit `package.json` dependency versions by hand and expect the lockfile to follow; use `wn packages add`/`upgrade`. +- Do not run `wn packages size` casually; it triggers a full production build. + +## Related skills + +- **`web-native`** — run, build, sync, debug, and release. +- **`web-native-config`** — bundle id, app name, version and build number, icons and splash screens, new projects. +- **`web-native-migrate`** — health checks and guided Capacitor, Angular, and Cordova migrations. diff --git a/cli/skill/web-native/SKILL.md b/cli/skill/web-native/SKILL.md new file mode 100644 index 0000000..302d5ed --- /dev/null +++ b/cli/skill/web-native/SKILL.md @@ -0,0 +1,138 @@ +--- +name: web-native +description: Run, build, sync, debug, and release web and mobile apps with the WebNative CLI (`npx wn`). Use for the day-to-day development loop on a project using Capacitor, Cordova, Ionic, Angular, React, Vue, Svelte, Next.js, Nuxt, Astro, or Vite — starting a dev server, running on an iOS or Android device, simulator, or emulator, live reload, building the web app, `cap sync`, debugging a WebView, running package.json scripts, and producing a signed ipa, apk, or aab. +--- + +# WebNative: run, build, and ship + +`npx wn` performs the everyday development loop: serve, build, sync, run on a device, debug, and release. For other tasks see [Related skills](#related-skills). + +## CLI contract + +- **Always pass `--json`.** Stdout is one object: `{ ok, command, data, errors[], warnings[], durationMs }`. Branch on `errors[].code`, never on message text. +- **`--json` implies no prompting.** Missing required input exits `5` with the valid values in `errors[0].choices` and the flag to use in `errors[0].hint`. Choose one and re-run — never guess an id. +- **Run `wn info --json` first** in any session. It reports the framework, package manager, Capacitor platforms, scripts, and monorepo layout that every other command depends on. +- **In a monorepo pass `--project `**, from `wn info`'s `monorepo.projects`. +- **`--dry-run`** prints the shell commands without running them. **`--yes`** accepts confirmations but never supplies a missing required value. +- Exit codes: `0` ok, `1` ran but failed, `2` usage error, `3` no project found, `4` missing prerequisite (Xcode, Android SDK, JDK, CocoaPods, adb, node_modules), `5` missing input, `6` blocking check findings, `7` timeout. + +Full option lists and payload shapes: [docs.md](../../docs.md). + +## Inspect + +```bash +wn info --json # framework, package manager, platforms, scripts, toolchain versions +wn devices list --json # run targets with their ids +wn scripts list --json # package.json scripts and NX targets +``` + +All read-only. `wn devices list --platform ios` narrows to one platform. + +## Run + +```bash +wn run web --port 4200 --background --json # returns url, externalUrl, pid, logFile +wn run ios --device --json +wn run android --device --live-reload --json +wn stop --json # stops everything wn started for this project +``` + +**Always use `--background` for `wn run web`.** In the foreground the command never returns. It prints a `pid` and a `logFile`; read the log file to check server output and call `wn stop` when finished. + +`wn run ios` and `wn run android` need `--device ` from `wn devices list --json`. Omitting it when several targets exist exits `5` with the choices. + +Useful flags: `--live-reload` (point the native app at the dev server), `--config ` (an `angular.json` configuration or a `.env.` file), `--no-sync`, `--flavor ` for Android product flavors, `--prod`. + +## Build and sync + +```bash +wn build --prod --json +wn build ios --prod --json # web build, then cap copy ios +wn sync --json # cap sync: copy web assets, update native deps +wn sync android --no-build --json +``` + +The build script resolves in this order: `wn:build`, `ionic:build`, `build`, then the framework default. Do not call `vite build` or `ng build` directly — `wn` picks the right one, applies the build configuration, and copies into the native projects. + +On failure, `errors[]` contains parsed compiler errors with `file`, `line`, and `column` for TypeScript, ESLint, ESBuild, Vite, Next.js, Vue, Jest, Jasmine, Java, and Swift/Xcode. Add `--stream-json` to receive those as newline-delimited events while the build runs instead of only at the end. + +## Debug + +```bash +wn debug web --browser chrome --json +wn debug android --list --json # debuggable WebViews on connected devices +wn debug android --webview --json # prints a cdpUrl to attach to +``` + +## Release + +```bash +wn release ios --json +wn release android --type aab --json +``` + +Android signing comes from `WN_KEYSTORE_PATH`, `WN_KEYSTORE_PASSWORD`, `WN_KEYSTORE_ALIAS`, and `WN_KEYSTORE_ALIAS_PASSWORD`. Never write credentials into a committed file and never echo them. The success payload contains `artifactPath`. + +--- + +## Workflows + +### Run on a device + +``` +- [ ] wn info --json -> confirm the platform is installed +- [ ] wn devices list --platform ios --json +- [ ] wn run ios --device --json +``` + +If the platform is not installed, add it first (`wn native add ios`, see `web-native-config`). On exit `4`, report the missing tool — do not attempt to install Xcode or Android Studio. + +### Fix a failing build + +``` +- [ ] wn build --json +- [ ] Read errors[] for file/line/column +- [ ] Edit the source +- [ ] wn build --json until it passes +``` + +### Ship a release build + +``` +- [ ] wn check --error-on error --json -> stop and report if it exits 6 +- [ ] wn native set version --json -> see web-native-config +- [ ] wn native set build --json +- [ ] wn sync --json +- [ ] wn release ios --json / wn release android --type aab --json +``` + +## Handling exit code 5 + +The most common non-fatal outcome. Read `errors[0]`: + +```json +{ + "code": "MISSING_INPUT", + "input": "device", + "choices": [ + { "id": "1B2C3D4E-...", "name": "iPhone 16 Pro", "type": "simulator" }, + { "id": "00008120-...", "name": "Damian's iPhone", "type": "device" } + ], + "hint": "Re-run with --device " +} +``` + +Pick from `choices` and re-run with the flag from `hint`. When the choice is consequential — which physical device, which build configuration — ask the user rather than deciding for them. + +## What not to do + +- Do not run `wn run web` in the foreground. +- Do not invent a device id. Run `wn devices list --json` first. +- Do not fall back to raw `cap`, `ng`, `vite`, or `npm run` commands when a `wn` command exists; `wn` resolves the project's script names, port, package manager, and platform, which a raw command will get wrong. +- Do not leave background dev servers running after a task; call `wn stop`. + +## Related skills + +- **`web-native-config`** — bundle id, app name, version and build number, icons and splash screens, new projects, Angular generation, CLI settings. +- **`web-native-packages`** — outdated packages, upgrades, security audit, peer dependencies, Capacitor plugin search and install. +- **`web-native-migrate`** — project health checks and guided Capacitor, Angular, and Cordova migrations. diff --git a/cli/src/bin.ts b/cli/src/bin.ts new file mode 100644 index 0000000..758d19f --- /dev/null +++ b/cli/src/bin.ts @@ -0,0 +1,111 @@ +#!/usr/bin/env node +import { parseArgs } from './cli/args'; +import { failureEnvelope, successEnvelope } from './cli/envelope'; +import { isWnError } from './cli/errors'; +import { ExitCode } from './cli/exit-codes'; +import { printCommandHelp, printRootHelp } from './cli/help'; +import { resolveCommand } from './cli/registry'; +import { createContext } from './core/context'; +import { Output } from './cli/output'; +import { inspectProject } from './project/inspect'; +import './commands/index'; + +async function main(): Promise { + const start = Date.now(); + let parsed; + try { + parsed = parseArgs(process.argv.slice(2)); + } catch (err) { + process.stderr.write(`Usage error: ${(err as Error).message}\n`); + return ExitCode.UsageError; + } + + const output = new Output(parsed.global); + + if (parsed.global.version) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const pkg = require('../package.json'); + process.stdout.write(`${pkg.version}\n`); + return ExitCode.Success; + } + + if (parsed.tokens.length === 0) { + if (parsed.global.help) { + printRootHelp(); + return ExitCode.Success; + } + // Summary mode: detect project and suggest next commands + try { + const project = await inspectProject(parsed.global.cwd, parsed.global.project); + const summary = { + name: project.name, + type: project.type, + framework: project.frameworkType, + packageManager: project.packageManager, + next: ['wn info --json', 'wn check --json', 'wn run web --background --json'], + }; + if (parsed.global.json) { + output.writeJson(successEnvelope('', summary, Date.now() - start)); + } else { + process.stdout.write( + `WebNative project: ${summary.name} (${summary.type}, ${summary.framework}, ${summary.packageManager})\n` + + `Try: wn info --json | wn check --json | wn run web --background --json\n` + + `Help: wn --help\n`, + ); + } + return ExitCode.Success; + } catch { + printRootHelp(); + return ExitCode.Success; + } + } + + const { def, name } = resolveCommand(parsed); + if (!def) { + if (parsed.global.help) { + printRootHelp(); + return ExitCode.Success; + } + output.error(`Unknown command: ${name || '(none)'}`); + printRootHelp(); + return ExitCode.UsageError; + } + + if (parsed.global.help) { + printCommandHelp(def.name, def.description); + return ExitCode.Success; + } + + const ctx = createContext(parsed, def.name); + + try { + const data = await def.run(ctx); + const envelope = successEnvelope(def.name, data, Date.now() - start, ctx.warnings); + if (parsed.global.json || parsed.global.streamJson) { + output.writeJson(envelope); + } else { + output.humanResult(envelope); + } + return ExitCode.Success; + } catch (err) { + const envelope = failureEnvelope(def.name, err, Date.now() - start, ctx.warnings); + // Surface nested build errors into envelope.errors + if (isWnError(err) && err.details?.errors && Array.isArray(err.details.errors)) { + envelope.errors = err.details.errors as typeof envelope.errors; + } + if (parsed.global.json || parsed.global.streamJson) { + output.writeJson(envelope); + } else { + output.humanResult(envelope); + } + if (isWnError(err)) return err.exitCode; + return ExitCode.CommandFailed; + } +} + +main() + .then((code) => process.exit(code)) + .catch((err) => { + process.stderr.write(String(err) + '\n'); + process.exit(ExitCode.CommandFailed); + }); diff --git a/cli/src/build/build-configuration.ts b/cli/src/build/build-configuration.ts new file mode 100644 index 0000000..a2d0e21 --- /dev/null +++ b/cli/src/build/build-configuration.ts @@ -0,0 +1,71 @@ +import { existsSync, readdirSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { Project } from '../project/project'; + +export function getBuildConfigurationName(configuration?: string): string { + if (!configuration || configuration === 'default') { + return ''; + } + return `(${configuration})`; +} + +function getConfigurationArgs(config: string | undefined, isDebugging?: boolean, useMode?: boolean): string { + let configuration = config; + if (isDebugging === true) { + if (configuration === 'production') { + configuration = 'development'; + } + } + if (!configuration || configuration === 'default') { + return ''; + } + if (useMode) { + return ` --mode=${configuration}`; + } + return ` --configuration=${configuration}`; +} + +export function getBuildConfigurationArgs(configuration?: string, isDebugging?: boolean, useMode?: boolean): string { + return getConfigurationArgs(configuration, isDebugging, useMode); +} + +export function getRunConfigurationArgs(configuration?: string, isDebugging?: boolean, useMode?: boolean): string { + return getConfigurationArgs(configuration, isDebugging, useMode); +} + +/** List available build/run configuration names for a project. */ +export function listConfigurations(project: Project, kind: 'build' | 'run' = 'build'): string[] { + const configs: string[] = []; + const filename = join(project.projectFolder(), 'angular.json'); + if (existsSync(filename)) { + configs.push(...getAngularBuildConfigs(filename)); + } + if (project.analyzer.exists('vue') || project.analyzer.exists('react')) { + configs.push(...getEnvConfigs(project)); + if (!configs.includes('development')) configs.push('development'); + if (!configs.includes('production')) configs.push('production'); + } + if (configs.length === 0) { + return []; + } + return ['default', ...configs]; +} + +function getEnvConfigs(project: Project): string[] { + const list = readdirSync(project.projectFolder(), 'utf8'); + const envFiles = list.filter((file) => file.startsWith('.env.')); + return envFiles.map((f) => f.replace('.env.', '')); +} + +function getAngularBuildConfigs(filename: string): string[] { + try { + const result: string[] = []; + const angular = JSON.parse(readFileSync(filename, 'utf8')); + for (const config of Object.keys(angular.projects.app.architect.build.configurations)) { + result.push(config); + } + return result; + } catch { + return []; + } +} diff --git a/cli/src/build/build.ts b/cli/src/build/build.ts new file mode 100644 index 0000000..d93812a --- /dev/null +++ b/cli/src/build/build.ts @@ -0,0 +1,141 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { MonoRepoType } from '../project/monorepo'; +import { Project } from '../project/project'; +import { getBuildConfigurationArgs } from './build-configuration'; +import { CommandResult, finalizeCommand } from './command-result'; +import { commandContext, npmRun, npx, preflightNPMCheck } from './node-commands'; + +export interface BuildOptions { + platform?: CapacitorPlatform; + arguments?: string; + sourceMaps?: boolean; + buildForProduction?: boolean; + projectName?: string; + buildConfiguration?: string; +} + +/** + * Creates the ionic build command + */ +export function build(project: Project, options: BuildOptions = {}): CommandResult { + const preop = preflightNPMCheck(project); + const prod = options.buildForProduction ?? false; + let args = options.arguments ?? ''; + if (options.projectName) { + args += ` --project=${options.projectName}`; + } + const additionalArgs = getBuildConfigurationArgs(options.buildConfiguration); + if (additionalArgs) { + if (additionalArgs.includes('--configuration=') && args.includes('--configuration')) { + // configuration already set + } else { + args += additionalArgs; + } + } + const ctx = commandContext(project); + switch (project.repoType) { + case MonoRepoType.none: + return finalizeCommand( + project, + `${preop}${runBuild(prod, project, ctx, args, options.platform, options.sourceMaps)}`, + ); + case MonoRepoType.bun: + case MonoRepoType.npm: + return finalizeCommand( + project, + `${preop}${runBuild(prod, project, ctx, args, options.platform, options.sourceMaps)}`, + ); + case MonoRepoType.nx: + return finalizeCommand(project, `${preop}${nxBuild(prod, project, args)}`); + case MonoRepoType.folder: + case MonoRepoType.yarn: + case MonoRepoType.lerna: + case MonoRepoType.pnpm: + return finalizeCommand( + project, + `${preop}${runBuild(prod, project, ctx, args, options.platform, options.sourceMaps)}`, + ); + default: + throw new Error('Unsupported Monorepo type'); + } +} + +function runBuild( + prod: boolean, + project: Project, + ctx: ReturnType, + configurationArg?: string, + platform?: CapacitorPlatform, + sourceMaps?: boolean, +): string { + let cmd = `${npx(project)} ${buildCmd(project, ctx)}`; + if (configurationArg) { + if (cmd.includes('wn:build') || cmd.includes('ionic:build')) { + cmd += ' -- --'; + } else if (cmd.includes('run ')) { + cmd += ' --'; + } + cmd += ` ${configurationArg}`; + } else if (prod) { + cmd += ' --prod'; + } + if (sourceMaps && cmd.includes('vite')) { + cmd += ` --sourcemap inline`; + } + + if (platform || project.analyzer.exists('@capacitor/ios') || project.analyzer.exists('@capacitor/android')) { + cmd += ` && ${npx(project)} cap copy`; + if (platform) cmd += ` ${platform}`; + } + + return cmd; +} + +function buildCmd(project: Project, ctx: ReturnType): string { + const guessed = guessBuildCommand(project, ctx); + if (guessed) { + return guessed; + } + switch (project.frameworkType) { + case 'angular': + case 'angular-standalone': + return 'ng build'; + case 'vue-vite': + case 'react-vite': + return 'vite build'; + case 'react': + return 'react-scripts build'; + case 'vue': + return 'vue-cli-service build'; + default: + console.error('build command is unknown'); + return ''; + } +} + +function guessBuildCommand(project: Project, ctx: ReturnType): string | undefined { + const filename = join(project.projectFolder(), 'package.json'); + if (existsSync(filename)) { + const packageFile = JSON.parse(readFileSync(filename, 'utf8')); + if (packageFile.scripts?.['wn:build']) { + return npmRun('wn:build', ctx); + } else if (packageFile.scripts?.['ionic:build']) { + return npmRun('ionic:build', ctx); + } else if (packageFile.scripts?.['build']) { + return npmRun('build', ctx); + } + } + return undefined; +} + +function nxBuild(prod: boolean, project: Project, configurationArg?: string): string { + let cmd = `${npx(project)} nx build ${project.monoRepo.name}`; + if (configurationArg) { + cmd += ` ${configurationArg}`; + } else if (prod) { + cmd += ' --configuration=production'; + } + return cmd; +} diff --git a/cli/src/build/capacitor-add.ts b/cli/src/build/capacitor-add.ts new file mode 100644 index 0000000..8622ac1 --- /dev/null +++ b/cli/src/build/capacitor-add.ts @@ -0,0 +1,32 @@ +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { MonoRepoType } from '../project/monorepo'; +import { Project } from '../project/project'; +import { CommandResult, finalizeCommand } from './command-result'; +import { npx } from './node-commands'; +import { useIonicCLI } from './capacitor-run'; + +/** + * Add a Capacitor Platform + */ +export function capacitorAdd(project: Project, platform: CapacitorPlatform): CommandResult { + const ionic = useIonicCLI(project) ? 'ionic ' : ''; + switch (project.repoType) { + case MonoRepoType.none: + return finalizeCommand(project, `${npx(project)} ${ionic}cap add ${platform}`); + case MonoRepoType.npm: + case MonoRepoType.bun: + case MonoRepoType.yarn: + case MonoRepoType.lerna: + case MonoRepoType.folder: + case MonoRepoType.pnpm: + return finalizeCommand(project, `${npx(project)} ${ionic}cap add ${platform}`); + case MonoRepoType.nx: + return finalizeCommand(project, nxAdd(project, platform)); + default: + throw new Error('Unsupported Monorepo type'); + } +} + +function nxAdd(project: Project, platform: CapacitorPlatform): string { + return `${npx(project)} nx run ${project.monoRepo.name}:add:${platform}`; +} diff --git a/cli/src/build/capacitor-build.ts b/cli/src/build/capacitor-build.ts new file mode 100644 index 0000000..ebd5935 --- /dev/null +++ b/cli/src/build/capacitor-build.ts @@ -0,0 +1,161 @@ +import { readFileSync, writeFileSync } from 'fs'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { getCapacitorConfigureFilename, writeCapacitorConfig } from '../project/capacitor-config-file'; +import { MonoRepoType } from '../project/monorepo'; +import { Project } from '../project/project'; +import { getStringFrom, isEmpty } from '../project/utilities-strings'; +import { CommandResult, finalizeCommand } from './command-result'; +import { npx } from './node-commands'; + +export interface KeyStoreSettings { + keyStorePath?: string; + keyStorePassword?: string; + keyAlias?: string; + keyPassword?: string; + signingType?: string; +} + +export interface CapBuildOptions { + /** APK or AAB for Android; ignored for iOS. */ + androidReleaseType?: 'APK' | 'AAB'; +} + +export type CapBuildSelection = 'ios-ipa' | 'android-apk' | 'android-aab'; + +/** Map a build selection to platform and CLI args. */ +export function selectionToPlatformAndArgs(selection: CapBuildSelection): { + platform: CapacitorPlatform; + args: string; +} { + if (selection === 'ios-ipa') { + return { platform: CapacitorPlatform.ios, args: '' }; + } + if (selection === 'android-apk') { + return { platform: CapacitorPlatform.android, args: ' --androidreleasetype=APK' }; + } + return { platform: CapacitorPlatform.android, args: ' --androidreleasetype=AAB' }; +} + +/** Returns available native build targets based on installed Capacitor platforms. */ +export function listCapBuildTargets(project: Project): CapBuildSelection[] { + const picks: CapBuildSelection[] = []; + if (project.analyzer.exists('@capacitor/ios')) { + picks.push('ios-ipa'); + } + if (project.analyzer.exists('@capacitor/android')) { + picks.push('android-apk', 'android-aab'); + } + return picks; +} + +export function capacitorBuildCommand( + project: Project, + platform: CapacitorPlatform, + args: string, + settings: KeyStoreSettings, +): CommandResult { + switch (project.repoType) { + case MonoRepoType.none: + return finalizeCommand(project, capCLIBuild(platform, project, args, settings)); + case MonoRepoType.folder: + case MonoRepoType.pnpm: + case MonoRepoType.yarn: + case MonoRepoType.lerna: + case MonoRepoType.bun: + case MonoRepoType.npm: + return finalizeCommand(project, capCLIBuild(platform, project, args, settings)); + case MonoRepoType.nx: + return finalizeCommand(project, nxCapBuild(project, platform, args)); + default: + throw new Error('Unsupported Monorepo type'); + } +} + +function capCLIBuild(platform: CapacitorPlatform, project: Project, args: string, settings: KeyStoreSettings): string { + if (platform === CapacitorPlatform.android) { + if (settings.keyAlias) args += ` --keystorealias="${settings.keyAlias}"`; + if (settings.keyPassword) args += ` --keystorealiaspass="${settings.keyPassword}"`; + if (settings.keyStorePassword) args += ` --keystorepass="${settings.keyStorePassword}"`; + if (settings.keyStorePath) args += ` --keystorepath="${settings.keyStorePath}"`; + } + return `${npx(project)} cap build ${platform}${args}`; +} + +function nxCapBuild(project: Project, platform: CapacitorPlatform, args: string): string { + return `${npx(project)} nx run ${project.monoRepo.name}:build:${platform}${args}`; +} + +export function readKeyStoreSettings(project: Project): KeyStoreSettings { + const result: KeyStoreSettings = {}; + const filename = getCapacitorConfigureFilename(project.projectFolder()); + if (!filename) { + return result; + } + try { + const data = readFileSync(filename, 'utf-8'); + if (data.includes('CapacitorConfig = {')) { + result.signingType = getValueFrom(data, 'signingType'); + if (isEmpty(result.signingType)) { + result.signingType = 'apksigner'; + } + result.keyStorePath = getValueFrom(data, 'keystorePath'); + result.keyAlias = getValueFrom(data, 'keystoreAlias'); + result.keyPassword = getValueFrom(data, 'keystoreAliasPassword'); + result.keyStorePassword = getValueFrom(data, 'keystorePassword'); + } + return result; + } catch (err) { + console.error(err); + return result; + } +} + +function getValueFrom(data: string, key: string): string { + let result = getStringFrom(data, `${key}: '`, `'`); + if (!result) { + result = getStringFrom(data, `${key}: "`, `"`); + } + return result; +} + +export function writeKeyStoreConfig(project: Project, settings: KeyStoreSettings): void { + const filename = getCapacitorConfigureFilename(project.projectFolder()); + if (!filename) { + return; + } + let data = readFileSync(filename, 'utf-8'); + + if (!data.includes('buildOptions')) { + data = data.replace( + '};', + `, + android: { + buildOptions: { + keystorePath: '', + keystoreAlias: '', + signingType: 'apksigner', + } + } + };`, + ); + } + if (!data.includes('signingType')) { + data = data.replace( + 'buildOptions: {', + `buildOptions: { + signingType: 'apksigner',`, + ); + } + writeFileSync(filename, data); + writeCapacitorConfig(project, [ + { key: 'keystorePath', value: settings.keyStorePath }, + { key: 'keystorePassword', value: settings.keyStorePassword }, + { key: 'keystoreAlias', value: settings.keyAlias }, + { key: 'keystoreAliasPassword', value: settings.keyPassword }, + { key: 'signingType', value: settings.signingType }, + ]); +} + +export function isCapBuildSupported(project: Project): boolean { + return project.analyzer.isGreaterOrEqual('@capacitor/cli', '4.4.0'); +} diff --git a/cli/src/build/capacitor-open.ts b/cli/src/build/capacitor-open.ts new file mode 100644 index 0000000..c6a9af0 --- /dev/null +++ b/cli/src/build/capacitor-open.ts @@ -0,0 +1,69 @@ +import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { MonoRepoType } from '../project/monorepo'; +import { Project } from '../project/project'; +import { CommandResult, finalizeCommand } from './command-result'; +import { npx } from './node-commands'; +import { useIonicCLI } from './capacitor-run'; + +/** + * Capacitor open command + */ +export function capacitorOpen(project: Project, platform: CapacitorPlatform): CommandResult { + const ionicCLI = useIonicCLI(project); + + if (platform === CapacitorPlatform.android) { + checkAndroidStudioJDK(project.projectFolder(), project); + } + switch (project.repoType) { + case MonoRepoType.none: + return finalizeCommand(project, ionicCLI ? ionicCLIOpen(platform, project) : capCLIOpen(platform, project)); + case MonoRepoType.folder: + case MonoRepoType.pnpm: + case MonoRepoType.yarn: + case MonoRepoType.lerna: + case MonoRepoType.bun: + case MonoRepoType.npm: + return finalizeCommand(project, ionicCLI ? ionicCLIOpen(platform, project) : capCLIOpen(platform, project)); + case MonoRepoType.nx: + return finalizeCommand(project, nxOpen(project, platform)); + default: + throw new Error('Unsupported Monorepo type'); + } +} + +function capCLIOpen(platform: CapacitorPlatform, project: Project): string { + return `${npx(project)} cap open ${platform}`; +} + +function ionicCLIOpen(platform: CapacitorPlatform, project: Project): string { + return `${npx(project)} ionic cap open ${platform}`; +} + +function nxOpen(project: Project, platform: CapacitorPlatform): string { + if (project.monoRepo.isNXStandalone) { + return capCLIOpen(platform, project); + } + return `${npx(project)} nx run ${project.monoRepo.name}:open:${platform}`; +} + +function checkAndroidStudioJDK(folder: string, project: Project): void { + if (project.analyzer.isGreaterOrEqual('@capacitor/android', '5.0.0')) { + if (existsSync(join(folder, 'android'))) { + const ideaFolder = join(folder, 'android', '.idea'); + if (!existsSync(ideaFolder)) { + mkdirSync(ideaFolder); + writeFileSync( + join(ideaFolder, 'compiler.xml'), + ` + + + + + `, + ); + } + } + } +} diff --git a/cli/src/build/capacitor-run.ts b/cli/src/build/capacitor-run.ts new file mode 100644 index 0000000..1e289ac --- /dev/null +++ b/cli/src/build/capacitor-run.ts @@ -0,0 +1,226 @@ +import { existsSync } from 'fs'; +import { join } from 'path'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { gradleToJson } from '../native/gradle-to-json'; +import { MonoRepoType } from '../project/monorepo'; +import { Project } from '../project/project'; +import { build } from './build'; +import { InternalCommand } from './command-name'; +import { CommandResult, finalizeCommand } from './command-result'; +import { certPath, liveReloadSSL } from './live-reload'; +import { isWindows } from './platform'; +import { npx, preflightNPMCheck } from './node-commands'; +import { serve } from './web-run'; + +export interface CapRunOptions { + projectDirty?: boolean; + syncDone?: CapacitorPlatform[]; + servePort?: number; + /** Android product flavor; when omitted and flavors exist, returns undefined command. */ + flavor?: string; + liveReload?: boolean; + internalAddress?: boolean; + httpsForWeb?: boolean; + buildForProduction?: boolean; + target?: string; + publicHost?: string; + createCerts?: boolean; +} + +/** + * Creates the command line to run for Capacitor + */ +export async function capacitorRun( + project: Project, + platform: CapacitorPlatform, + options: CapRunOptions = {}, +): Promise { + let preop = ''; + let rebuilt = false; + const syncDone = options.syncDone ?? []; + const noSync = syncDone.includes(platform); + + if (options.projectDirty) { + preop = commandString(await build(project, { platform })) + ' && '; + rebuilt = true; + } else { + preop = preflightNPMCheck(project); + } + + switch (project.repoType) { + case MonoRepoType.none: + case MonoRepoType.folder: + case MonoRepoType.pnpm: + case MonoRepoType.yarn: + case MonoRepoType.lerna: + case MonoRepoType.bun: + case MonoRepoType.npm: + return finalizeCommand( + project, + preop + (await capRun(platform, project.repoType, rebuilt, noSync, project, options)), + ); + case MonoRepoType.nx: + return finalizeCommand( + project, + preop + (await nxRun(platform, project.repoType, rebuilt, noSync, project, options)), + ); + default: + throw new Error('Unsupported Monorepo type'); + } +} + +function commandString(result: CommandResult): string { + return typeof result === 'string' ? result : result.command; +} + +export function capacitorDevicesCommand(platform: CapacitorPlatform, project: Project): string { + const ionic = useIonicCLI(project) ? 'ionic ' : ''; + return `${npx(project)} ${ionic}cap run ${platform} --list`; +} + +export function useIonicCLI(project: Project): boolean { + if (project.analyzer.exists('@capacitor/cli')) { + return false; + } + return project.analyzer.exists('@ionic/cli'); +} + +async function capRun( + platform: CapacitorPlatform, + repoType: MonoRepoType, + _noBuild: boolean, + noSync: boolean, + project: Project, + options: CapRunOptions, +): Promise { + let liveReload = options.liveReload ?? false; + const externalIP = !options.internalAddress && liveReload; + const httpsForWeb = options.httpsForWeb ?? false; + const prod = options.buildForProduction ?? false; + + if (liveReload && project.repoType === MonoRepoType.npm) { + console.error('Live Reload is not supported with npm workspaces. Ignoring the live reload option'); + liveReload = false; + } + + let capRunFlags = liveReload ? '--live-reload' : ''; + + if ( + liveReload && + project.analyzer.exists('@ionic-enterprise/auth') && + project.analyzer.isLess('@ionic-enterprise/auth', '3.9.4') + ) { + capRunFlags = ''; + console.warn('Live Update was ignored as you have less than v3.9.4 of @ionic-enterprise/auth in your project'); + } + + const ionic = ''; + + if (externalIP) { + if (project.analyzer.isLess('@capacitor/core', '7.0.0')) { + if (capRunFlags.length >= 0) capRunFlags += ' '; + capRunFlags += '--external'; + } + } + + if (ionic !== '' && prod) { + if (capRunFlags.length >= 0) capRunFlags += ' '; + capRunFlags += '--prod'; + } + + if (noSync) { + if (capRunFlags.length >= 0) capRunFlags += ' '; + capRunFlags += '--no-sync'; + } + + if (liveReload && options.servePort) { + capRunFlags += ` --port=${options.servePort}`; + } + + const flavors = getFlavors(platform, project, options.flavor); + if (flavors === undefined) return ''; + capRunFlags += flavors; + + if (externalIP && options.publicHost) { + capRunFlags += ` --public-host=${options.publicHost}`; + } else if (externalIP) { + capRunFlags += ` ${InternalCommand.publicHost}`; + } + + if (httpsForWeb) { + if (capRunFlags.length >= 0) capRunFlags += ' '; + capRunFlags += '--ssl'; + + if (!existsSync(certPath('crt'))) { + if (options.createCerts) { + await liveReloadSSL(project); + } + return ''; + } + capRunFlags += ` -- --ssl-cert='${certPath('crt')}'`; + capRunFlags += ` --ssl-key='${certPath('key')}'`; + } + + const target = options.target ?? InternalCommand.target; + let post = ''; + const capRunCommand = `${npx(project)} ${ionic}cap run ${platform} --target=${target} ${capRunFlags}`; + if (liveReload) { + const serveResult = await serve(project, { + dontOpenBrowser: true, + isDebugging: false, + isNative: true, + createCerts: options.createCerts, + }); + const serveCmd = commandString(serveResult.command); + if (isWindows()) { + return `start /B ${serveCmd} & ${capRunCommand}`; + } + post = ` & ${serveCmd}`; + } + return `${capRunCommand}${post}`; +} + +async function nxRun( + platform: CapacitorPlatform, + _repoType: MonoRepoType, + noBuild: boolean, + noSync: boolean, + project: Project, + options: CapRunOptions, +): Promise { + if (project.monoRepo?.isNXStandalone) { + return capRun(platform, project.repoType, noBuild, noSync, project, options); + } + const target = options.target ?? InternalCommand.target; + return `${npx(project)} nx run ${project.monoRepo.name}:cap --cmd "run ${platform} --target=${target}"`; +} + +function getFlavors(platform: CapacitorPlatform, prj: Project, selected?: string): string | undefined { + if (platform === CapacitorPlatform.ios) { + return ''; + } + + const buildGradle = join(prj.projectFolder(), 'android', 'app', 'build.gradle'); + const data = gradleToJson(buildGradle); + const list: string[] = []; + if (data?.android?.productFlavors) { + list.push(...Object.keys(data.android.productFlavors)); + } + if (list.length === 0) { + return ''; + } + if (!selected) { + return undefined; + } + return ` --flavor=${selected}`; +} + +/** List Android product flavors from build.gradle, if any. */ +export function listAndroidFlavors(project: Project): string[] { + const buildGradle = join(project.projectFolder(), 'android', 'app', 'build.gradle'); + const data = gradleToJson(buildGradle); + if (data?.android?.productFlavors) { + return Object.keys(data.android.productFlavors); + } + return []; +} diff --git a/cli/src/build/capacitor-sync.ts b/cli/src/build/capacitor-sync.ts new file mode 100644 index 0000000..adf4bd8 --- /dev/null +++ b/cli/src/build/capacitor-sync.ts @@ -0,0 +1,59 @@ +import { Project } from '../project/project'; +import { MonoRepoType } from '../project/monorepo'; +import { getBuildConfigurationArgs } from './build-configuration'; +import { CommandResult, finalizeCommand } from './command-result'; +import { npx, preflightNPMCheck } from './node-commands'; +import { useIonicCLI } from './capacitor-run'; + +/** + * Creates the capacitor sync command + */ +export function capacitorSync(project: Project, buildConfiguration?: string, useMode?: boolean): CommandResult { + const preop = preflightNPMCheck(project); + const ionicCLI = useIonicCLI(project); + switch (project.repoType) { + case MonoRepoType.none: + return finalizeCommand( + project, + preop + + (ionicCLI + ? ionicCLISync(project, buildConfiguration, useMode) + : capCLISync(project, buildConfiguration, useMode)), + ); + case MonoRepoType.folder: + case MonoRepoType.pnpm: + case MonoRepoType.lerna: + case MonoRepoType.yarn: + case MonoRepoType.bun: + case MonoRepoType.npm: + return finalizeCommand( + project, + preop + + (ionicCLI + ? ionicCLISync(project, buildConfiguration, useMode) + : capCLISync(project, buildConfiguration, useMode)), + ); + case MonoRepoType.nx: + return finalizeCommand(project, preop + nxSync(project, buildConfiguration, useMode)); + default: + throw new Error('Unsupported Monorepo type'); + } +} + +function capCLISync(project: Project, buildConfiguration?: string, useMode?: boolean): string { + if (project.analyzer.isGreaterOrEqual('@capacitor/cli', '4.1.0')) { + return `${npx(project)} cap sync --inline`; + } + return `${npx(project)} cap sync${getBuildConfigurationArgs(buildConfiguration, undefined, useMode)}`; +} + +function ionicCLISync(project: Project, buildConfiguration?: string, useMode?: boolean): string { + return `${npx(project)} ionic cap sync --inline${getBuildConfigurationArgs(buildConfiguration, undefined, useMode)}`; +} + +function nxSync(project: Project, buildConfiguration?: string, useMode?: boolean): string { + if (project.monoRepo.isNXStandalone) { + return capCLISync(project, buildConfiguration, useMode); + } + return `${npx(project)} nx sync ${project.monoRepo.name}${getBuildConfigurationArgs(buildConfiguration, undefined, useMode)}`; +} diff --git a/cli/src/build/command-name.ts b/cli/src/build/command-name.ts new file mode 100644 index 0000000..19c5c37 --- /dev/null +++ b/cli/src/build/command-name.ts @@ -0,0 +1,7 @@ +export enum InternalCommand { + cwd = '[@cwd]', + target = '[@target]', + publicHost = '[@public-host]', + removeCordova = 'rem-cordova', + ionicInit = '[@ionic-init]', +} diff --git a/cli/src/build/command-result.ts b/cli/src/build/command-result.ts new file mode 100644 index 0000000..e05f5cf --- /dev/null +++ b/cli/src/build/command-result.ts @@ -0,0 +1,33 @@ +import { MonoRepoType } from '../project/monorepo'; +import { Project } from '../project/project'; +import { InternalCommand } from './command-name'; + +export type CommandResult = string | { command: string; cwd: string }; + +export function needsMonorepoCwd(project: Project): boolean { + switch (project.repoType) { + case MonoRepoType.npm: + case MonoRepoType.bun: + case MonoRepoType.yarn: + case MonoRepoType.lerna: + case MonoRepoType.pnpm: + case MonoRepoType.folder: + return true; + default: + return false; + } +} + +/** Strip [@cwd] tokens and return a CommandResult with cwd when appropriate. */ +export function finalizeCommand(project: Project, command: string): CommandResult { + const hadCwd = command.includes(InternalCommand.cwd); + const cleaned = command.split(InternalCommand.cwd).join(''); + if (hadCwd || needsMonorepoCwd(project)) { + return { command: cleaned, cwd: project.projectFolder() }; + } + return cleaned; +} + +export function commandString(result: CommandResult): string { + return typeof result === 'string' ? result : result.command; +} diff --git a/cli/src/build/error-parsers.ts b/cli/src/build/error-parsers.ts new file mode 100644 index 0000000..2b5bb37 --- /dev/null +++ b/cli/src/build/error-parsers.ts @@ -0,0 +1,494 @@ +import { existsSync } from 'fs'; +import { join } from 'path'; +import { uncolor } from './uncolor'; +import { getStringFrom } from '../project/utilities-strings'; + +export interface ParsedError { + file?: string; + line?: number; + column?: number; + message: string; +} + +interface Position { + line: number; + column: number; +} + +interface ErrorLine { + file?: string; + line?: number; + column?: number; + message: string; +} + +export function extractErrors(output: string): ParsedError[] { + const logs = output ? output.split('\n') : []; + const errorText = output; + const errors = extractErrorLines(errorText, logs); + return errors.map(toParsedError); +} + +function toParsedError(e: ErrorLine): ParsedError { + return { + file: e.file, + line: e.line, + column: e.column, + message: e.message, + }; +} + +function extractErrorLines(errorText: string, logs: string[]): ErrorLine[] { + const errors: ErrorLine[] = []; + + if (logs.length === 0 && errorText) { + logs = errorText.split('\n'); + } + + const isSwiftBuild = logs.some((log) => log.includes('Building xArchive')); + if (isSwiftBuild) { + return extractSwiftBuildErrors(logs); + } + + if (logs.length > 0) { + let line: string | undefined; + let rcline: string | undefined; + let vueline: string | undefined; + let tsline: string | undefined; + let javaLine: string | undefined; + let jasmineLine: string | undefined; + + if (errorText) { + let error: ErrorLine | undefined; + if (errorText.startsWith('Failed to compile.')) { + error = extractNextJSErrorFrom(errorText); + } + if (!error && errorText.includes('Error:')) { + error = extractLintStyleError(errorText); + } + if (!error && errorText.startsWith('✘ [ERROR]')) { + error = extractESBuildStyleError(errorText); + } + if (logs[0]?.startsWith('[error] Command line invocation:')) { + logs.shift(); + } + if (error) { + errors.push(error); + return errors; + } + } + + for (let log of logs) { + if (log.startsWith('[capacitor]')) { + log = log.replace('[capacitor]', '').trim(); + } + + if (log.endsWith('.ts') || log.endsWith('.tsx')) { + line = log; + } else { + if (line) { + const error = extractErrorLineFrom(log, line); + if (error) { + errors.push(error); + } else { + line = undefined; + } + } + } + + if (log.startsWith('TypeScript error in ')) { + rcline = log; + } else { + if (rcline) { + errors.push(extractTypescriptErrorFrom(rcline, log.trim())); + rcline = undefined; + } + } + + if (log.includes(': error ')) { + errors.push(extractViteErrorFrom2(log)); + } + if (log.includes('error in ')) { + tsline = log; + } else { + if (tsline) { + if (log.trim().length > 0) { + errors.push(extractVueTypescriptErrorFrom(tsline, log.trim())); + tsline = undefined; + } + } + } + + if (log.startsWith('SyntaxError:')) { + errors.push(extractSyntaxError(log)); + } + + if (log.includes('error:')) { + javaLine = log; + } else { + if (javaLine) { + errors.push(extractJavaError(javaLine, log)); + javaLine = undefined; + } + } + + if (log.includes('Error:')) { + jasmineLine = log; + } else { + if (jasmineLine) { + if (!log.includes('')) { + errors.push(extractJasmineError(jasmineLine, log)); + jasmineLine = undefined; + } + } + } + + if (log.endsWith('.vue')) { + vueline = log; + } else { + if (vueline) { + errors.push(extractVueErrorFrom(vueline, log.trim())); + vueline = undefined; + } + } + } + } + + if (errors.length === 0 && errorText) { + const lines = errorText.split('\n'); + let fail: string | undefined; + let errorIn = -1; + let failIn: string | undefined; + for (const ln of lines) { + if (ln.startsWith('Error: ')) { + const parsed = extractErrorFrom(ln); + if (parsed) errors.push(parsed); + } else if (ln.includes('- error TS')) { + const parsed = extractTSErrorFrom(ln); + if (parsed) errors.push(parsed); + } else if (ln.startsWith('FAIL')) { + fail = ln; + } else if (ln.startsWith('✘ [ERROR] ')) { + errorIn = 2; + failIn = ln.replace('✘ [ERROR] ', '').trim(); + } else { + if (fail) { + const parsed = extractJestErrorFrom(fail, ln); + if (parsed) errors.push(parsed); + fail = undefined; + } + if (errorIn > 0) { + errorIn--; + if (errorIn === 0 && failIn) { + const parsed = extractViteErrorFrom(failIn, ln); + if (parsed) errors.push(parsed); + errorIn = -1; + } + } + } + } + } + return errors; +} + +function extractESBuildStyleError(errorText: string): ErrorLine | undefined { + try { + const lines = errorText.split('\n'); + let error = lines[0].replace('✘ [ERROR] ', '').trim(); + if (lines[1] && lines[1].length > 1) error += ' ' + lines[1].trim(); + + let fileLineIndex = -1; + for (let i = 2; i < lines.length && i < 10; i++) { + const trimmed = lines[i].trim(); + if (trimmed && trimmed.match(/^[^\s]+\.(ts|tsx|js|jsx|vue):\d+:\d+:?$/)) { + fileLineIndex = i; + break; + } + } + + if (fileLineIndex === -1) return undefined; + + const args = lines[fileLineIndex].trim().split(':'); + const linenumber = parseInt(args[1]) - 1; + const column = parseInt(args[2]) - 1; + const filename = args[0]; + return { line: linenumber, column, file: filename, message: error }; + } catch { + return undefined; + } +} + +function extractNextJSErrorFrom(errorText: string): ErrorLine | undefined { + try { + const lines = uncolor(errorText).split('\n'); + const error = lines[3].replace('Error:', '').trim(); + const args = lines[4].replace(' ,-[', '').replace(']', '').split(':'); + const line = parseInt(args[1]); + const column = parseInt(args[2]); + return { line, column, file: args[0], message: error }; + } catch { + return undefined; + } +} + +function extractLintStyleError(errorText: string): ErrorLine | undefined { + try { + const lines = errorText.split('\n'); + const filename = lines[1]; + const args = lines[2].replace('Error: ', ':').trim().split(':'); + const linenumber = parseInt(args[0]) - 1; + const column = parseInt(args[1]) - 1; + const error = args[2].trim(); + return { line: linenumber, column, file: filename, message: error }; + } catch { + return undefined; + } +} + +function extractTSErrorFrom(line: string): ErrorLine | undefined { + try { + const codeline = line.replace('ERROR in ', '').split(':')[0]; + const args = line.split(':'); + const column = parseInt(args[2]) - 1; + const linenumber = parseInt(args[1].trim()) - 1; + const errormsg = line.substring(line.indexOf('- ', codeline.length) + 2); + return { + line: linenumber, + column, + file: codeline, + message: errormsg + `line:${linenumber} pos:${column}`, + }; + } catch { + return undefined; + } +} + +function extractErrorFrom(line: string): ErrorLine | undefined { + try { + const codeline = line.replace('Error: ', '').split(':')[0]; + const args = line.split(':'); + const linenumber = parseInt(args[2]) - 1; + const column = parseInt(args[3].substring(0, args[3].indexOf(' ')) + 2) - 1; + const errormsg = line.substring(line.indexOf('- ', codeline.length + 7) + 2); + return { line: linenumber, column, file: codeline, message: errormsg }; + } catch { + return undefined; + } +} + +function extractViteErrorFrom2(txt: string): ErrorLine | undefined { + try { + const args = txt.split(':'); + const file = args[0].split('(')[0]; + const error = args[1] + ' ' + args[2].trim(); + const pos = getStringFrom(txt, '(', ')')?.split(',') ?? []; + const line = parseInt(pos[0]) - 1; + const column = parseInt(pos[1]) - 1; + return { file, message: error, line, column }; + } catch { + return undefined; + } +} + +function extractViteErrorFrom(fail: string, line: string): ErrorLine | undefined { + try { + const codeline = line.trim().split(':')[0]; + const args = line.split(':'); + const linenumber = parseInt(args[1]) - 1; + const column = parseInt(args[2]); + return { line: linenumber, column, file: codeline, message: fail }; + } catch { + return undefined; + } +} + +function extractJestErrorFrom(line: string, testError: string): ErrorLine | undefined { + try { + const filename = line.replace('FAIL ', '').trim(); + const message = testError.replace(' ● ', ''); + return { line: 0, column: 0, file: filename, message }; + } catch { + return undefined; + } +} + +function extractErrorLineFrom(msg: string, filename: string): ErrorLine | undefined { + const pos = parsePosition(msg); + const errormsg = extractErrorMessage(msg); + if (!errormsg || errormsg.length === 0 || !msg.includes('error')) { + return undefined; + } + return { message: errormsg, file: filename, line: pos.line, column: pos.column }; +} + +function extractJavaError(line1: string, line2: string): ErrorLine | undefined { + try { + const args = line1.split(' error: '); + const filename = args[0].split(':')[0].trim(); + const linenumber = parseInt(args[0].split(':')[1]) - 1; + return { file: filename, line: linenumber, column: 0, message: args[1].trim() + ' ' + line2.trim() }; + } catch { + return undefined; + } +} + +function extractSwiftError(line1: string, line2: string): ErrorLine | undefined { + try { + const cleanLine = line1.replace(/\s*\(in target.*\)$/, ''); + const errorMatch = cleanLine.match(/:\s+(?:error|Error):\s+/i); + if (!errorMatch) return undefined; + + const args = cleanLine.split(':'); + const filename = args[0].trim(); + const linenumber = parseInt(args[1]) - 1; + const column = parseInt(args[2]) - 1; + + const errorIndex = cleanLine.toLowerCase().indexOf(': error:'); + const errorMessage = + errorIndex >= 0 + ? cleanLine.substring(errorIndex + ': error:'.length).trim() + : cleanLine.substring(cleanLine.indexOf(':') + 1).trim(); + + return { + file: filename, + line: linenumber, + column, + message: errorMessage + (line2.trim() ? ' ' + line2.trim() : ''), + }; + } catch { + return undefined; + } +} + +function extractJasmineError(line1: string, line2: string): ErrorLine | undefined { + try { + let txt = line1.replace('Error: ', ''); + if (txt.length > 100) { + txt = txt.substring(0, 80) + '...' + txt.substring(txt.length - 16, txt.length); + } + const place = line2.substring(line2.indexOf('(') + 1); + const args = place.split(':'); + const filename = args[0]; + const linenumber = parseInt(args[1]) - 1; + const column = parseInt(args[2].replace(')', '')) - 1; + return { file: filename, line: linenumber, column, message: txt }; + } catch { + return undefined; + } +} + +function extractErrorMessage(msg: string): string { + try { + const pos = parsePosition(msg); + if (pos.line > 0 || pos.column > 0) { + msg = msg.trim(); + msg = msg.substring(msg.indexOf(' ')).trim(); + if (msg.startsWith('error')) { + return msg.replace('error', '').trim(); + } else if (msg.startsWith('warning')) { + return msg.replace('warning', '').trim(); + } + } + } catch { + return msg; + } + return msg; +} + +function parsePosition(msg: string): Position { + msg = msg.trim(); + if (msg.indexOf(' ') > -1) { + const pos = msg.substring(0, msg.indexOf(' ')); + if (pos.indexOf(':') > -1) { + try { + const args = pos.split(':'); + return { line: parseInt(args[0]) - 1, column: parseInt(args[1]) - 1 }; + } catch { + return { line: 0, column: 0 }; + } + } + } + return { line: 0, column: 0 }; +} + +function extractTypescriptErrorFrom(msg: string, errorText: string): ErrorLine | undefined { + try { + msg = msg.replace('TypeScript error in ', ''); + const filename = msg.substring(0, msg.lastIndexOf('(')); + const args = msg.substring(msg.lastIndexOf('(') + 1).split(','); + const linenumber = parseInt(args[0]); + const column = parseInt(args[1].replace('):', '')); + return { line: linenumber, column, message: errorText, file: filename }; + } catch { + return undefined; + } +} + +function extractVueTypescriptErrorFrom(msg: string, errorText: string): ErrorLine | undefined { + try { + msg = msg.replace(' error in ', ''); + const filename = msg.substring(0, msg.indexOf(':')); + const args = msg.substring(msg.indexOf(':') + 1).split(':'); + const linenumber = parseInt(args[0]); + const column = parseInt(args[1]); + return { line: linenumber, column, message: errorText, file: filename }; + } catch { + return undefined; + } +} + +function extractVueErrorFrom(filename: string, msg: string): ErrorLine | undefined { + return extractErrorLineFrom(msg, filename); +} + +function extractSyntaxError(msg: string): ErrorLine | undefined { + try { + msg = msg.replace('SyntaxError: ', ''); + const filename = msg.substring(0, msg.indexOf(':')); + const args = msg.substring(msg.lastIndexOf('(') + 1).split(':'); + const linenumber = parseInt(args[0]); + const column = parseInt(args[1].replace(')', '')); + let errorText = msg.substring(msg.indexOf(':') + 1); + errorText = errorText.substring(0, errorText.lastIndexOf('(')).trim(); + return { line: linenumber, column, message: errorText, file: filename }; + } catch { + return undefined; + } +} + +function extractSwiftBuildErrors(logs: string[]): ErrorLine[] { + const errors: ErrorLine[] = []; + let swiftLine: string | undefined; + let skipLines = 0; + + for (const log of logs) { + if (skipLines > 0) { + skipLines--; + continue; + } + + if (log.includes('.swift:') && (log.toLowerCase().includes(': error:') || log.toLowerCase().includes(': error '))) { + swiftLine = log; + } else { + if (swiftLine && log.trim().length > 0 && !log.trim().startsWith('(')) { + const extracted = extractSwiftError(swiftLine, log); + if (extracted) { + errors.push(extracted); + skipLines = 2; + } + swiftLine = undefined; + } + } + } + + return errors; +} + +/** Resolve a relative file path against a project folder when the file is not absolute. */ +export function resolveErrorFile(file: string | undefined, projectFolder: string): string | undefined { + if (!file) return undefined; + if (existsSync(file)) return file; + const joined = join(projectFolder, file); + if (existsSync(joined)) return joined; + return file; +} diff --git a/cli/src/build/live-reload.ts b/cli/src/build/live-reload.ts new file mode 100644 index 0000000..316c258 --- /dev/null +++ b/cli/src/build/live-reload.ts @@ -0,0 +1,239 @@ +import { execSync } from 'child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import { homedir } from 'os'; +import { basename, join } from 'path'; +import { networkInterfaces } from 'os'; +import { Project } from '../project/project'; + +const CERT_DIR = join(homedir(), '.config', 'wn', 'certs'); + +export function certStorePath(): string { + if (!existsSync(CERT_DIR)) { + mkdirSync(CERT_DIR, { recursive: true }); + } + return CERT_DIR; +} + +export function certPath(ext: string): string { + return join(certStorePath(), `server.${ext}`); +} + +function getRootCAKeyFilename(): string { + return join(certStorePath(), 'ca.key'); +} + +function getRootCACertFilename(): string { + return join(certStorePath(), 'ca.crt'); +} + +function hasRootCA(): boolean { + return existsSync(getRootCACertFilename()) && existsSync(getRootCAKeyFilename()); +} + +function runCommand(cmd: string, cwd: string): string { + try { + return execSync(cmd, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); + } catch (err: any) { + const stderr = err.stderr?.toString?.() ?? ''; + const stdout = err.stdout?.toString?.() ?? ''; + throw new Error(stderr || stdout || String(err)); + } +} + +export interface LiveReloadSSLOptions { + /** When false, skip creating a root CA if missing. Default true. */ + createRootCA?: boolean; +} + +export async function liveReloadSSL(project: Project, options: LiveReloadSSLOptions = {}): Promise { + const shouldCreateRootCA = options.createRootCA ?? true; + if (!hasRootCA()) { + if (!shouldCreateRootCA) { + throw new Error('Root CA certificate is required for HTTPS live reload'); + } + const keyFilename = await createRootCAKey(); + await createRootCACert(keyFilename); + } + await setupServerCertificate(project); +} + +async function setupServerCertificate(project: Project): Promise { + if (!hasRootCA()) { + throw new Error('Root CA certificate is required for HTTPS live reload'); + } + + const crFile = createCertificateRequest(); + const cmd = `openssl req -new -nodes -sha256 -keyout '${certPath('key')}' -config '${crFile}' -out '${certPath( + 'csr', + )}' -newkey rsa:4096 -subj "/C=US/ST=/L=/O=/CN=myserver"`; + runCommand(cmd, project.folder); + + const cmd2 = `openssl x509 -sha256 -extfile '${crFile}' -extensions x509_ext -req -in '${certPath( + 'csr', + )}' -CA '${getRootCACertFilename()}' -CAkey '${getRootCAKeyFilename()}' -CAcreateserial -out '${certPath( + 'crt', + )}' -days 180`; + runCommand(cmd2, project.folder); + + rmSync(crFile, { force: true }); + + if (!existsSync(certPath('crt'))) { + throw new Error('Unable to create server certificate'); + } +} + +function createCertificateRequest(): string { + const filename = join(certStorePath(), 'cr.txt'); + let data = ` + [req] + default_bits = 4096 + default_md = sha256 + distinguished_name = subject + req_extensions = req_ext + x509_extensions = x509_ext + string_mask = utf8only + prompt = no + + [ subject ] + C = US + ST = WI + L = Madison + O = Ionic + OU = Development + CN = ${getAddress()} + + [ x509_ext ] + subjectKeyIdentifier = hash + authorityKeyIdentifier = keyid,issuer + basicConstraints = CA:FALSE + keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment + subjectAltName = @alternate_names + + [ req_ext ] + subjectKeyIdentifier = hash + basicConstraints = CA:FALSE + keyUsage = digitalSignature, keyEncipherment + subjectAltName = @alternate_names + + [ alternate_names ] + DNS.2 =localhost + `; + + let i = 0; + for (const address of getAddresses()) { + i++; + data += `IP.${i} =${address}\n`; + } + + writeFileSync(filename, data); + return filename; +} + +async function createRootCACert(keyFilename: string): Promise { + const filename = join(certStorePath(), 'cr-ca.txt'); + const data = ` + [req] + default_bits = 4096 + default_md = sha256 + distinguished_name = subject + req_extensions = req_ext + x509_extensions = x509_ext + string_mask = utf8only + prompt = no + + [ subject ] + C = US + ST = WI + L = Madison + O = Ionic + OU = Development + CN = Ionic Root CA Certificate + + [ req_ext ] + basicConstraints = critical, CA:true + keyUsage = critical, keyCertSign, cRLSign + subjectKeyIdentifier = hash + subjectAltName = @subject_alt_name + authorityKeyIdentifier = keyid:always,issuer:always + issuerAltName = issuer:copy + + [ x509_ext ] + subjectKeyIdentifier = hash + authorityKeyIdentifier = keyid:always,issuer + basicConstraints = critical, CA:TRUE + keyUsage = critical, digitalSignature, keyEncipherment, cRLSign, keyCertSign + + [ subject_alt_name ] + URI = https://ionic.io/ + email = support@ionic.io + `; + writeFileSync(filename, data); + + const certFilePath = getRootCACertFilename(); + const certFName = basename(certFilePath); + if (existsSync(certFilePath)) { + rmSync(certFilePath); + } + + const cmd = `openssl req -config '${filename}' -key ${keyFilename} -new -x509 -days 3650 -sha256 -out ${certFName}`; + runCommand(cmd, certStorePath()); + + if (!existsSync(certFilePath)) { + throw new Error('Unable to create root CA Certificate'); + } + return certFilePath; +} + +async function createRootCAKey(): Promise { + const keyFilename = getRootCAKeyFilename(); + const filename = basename(keyFilename); + + if (existsSync(keyFilename)) { + rmSync(keyFilename); + } + + const cmd = `openssl genrsa -out ${filename} 4096`; + runCommand(cmd, certStorePath()); + + if (!existsSync(keyFilename)) { + throw new Error('Unable to create root CA key'); + } + return filename; +} + +function getAddress(): string { + const nets = networkInterfaces(); + for (const name of Object.keys(nets)) { + for (const net of nets[name] ?? []) { + if (net.family === 'IPv4' && !net.internal) { + return net.address; + } + } + } + return '127.0.0.1'; +} + +function getAddresses(): string[] { + const result: string[] = []; + const nets = networkInterfaces(); + for (const name of Object.keys(nets)) { + for (const net of nets[name] ?? []) { + if (net.family === 'IPv4') { + result.push(net.address); + } + } + } + return result; +} + +/** Return the path to the root CA certificate for trust installation. */ +export function getRootCACertPath(): string { + return getRootCACertFilename(); +} + +/** Read root CA cert contents for display or installation instructions. */ +export function readRootCACert(): string | undefined { + const path = getRootCACertFilename(); + if (!existsSync(path)) return undefined; + return readFileSync(path, 'utf8'); +} diff --git a/cli/src/build/node-commands.ts b/cli/src/build/node-commands.ts new file mode 100644 index 0000000..e963ae1 --- /dev/null +++ b/cli/src/build/node-commands.ts @@ -0,0 +1,289 @@ +import { getMonoRepoFolder, MonoRepoType, MonoRepoProject } from '../project/monorepo'; +import { Project } from '../project/project'; +import { existsSync } from 'fs'; +import { InternalCommand } from './command-name'; + +export enum PackageManager { + npm, + yarn, + pnpm, + bun, +} + +enum PMOperation { + install, + installAll, + uninstall, + update, + run, +} + +export interface PackageCommandContext { + packageManager: PackageManager; + repoType: MonoRepoType; + workspaceName?: string; + monoRepoProjects?: MonoRepoProject[]; +} + +export function outdatedCommand(project: Project): string { + switch (project.packageManager) { + case PackageManager.yarn: { + if (project.isYarnV1()) { + return 'yarn outdated --json'; + } + return 'yarn outdated --format=json'; + } + case PackageManager.bun: + return 'npm outdated --json'; + case PackageManager.pnpm: + return 'pnpm outdated --json'; + default: + return 'npm outdated --json'; + } +} + +export function listCommand(project: Project): string { + switch (project.packageManager) { + case PackageManager.yarn: + return project.isYarnV1() ? 'yarn list --json' : 'yarn info --json'; + case PackageManager.pnpm: + return 'pnpm list --json'; + case PackageManager.bun: + return 'npm list --json'; + default: + return 'npm list --json'; + } +} + +export function saveDevArgument(packageManager: PackageManager): string { + switch (packageManager) { + case PackageManager.yarn: + return '--dev'; + default: + return '--save-dev'; + } +} + +export function installForceArgument(packageManager: PackageManager): string { + switch (packageManager) { + case PackageManager.yarn: + return ''; + default: + return '--force'; + } +} + +export function npmInstall(name: string, ctx: PackageCommandContext, ...args: string[]): string { + const argList = args.join(' ').trim(); + + switch (ctx.repoType) { + case MonoRepoType.npm: + return `${pm(PMOperation.install, ctx, name)} ${argList} --workspace=${getMonoRepoFolder( + ctx.workspaceName ?? '', + '', + ctx.monoRepoProjects ?? [], + )}`; + case MonoRepoType.bun: + case MonoRepoType.yarn: + case MonoRepoType.folder: + case MonoRepoType.lerna: + case MonoRepoType.pnpm: + return InternalCommand.cwd + `${pm(PMOperation.install, ctx, name)} ${notForce(ctx, argList)}`; + default: + return `${pm(PMOperation.install, ctx, name)} ${notForce(ctx, argList)}`; + } +} + +function notForce(ctx: PackageCommandContext, args: string): string { + if (ctx.packageManager !== PackageManager.yarn) return args; + return args.replace('--force', ''); +} + +export function addCommand(ctx: PackageCommandContext): string { + const a = pm(PMOperation.install, ctx, '*'); + return a.replace('*', '').replace('--save-exact', '').replace('--exact', '').trim(); +} + +export function preflightNPMCheck(project: Project): string { + const nmf = project.getNodeModulesFolder(); + const ctx: PackageCommandContext = { + packageManager: project.packageManager, + repoType: project.repoType, + workspaceName: project.monoRepo?.name, + monoRepoProjects: project.monoRepoProjects, + }; + const preop = !existsSync(nmf) && !project.isModernYarn() ? npmInstallAll(ctx) + ' && ' : ''; + + if (!process.env.ANDROID_SDK_ROOT && !process.env.ANDROID_HOME && process.platform !== 'win32') { + process.env.ANDROID_HOME = `~/Library/Android/sdk`; + } + + return preop; +} + +export function npmInstallAll(ctx: PackageCommandContext): string { + switch (ctx.repoType) { + case MonoRepoType.pnpm: + case MonoRepoType.lerna: + case MonoRepoType.folder: + return InternalCommand.cwd + pm(PMOperation.installAll, ctx); + default: + return pm(PMOperation.installAll, ctx); + } +} + +export function npmUpdate(ctx: PackageCommandContext): string { + switch (ctx.repoType) { + case MonoRepoType.pnpm: + case MonoRepoType.lerna: + case MonoRepoType.folder: + return InternalCommand.cwd + pm(PMOperation.update, ctx); + default: + return pm(PMOperation.update, ctx); + } +} + +function pm(operation: PMOperation, ctx: PackageCommandContext, name?: string): string { + switch (ctx.packageManager) { + case PackageManager.npm: + return npmPm(operation, name); + case PackageManager.yarn: + return yarnPm(operation, name); + case PackageManager.pnpm: + return pnpmPm(operation, name); + case PackageManager.bun: + return bunPm(operation, name); + default: + console.error('Unknown package manager'); + return ''; + } +} + +function yarnPm(operation: PMOperation, name?: string): string { + switch (operation) { + case PMOperation.installAll: + return 'yarn install'; + case PMOperation.install: + return `yarn add ${name} --exact`; + case PMOperation.uninstall: + return `yarn remove ${name}`; + case PMOperation.run: + return `yarn run ${name}`; + case PMOperation.update: + return `yarn update`; + } +} + +function npmPm(operation: PMOperation, name?: string): string { + switch (operation) { + case PMOperation.installAll: + return 'npm install'; + case PMOperation.install: + return `npm install ${name} --save-exact`; + case PMOperation.uninstall: + return `npm uninstall ${name}`; + case PMOperation.run: + return `npm run ${name}`; + case PMOperation.update: + return 'npm update'; + } +} + +function pnpmPm(operation: PMOperation, name?: string): string { + switch (operation) { + case PMOperation.installAll: + return 'pnpm install'; + case PMOperation.install: + return `pnpm add ${name} --save-exact`; + case PMOperation.uninstall: + return `pnpm remove ${name}`; + case PMOperation.update: + return 'pnpm update'; + } +} + +function bunPm(operation: PMOperation, name?: string): string { + switch (operation) { + case PMOperation.installAll: + return 'bun install'; + case PMOperation.install: + return `bun install ${name} --save-exact`; + case PMOperation.uninstall: + return `bun uninstall ${name}`; + case PMOperation.run: + return `bun run ${name}`; + case PMOperation.update: + return 'bun update'; + } +} + +interface NpxOptions { + forceNpx?: boolean; +} + +export function npx(project: Project, options?: NpxOptions): string { + switch (project.packageManager) { + case PackageManager.bun: + return `${InternalCommand.cwd}bunx`; + case PackageManager.pnpm: + return options?.forceNpx ? `${InternalCommand.cwd}npx` : `${InternalCommand.cwd}pnpm exec`; + case PackageManager.yarn: + if (options?.forceNpx && !project.isModernYarn()) { + return `${InternalCommand.cwd}npx`; + } + if (project.analyzer.exists('@yarnpkg/pnpify')) { + return `${InternalCommand.cwd}yarn pnpify`; + } + return `${InternalCommand.cwd}yarn exec`; + default: + return `${InternalCommand.cwd}npx`; + } +} + +export function npmUninstall(name: string, ctx: PackageCommandContext): string { + switch (ctx.repoType) { + case MonoRepoType.npm: + return `${pm(PMOperation.uninstall, ctx, name)} --workspace=${getMonoRepoFolder( + ctx.workspaceName ?? '', + '', + ctx.monoRepoProjects ?? [], + )}`; + case MonoRepoType.bun: + case MonoRepoType.folder: + case MonoRepoType.yarn: + case MonoRepoType.lerna: + case MonoRepoType.pnpm: + return `${InternalCommand.cwd}${pm(PMOperation.uninstall, ctx, name)}`; + default: + return pm(PMOperation.uninstall, ctx, name); + } +} + +export function npmRun(name: string, ctx: PackageCommandContext): string { + switch (ctx.repoType) { + case MonoRepoType.npm: + return `${pm(PMOperation.run, ctx, name)} --workspace=${getMonoRepoFolder( + ctx.workspaceName ?? '', + '', + ctx.monoRepoProjects ?? [], + )}`; + case MonoRepoType.bun: + case MonoRepoType.folder: + case MonoRepoType.yarn: + case MonoRepoType.lerna: + case MonoRepoType.pnpm: + return `${InternalCommand.cwd}${pm(PMOperation.run, ctx, name)}`; + default: + return pm(PMOperation.run, ctx, name); + } +} + +/** Build a PackageCommandContext from a Project instance. */ +export function commandContext(project: Project): PackageCommandContext { + return { + packageManager: project.packageManager, + repoType: project.repoType, + workspaceName: project.monoRepo?.name, + monoRepoProjects: project.monoRepoProjects, + }; +} diff --git a/cli/src/build/platform.ts b/cli/src/build/platform.ts new file mode 100644 index 0000000..1a7151f --- /dev/null +++ b/cli/src/build/platform.ts @@ -0,0 +1,3 @@ +export function isWindows(): boolean { + return process.platform === 'win32'; +} diff --git a/cli/src/build/scripts.ts b/cli/src/build/scripts.ts new file mode 100644 index 0000000..85587d7 --- /dev/null +++ b/cli/src/build/scripts.ts @@ -0,0 +1,86 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { MonoRepoType } from '../project/monorepo'; +import { Project } from '../project/project'; +import { commandContext, npmRun } from './node-commands'; + +export interface ScriptEntry { + name: string; + command: string; + description?: string; +} + +function toTitleCase(name: string): string { + return name.replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function niceName(name: string): string { + return toTitleCase(name.replace(/-/g, ' ')); +} + +function readPackageJSON(folder: string): Record { + const filename = join(folder, 'package.json'); + if (!existsSync(filename)) return {}; + try { + return JSON.parse(readFileSync(filename, 'utf8')); + } catch { + return {}; + } +} + +/** List npm scripts from package.json as runnable commands. */ +export function listScripts(project: Project, isWeb = false): ScriptEntry[] { + const entries: ScriptEntry[] = []; + const ctx = commandContext(project); + const expand = !( + project.analyzer.exists('@capacitor/core') || + project.analyzer.exists('cordova-ios') || + project.analyzer.exists('cordova-android') || + isWeb + ); + + if (!expand && !isWeb) { + // Still return scripts when explicitly requested + } + + addScriptsFrom(readPackageJSON(project.projectFolder()), ctx, entries); + + if (project.repoType === MonoRepoType.nx) { + addScriptsFrom(readPackageJSON(project.folder), ctx, entries); + addNXScripts(['build', 'test', 'lint', 'e2e'], project, entries); + } + + return entries; +} + +function addScriptsFrom( + packages: Record, + ctx: ReturnType, + entries: ScriptEntry[], +): void { + if (packages.scripts) { + for (const script of Object.keys(packages.scripts)) { + entries.push({ + name: niceName(script), + command: npmRun(script, ctx), + description: `Runs 'npm run ${script}' found in package.json`, + }); + } + } +} + +function addNXScripts(names: string[], project: Project, entries: ScriptEntry[]): void { + for (const name of names) { + entries.push({ + name: `${project.monoRepo.name} ${name}`, + command: `npx nx run ${project.monoRepo.name}:${name}`, + description: `Runs nx ${name} for ${project.monoRepo.name}`, + }); + } +} + +/** Whether the project looks like a Capacitor plugin (has native platform keys in package.json). */ +export function isCapacitorPlugin(project: Project): boolean { + const packages = readPackageJSON(project.projectFolder()); + return !!(packages.capacitor?.ios || packages.capacitor?.android); +} diff --git a/cli/src/build/uncolor.ts b/cli/src/build/uncolor.ts new file mode 100644 index 0000000..c35f1fa --- /dev/null +++ b/cli/src/build/uncolor.ts @@ -0,0 +1,4 @@ +export function uncolor(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/[\x1b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g, ''); +} diff --git a/cli/src/build/web-run.ts b/cli/src/build/web-run.ts new file mode 100644 index 0000000..6635fa0 --- /dev/null +++ b/cli/src/build/web-run.ts @@ -0,0 +1,228 @@ +import { existsSync, readFileSync } from 'fs'; +import { createServer } from 'http'; +import { networkInterfaces } from 'os'; +import { join } from 'path'; +import { Project } from '../project/project'; +import { getRunConfigurationArgs } from './build-configuration'; +import { CommandResult, finalizeCommand } from './command-result'; +import { certPath, liveReloadSSL } from './live-reload'; +import { MonoRepoType } from '../project/monorepo'; +import { commandContext, npmRun, npx, preflightNPMCheck } from './node-commands'; + +export interface ServeOptions { + dontOpenBrowser?: boolean; + isDebugging?: boolean; + isNative?: boolean; + httpsForWeb?: boolean; + externalIP?: boolean; + defaultPort?: number; + runConfiguration?: string; + projectName?: string; + /** Selected external IP for live reload (replaces interactive quick-pick). */ + host?: string; + /** When true, create SSL certs if missing instead of returning empty command. */ + createCerts?: boolean; +} + +export interface ServeResult { + command: CommandResult; + port?: number; +} + +/** + * Create the ionic serve command + */ +export async function serve(project: Project, options: ServeOptions = {}): Promise { + const dontOpenBrowser = options.dontOpenBrowser ?? false; + switch (project.repoType) { + case MonoRepoType.none: + return runServe(project, dontOpenBrowser, options); + case MonoRepoType.nx: + return { command: finalizeCommand(project, nxServe(project, options)) }; + case MonoRepoType.bun: + case MonoRepoType.npm: + case MonoRepoType.yarn: + case MonoRepoType.lerna: + case MonoRepoType.pnpm: + case MonoRepoType.folder: + return runServe(project, dontOpenBrowser, options); + default: + throw new Error('Unsupported Monorepo type'); + } +} + +async function runServe(project: Project, dontOpenBrowser: boolean, options: ServeOptions): Promise { + const preop = preflightNPMCheck(project); + let externalIP = options.externalIP; + let defaultPort = options.defaultPort; + + if (project.analyzer.exists('next')) { + externalIP = undefined; + defaultPort = undefined; + } + + let serveFlags = ''; + if (project.frameworkType === 'angular-standalone') { + if (dontOpenBrowser) { + serveFlags += ' --no-open'; + } else { + serveFlags += ' --open'; + } + } + + if (externalIP) { + serveFlags += ` ${await externalArg(project, options.isNative, options.host)}`; + } + + let port: number | undefined; + if (defaultPort) { + port = await findNextPort(defaultPort, externalIP ? '0.0.0.0' : undefined); + serveFlags += ` --port=${port}`; + } + + if (options.projectName) { + serveFlags += ` --project=${options.projectName}`; + } + + serveFlags += getRunConfigurationArgs(options.runConfiguration, options.isDebugging); + + if (options.httpsForWeb) { + serveFlags += ' --ssl'; + if (!existsSync(certPath('crt'))) { + if (options.createCerts) { + await liveReloadSSL(project); + } + return { command: '', port }; + } + serveFlags += ` --ssl-cert='${certPath('crt')}'`; + serveFlags += ` --ssl-key='${certPath('key')}'`; + } + + const ctx = commandContext(project); + const cmd = `${preop}${npx(project)} ${serveCmd(project, ctx)}${serveFlags}`; + return { command: finalizeCommand(project, cmd), port }; +} + +function serveCmd(project: Project, ctx: ReturnType): string { + const guessed = guessServeCommand(project, ctx); + if (guessed) { + return guessed + ' -- '; + } + switch (project.frameworkType) { + case 'angular': + case 'angular-standalone': + return 'ng serve'; + case 'vue-vite': + case 'react-vite': + return 'vite'; + case 'react': + return 'react-scripts start'; + case 'vue': + return 'vue-cli-service serve'; + default: + if (project.analyzer.exists('vite')) { + return 'vite'; + } + console.error( + 'Unable to determine the command used to serve your web application. Please add a "serve" script to your package.json.', + ); + return ''; + } +} + +function guessServeCommand(project: Project, ctx: ReturnType): string | undefined { + const filename = join(project.projectFolder(), 'package.json'); + if (existsSync(filename)) { + const packageFile = JSON.parse(readFileSync(filename, 'utf8')); + if (packageFile.scripts?.['wn:serve']) { + return npmRun('wn:serve', ctx); + } else if (packageFile.scripts?.['ionic:serve']) { + return npmRun('ionic:serve', ctx); + } else if (packageFile.scripts?.serve) { + return npmRun('serve', ctx); + } else if (packageFile.scripts?.dev) { + return npmRun('dev', ctx); + } else if (packageFile.scripts?.start) { + return npmRun('start', ctx); + } + } + return undefined; +} + +export async function findNextPort(port: number, host: string | undefined): Promise { + let availablePort = port; + while (await isPortInUse(availablePort, host)) { + availablePort++; + } + return availablePort; +} + +export async function isPortInUse(port: number, host: string | undefined): Promise { + return new Promise((resolve) => { + const server = createServer(); + + server.once('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + resolve(true); + } else { + resolve(false); + } + }); + + server.once('listening', () => { + server.close(); + resolve(false); + }); + + server.listen(port, host); + }); +} + +async function externalArg(project: Project, isNative?: boolean, host?: string): Promise { + if (isNative && host) { + return `--host=${host}`; + } + if (!project.analyzer.exists('@angular/core')) { + return '--host'; + } + return '--host=0.0.0.0'; +} + +function bestAddress(): string { + const list = getExternalAddresses(); + return list.length === 1 ? list[0] : '0.0.0.0'; +} + +function nxServe(project: Project, options: ServeOptions): string { + let serveFlags = ''; + if (options.externalIP) { + serveFlags += ` --host=${bestAddress()}`; + } + return `${npx(project)} nx serve ${project.monoRepo.name}${serveFlags}`; +} + +/** Return external IPv4 addresses suitable for device live reload. */ +export function getExternalAddresses(): string[] { + const nets = networkInterfaces(); + const result: string[] = []; + for (const name of Object.keys(nets)) { + for (const net of nets[name] ?? []) { + if (net.family === 'IPv4' && !net.internal && !net.address.startsWith('169.254')) { + result.push(net.address); + } + } + } + return result; +} + +/** Resolve the external IP to use; prefers `host`, then `lastHost`, then the sole address. */ +export function resolveExternalHost(host?: string, lastHost?: string): string | undefined { + const list = getExternalAddresses(); + if (host) return host; + if (list.length <= 1) return list[0]; + if (lastHost && list.includes(lastHost)) return lastHost; + return undefined; +} + +// Legacy alias +export const selectExternalIPAddress = resolveExternalHost; diff --git a/cli/src/cli/args.ts b/cli/src/cli/args.ts new file mode 100644 index 0000000..30b1214 --- /dev/null +++ b/cli/src/cli/args.ts @@ -0,0 +1,231 @@ +export interface GlobalOptions { + cwd: string; + project?: string; + json: boolean; + streamJson: boolean; + yes: boolean; + noInput: boolean; + dryRun: boolean; + verbose: boolean; + quiet: boolean; + noColor: boolean; + packageManager?: string; + timeout?: number; + help: boolean; + version: boolean; +} + +export interface ParsedArgs { + global: GlobalOptions; + /** All non-flag tokens before `--` */ + tokens: string[]; + flags: Record; + passthrough: string[]; + /** @deprecated use tokens — kept for compatibility during resolve */ + command: string[]; + positionals: string[]; +} + +const GLOBAL_FLAGS_WITH_VALUE = new Set(['cwd', 'project', 'package-manager', 'timeout']); +const GLOBAL_FLAGS_BOOL = new Set([ + 'json', + 'stream-json', + 'yes', + 'y', + 'no-input', + 'dry-run', + 'verbose', + 'v', + 'quiet', + 'q', + 'no-color', + 'help', + 'h', + 'version', +]); + +function envFlag(name: string): boolean { + const v = process.env[name]; + return v === '1' || v === 'true'; +} + +export function createDefaultGlobalOptions(): GlobalOptions { + return { + cwd: process.cwd(), + project: process.env.WN_PROJECT, + json: envFlag('WN_JSON'), + streamJson: false, + yes: false, + noInput: envFlag('WN_NO_INPUT'), + dryRun: false, + verbose: false, + quiet: false, + noColor: !!process.env.NO_COLOR, + packageManager: process.env.WN_PACKAGE_MANAGER, + timeout: undefined, + help: false, + version: false, + }; +} + +function takeValue(argv: string[], i: number): { value: string; next: number } { + const current = argv[i]; + const eq = current.indexOf('='); + if (eq !== -1) { + return { value: current.slice(eq + 1), next: i }; + } + const next = argv[i + 1]; + if (next == null || next.startsWith('-')) { + throw new Error(`Missing value for ${current.split('=')[0]}`); + } + return { value: next, next: i + 1 }; +} + +function flagName(raw: string): string { + const withoutEq = raw.includes('=') ? raw.slice(0, raw.indexOf('=')) : raw; + if (withoutEq.startsWith('--')) return withoutEq.slice(2); + if (withoutEq.startsWith('-')) return withoutEq.slice(1); + return withoutEq; +} + +export function parseArgs(argv: string[]): ParsedArgs { + const global = createDefaultGlobalOptions(); + const flags: Record = {}; + const tokens: string[] = []; + const passthrough: string[] = []; + + let i = 0; + let inPassthrough = false; + + while (i < argv.length) { + const arg = argv[i]; + + if (inPassthrough) { + passthrough.push(arg); + i++; + continue; + } + + if (arg === '--') { + inPassthrough = true; + i++; + continue; + } + + if (arg.startsWith('-')) { + const name = flagName(arg); + + if (GLOBAL_FLAGS_BOOL.has(name)) { + applyGlobal(global, name, true); + i++; + continue; + } + + if (GLOBAL_FLAGS_WITH_VALUE.has(name)) { + const { value, next } = takeValue(argv, i); + applyGlobal(global, name, value); + i = next + 1; + continue; + } + + // Command-specific flag + if (arg.includes('=') || (argv[i + 1] && !argv[i + 1].startsWith('-'))) { + const { value, next } = takeValue(argv, i); + const existing = flags[name]; + if (existing === undefined) flags[name] = value; + else if (Array.isArray(existing)) existing.push(value); + else flags[name] = [String(existing), value]; + i = next + 1; + } else { + flags[name] = true; + i++; + } + continue; + } + + tokens.push(arg); + i++; + } + + if (global.json) { + global.noInput = true; + global.noColor = true; + } + + return { + global, + tokens, + flags, + passthrough, + command: tokens.slice(0, 1), + positionals: tokens.slice(1), + }; +} + +function applyGlobal(global: GlobalOptions, name: string, value: string | boolean): void { + switch (name) { + case 'cwd': + global.cwd = String(value); + break; + case 'project': + global.project = String(value); + break; + case 'json': + global.json = true; + break; + case 'stream-json': + global.streamJson = true; + break; + case 'yes': + case 'y': + global.yes = true; + break; + case 'no-input': + global.noInput = true; + break; + case 'dry-run': + global.dryRun = true; + break; + case 'verbose': + case 'v': + global.verbose = true; + break; + case 'quiet': + case 'q': + global.quiet = true; + break; + case 'no-color': + global.noColor = true; + break; + case 'package-manager': + global.packageManager = String(value); + break; + case 'timeout': + global.timeout = Number(value); + break; + case 'help': + case 'h': + global.help = true; + break; + case 'version': + global.version = true; + break; + } +} + +export function flagBool(flags: Record, name: string): boolean { + const v = flags[name]; + return v === true || v === 'true'; +} + +export function flagString(flags: Record, name: string): string | undefined { + const v = flags[name]; + if (v === undefined || typeof v === 'boolean') return undefined; + return Array.isArray(v) ? v[0] : v; +} + +export function flagStringArray(flags: Record, name: string): string[] { + const v = flags[name]; + if (v === undefined || typeof v === 'boolean') return []; + return Array.isArray(v) ? v : [v]; +} diff --git a/cli/src/cli/envelope.ts b/cli/src/cli/envelope.ts new file mode 100644 index 0000000..2d9d509 --- /dev/null +++ b/cli/src/cli/envelope.ts @@ -0,0 +1,56 @@ +import { isWnError, WnError } from './errors'; + +export interface EnvelopeError { + code: string; + message: string; + input?: string; + choices?: unknown[]; + hint?: string; + file?: string; + line?: number; + column?: number; + [key: string]: unknown; +} + +export interface Envelope { + ok: boolean; + command: string; + data: unknown; + warnings: string[]; + errors: EnvelopeError[]; + durationMs: number; +} + +export function successEnvelope(command: string, data: unknown, durationMs: number, warnings: string[] = []): Envelope { + return { ok: true, command, data, warnings, errors: [], durationMs }; +} + +export function failureEnvelope(command: string, err: unknown, durationMs: number, warnings: string[] = []): Envelope { + const errors: EnvelopeError[] = []; + if (isWnError(err)) { + errors.push(err.toJSON()); + } else if (err instanceof Error) { + errors.push({ code: 'COMMAND_FAILED', message: err.message }); + } else { + errors.push({ code: 'COMMAND_FAILED', message: String(err) }); + } + return { ok: false, command, data: null, warnings, errors, durationMs }; +} + +export function errorFromParsed(parsed: { + file?: string; + line?: number; + column?: number; + message: string; + code?: string; +}): EnvelopeError { + return { + code: parsed.code ?? 'BUILD_FAILED', + message: parsed.message, + ...(parsed.file ? { file: parsed.file } : {}), + ...(parsed.line != null ? { line: parsed.line } : {}), + ...(parsed.column != null ? { column: parsed.column } : {}), + }; +} + +export { WnError }; diff --git a/cli/src/cli/errors.ts b/cli/src/cli/errors.ts new file mode 100644 index 0000000..da37447 --- /dev/null +++ b/cli/src/cli/errors.ts @@ -0,0 +1,51 @@ +import { ExitCode, ExitCodeValue } from './exit-codes'; + +export interface ErrorChoice { + id: string; + name?: string; + type?: string; + [key: string]: unknown; +} + +export interface WnErrorOptions { + exitCode?: ExitCodeValue; + input?: string; + choices?: ErrorChoice[]; + hint?: string; + details?: Record; +} + +export class WnError extends Error { + readonly code: string; + readonly exitCode: ExitCodeValue; + readonly input?: string; + readonly choices?: ErrorChoice[]; + readonly hint?: string; + readonly details?: Record; + + constructor(code: string, message: string, options: WnErrorOptions = {}) { + super(message); + this.name = 'WnError'; + this.code = code; + this.exitCode = options.exitCode ?? ExitCode.CommandFailed; + this.input = options.input; + this.choices = options.choices; + this.hint = options.hint; + this.details = options.details; + } + + toJSON() { + return { + code: this.code, + message: this.message, + ...(this.input ? { input: this.input } : {}), + ...(this.choices ? { choices: this.choices } : {}), + ...(this.hint ? { hint: this.hint } : {}), + ...(this.details ? this.details : {}), + }; + } +} + +export function isWnError(err: unknown): err is WnError { + return err instanceof WnError; +} diff --git a/cli/src/cli/exit-codes.ts b/cli/src/cli/exit-codes.ts new file mode 100644 index 0000000..8b53e84 --- /dev/null +++ b/cli/src/cli/exit-codes.ts @@ -0,0 +1,14 @@ +/** Exit codes from cli/docs.md */ +export const ExitCode = { + Success: 0, + CommandFailed: 1, + UsageError: 2, + NoProject: 3, + MissingTool: 4, + MissingInput: 5, + CheckFailed: 6, + Timeout: 7, + Interrupted: 130, +} as const; + +export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode]; diff --git a/cli/src/cli/help.ts b/cli/src/cli/help.ts new file mode 100644 index 0000000..d69fab4 --- /dev/null +++ b/cli/src/cli/help.ts @@ -0,0 +1,38 @@ +import { listCommands } from './registry'; + +export function printRootHelp(): void { + const cmds = listCommands(); + const lines = [ + 'WebNative CLI (wn)', + '', + 'Usage: wn [options]', + '', + 'Global options:', + ' --cwd Workspace root', + ' --project Monorepo sub-project', + ' --json Machine-readable JSON on stdout', + ' --stream-json NDJSON progress events + final envelope', + ' --yes, -y Accept confirmations', + ' --no-input Never prompt', + ' --dry-run Print commands without running', + ' --verbose, -v Show underlying shell commands', + ' --quiet, -q Suppress progress', + ' --no-color Disable ANSI color', + ' --package-manager npm | pnpm | yarn | bun', + ' --timeout Abort after N seconds', + ' --help, -h Show help', + ' --version Show version', + '', + 'Commands:', + ]; + for (const c of cmds) { + lines.push(` ${c.name.padEnd(28)} ${c.description}`); + } + lines.push(''); + lines.push('See cli/docs.md for the full specification.'); + process.stdout.write(lines.join('\n') + '\n'); +} + +export function printCommandHelp(name: string, description: string): void { + process.stdout.write(`wn ${name}\n\n${description}\n\nRun with --json for machine-readable output.\n`); +} diff --git a/cli/src/cli/output.ts b/cli/src/cli/output.ts new file mode 100644 index 0000000..0df3f11 --- /dev/null +++ b/cli/src/cli/output.ts @@ -0,0 +1,84 @@ +import { Envelope } from './envelope'; +import { GlobalOptions } from './args'; + +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + yellow: '\x1b[33m', + green: '\x1b[32m', + dim: '\x1b[2m', + cyan: '\x1b[36m', + bold: '\x1b[1m', +}; + +export class Output { + constructor(private readonly opts: GlobalOptions) {} + + private color(code: string, text: string): string { + if (this.opts.noColor) return text; + return `${code}${text}${colors.reset}`; + } + + writeJson(envelope: Envelope): void { + process.stdout.write(JSON.stringify(envelope) + '\n'); + } + + writeStreamEvent(event: Record): void { + if (this.opts.streamJson || this.opts.json) { + process.stdout.write(JSON.stringify(event) + '\n'); + } + } + + info(message: string): void { + if (this.opts.json || this.opts.quiet) return; + process.stderr.write(message + '\n'); + } + + verbose(message: string): void { + if (!this.opts.verbose || this.opts.json) return; + process.stderr.write(this.color(colors.dim, message) + '\n'); + } + + warn(message: string): void { + if (this.opts.json) { + process.stderr.write(message + '\n'); + return; + } + process.stderr.write(this.color(colors.yellow, message) + '\n'); + } + + error(message: string): void { + process.stderr.write(this.color(colors.red, message) + '\n'); + } + + success(message: string): void { + if (this.opts.json || this.opts.quiet) return; + process.stderr.write(this.color(colors.green, message) + '\n'); + } + + humanResult(envelope: Envelope): void { + if (envelope.ok) { + if (envelope.data != null) { + if (typeof envelope.data === 'string') { + process.stdout.write(envelope.data + '\n'); + } else { + process.stdout.write(JSON.stringify(envelope.data, null, 2) + '\n'); + } + } + for (const w of envelope.warnings) { + this.warn(w); + } + } else { + for (const e of envelope.errors) { + this.error(`${e.code}: ${e.message}`); + if (e.hint) this.info(e.hint); + if (e.choices?.length) { + this.info('Choices:'); + for (const c of e.choices) { + this.info(` ${JSON.stringify(c)}`); + } + } + } + } + } +} diff --git a/cli/src/cli/prompt.ts b/cli/src/cli/prompt.ts new file mode 100644 index 0000000..bdf2a35 --- /dev/null +++ b/cli/src/cli/prompt.ts @@ -0,0 +1,88 @@ +import * as readline from 'readline'; +import { GlobalOptions } from './args'; +import { ErrorChoice, WnError } from './errors'; +import { ExitCode } from './exit-codes'; + +export function canPrompt(opts: GlobalOptions): boolean { + return !opts.noInput && !opts.json && !!process.stdin.isTTY; +} + +export async function ask(question: string, opts: GlobalOptions, defaultValue?: string): Promise { + if (!canPrompt(opts)) { + if (opts.yes && defaultValue !== undefined) return defaultValue; + throw new WnError('MISSING_INPUT', `Missing required input: ${question}`, { + exitCode: ExitCode.MissingInput, + hint: 'Re-run with the appropriate flag or without --no-input', + }); + } + + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + const suffix = defaultValue !== undefined ? ` [${defaultValue}]` : ''; + const answer = await new Promise((resolve) => { + rl.question(`${question}${suffix}: `, (a) => resolve(a)); + }); + rl.close(); + return answer.trim() || defaultValue || ''; +} + +export async function confirm(question: string, opts: GlobalOptions, defaultYes = true): Promise { + if (opts.yes) return true; + if (!canPrompt(opts)) return defaultYes; + const hint = defaultYes ? 'Y/n' : 'y/N'; + const answer = await ask(`${question} (${hint})`, opts); + if (!answer) return defaultYes; + return /^y(es)?$/i.test(answer); +} + +export async function requireChoice( + input: string, + choices: T[], + opts: GlobalOptions, + hint: string, + selected?: string, +): Promise { + if (selected) { + const match = choices.find((c) => c.id === selected || c.name === selected); + if (!match) { + throw new WnError('MISSING_INPUT', `Unknown ${input}: ${selected}`, { + exitCode: ExitCode.MissingInput, + input, + choices, + hint, + }); + } + return match; + } + + if (choices.length === 1) return choices[0]; + + if (choices.length === 0) { + throw new WnError('MISSING_INPUT', `No ${input} available`, { + exitCode: ExitCode.MissingInput, + input, + choices, + hint, + }); + } + + if (canPrompt(opts)) { + process.stderr.write(`Select ${input}:\n`); + choices.forEach((c, i) => { + process.stderr.write(` ${i + 1}) ${c.name ?? c.id}${c.type ? ` (${c.type})` : ''}\n`); + }); + const answer = await ask('Enter number or id', opts); + const byIndex = Number(answer); + if (!Number.isNaN(byIndex) && byIndex >= 1 && byIndex <= choices.length) { + return choices[byIndex - 1]; + } + const match = choices.find((c) => c.id === answer || c.name === answer); + if (match) return match; + } + + throw new WnError('MISSING_INPUT', `No ${input} selected and more than one is available.`, { + exitCode: ExitCode.MissingInput, + input, + choices, + hint, + }); +} diff --git a/cli/src/cli/registry.ts b/cli/src/cli/registry.ts new file mode 100644 index 0000000..f9d04c4 --- /dev/null +++ b/cli/src/cli/registry.ts @@ -0,0 +1,50 @@ +import { CliContext } from '../core/context'; +import { ParsedArgs } from './args'; + +export interface CommandDef { + name: string; + description: string; + run: (ctx: CliContext) => Promise; + /** If true, skip project detection */ + noProject?: boolean; +} + +const commands = new Map(); + +export function registerCommand(def: CommandDef): void { + commands.set(def.name, def); +} + +export function getCommand(name: string): CommandDef | undefined { + return commands.get(name); +} + +export function listCommands(): CommandDef[] { + return [...commands.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** Resolve command from tokens using longest registered name match. */ +export function resolveCommand(parsed: ParsedArgs): { + def?: CommandDef; + name: string; + rest: string[]; +} { + const tokens = parsed.tokens; + if (tokens.length === 0) { + return { name: '', rest: [] }; + } + + for (let len = Math.min(tokens.length, 4); len >= 1; len--) { + const name = tokens.slice(0, len).join(' '); + const def = commands.get(name); + if (def) { + const rest = tokens.slice(len); + // Mutate for command handlers that read positionals + parsed.positionals = rest; + parsed.command = name.split(' '); + return { def, name, rest }; + } + } + + return { name: tokens[0], rest: tokens.slice(1) }; +} diff --git a/cli/src/commands/assets.ts b/cli/src/commands/assets.ts new file mode 100644 index 0000000..ba71e1c --- /dev/null +++ b/cli/src/commands/assets.ts @@ -0,0 +1,199 @@ +import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { extname, join } from 'path'; +import { flagBool } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { registerCommand } from '../cli/registry'; +import { commandContext, npmUninstall, npx } from '../build/node-commands'; +import { CliContext } from '../core/context'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { ensureProject, positional, runShell } from './helpers'; + +export enum AssetType { + splash = 'splash.png', + splashDark = 'splash-dark.png', + icon = 'icon.png', + adaptiveForeground = 'icon-foreground.png', + adaptiveBackground = 'icon-background.png', +} + +const ASSET_ALIASES: Record = { + splash: AssetType.splash, + 'splash-dark': AssetType.splashDark, + icon: AssetType.icon, + 'icon-foreground': AssetType.adaptiveForeground, + 'icon-background': AssetType.adaptiveBackground, +}; + +function getResourceFolder(folder: string, filename: AssetType, createIfMissing?: boolean): string { + let resourceFolder = join(folder, 'resources'); + if (createIfMissing && !existsSync(resourceFolder)) { + mkdirSync(resourceFolder); + } + if (filename == AssetType.adaptiveBackground || filename == AssetType.adaptiveForeground) { + resourceFolder = join(resourceFolder, 'android'); + if (createIfMissing && !existsSync(resourceFolder)) { + mkdirSync(resourceFolder); + } + } + return resourceFolder; +} + +function assetPath(folder: string, type: AssetType): string { + return join(getResourceFolder(folder, type), type); +} + +function assetStatus(folder: string, type: AssetType): { type: string; path: string; exists: boolean } { + const path = assetPath(folder, type); + return { + type: Object.keys(ASSET_ALIASES).find((k) => ASSET_ALIASES[k] === type) ?? type, + path, + exists: existsSync(path), + }; +} + +function resolveAssetType(raw: string): AssetType { + const type = ASSET_ALIASES[raw]; + if (!type) { + throw new WnError('USAGE_ERROR', `Unknown asset type: ${raw}`, { + exitCode: ExitCode.UsageError, + choices: Object.keys(ASSET_ALIASES).map((id) => ({ id, name: id })), + }); + } + return type; +} + +function hasNeededAssets(folder: string): string | undefined { + if (!existsSync(assetPath(folder, AssetType.icon))) return 'An icon needs to be specified next.'; + if (!existsSync(assetPath(folder, AssetType.splash))) return 'A splash screen needs to be specified next.'; + if (!existsSync(assetPath(folder, AssetType.splashDark))) + return 'A dark mode splash screen needs to be specified next.'; + return undefined; +} + +function addToGitIgnore(folder: string, ignoreGlob: string): void { + const filename = join(folder, '.gitignore'); + if (existsSync(filename)) { + let txt = readFileSync(filename, { encoding: 'utf-8' }); + if (!txt.includes(ignoreGlob)) { + txt = txt + `\n${ignoreGlob}`; + writeFileSync(filename, txt); + } + } +} + +async function runAssetsList(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const folder = project.projectFolder(); + return { + assets: Object.keys(ASSET_ALIASES).map((key) => assetStatus(folder, ASSET_ALIASES[key])), + }; +} + +async function runAssetsSet(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const folder = project.projectFolder(); + const typeRaw = positional(ctx, 0); + const sourceFile = positional(ctx, 1); + if (!typeRaw || !sourceFile) { + throw new WnError('USAGE_ERROR', 'Usage: wn assets set ', { exitCode: ExitCode.UsageError }); + } + + const assetType = resolveAssetType(typeRaw); + if (extname(sourceFile) !== '.png') { + throw new WnError('INVALID_IMAGE', 'The file must be a png', { exitCode: ExitCode.UsageError }); + } + if (!existsSync(sourceFile)) { + throw new WnError('MISSING_SOURCE_ASSET', `File not found: ${sourceFile}`, { exitCode: ExitCode.CommandFailed }); + } + + const resourceFolder = getResourceFolder(folder, assetType, true); + const dest = join(resourceFolder, assetType); + if (!ctx.opts.dryRun) { + copyFileSync(sourceFile, dest); + if (assetType == AssetType.icon) { + const bg = join(getResourceFolder(folder, AssetType.adaptiveBackground, true), AssetType.adaptiveBackground); + const fg = join(getResourceFolder(folder, AssetType.adaptiveForeground, true), AssetType.adaptiveForeground); + if (!existsSync(bg)) copyFileSync(sourceFile, bg); + if (!existsSync(fg)) copyFileSync(sourceFile, fg); + } + } + + return { type: typeRaw, path: dest }; +} + +async function runAssetsGenerate(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const folder = project.projectFolder(); + const needed = hasNeededAssets(folder); + if (needed) { + throw new WnError('MISSING_SOURCE_ASSET', needed, { exitCode: ExitCode.MissingInput }); + } + + const ios = flagBool(ctx.args.flags, 'ios') || project.hasCapacitorProject(CapacitorPlatform.ios); + const android = flagBool(ctx.args.flags, 'android') || project.hasCapacitorProject(CapacitorPlatform.android); + const pwa = flagBool(ctx.args.flags, 'pwa') || project.analyzer.exists('@angular/service-worker'); + const explicitTarget = + flagBool(ctx.args.flags, 'ios') || flagBool(ctx.args.flags, 'android') || flagBool(ctx.args.flags, 'pwa'); + + const runIos = explicitTarget ? flagBool(ctx.args.flags, 'ios') : ios; + const runAndroid = explicitTarget ? flagBool(ctx.args.flags, 'android') : android; + const runPwa = explicitTarget ? flagBool(ctx.args.flags, 'pwa') : pwa; + + const commands: string[] = []; + if (project.analyzer.exists('cordova-res')) { + commands.push(npmUninstall('cordova-res', commandContext(project))); + } + + if (runIos) { + commands.push(`${npx(project, { forceNpx: true })} @capacitor/assets generate --ios`); + } + if (runAndroid) { + commands.push(`${npx(project, { forceNpx: true })} @capacitor/assets generate --android`); + } + if (runPwa) { + commands.push( + `${npx(project, { forceNpx: true })} @capacitor/assets generate --pwa --pwaManifestPath './src/manifest.webmanifest'`, + ); + } + + if (commands.length === 0) { + throw new WnError('GENERATE_FAILED', 'No Capacitor platforms found. Add iOS, Android, or PWA platform first.', { + exitCode: ExitCode.CommandFailed, + }); + } + + for (const cmd of commands) { + ctx.logger.writeWN(cmd); + await runShell(ctx, { command: cmd, cwd: folder }); + } + + if (!ctx.opts.dryRun) { + if (runIos) addToGitIgnore(folder, 'resources/ios/**/*'); + if (runAndroid) addToGitIgnore(folder, 'resources/android/**/*'); + } + + return { generated: { ios: runIos, android: runAndroid, pwa: runPwa } }; +} + +async function runAssets(ctx: CliContext): Promise { + const sub = positional(ctx, 0); + switch (sub) { + case 'list': + return runAssetsList(ctx); + case 'set': + return runAssetsSet(ctx); + case 'generate': + return runAssetsGenerate(ctx); + default: + throw new WnError('USAGE_ERROR', 'Usage: wn assets ', { exitCode: ExitCode.UsageError }); + } +} + +export function registerAssetsCommand(): void { + registerCommand({ + name: 'assets', + description: 'Manage splash screens and app icons', + run: runAssets, + }); +} diff --git a/cli/src/commands/build.ts b/cli/src/commands/build.ts new file mode 100644 index 0000000..6b0f1cb --- /dev/null +++ b/cli/src/commands/build.ts @@ -0,0 +1,78 @@ +import { registerCommand } from '../cli/registry'; +import { flagBool, flagString } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { build as buildCommand } from '../build/build'; +import { extractErrors } from '../build/error-parsers'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { CliContext } from '../core/context'; +import { ensureProject, runCommandResult } from './helpers'; + +async function runBuild(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const platformArg = ctx.args.positionals[0] || 'web'; + let platform: CapacitorPlatform | undefined; + if (platformArg === 'ios') platform = CapacitorPlatform.ios; + else if (platformArg === 'android') platform = CapacitorPlatform.android; + else if (platformArg === 'all') { + // build web then copy both — handled by omitting platform for web build and syncing + platform = undefined; + } else if (platformArg !== 'web') { + throw new WnError('USAGE_ERROR', `Unknown platform: ${platformArg}`, { exitCode: ExitCode.UsageError }); + } + + const noCopy = flagBool(ctx.args.flags, 'no-copy'); + const prod = flagBool(ctx.args.flags, 'prod') || ctx.config.buildForProduction; + const config = flagString(ctx.args.flags, 'config'); + const sourcemap = flagBool(ctx.args.flags, 'sourcemap'); + + const result = buildCommand(project, { + platform: noCopy ? undefined : platform, + buildForProduction: prod, + buildConfiguration: config, + sourceMaps: sourcemap, + projectName: project.monoRepo?.name, + }); + + const start = Date.now(); + try { + await runCommandResult(ctx, result); + } catch (err) { + const details = err instanceof WnError ? err.details : undefined; + const output = String(details?.stdout || '') + '\n' + String(details?.stderr || ''); + const parsed = extractErrors(output); + throw new WnError('BUILD_FAILED', 'Build failed', { + exitCode: ExitCode.CommandFailed, + details: { + errors: parsed.map((e) => ({ + code: 'BUILD_FAILED', + message: e.message, + file: e.file, + line: e.line, + column: e.column, + })), + }, + }); + } + + if (platformArg === 'all' && !noCopy) { + for (const p of [CapacitorPlatform.ios, CapacitorPlatform.android]) { + if (project.hasCapacitorProject(p)) { + const copy = buildCommand(project, { platform: p, buildForProduction: prod, buildConfiguration: config }); + await runCommandResult(ctx, copy); + } + } + } + + return { + outputDir: project.getDistFolder(), + durationMs: Date.now() - start, + platform: platformArg, + }; +} + +registerCommand({ + name: 'build', + description: 'Build the web app (and optionally copy to native)', + run: runBuild, +}); diff --git a/cli/src/commands/check.ts b/cli/src/commands/check.ts new file mode 100644 index 0000000..bcc9625 --- /dev/null +++ b/cli/src/commands/check.ts @@ -0,0 +1,201 @@ +import { finalizeCommand } from '../build/command-result'; +import { flagBool, flagString } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { registerCommand } from '../cli/registry'; +import { CliContext } from '../core/context'; +import { inspectProject } from '../project/inspect'; +import { Category, Finding, Severity } from '../rules/finding'; +import { runChecks } from '../rules/engine'; +import { addIgnoredId, getIgnoredIds, isIgnored } from '../rules/ignore'; + +const SEVERITY_ORDER: Record = { + info: 0, + warning: 1, + error: 2, +}; + +const VALID_SEVERITIES = new Set(['error', 'warning', 'info']); +const VALID_CATEGORIES = new Set([ + 'packages', + 'capacitor', + 'cordova', + 'angular', + 'typescript', + 'android', + 'ios', + 'browserslist', + 'security', + 'privacy', +]); + +function parseSeverityList(raw: string | undefined): Set | undefined { + if (!raw) return undefined; + const values = raw.split(',').map((s) => s.trim()) as Severity[]; + for (const v of values) { + if (!VALID_SEVERITIES.has(v)) { + throw new WnError('USAGE_ERROR', `Invalid severity: ${v}`, { exitCode: ExitCode.UsageError }); + } + } + return new Set(values); +} + +function parseCategoryList(raw: string | undefined): Set | undefined { + if (!raw) return undefined; + const values = raw.split(',').map((s) => s.trim()) as Category[]; + for (const v of values) { + if (!VALID_CATEGORIES.has(v)) { + throw new WnError('USAGE_ERROR', `Invalid category: ${v}`, { exitCode: ExitCode.UsageError }); + } + } + return new Set(values); +} + +function parseErrorOn(raw: string | undefined): Severity | undefined { + if (!raw) return undefined; + if (!VALID_SEVERITIES.has(raw as Severity)) { + throw new WnError('USAGE_ERROR', `Invalid --error-on severity: ${raw}`, { exitCode: ExitCode.UsageError }); + } + return raw as Severity; +} + +function filterFindings( + findings: Finding[], + options: { + severities?: Set; + categories?: Set; + ignored: string[]; + showIgnored: boolean; + }, +): Finding[] { + return findings.filter((f) => { + if (!options.showIgnored && isIgnored(f.id, options.ignored)) return false; + if (options.severities && !options.severities.has(f.severity)) return false; + if (options.categories && !options.categories.has(f.category)) return false; + return true; + }); +} + +function meetsErrorThreshold(finding: Finding, threshold: Severity): boolean { + return SEVERITY_ORDER[finding.severity] >= SEVERITY_ORDER[threshold]; +} + +function findById(findings: Finding[], id: string): Finding | undefined { + return findings.find((f) => f.id === id); +} + +async function runFixCommand(ctx: CliContext, finding: Finding): Promise { + if (!finding.fixable || !finding.fix?.command) { + throw new WnError('NOT_FIXABLE', `Finding ${finding.id} is not automatically fixable`, { + exitCode: ExitCode.CommandFailed, + }); + } + + const commands = Array.isArray(finding.fix.command) ? finding.fix.command : [finding.fix.command]; + for (const cmd of commands) { + const finalized = finalizeCommand(ctx.project!, cmd); + if (typeof finalized === 'string') { + await ctx.exec.run({ command: finalized, cwd: ctx.project!.projectFolder() }); + } else { + await ctx.exec.run(finalized); + } + } +} + +async function runCheck(ctx: CliContext): Promise { + const started = Date.now(); + const project = await inspectProject(ctx.cwd, ctx.opts.project); + ctx.project = project; + + const packages = project.analyzer.getAllDependencies(); + const allFindings = await runChecks(project, packages); + + const ignored = getIgnoredIds(ctx.cwd, ctx.opts); + const showIgnored = flagBool(ctx.args.flags, 'show-ignored'); + const severities = parseSeverityList(flagString(ctx.args.flags, 'severity')); + const categories = parseCategoryList(flagString(ctx.args.flags, 'category')); + const errorOn = parseErrorOn(flagString(ctx.args.flags, 'error-on') ?? ctx.config.check?.errorOn); + + const fixId = flagString(ctx.args.flags, 'fix'); + const fixAllRaw = ctx.args.flags['fix-all']; + const ignoreId = flagString(ctx.args.flags, 'ignore'); + + if (ignoreId) { + addIgnoredId(ctx.cwd, ignoreId, ctx.opts); + ignored.push(ignoreId); + } + + let findings = filterFindings(allFindings, { severities, categories, ignored, showIgnored }); + + if (fixId) { + const target = findById(allFindings, fixId); + if (!target) { + throw new WnError('UNKNOWN_FINDING', `No finding with id ${fixId}`, { exitCode: ExitCode.CommandFailed }); + } + try { + await runFixCommand(ctx, target); + } catch (err) { + if (err instanceof WnError) throw err; + throw new WnError('FIX_FAILED', err instanceof Error ? err.message : String(err), { + exitCode: ExitCode.CommandFailed, + }); + } + } + + if (fixAllRaw !== undefined) { + const minSeverity = + typeof fixAllRaw === 'string' && fixAllRaw !== 'true' ? parseErrorOn(fixAllRaw) : ('warning' as Severity); + const fixable = findings.filter( + (f) => f.fixable && f.fix?.command && meetsErrorThreshold(f, minSeverity ?? 'warning'), + ); + for (const finding of fixable) { + try { + await runFixCommand(ctx, finding); + } catch (err) { + if (err instanceof WnError) throw err; + throw new WnError('FIX_FAILED', err instanceof Error ? err.message : String(err), { + exitCode: ExitCode.CommandFailed, + }); + } + } + } + + if (errorOn) { + const blocking = allFindings.filter((f) => !isIgnored(f.id, ignored) && meetsErrorThreshold(f, errorOn)); + if (blocking.length > 0) { + throw new WnError('CHECK_FAILED', `Found ${blocking.length} finding(s) at or above ${errorOn} severity`, { + exitCode: ExitCode.CheckFailed, + details: { count: blocking.length }, + }); + } + } + + const summary = { + findings, + total: allFindings.length, + ignored: allFindings.filter((f) => isIgnored(f.id, ignored)).length, + }; + + if (ctx.opts.json) { + return summary; + } + + if (!ctx.opts.quiet) { + for (const f of findings) { + const prefix = f.severity.toUpperCase().padEnd(7); + ctx.logger.output.info(`[${prefix}] ${f.id}: ${f.title}`); + if (f.detail) ctx.logger.output.info(` ${f.detail}`); + } + ctx.logger.output.info(`\n${findings.length} finding(s) shown (${allFindings.length} total)`); + } + + return summary; +} + +registerCommand({ + name: 'check', + description: 'Run the recommendation engine and report findings', + run: runCheck, +}); + +export { runCheck }; diff --git a/cli/src/commands/config.ts b/cli/src/commands/config.ts new file mode 100644 index 0000000..fb39e6b --- /dev/null +++ b/cli/src/commands/config.ts @@ -0,0 +1,167 @@ +import { flagBool } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { registerCommand } from '../cli/registry'; +import { getRootCACertPath, liveReloadSSL } from '../build/live-reload'; +import { + getConfigDefaults, + globalConfigPath, + loadConfig, + projectConfigPath, + saveConfig, + unsetConfigKey, + WnConfig, +} from '../core/config'; +import { CliContext } from '../core/context'; +import { Project } from '../project/project'; +import { ensureProject, positional, usageError } from './helpers'; + +const CONFIG_KEYS: (keyof WnConfig)[] = [ + 'defaultPort', + 'buildForProduction', + 'packageManager', + 'javaHome', + 'androidSdk', + 'adbPath', + 'shellPath', + 'internalAddress', + 'debugBrowser', + 'androidDebugWebRoot', + 'telemetry', +]; + +function configFile(ctx: CliContext, global: boolean): string { + return global ? globalConfigPath() : projectConfigPath(ctx.cwd); +} + +function parseConfigValue(key: string, raw: string): unknown { + if (raw === 'true') return true; + if (raw === 'false') return false; + if (raw === 'null') return null; + const num = Number(raw); + if (!Number.isNaN(num) && ['defaultPort'].includes(key)) return num; + if (raw.startsWith('{') || raw.startsWith('[')) { + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + return raw; +} + +function getNestedValue(config: WnConfig, key: string): unknown { + if (key.includes('.')) { + const parts = key.split('.'); + let cur: any = config; + for (const part of parts) { + cur = cur?.[part]; + } + return cur; + } + return config[key]; +} + +async function runConfigList(ctx: CliContext): Promise { + const globalCfg = loadConfig(ctx.cwd, ctx.opts); + const defaults = getConfigDefaults(); + return { + project: projectConfigPath(ctx.cwd), + global: globalConfigPath(), + values: globalCfg, + defaults, + keys: CONFIG_KEYS, + }; +} + +async function runConfigGet(ctx: CliContext): Promise { + const key = positional(ctx, 0); + if (!key) throw usageError('Usage: wn config get '); + const config = loadConfig(ctx.cwd, ctx.opts); + const value = getNestedValue(config, key); + if (value === undefined) { + throw new WnError('UNKNOWN_KEY', `Unknown config key: ${key}`, { exitCode: ExitCode.UsageError }); + } + return { key, value }; +} + +async function runConfigSet(ctx: CliContext): Promise { + const key = positional(ctx, 0); + const valueRaw = positional(ctx, 1); + if (!key || valueRaw === undefined) throw usageError('Usage: wn config set [--global]'); + const global = flagBool(ctx.args.flags, 'global'); + const file = configFile(ctx, global); + const value = parseConfigValue(key, valueRaw); + if (!ctx.opts.dryRun) { + saveConfig(file, { [key]: value } as Partial); + } + return { key, value, file, global }; +} + +async function runConfigUnset(ctx: CliContext): Promise { + const key = positional(ctx, 0); + if (!key) throw usageError('Usage: wn config unset [--global]'); + const global = flagBool(ctx.args.flags, 'global'); + const file = configFile(ctx, global); + if (!ctx.opts.dryRun) { + unsetConfigKey(file, key); + } + return { key, file, global }; +} + +async function runConfigSslCreate(ctx: CliContext): Promise { + let folder = ctx.cwd; + try { + const project = await ensureProject(ctx); + folder = project.projectFolder(); + } catch { + /* ssl can be created without a project */ + } + const stub = { folder } as Project; + if (!ctx.opts.dryRun) { + await liveReloadSSL(stub); + } else { + ctx.logger.write('[dry-run] create SSL certificates for live reload'); + } + return { + caCertPath: getRootCACertPath(), + certDir: getRootCACertPath().replace(/ca\.crt$/, ''), + hint: 'Trust the root CA certificate on devices for HTTPS live reload', + }; +} + +async function runConfig(ctx: CliContext): Promise { + const sub = positional(ctx, 0); + switch (sub) { + case 'list': + return runConfigList(ctx); + case 'get': + return runConfigGet(ctx); + case 'set': + return runConfigSet(ctx); + case 'unset': + return runConfigUnset(ctx); + case 'ssl': + if (positional(ctx, 1) === 'create') { + return runConfigSslCreate(ctx); + } + throw usageError('Usage: wn config ssl create'); + default: + throw usageError('Usage: wn config '); + } +} + +export function registerConfigCommand(): void { + registerCommand({ + name: 'config', + description: 'Read and write CLI settings', + noProject: true, + run: runConfig, + }); + registerCommand({ + name: 'config ssl create', + description: 'Generate SSL certificates for HTTPS live reload', + noProject: true, + run: runConfigSslCreate, + }); +} diff --git a/cli/src/commands/debug.ts b/cli/src/commands/debug.ts new file mode 100644 index 0000000..d1fa408 --- /dev/null +++ b/cli/src/commands/debug.ts @@ -0,0 +1,143 @@ +import { spawn } from 'child_process'; +import { registerCommand } from '../cli/registry'; +import { flagBool, flagString } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { requireChoice } from '../cli/prompt'; +import { findDevices, findWebViews, forwardDebugger, verifyAndroidDebugBridge } from '../devices/adb'; +import { Device, WebView } from '../devices/models'; +import { CliContext } from '../core/context'; +import { usageError } from './helpers'; + +async function debugAndroid(ctx: CliContext): Promise { + const adbPath = ctx.config.adbPath; + await verifyAndroidDebugBridge({ adbPath }); + + if (flagBool(ctx.args.flags, 'list')) { + const devices = await findDevices({ adbPath }); + const webviews: Array> = []; + for (const device of devices) { + const wvs = await findWebViews(device, { adbPath }); + for (const wv of wvs) { + webviews.push({ + id: `${device.serial}:${wv.socket}`, + device: device.serial, + deviceName: device.model || device.product, + packageName: wv.packageName, + socketName: wv.socket, + }); + } + } + return { webviews }; + } + + const devices = await findDevices({ adbPath }); + if (devices.length === 0) { + throw new WnError('NO_DEVICES', 'No Android devices found', { exitCode: ExitCode.MissingTool }); + } + + const deviceFlag = flagString(ctx.args.flags, 'device'); + let device: Device; + if (deviceFlag) { + const match = devices.find((d) => d.serial === deviceFlag); + if (!match) { + throw new WnError('MISSING_INPUT', `Device not found: ${deviceFlag}`, { + exitCode: ExitCode.MissingInput, + input: 'device', + choices: devices.map((d) => ({ id: d.serial, name: d.model || d.serial })), + hint: 'Re-run with --device ', + }); + } + device = match; + } else if (devices.length === 1) { + device = devices[0]; + } else { + const chosen = await requireChoice( + 'device', + devices.map((d) => ({ id: d.serial, name: d.model || d.product || d.serial, type: 'device' })), + ctx.opts, + 'Re-run with --device ', + ); + device = devices.find((d) => d.serial === chosen.id)!; + } + + const webviews = await findWebViews(device, { adbPath }); + if (webviews.length === 0) { + throw new WnError('NO_WEBVIEWS', 'No debuggable WebViews found', { exitCode: ExitCode.CommandFailed }); + } + + const webviewFlag = flagString(ctx.args.flags, 'webview'); + let webview: WebView; + if (webviewFlag) { + const match = webviews.find((w) => w.socket === webviewFlag || `${device.serial}:${w.socket}` === webviewFlag); + if (!match) { + throw new WnError('MISSING_INPUT', `WebView not found: ${webviewFlag}`, { + exitCode: ExitCode.MissingInput, + input: 'webview', + choices: webviews.map((w) => ({ + id: `${device.serial}:${w.socket}`, + name: w.packageName || w.socket, + })), + hint: 'Re-run with --webview ', + }); + } + webview = match; + } else if (webviews.length === 1) { + webview = webviews[0]; + } else { + const chosen = await requireChoice( + 'webview', + webviews.map((w) => ({ + id: `${device.serial}:${w.socket}`, + name: w.packageName || w.socket, + type: 'webview', + })), + ctx.opts, + 'Re-run with --webview ', + ); + webview = webviews.find((w) => `${device.serial}:${w.socket}` === chosen.id)!; + } + + const requestedPort = Number(flagString(ctx.args.flags, 'port') || 9222); + const port = await forwardDebugger(webview, requestedPort, { adbPath }); + const cdpUrl = `ws://localhost:${port}/devtools/page`; + return { cdpUrl, port, device: device.serial, webview: webview.socket }; +} + +async function debugWeb(ctx: CliContext): Promise { + const browser = flagString(ctx.args.flags, 'browser') || ctx.config.debugBrowser || 'chrome'; + const port = Number(flagString(ctx.args.flags, 'port') || 9222); + const url = flagString(ctx.args.flags, 'url') || 'http://localhost:8100'; + + const chromeArgs = [`--remote-debugging-port=${port}`, `--user-data-dir=/tmp/wn-chrome-debug`, url]; + let bin = 'google-chrome'; + if (process.platform === 'darwin') { + bin = + browser === 'edge' + ? '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' + : '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + } else if (process.platform === 'win32') { + bin = browser === 'edge' ? 'msedge' : 'chrome'; + } else if (browser === 'edge') { + bin = 'microsoft-edge'; + } + + if (!ctx.opts.dryRun) { + spawn(bin, chromeArgs, { detached: true, stdio: 'ignore' }).unref(); + } else { + ctx.logger.write(`[dry-run] ${bin} ${chromeArgs.join(' ')}`); + } + + return { cdpUrl: `ws://localhost:${port}/devtools/browser`, port, browser, url }; +} + +async function runDebug(ctx: CliContext): Promise { + const target = ctx.args.positionals[0] || ctx.commandName.replace(/^debug\s*/, ''); + if (target === 'android' || ctx.commandName === 'debug android') return debugAndroid(ctx); + if (target === 'web' || ctx.commandName === 'debug web') return debugWeb(ctx); + throw usageError('Usage: wn debug '); +} + +registerCommand({ name: 'debug', description: 'Attach a debugger', run: runDebug }); +registerCommand({ name: 'debug web', description: 'Debug in Chrome/Edge', run: debugWeb }); +registerCommand({ name: 'debug android', description: 'Debug Android WebViews via CDP', run: debugAndroid }); diff --git a/cli/src/commands/devices.ts b/cli/src/commands/devices.ts new file mode 100644 index 0000000..a556d0d --- /dev/null +++ b/cli/src/commands/devices.ts @@ -0,0 +1,147 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { registerCommand } from '../cli/registry'; +import { flagString } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { capacitorDevicesCommand } from '../build/capacitor-run'; +import { listDevices, DeviceInfo } from '../devices/capacitor-device'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { CliContext } from '../core/context'; +import { ensureProject } from './helpers'; + +function defaultsPath(): string { + return path.join(os.homedir(), '.config', 'wn', 'devices.json'); +} + +function readDefaults(): Record { + try { + return JSON.parse(fs.readFileSync(defaultsPath(), 'utf8')); + } catch { + return {}; + } +} + +function writeDefaults(data: Record): void { + fs.mkdirSync(path.dirname(defaultsPath()), { recursive: true }); + fs.writeFileSync(defaultsPath(), JSON.stringify(data, null, 2) + '\n'); +} + +async function listAll(ctx: CliContext): Promise<{ ios: DeviceInfo[]; android: DeviceInfo[] }> { + const project = await ensureProject(ctx); + const platform = flagString(ctx.args.flags, 'platform'); + const run = async (cmd: string, cwd: string) => ctx.exec.getOutput({ command: cmd, cwd }, { ignoreExitCode: true }); + + const ios: DeviceInfo[] = []; + const android: DeviceInfo[] = []; + + if ((!platform || platform === 'ios') && project.hasCapacitorProject(CapacitorPlatform.ios)) { + try { + const cmd = capacitorDevicesCommand(CapacitorPlatform.ios, project); + ios.push(...(await listDevices(cmd, project.projectFolder(), run))); + } catch { + /* tool missing */ + } + } + if ((!platform || platform === 'android') && project.hasCapacitorProject(CapacitorPlatform.android)) { + try { + const cmd = capacitorDevicesCommand(CapacitorPlatform.android, project); + android.push(...(await listDevices(cmd, project.projectFolder(), run))); + } catch { + /* tool missing */ + } + } + + return { ios, android }; +} + +registerCommand({ + name: 'devices', + description: 'List iOS/Android run targets', + run: async (ctx) => listAll(ctx), +}); + +registerCommand({ + name: 'devices list', + description: 'List iOS/Android run targets', + run: async (ctx) => listAll(ctx), +}); + +registerCommand({ + name: 'devices default', + description: 'Get or set the default device for run', + run: async (ctx) => { + const platform = flagString(ctx.args.flags, 'platform') || 'ios'; + const setId = flagString(ctx.args.flags, 'set'); + const clear = ctx.args.flags['clear'] === true; + + const defaults = readDefaults(); + if (clear) { + delete defaults[platform]; + writeDefaults(defaults); + return { cleared: platform }; + } + if (setId) { + defaults[platform] = setId; + writeDefaults(defaults); + return { platform, device: setId }; + } + return { platform, device: defaults[platform] || null }; + }, +}); + +export async function resolveDeviceId( + ctx: CliContext, + platform: CapacitorPlatform, + deviceFlag?: string, + deviceName?: string, +): Promise { + const { ios, android } = await listAll(ctx); + const devices = platform === CapacitorPlatform.ios ? ios : android; + + if (deviceFlag) { + const match = devices.find((d) => d.id === deviceFlag || d.name === deviceFlag); + if (!match) { + throw new WnError('MISSING_INPUT', `Device not found: ${deviceFlag}`, { + exitCode: ExitCode.MissingInput, + input: 'device', + choices: devices.map((d) => ({ id: d.id, name: d.name, type: d.type })), + hint: 'Re-run with --device ', + }); + } + return match.id; + } + + if (deviceName) { + const matches = devices.filter((d) => d.name === deviceName || d.name.includes(deviceName)); + if (matches.length === 1) return matches[0].id; + throw new WnError('MISSING_INPUT', `Ambiguous device name: ${deviceName}`, { + exitCode: ExitCode.MissingInput, + input: 'device', + choices: (matches.length ? matches : devices).map((d) => ({ id: d.id, name: d.name, type: d.type })), + hint: 'Re-run with --device ', + }); + } + + const defaults = readDefaults(); + if (defaults[platform]) return defaults[platform]; + + if (devices.length === 0) { + throw new WnError('NO_DEVICES', `No ${platform} devices found`, { + exitCode: ExitCode.CommandFailed, + hint: 'Connect a device or start a simulator/emulator', + }); + } + + if (devices.length === 1) return devices[0].id; + + const { requireChoice } = await import('../cli/prompt'); + const chosen = await requireChoice( + 'device', + devices.map((d) => ({ id: d.id, name: d.name, type: d.type })), + ctx.opts, + 'Re-run with --device ', + ); + return chosen.id; +} diff --git a/cli/src/commands/generate.ts b/cli/src/commands/generate.ts new file mode 100644 index 0000000..8e75974 --- /dev/null +++ b/cli/src/commands/generate.ts @@ -0,0 +1,264 @@ +import { existsSync } from 'fs'; +import { join, sep } from 'path'; +import { flagBool } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { confirm } from '../cli/prompt'; +import { registerCommand } from '../cli/registry'; +import { commandContext, npmInstall, npx } from '../build/node-commands'; +import { replaceAll } from '../core/text'; +import { getStringFrom } from '../project/utilities-strings'; +import { CliContext } from '../core/context'; +import { ensureProject, positional, runCommandSequence, runShell } from './helpers'; + +export interface AngularMigration { + id: string; + title: string; + minimumVersion: string; + description: string; + command?: string; + commandFn?: (ctx: CliContext) => Promise; +} + +export const angularMigrations: AngularMigration[] = [ + { + id: 'standalone-ionic', + title: 'Migrate to Ionic standalone components', + minimumVersion: '14.0.0', + description: + 'This will replace IonicModule with individual Ionic components and icons in your project. Are you sure?', + commandFn: migrateToAngularStandalone, + }, + { + id: 'signal-inputs', + title: 'Migrate to signal inputs', + minimumVersion: '19.0.0', + description: 'This will change your @Input decorators to Signal Inputs. Are you sure?', + command: `npx ng generate @angular/core:signal-input-migration --interactive=false --defaults=true --path=".${sep}"`, + }, + { + id: 'control-flow', + title: 'Migrate to the built-in control flow syntax', + minimumVersion: '17.0.0', + description: 'This will change your Angular templates to use the new built-in control flow syntax. Are you sure?', + command: `npx ng generate @angular/core:control-flow --interactive=false --defaults=true --path=".${sep}"`, + }, + { + id: 'output-migration', + title: 'Migrate to replace @Output with Output functions', + minimumVersion: '19.0.0', + description: 'This will replace your @Output decorators with Output functions. Are you sure?', + command: `npx ng generate @angular/core:output-migration --interactive=false --defaults=true --path=".${sep}"`, + }, + { + id: 'inject', + title: 'Migrate to use inject for dependency injection', + minimumVersion: '19.0.0', + description: 'This will replace dependency injection to use the inject function. Are you sure?', + command: `npx ng generate @angular/core:inject --interactive=false --defaults=true --path=".${sep}"`, + }, + { + id: 'signal-queries', + title: 'Migrate ViewChild and ContentChild to use signals', + minimumVersion: '19.0.0', + description: + 'This will replace @ViewChild and @ContentChild decorators with the equivalent signal query. Are you sure?', + command: `npx ng generate @angular/core:signal-queries --interactive=false --defaults=true --path=".${sep}"`, + }, + { + id: 'karma-to-vitest', + title: 'Migrate Karma to Vitest', + minimumVersion: '22.0.0', + description: 'This will migrate your Karma test configuration to Vitest. Are you sure?', + command: 'ng update @angular/cli --name migrate-karma-to-vitest', + }, + { + id: 'application-builder', + title: 'Migrate to Use Application Builder', + minimumVersion: '22.0.0', + description: 'This will migrate your project to use the application builder. Are you sure?', + command: 'ng update @angular/cli --name use-application-builder', + }, +]; + +const angularSchematics = ['component', 'service', 'module', 'class', 'directive', 'pipe', 'guard', 'interceptor']; + +function qualifyAngularCommand( + command: string, + project: { analyzer: { exists: (lib: string) => boolean } } & Parameters[0], +): string { + if (command.startsWith('npx ')) { + return command.replace('npx ', `${npx(project)} `); + } + if (command.startsWith('ng ')) { + return `${npx(project)} ${command}`; + } + return command; +} + +async function migrateToAngularStandalone(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const ctxPm = commandContext(project); + const commands = ['npx @ionic/angular-standalone-codemods --non-interactive']; + if (project.analyzer.isGreaterOrEqual('@ionic/angular', '7.0.0')) { + if (project.analyzer.isLess('@ionic/angular', '7.5.1')) { + commands.unshift(npmInstall('@ionic/angular@7.5.1', ctxPm)); + } + } else { + throw new WnError('PRECONDITION_FAILED', 'You must be using @ionic/angular version 7 or higher.', { + exitCode: ExitCode.CommandFailed, + }); + } + if (project.analyzer.isLess('ionicons', '7.2.1')) { + commands.unshift(npmInstall('ionicons@latest', ctxPm)); + } + await runCommandSequence(ctx, commands, project.projectFolder()); +} + +function listAvailableSchematics(project: Awaited>): string[] { + const types = [...angularSchematics]; + if (project.analyzer.exists('@ionic/angular-toolkit')) { + types.push('page'); + } + return types; +} + +function listAvailableMigrations(project: Awaited>): AngularMigration[] { + return angularMigrations.filter((m) => project.analyzer.isGreaterOrEqual('@angular/core', m.minimumVersion)); +} + +async function runAngularGenerate(ctx: CliContext, angularType: string, name: string): Promise { + const project = await ensureProject(ctx); + if (!project.analyzer.exists('@angular/core')) { + throw new WnError('NOT_ANGULAR', 'This project is not an Angular project', { exitCode: ExitCode.CommandFailed }); + } + + let args = ''; + if (project.analyzer.isGreaterOrEqual('@angular/core', '15.0.0')) { + const isOlder = + project.analyzer.exists('@ionic/angular-toolkit') && + project.analyzer.isLessOrEqual('@ionic/angular-toolkit', '8.1.0'); + if (angularType == 'page' && !isOlder) { + args += ' --standalone'; + } + const isOld = + project.analyzer.exists('@ionic/angular-toolkit') && + project.analyzer.isLessOrEqual('@ionic/angular-toolkit', '11.0.1'); + if (angularType == 'component' && !isOld) { + args += ' --standalone'; + } + } + + const normalizedName = replaceAll(name, ' ', '-').trim(); + let cmd = `${npx(project)} ng generate ${angularType} ${normalizedName}${args}`; + if (angularType == 'directive') { + cmd += ` --skip-import`; + } + + if (ctx.args.passthrough.length) { + cmd += ' ' + ctx.args.passthrough.join(' '); + } + + ctx.logger.writeWN(`Creating Angular ${angularType} named ${normalizedName}..`); + const out = await ctx.exec.getOutput({ command: cmd, cwd: project.projectFolder() }); + const src = getStringFrom(out, 'CREATE ', '.ts'); + const path = join(project.projectFolder(), src + '.ts'); + const created = !!(src && existsSync(path)); + + return { + schematic: angularType, + name: normalizedName, + created, + output: out, + path: created ? path : undefined, + }; +} + +async function runMigration(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const migrationId = positional(ctx, 1); + + if (flagBool(ctx.args.flags, 'list') || !migrationId) { + return { + migrations: listAvailableMigrations(project).map((m) => ({ + id: m.id, + title: m.title, + minimumVersion: m.minimumVersion, + description: m.description, + })), + }; + } + + const migration = angularMigrations.find((m) => m.id === migrationId); + if (!migration) { + throw new WnError('UNKNOWN_SCHEMATIC', `Unknown migration: ${migrationId}`, { + exitCode: ExitCode.UsageError, + choices: listAvailableMigrations(project).map((m) => ({ id: m.id, name: m.title })), + }); + } + + if (!project.analyzer.isGreaterOrEqual('@angular/core', migration.minimumVersion)) { + throw new WnError('PRECONDITION_FAILED', `@angular/core ${migration.minimumVersion}+ is required`, { + exitCode: ExitCode.CommandFailed, + }); + } + + if (!ctx.opts.dryRun && !ctx.opts.yes && !ctx.opts.noInput && !ctx.opts.json) { + const ok = await confirm(migration.description, ctx.opts, false); + if (!ok) return { cancelled: true }; + } + + if (migration.commandFn) { + await migration.commandFn(ctx); + } else if (migration.command) { + const cmd = qualifyAngularCommand(migration.command, project); + await runShell(ctx, { command: cmd, cwd: project.projectFolder() }); + } + + return { migration: migration.id, applied: true }; +} + +async function runGenerate(ctx: CliContext): Promise { + const sub = positional(ctx, 0); + + if (flagBool(ctx.args.flags, 'list') || !sub) { + const project = await ensureProject(ctx); + return { schematics: listAvailableSchematics(project) }; + } + + if (sub === 'migration') { + return runMigration(ctx); + } + + const name = positional(ctx, 1); + if (!name) { + throw new WnError('MISSING_INPUT', `Name is required for generate ${sub}`, { + exitCode: ExitCode.MissingInput, + input: 'name', + }); + } + + const project = await ensureProject(ctx); + const available = listAvailableSchematics(project); + if (!available.includes(sub)) { + throw new WnError('UNKNOWN_SCHEMATIC', `Unknown schematic: ${sub}`, { + exitCode: ExitCode.UsageError, + choices: available.map((s) => ({ id: s, name: s })), + }); + } + + return runAngularGenerate(ctx, sub, name); +} + +export function registerGenerateCommand(): void { + registerCommand({ + name: 'generate', + description: 'Generate Angular schematics or run codemod migrations', + run: runGenerate, + }); + registerCommand({ + name: 'generate migration', + description: 'Run Angular codemod migrations', + run: runMigration, + }); +} diff --git a/cli/src/commands/helpers.ts b/cli/src/commands/helpers.ts new file mode 100644 index 0000000..7545540 --- /dev/null +++ b/cli/src/commands/helpers.ts @@ -0,0 +1,111 @@ +import { flagBool } from '../cli/args'; +import { CommandResult } from '../build/command-result'; +import { capacitorSync } from '../build/capacitor-sync'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { CliContext } from '../core/context'; +import { CommandSpec } from '../core/exec'; +import { inspectProject } from '../project/inspect'; +import { Project } from '../project/project'; + +export async function ensureProject(ctx: CliContext): Promise { + if (ctx.project) return ctx.project; + const project = await inspectProject(ctx.cwd, ctx.opts.project); + if (!project.analyzer.hasPackageJson) { + throw new WnError('NO_PROJECT', 'No supported project found at this location', { + exitCode: ExitCode.NoProject, + hint: 'Run from a project root or pass --cwd', + }); + } + ctx.project = project; + return project; +} + +/** Alias used by packages/plugins commands. */ +export const requireProject = ensureProject; + +export function projectFolder(ctx: CliContext): string { + return ctx.project?.projectFolder() ?? ctx.cwd; +} + +export function positional(ctx: CliContext, index = 0): string | undefined { + return ctx.args.positionals[index]; +} + +export async function runShell(ctx: CliContext, spec: CommandSpec): Promise { + await ctx.exec.run(spec); +} + +export async function runCommandResult(ctx: CliContext, result: CommandResult, redact?: string[]): Promise { + if (typeof result === 'string') { + await runShell(ctx, { + command: result, + cwd: ctx.project?.projectFolder() ?? ctx.cwd, + redact, + }); + return; + } + await runShell(ctx, { command: result.command, cwd: result.cwd, redact }); +} + +export async function runProjectCommand( + ctx: CliContext, + command: CommandResult | string, + redact?: string[], +): Promise { + await runCommandResult(ctx, command, redact); +} + +export async function runCommandSequence(ctx: CliContext, commands: string[], cwd?: string): Promise { + let folder = cwd ?? ctx.cwd; + for (const cmd of commands) { + if (cmd.startsWith('#')) { + folder = cmd.slice(1); + ctx.logger.writeWN(`Folder changed to ${folder}`); + continue; + } + ctx.logger.writeWN(cmd); + await runShell(ctx, { command: cmd, cwd: folder }); + } +} + +export async function maybeCapSync(ctx: CliContext, project: Project): Promise { + if (!project.isCapacitor || flagBool(ctx.args.flags, 'no-sync')) return; + if (ctx.opts.dryRun) { + ctx.logger.write('[dry-run] cap sync'); + return; + } + await runCommandResult(ctx, capacitorSync(project)); +} + +export function stripJsonPrefix(data: string, startChar: string): string { + const idx = data.indexOf(startChar); + return idx >= 0 ? data.slice(idx) : data; +} + +export async function getProjectOutput(ctx: CliContext, command: string): Promise { + const project = await ensureProject(ctx); + return ctx.exec.getOutput({ command, cwd: project.projectFolder() }); +} + +export function usageError(message: string): WnError { + return new WnError('USAGE_ERROR', message, { exitCode: ExitCode.UsageError }); +} + +export async function isGitDirty(folder: string, ctx: CliContext): Promise { + try { + const out = await ctx.exec.getOutput({ command: 'git status --porcelain', cwd: folder }, { ignoreExitCode: true }); + return out.trim().length > 0; + } catch { + return false; + } +} + +export function requireCleanGit(_folder: string, dirty: boolean): void { + if (dirty) { + throw new WnError('DIRTY_GIT', 'Git repository has uncommitted changes. Commit or stash before migrating.', { + exitCode: ExitCode.CheckFailed, + hint: 'Commit or stash your changes, then re-run the migration.', + }); + } +} diff --git a/cli/src/commands/index.ts b/cli/src/commands/index.ts new file mode 100644 index 0000000..531279d --- /dev/null +++ b/cli/src/commands/index.ts @@ -0,0 +1,34 @@ +/** Side-effect registration of all CLI commands. */ +import './info'; +import './run'; +import './build'; +import './sync'; +import './open'; +import './stop'; +import './devices'; +import './debug'; +import './scripts'; +import './check'; +import './packages'; +import './plugins'; +import './migrate'; + +import { registerAssetsCommand } from './assets'; +import { registerConfigCommand } from './config'; +import { registerGenerateCommand } from './generate'; +import { registerIntegrateCommand } from './integrate'; +import { registerNativeCommand } from './native'; +import { registerNewCommand } from './new'; +import { registerReleaseCommand } from './release'; + +registerNewCommand(); +registerIntegrateCommand(); +registerGenerateCommand(); +registerConfigCommand(); +registerAssetsCommand(); +registerReleaseCommand(); +registerNativeCommand(); + +export function registerAllCommands(): void { + // Commands register via side-effect imports above. +} diff --git a/cli/src/commands/info.ts b/cli/src/commands/info.ts new file mode 100644 index 0000000..c96d689 --- /dev/null +++ b/cli/src/commands/info.ts @@ -0,0 +1,149 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { execSync } from 'child_process'; +import { registerCommand } from '../cli/registry'; +import { flagBool } from '../cli/args'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { getCapacitorConfigureFilename, getCapacitorConfigWebDir } from '../project/capacitor-config-file'; +import { getCapacitorProjectState } from '../native/configure'; +import { MonoRepoType } from '../project/monorepo'; +import { PackageManager } from '../build/node-commands'; +import { runChecks } from '../rules/engine'; +import { ensureProject } from './helpers'; +import { CliContext } from '../core/context'; + +function toolVersion(cmd: string): string | undefined { + try { + return execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }) + .trim() + .split('\n')[0]; + } catch { + return undefined; + } +} + +function ver(v: { version?: string } | string | null | undefined): string | undefined { + if (!v) return undefined; + if (typeof v === 'string') return v; + return v.version; +} + +async function runInfo(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const folder = project.projectFolder(); + const pkg = JSON.parse(readFileSync(join(folder, 'package.json'), 'utf8')); + const platformsOnly = flagBool(ctx.args.flags, 'platforms'); + const withCheck = flagBool(ctx.args.flags, 'check'); + + const capConfigFile = getCapacitorConfigureFilename(folder); + let appId: string | undefined; + let appName: string | undefined; + if (capConfigFile) { + try { + const raw = readFileSync(capConfigFile, 'utf8'); + const idMatch = raw.match(/appId:\s*['"`]([^'"`]+)['"`]/); + const nameMatch = raw.match(/appName:\s*['"`]([^'"`]+)['"`]/); + appId = idMatch?.[1]; + appName = nameMatch?.[1]; + } catch { + /* ignore */ + } + } + + const native = (await getCapacitorProjectState(folder)) || {}; + const platforms = [ + { + name: 'ios', + installed: project.hasCapacitorProject(CapacitorPlatform.ios), + version: native.iosVersion, + buildNumber: native.iosBuild != null ? String(native.iosBuild) : undefined, + appId: native.iosBundleId, + appName: native.iosDisplayName, + }, + { + name: 'android', + installed: project.hasCapacitorProject(CapacitorPlatform.android), + version: native.androidVersion, + buildNumber: native.androidBuild != null ? String(native.androidBuild) : undefined, + appId: native.androidBundleId, + appName: native.androidDisplayName, + }, + ]; + + if (platformsOnly) { + return { platforms }; + } + + const plugins: Array<{ name: string; version: string }> = []; + for (const [name, version] of Object.entries(project.analyzer.getAllDependencies())) { + if ( + name.startsWith('@capacitor/') && + !['@capacitor/core', '@capacitor/cli', '@capacitor/ios', '@capacitor/android'].includes(name) + ) { + plugins.push({ name, version: String(version).replace(/^[\^~]/, '') }); + } + } + + const data: Record = { + root: project.folder, + name: pkg.name || project.name, + type: project.type, + framework: project.frameworkType, + packageManager: PackageManager[project.packageManager] || project.packageManager, + monorepo: + project.repoType != null && project.repoType !== MonoRepoType.none + ? { + type: MonoRepoType[project.repoType] || project.repoType, + projects: (project.monoRepoProjects || []).map((p) => p.name), + selected: project.monoRepo?.name, + } + : undefined, + webDir: getCapacitorConfigWebDir(folder) || undefined, + buildScript: pkg.scripts?.['wn:build'] + ? 'wn:build' + : pkg.scripts?.['ionic:build'] + ? 'ionic:build' + : pkg.scripts?.build + ? 'build' + : undefined, + capacitor: project.isCapacitor + ? { + cliVersion: ver(project.analyzer.getPackageVersion('@capacitor/cli')), + coreVersion: ver(project.analyzer.getPackageVersion('@capacitor/core')), + configFile: capConfigFile ? capConfigFile.replace(folder + '/', '') : undefined, + appId, + appName, + platforms, + } + : undefined, + plugins, + scripts: Object.keys(pkg.scripts || {}), + toolchain: { + node: process.version.replace(/^v/, ''), + xcode: process.platform === 'darwin' ? toolVersion('xcodebuild -version') : undefined, + jdk: toolVersion('java -version 2>&1') || undefined, + cocoapods: process.platform === 'darwin' ? toolVersion('pod --version') : undefined, + adb: ctx.config.adbPath || toolVersion('which adb') || undefined, + }, + }; + + if (withCheck) { + const findings = await runChecks(project); + data.checks = { + counts: { + error: findings.filter((f) => f.severity === 'error').length, + warning: findings.filter((f) => f.severity === 'warning').length, + info: findings.filter((f) => f.severity === 'info').length, + }, + findings: findings.slice(0, 20), + }; + } + + return data; +} + +registerCommand({ + name: 'info', + description: 'Inspect the project (framework, platforms, toolchain)', + run: runInfo, +}); diff --git a/cli/src/commands/integrate.ts b/cli/src/commands/integrate.ts new file mode 100644 index 0000000..b46a1f6 --- /dev/null +++ b/cli/src/commands/integrate.ts @@ -0,0 +1,279 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { flagBool, flagString, flagStringArray } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { confirm } from '../cli/prompt'; +import { registerCommand } from '../cli/registry'; +import { capacitorAdd } from '../build/capacitor-add'; +import { InternalCommand } from '../build/command-name'; +import { commandContext, npmInstall, npmRun, npx, saveDevArgument } from '../build/node-commands'; +import { CliContext } from '../core/context'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { MonoRepoType } from '../project/monorepo'; +import { asAppId } from '../templates/new'; +import { ensureProject, positional, runCommandResult, runCommandSequence, runShell } from './helpers'; + +function readAngularOutputPath(projectFolder: string): string | undefined { + const filename = join(projectFolder, 'angular.json'); + if (!existsSync(filename)) return undefined; + try { + const angular = JSON.parse(readFileSync(filename, 'utf8')); + for (const projectName of Object.keys(angular.projects ?? {})) { + const outputPath = angular.projects[projectName]?.architect?.build?.options?.outputPath; + if (outputPath) { + const browser = join(projectFolder, outputPath, 'browser'); + return existsSync(browser) ? browser : outputPath; + } + } + } catch { + return undefined; + } + return undefined; +} + +export function inferWebDir(projectFolder: string, analyzer: { exists: (lib: string) => boolean }): string { + if (analyzer.exists('@ionic/angular') || analyzer.exists('ionicons')) { + if (existsSync(join(projectFolder, 'www'))) return 'www'; + } + + if (!existsSync(join(projectFolder, 'www'))) { + if (existsSync(join(projectFolder, 'build')) || analyzer.exists('react')) { + return 'build'; + } + if (analyzer.exists('@angular/core')) { + return readAngularOutputPath(projectFolder) ?? 'dist'; + } + if (existsSync(join(projectFolder, 'dist')) || analyzer.exists('vue')) { + return 'dist'; + } + } + return 'www'; +} + +async function ionicInit(ctx: CliContext, folder: string): Promise { + const filename = join(folder, 'package.json'); + const packageFile = JSON.parse(readFileSync(filename, 'utf8')); + if (!packageFile.name) { + packageFile.name = 'my-app'; + } + if (!packageFile.version) { + packageFile.version = '0.0.0'; + } + + const cfg = join(folder, 'ionic.config.json'); + if (!existsSync(cfg)) { + await runShell(ctx, { command: `npx ionic init "${packageFile.name}" --type=custom`, cwd: folder }); + } + + if (packageFile.scripts?.build) { + packageFile.scripts['ionic:build'] = 'npm run build'; + if (ctx.project?.analyzer.exists('@nuxtjs/ionic') && packageFile.scripts?.generate) { + packageFile.scripts['ionic:build'] = 'npm run generate'; + } + } + if (packageFile.scripts?.dev) { + packageFile.scripts['ionic:serve'] = 'npm run dev'; + } + if (packageFile.scripts?.serve) { + packageFile.scripts['ionic:serve'] = 'npm run serve'; + } else if (packageFile.scripts?.start) { + packageFile.scripts['ionic:serve'] = 'npm run start'; + } + writeFileSync(filename, JSON.stringify(packageFile, undefined, 2)); + + if (existsSync(cfg)) { + try { + const ionicConfig = JSON.parse(readFileSync(cfg, 'utf8')); + ionicConfig.integrations = ionicConfig.integrations ?? {}; + ionicConfig.integrations.capacitor = {}; + writeFileSync(cfg, JSON.stringify(ionicConfig, undefined, 2)); + } catch { + /* ignore */ + } + } +} + +async function integrateCapacitor(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + if (project.isCapacitor) { + throw new WnError('ALREADY_INTEGRATED', 'Capacitor is already integrated in this project', { + exitCode: ExitCode.CommandFailed, + }); + } + + const appId = flagString(ctx.args.flags, 'app-id') ?? asAppId(project.name); + const appName = flagString(ctx.args.flags, 'app-name') ?? project.name; + const webDir = flagString(ctx.args.flags, 'web-dir') ?? inferWebDir(project.projectFolder(), project.analyzer); + const platformFlags = flagStringArray(ctx.args.flags, 'platforms'); + const platforms = + platformFlags.length > 0 + ? platformFlags.map((p) => p.toLowerCase() as CapacitorPlatform) + : [CapacitorPlatform.ios, CapacitorPlatform.android]; + + const ctxPm = commandContext(project); + const pre = project.repoType != MonoRepoType.none ? InternalCommand.cwd : ''; + const commands = [ + npmInstall(`@capacitor/core`, ctxPm), + npmInstall(`@capacitor/cli`, ctxPm), + npmInstall(`@capacitor/app @capacitor/haptics @capacitor/keyboard @capacitor/status-bar`, ctxPm), + `${pre}${npx(project)} capacitor init "${appName}" "${appId}" --web-dir ${webDir}`, + InternalCommand.ionicInit, + ]; + + if (!ctx.opts.dryRun && !ctx.opts.yes && !ctx.opts.noInput && !ctx.opts.json) { + const ok = await confirm(`Integrate Capacitor with webDir "${webDir}"?`, ctx.opts); + if (!ok) return { cancelled: true }; + } + + for (const cmd of commands) { + if (cmd === InternalCommand.ionicInit) { + if (!ctx.opts.dryRun) await ionicInit(ctx, project.projectFolder()); + else ctx.logger.write('[dry-run] ionic init'); + continue; + } + ctx.logger.writeWN(cmd.replace(InternalCommand.cwd, '')); + await runShell(ctx, { command: cmd.replace(InternalCommand.cwd, ''), cwd: project.projectFolder() }); + } + + for (const platform of platforms) { + if (platform !== CapacitorPlatform.ios && platform !== CapacitorPlatform.android) continue; + await runCommandResult(ctx, capacitorAdd(project, platform)); + } + + return { appId, appName, webDir, platforms }; +} + +async function integratePwa(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + if (!project.analyzer.exists('@angular/core')) { + throw new WnError('NOT_ANGULAR', 'PWA integration requires an Angular project', { + exitCode: ExitCode.CommandFailed, + }); + } + + if (!ctx.opts.dryRun && !ctx.opts.yes && !ctx.opts.noInput && !ctx.opts.json) { + const ok = await confirm('Add @angular/pwa to this project?', ctx.opts); + if (!ok) return { cancelled: true }; + } + + const cmd = `${npx(project)} ng add @angular/pwa --defaults --skip-confirmation true`; + await runShell(ctx, { command: cmd, cwd: project.projectFolder() }); + return { integrated: 'pwa' }; +} + +function defaultPrettierConfig(): string { + return JSON.stringify( + { + printWidth: 120, + tabWidth: 2, + useTabs: false, + semi: true, + singleQuote: true, + quoteProps: 'as-needed', + jsxSingleQuote: false, + trailingComma: 'all', + bracketSpacing: true, + bracketSameLine: false, + arrowParens: 'always', + overrides: [ + { + files: ['*.java'], + options: { + printWidth: 140, + tabWidth: 4, + useTabs: false, + trailingComma: 'none', + }, + }, + { + files: '*.md', + options: { parser: 'mdx' }, + }, + ], + }, + undefined, + 2, + ); +} + +async function integratePrettier(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const ctxPm = commandContext(project); + const saveDev = saveDevArgument(project.packageManager); + + if (!ctx.opts.dryRun && !ctx.opts.yes && !ctx.opts.noInput && !ctx.opts.json) { + const ok = await confirm('Add Prettier, husky, and lint-staged to this project?', ctx.opts); + if (!ok) return { cancelled: true }; + } + + const commands = [ + npmInstall('husky', ctxPm, saveDev, '--save-exact'), + npmInstall('prettier', ctxPm, saveDev, '--save-exact'), + npmInstall('lint-staged', ctxPm, saveDev, '--save-exact'), + ]; + await runCommandSequence(ctx, commands, project.projectFolder()); + + const filename = join(project.projectFolder(), 'package.json'); + const packageFile = JSON.parse(readFileSync(filename, 'utf8')); + packageFile.scripts = packageFile.scripts ?? {}; + + if (!packageFile.scripts['prettify']) { + packageFile.scripts['prettify'] = `prettier "**/*.{ts,html}" --write`; + } + if (!packageFile.scripts['prepare']) { + packageFile.scripts['prepare'] = `husky install`; + } + if (!packageFile['husky']) { + packageFile['husky'] = { hooks: { 'pre-commit': 'npx lint-staged && npm run lint' } }; + } + if (!packageFile['lint-staged']) { + packageFile['lint-staged'] = { + '*.{css,html,js,jsx,scss,ts,tsx}': ['prettier --write'], + '*.{md,json}': ['prettier --write'], + }; + } + + if (!ctx.opts.dryRun) { + writeFileSync(filename, JSON.stringify(packageFile, undefined, 2)); + const prettierrc = join(project.projectFolder(), '.prettierrc.json'); + if (!existsSync(prettierrc)) { + writeFileSync(prettierrc, defaultPrettierConfig()); + } + } + + const applyNow = flagBool(ctx.args.flags, 'apply') || flagBool(ctx.args.flags, 'husky'); + if (applyNow && !ctx.opts.dryRun) { + await runShell(ctx, { command: npmRun('prettify', ctxPm), cwd: project.projectFolder() }); + if (packageFile.scripts['lint']) { + await runShell(ctx, { command: npmRun('lint -- --fix', ctxPm), cwd: project.projectFolder() }); + } + } + + return { integrated: 'prettier' }; +} + +async function runIntegrate(ctx: CliContext): Promise { + const sub = positional(ctx, 0); + switch (sub) { + case 'capacitor': + return integrateCapacitor(ctx); + case 'pwa': + return integratePwa(ctx); + case 'prettier': + return integratePrettier(ctx); + default: + throw new WnError('USAGE_ERROR', 'Usage: wn integrate ', { + exitCode: ExitCode.UsageError, + hint: 'wn integrate capacitor [--web-dir ] [--platforms ios,android]', + }); + } +} + +export function registerIntegrateCommand(): void { + registerCommand({ + name: 'integrate', + description: 'Add Capacitor, PWA, or Prettier to an existing project', + run: runIntegrate, + }); +} diff --git a/cli/src/commands/migrate.ts b/cli/src/commands/migrate.ts new file mode 100644 index 0000000..9d368d2 --- /dev/null +++ b/cli/src/commands/migrate.ts @@ -0,0 +1,163 @@ +import { flagBool, flagString } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { registerCommand } from '../cli/registry'; +import { + applyAngularPostFixes, + ensureValidDependencies, + planAngularMigration, + resolveAngularTarget, +} from '../migrate/angular'; +import { + checkMigrationPreconditions, + listAllMigrations, + planCapacitorMigration, + resolveCapacitorMigration, + runCapacitorMigration, +} from '../migrate/capacitor'; +import { planCordovaMigration, planCordovaRemoval } from '../migrate/cordova'; +import { planPackageManagerMigration, TargetPackageManager } from '../migrate/package-manager'; +import { planSpmMigration } from '../migrate/spm'; +import { npmInstallAll } from '../build/node-commands'; +import { commandContext } from '../build/node-commands'; +import { InternalCommand } from '../build/command-name'; +import { isGitDirty, requireCleanGit, requireProject, runProjectCommand } from './helpers'; +import { CliContext } from '../core/context'; + +function runCallback(ctx: CliContext) { + return (command: string, folder: string) => ctx.exec.getOutput({ command, cwd: folder }, { ignoreExitCode: true }); +} + +async function migrateList(ctx: CliContext) { + const project = await requireProject(ctx); + return { migrations: listAllMigrations(project) }; +} + +async function migrateCapacitorCmd(ctx: CliContext) { + const project = await requireProject(ctx); + if (!project.isCapacitor) { + throw new WnError('NOT_APPLICABLE', 'Capacitor migration requires a Capacitor project.', { + exitCode: ExitCode.CommandFailed, + }); + } + + const options = resolveCapacitorMigration(project, flagString(ctx.args.flags, 'to')); + const warnings = await checkMigrationPreconditions(project, options); + ctx.warnings.push(...warnings); + + const plan = await planCapacitorMigration(project, options, runCallback(ctx)); + if (ctx.opts.dryRun) return { ...plan, target: options.versionTitle, changesLink: options.changesLink }; + + if (!ctx.opts.yes) { + const dirty = await isGitDirty(project.folder, ctx); + requireCleanGit(project.folder, dirty); + } + + if (plan.incompatible.length > 0 && !ctx.opts.yes) { + throw new WnError('PRECONDITION_FAILED', `Incompatible plugins: ${plan.incompatible.join(', ')}`, { + exitCode: ExitCode.CheckFailed, + details: { incompatible: plan.incompatible }, + hint: 'Re-run with --yes to continue anyway', + }); + } + + const folder = project.projectFolder(); + const runInProject = (command: string) => + ctx.exec.getOutput({ command: command.replace(InternalCommand.cwd, ''), cwd: folder }, { ignoreExitCode: true }); + + const result = await runCapacitorMigration(project, plan, (cmd) => runInProject(cmd)); + return { target: options.versionTitle, ...result, changesLink: options.changesLink }; +} + +async function migrateAngularCmd(ctx: CliContext) { + const project = await requireProject(ctx); + const dirty = await isGitDirty(project.folder, ctx); + requireCleanGit(project.folder, dirty); + + const target = resolveAngularTarget(project, flagString(ctx.args.flags, 'to'), flagBool(ctx.args.flags, 'all')); + const current = project.analyzer.getPackageVersion('@angular/core')!.major; + const commands: string[] = []; + + for (let v = current + 1; v <= target; v++) { + commands.push(...planAngularMigration(project, v).commands); + } + + if (ctx.opts.dryRun) return { commands, from: current, to: target }; + + await ensureValidDependencies(project, runCallback(ctx), async () => { + await runProjectCommand(ctx, `${InternalCommand.cwd}${npmInstallAll(commandContext(project))}`); + }); + + for (const command of commands) { + await runProjectCommand(ctx, command); + } + + const postFixes: string[] = []; + for (let v = current + 1; v <= target; v++) { + postFixes.push(...applyAngularPostFixes(project, v)); + } + return { from: current, to: target, postFixes }; +} + +async function migrateCordovaCmd(ctx: CliContext) { + const project = await requireProject(ctx); + const remove = flagBool(ctx.args.flags, 'remove'); + const plan = remove ? planCordovaRemoval(project) : planCordovaMigration(project); + const commands = remove ? (plan.remove ?? []) : plan.integrate; + + if (ctx.opts.dryRun) return { ...plan, commands }; + + if (!remove) { + const dirty = await isGitDirty(project.folder, ctx); + requireCleanGit(project.folder, dirty); + } + + for (const command of commands) { + await runProjectCommand(ctx, command); + } + return { completed: remove ? 'remove' : 'integrate', notes: plan.notes }; +} + +async function migrateSpmCmd(ctx: CliContext) { + const project = await requireProject(ctx); + const plan = planSpmMigration(project); + if (ctx.opts.dryRun) return plan; + await runProjectCommand(ctx, plan.command); + return { started: true, docs: plan.docs }; +} + +async function migratePackageManagerCmd(ctx: CliContext) { + const project = await requireProject(ctx); + const target = (ctx.args.positionals[0] ?? flagString(ctx.args.flags, 'to')) as TargetPackageManager; + if (!target) { + throw new WnError('MISSING_INPUT', 'Target package manager is required.', { + exitCode: ExitCode.MissingInput, + choices: [{ id: 'pnpm' }, { id: 'bun' }, { id: 'npm' }, { id: 'yarn' }], + hint: 'wn migrate package-manager ', + }); + } + const plan = planPackageManagerMigration(project, target); + if (ctx.opts.dryRun) return plan; + const dirty = await isGitDirty(project.folder, ctx); + requireCleanGit(project.folder, dirty); + for (const command of plan.commands) { + await runProjectCommand(ctx, command); + } + return { target: plan.target, completed: true }; +} + +async function migrateRoot(ctx: CliContext) { + return migrateList(ctx); +} + +function register(name: string, description: string, run: (ctx: CliContext) => Promise) { + registerCommand({ name, description, run }); +} + +register('migrate', 'Project migrations', migrateRoot); +register('migrate list', 'List available migrations', migrateList); +register('migrate capacitor', 'Migrate Capacitor to the next major version', migrateCapacitorCmd); +register('migrate angular', 'Migrate Angular to a newer major version', migrateAngularCmd); +register('migrate cordova', 'Convert or remove Cordova integration', migrateCordovaCmd); +register('migrate spm', 'Migrate iOS from CocoaPods to SPM', migrateSpmCmd); +register('migrate package-manager', 'Convert package manager', migratePackageManagerCmd); diff --git a/cli/src/commands/native.ts b/cli/src/commands/native.ts new file mode 100644 index 0000000..7b91f4d --- /dev/null +++ b/cli/src/commands/native.ts @@ -0,0 +1,223 @@ +import { flagString } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { registerCommand } from '../cli/registry'; +import { capacitorAdd } from '../build/capacitor-add'; +import { + getCapacitorProjectState, + NativePlatform, + setBuild, + setBundleId, + setDisplayName, + setVersion, + validateBuild, + validateBundleId, + validateVersion, +} from '../native/configure'; +import { checkPrivacyManifest, createPrivacyManifest, setPrivacyCategory } from '../native/privacy'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { CliContext } from '../core/context'; +import { ensureProject, positional, runCommandResult } from './helpers'; + +function parsePlatform(raw?: string): NativePlatform | undefined { + if (!raw) return undefined; + switch (raw.toLowerCase()) { + case 'ios': + return NativePlatform.iOSOnly; + case 'android': + return NativePlatform.AndroidOnly; + default: + throw new WnError('USAGE_ERROR', `Unknown platform: ${raw}`, { exitCode: ExitCode.UsageError }); + } +} + +async function runNativeGet(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const state = await getCapacitorProjectState(project.projectFolder()); + if (!state) { + throw new WnError('PLATFORM_NOT_ADDED', 'No native iOS or Android project found', { + exitCode: ExitCode.CommandFailed, + hint: 'Run wn native add ios|android', + }); + } + return { + ios: state.iosBundleId + ? { + appId: state.iosBundleId, + appName: state.iosDisplayName, + version: state.iosVersion, + build: state.iosBuild, + } + : undefined, + android: state.androidBundleId + ? { + appId: state.androidBundleId, + appName: state.androidDisplayName, + version: state.androidVersion, + build: state.androidBuild, + } + : undefined, + }; +} + +async function runNativeSet(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const property = positional(ctx, 0); + const value = positional(ctx, 1); + const platform = parsePlatform(flagString(ctx.args.flags, 'platform')); + + if (!property || value === undefined) { + throw new WnError('USAGE_ERROR', 'Usage: wn native set [--platform ios|android]', { + exitCode: ExitCode.UsageError, + }); + } + + const folder = project.projectFolder(); + switch (property) { + case 'app-id': { + const err = validateBundleId(value); + if (err) throw new WnError('INVALID_VALUE', err, { exitCode: ExitCode.UsageError }); + if (!ctx.opts.dryRun) await setBundleId(folder, value, platform); + break; + } + case 'app-name': + if (!ctx.opts.dryRun) await setDisplayName(folder, value, platform); + break; + case 'version': { + const err = validateVersion(value); + if (err) throw new WnError('INVALID_VALUE', err, { exitCode: ExitCode.UsageError }); + if (!ctx.opts.dryRun) await setVersion(folder, value, platform); + break; + } + case 'build': { + const err = validateBuild(value); + if (err) throw new WnError('INVALID_VALUE', err, { exitCode: ExitCode.UsageError }); + if (!ctx.opts.dryRun) await setBuild(folder, value, platform); + break; + } + default: + throw new WnError('USAGE_ERROR', `Unknown property: ${property}`, { + exitCode: ExitCode.UsageError, + hint: 'Properties: app-id, app-name, version, build', + }); + } + + return { property, value, platform: platform ?? 'both' }; +} + +async function runNativeAdd(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const platformRaw = positional(ctx, 0); + if (!platformRaw) { + throw new WnError('USAGE_ERROR', 'Usage: wn native add ', { exitCode: ExitCode.UsageError }); + } + const platform = platformRaw.toLowerCase() as CapacitorPlatform; + if (platform !== CapacitorPlatform.ios && platform !== CapacitorPlatform.android) { + throw new WnError('USAGE_ERROR', 'Platform must be ios or android', { exitCode: ExitCode.UsageError }); + } + await runCommandResult(ctx, capacitorAdd(project, platform)); + return { platform, added: true }; +} + +async function runNativePrivacyCheck(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const result = await checkPrivacyManifest(project.projectFolder(), (plugin) => project.analyzer.exists(plugin)); + return result; +} + +async function runNativePrivacyAdd(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const check = await checkPrivacyManifest(project.projectFolder(), (plugin) => project.analyzer.exists(plugin)); + const reasons = flagString(ctx.args.flags, 'reason')?.split(',') ?? []; + + if (check.needsCreation) { + if (ctx.opts.dryRun) { + return { created: true, dryRun: true }; + } + const created = await createPrivacyManifest(project.projectFolder()); + if (!created.success) { + throw new WnError('WRITE_FAILED', created.error ?? 'Unable to create privacy manifest', { + exitCode: ExitCode.CommandFailed, + }); + } + } + + const manifestPath = check.manifestPath; + if (!manifestPath && !check.needsCreation) { + throw new WnError('WRITE_FAILED', check.error ?? 'Privacy manifest not available', { + exitCode: ExitCode.CommandFailed, + }); + } + + const path = manifestPath ?? (await createPrivacyManifest(project.projectFolder())).path; + if (!path) { + throw new WnError('WRITE_FAILED', 'Privacy manifest path not found', { exitCode: ExitCode.CommandFailed }); + } + + const applied: Array<{ category: string; reason: string }> = []; + if (!ctx.opts.dryRun) { + for (const missing of check.missingCategories) { + const reason = reasons[0] ?? missing.reasons[0]; + if (reason) { + setPrivacyCategory(path, missing.api, reason); + applied.push({ category: missing.api, reason }); + } + } + if (reasons.length && check.apisUsed.length) { + for (const api of check.apisUsed) { + for (const reason of reasons) { + setPrivacyCategory(path, api.api, reason); + applied.push({ category: api.api, reason }); + } + } + } + } + + return { path, applied, check }; +} + +async function runNativePrivacy(ctx: CliContext): Promise { + const action = positional(ctx, 1); + switch (action) { + case 'check': + return runNativePrivacyCheck(ctx); + case 'add': + return runNativePrivacyAdd(ctx); + default: + throw new WnError('USAGE_ERROR', 'Usage: wn native privacy ', { exitCode: ExitCode.UsageError }); + } +} + +async function runNative(ctx: CliContext): Promise { + const sub = positional(ctx, 0); + switch (sub) { + case 'get': + return runNativeGet(ctx); + case 'set': + return runNativeSet(ctx); + case 'add': + return runNativeAdd(ctx); + case 'privacy': + return runNativePrivacy(ctx); + default: + throw new WnError('USAGE_ERROR', 'Usage: wn native ', { exitCode: ExitCode.UsageError }); + } +} + +export function registerNativeCommand(): void { + registerCommand({ + name: 'native', + description: 'Read and write native project settings', + run: runNative, + }); + registerCommand({ + name: 'native privacy check', + description: 'Check iOS privacy manifest requirements', + run: runNativePrivacyCheck, + }); + registerCommand({ + name: 'native privacy add', + description: 'Create or update iOS privacy manifest', + run: runNativePrivacyAdd, + }); +} diff --git a/cli/src/commands/new.ts b/cli/src/commands/new.ts new file mode 100644 index 0000000..cb95ae9 --- /dev/null +++ b/cli/src/commands/new.ts @@ -0,0 +1,98 @@ +import { flagBool, flagString } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { confirm } from '../cli/prompt'; +import { registerCommand } from '../cli/registry'; +import { CliContext } from '../core/context'; +import { + buildScaffoldCommands, + findTemplate, + folderExists, + getPackageId, + getProjectName, + listTemplateCatalog, + parseTargets, + resolveProjectFolder, + starterTemplates, + templateId, +} from '../templates/new'; +import { runCommandSequence, usageError } from './helpers'; + +async function runNew(ctx: CliContext): Promise { + if (flagBool(ctx.args.flags, 'list-templates')) { + return { templates: listTemplateCatalog() }; + } + + const nameArg = ctx.args.positionals[0] ?? flagString(ctx.args.flags, 'name'); + let name = nameArg; + if (!name) { + throw new WnError('MISSING_INPUT', 'Project name is required', { + exitCode: ExitCode.MissingInput, + input: 'name', + hint: 'Pass a name argument or --name ', + }); + } + + name = getProjectName(name); + const packageId = getPackageId(name); + const templateFlag = flagString(ctx.args.flags, 'template'); + const frameworkFlag = flagString(ctx.args.flags, 'framework'); + const template = findTemplate(templateFlag, frameworkFlag); + + if (!template) { + const choices = starterTemplates.map((t) => ({ id: templateId(t), name: `${t.typeName} — ${t.name}` })); + throw new WnError('UNKNOWN_TEMPLATE', `Unknown template: ${templateFlag ?? frameworkFlag ?? '(none)'}`, { + exitCode: ExitCode.MissingInput, + input: 'template', + choices, + hint: 'Run wn new --list-templates --json', + }); + } + + const dir = flagString(ctx.args.flags, 'dir') ?? ctx.cwd; + const folder = resolveProjectFolder(dir, name); + if (folderExists(folder)) { + throw new WnError('DIR_NOT_EMPTY', `The folder "${folder}" already exists`, { + exitCode: ExitCode.CommandFailed, + hint: 'Choose a unique project name or use --dir', + }); + } + + const targets = parseTargets(flagString(ctx.args.flags, 'targets')); + const noInstall = flagBool(ctx.args.flags, 'no-install'); + const noGit = flagBool(ctx.args.flags, 'no-git'); + + const projectInput = { + name, + type: template.type, + template: template.name, + targets, + }; + + const options = { noGit, folder, packageId, name, noInstall }; + const commands = buildScaffoldCommands(projectInput, template, options); + + if (!ctx.opts.dryRun && !ctx.opts.yes && !ctx.opts.noInput && !ctx.opts.json) { + const ok = await confirm(`Create ${template.typeName} "${template.name}" at ${folder}?`, ctx.opts); + if (!ok) return { cancelled: true }; + } + + await runCommandSequence(ctx, commands, dir); + + return { + name, + folder, + template: templateId(template), + packageId, + commands, + }; +} + +export function registerNewCommand(): void { + registerCommand({ + name: 'new', + description: 'Create a new project from a starter template', + noProject: true, + run: runNew, + }); +} diff --git a/cli/src/commands/open.ts b/cli/src/commands/open.ts new file mode 100644 index 0000000..f152023 --- /dev/null +++ b/cli/src/commands/open.ts @@ -0,0 +1,52 @@ +import { registerCommand } from '../cli/registry'; +import { flagString } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { capacitorOpen } from '../build/capacitor-open'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { CliContext } from '../core/context'; +import { ensureProject, runCommandResult, usageError } from './helpers'; +import { join } from 'path'; +import { spawn } from 'child_process'; + +async function runOpen(ctx: CliContext): Promise { + const target = ctx.args.positionals[0]; + if (!target) throw usageError('Usage: wn open '); + + if (target === 'browser') { + const url = flagString(ctx.args.flags, 'url') || 'http://localhost:8100'; + const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; + spawn(opener, [url], { detached: true, stdio: 'ignore' }).unref(); + return { opened: url }; + } + + if (target === 'folder') { + const which = ctx.args.positionals[1] || 'www'; + const project = await ensureProject(ctx); + const folder = join(project.projectFolder(), which); + const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open'; + spawn(opener, [folder], { detached: true, stdio: 'ignore' }).unref(); + return { opened: folder }; + } + + const project = await ensureProject(ctx); + let platform: CapacitorPlatform; + if (target === 'xcode' || target === 'ios') platform = CapacitorPlatform.ios; + else if (target === 'android-studio' || target === 'android') platform = CapacitorPlatform.android; + else throw usageError(`Unknown open target: ${target}`); + + if (!project.hasCapacitorProject(platform)) { + throw new WnError('PLATFORM_NOT_ADDED', `Platform ${platform} is not added`, { + exitCode: ExitCode.CommandFailed, + }); + } + + await runCommandResult(ctx, capacitorOpen(project, platform)); + return { opened: platform }; +} + +registerCommand({ + name: 'open', + description: 'Open project in Xcode, Android Studio, browser, or folder', + run: runOpen, +}); diff --git a/cli/src/commands/packages.ts b/cli/src/commands/packages.ts new file mode 100644 index 0000000..15f7269 --- /dev/null +++ b/cli/src/commands/packages.ts @@ -0,0 +1,181 @@ +import { flagBool, flagString } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { registerCommand } from '../cli/registry'; +import { parseAuditOutput, filterBySeverity } from '../packages/audit'; +import { exportProjectSummary, packagesForExport } from '../packages/export'; +import { listPackages } from '../packages/list'; +import { applyMinorUpdates, findMinorUpdates, installCommand, packagesFromProject } from '../packages/minor'; +import { + addPackageCommand, + buildUpgradeSpec, + installAllCommand, + removePackageCommand, + updateAllCommand, + upgradePackageCommand, +} from '../packages/mutate'; +import { checkPeerDependencies } from '../packages/peer-dependencies'; +import { analyzeBundleSize } from '../packages/size'; +import { maybeCapSync, requireProject, runProjectCommand } from './helpers'; +import { CliContext } from '../core/context'; + +function runCallback(ctx: CliContext) { + return (command: string, folder: string) => ctx.exec.getOutput({ command, cwd: folder }, { ignoreExitCode: true }); +} + +async function packagesList(ctx: CliContext) { + const project = await requireProject(ctx); + const data = await listPackages(project, runCallback(ctx), { + outdated: flagBool(ctx.args.flags, 'outdated'), + plugins: flagBool(ctx.args.flags, 'plugins'), + }); + return data; +} + +async function packagesAdd(ctx: CliContext) { + const project = await requireProject(ctx); + const name = ctx.args.positionals[0]; + if (!name) { + throw new WnError('MISSING_INPUT', 'Package name is required.', { + exitCode: ExitCode.MissingInput, + input: 'package', + hint: 'wn packages add ', + }); + } + const cmd = addPackageCommand(project, name, { dev: flagBool(ctx.args.flags, 'dev') }); + if (ctx.opts.dryRun) return { command: cmd }; + await runProjectCommand(ctx, cmd); + await maybeCapSync(ctx, project); + return { installed: name }; +} + +async function packagesRemove(ctx: CliContext) { + const project = await requireProject(ctx); + const name = ctx.args.positionals[0]; + if (!name) { + throw new WnError('MISSING_INPUT', 'Package name is required.', { + exitCode: ExitCode.MissingInput, + hint: 'wn packages remove ', + }); + } + const cmd = removePackageCommand(project, name); + if (ctx.opts.dryRun) return { command: cmd }; + await runProjectCommand(ctx, cmd); + await maybeCapSync(ctx, project); + return { removed: name }; +} + +async function packagesUpgrade(ctx: CliContext) { + const project = await requireProject(ctx); + const all = flagBool(ctx.args.flags, 'all'); + const latest = flagBool(ctx.args.flags, 'latest'); + const version = flagString(ctx.args.flags, 'version'); + const name = ctx.args.positionals[0]; + + if (all) { + const cmd = updateAllCommand(project); + if (ctx.opts.dryRun) return { command: cmd }; + await runProjectCommand(ctx, cmd); + await maybeCapSync(ctx, project); + return { upgraded: 'all' }; + } + + if (!name) { + throw new WnError('MISSING_INPUT', 'Package name is required unless using --all.', { + exitCode: ExitCode.MissingInput, + hint: 'wn packages upgrade [--version ] [--latest]', + }); + } + + const spec = buildUpgradeSpec(name, version, latest); + const cmd = upgradePackageCommand(project, spec, { force: true }); + if (ctx.opts.dryRun) return { command: cmd, spec }; + await runProjectCommand(ctx, cmd); + await maybeCapSync(ctx, project); + return { upgraded: spec }; +} + +async function packagesMinor(ctx: CliContext) { + const project = await requireProject(ctx); + const packages = packagesFromProject(project); + const updates = await findMinorUpdates(project, packages, runCallback(ctx)); + if (!flagBool(ctx.args.flags, 'apply')) return { updates, count: updates.length }; + if (updates.length === 0) return { updates: [], applied: [] }; + const install = async (spec: string) => runProjectCommand(ctx, installCommand(project, spec)); + const result = await applyMinorUpdates(project, updates, install); + await maybeCapSync(ctx, project); + return { updates, ...result }; +} + +async function packagesAudit(ctx: CliContext) { + const project = await requireProject(ctx); + const folder = project.projectFolder(); + const fix = flagBool(ctx.args.flags, 'fix'); + const cmd = fix ? 'npm audit fix' : 'npm audit --json'; + if (ctx.opts.dryRun) return { command: cmd, cwd: folder }; + const output = await ctx.exec.getOutput({ command: cmd, cwd: folder }, { ignoreExitCode: !fix }); + if (fix) return { fixed: true, output }; + const deps = project.analyzer.getAllPackageNames(); + const audit = parseAuditOutput(output, deps); + const severity = flagString(ctx.args.flags, 'severity'); + audit.vulnerabilities = filterBySeverity(audit.vulnerabilities, severity); + return audit; +} + +async function packagesPeers(ctx: CliContext) { + const project = await requireProject(ctx); + const coreVersion = project.analyzer.getPackageVersion('@capacitor/core')?.version ?? 'latest'; + const report = await checkPeerDependencies( + project.projectFolder(), + project, + [{ name: '@capacitor/core', version: coreVersion }], + [], + runCallback(ctx), + ); + if (flagBool(ctx.args.flags, 'fix') && report.commands.length > 0) { + if (ctx.opts.dryRun) return { ...report, dryRun: true }; + for (const command of report.commands) await runProjectCommand(ctx, command); + await maybeCapSync(ctx, project); + } + return report; +} + +async function packagesInstall(ctx: CliContext) { + const project = await requireProject(ctx); + const cmd = installAllCommand(project, { frozenLockfile: flagBool(ctx.args.flags, 'frozen-lockfile') }); + if (ctx.opts.dryRun) return { command: cmd }; + await runProjectCommand(ctx, cmd); + return { installed: true }; +} + +async function packagesSize(ctx: CliContext) { + const project = await requireProject(ctx); + const run = (command: string, cwd: string) => ctx.exec.getOutput({ command, cwd }); + if (ctx.opts.dryRun) { + return { note: 'Production build and source-map-explorer analysis', dist: project.getDistFolder() }; + } + return analyzeBundleSize(project, run); +} + +async function packagesExport(ctx: CliContext) { + const project = await requireProject(ctx); + const packages = packagesForExport(project); + if (ctx.opts.dryRun) return { packages: Object.keys(packages), filename: 'project-summary.md' }; + return exportProjectSummary(project, packages); +} + +function register(name: string, description: string, run: (ctx: CliContext) => Promise) { + registerCommand({ name, description, run }); +} + +register('packages', 'Manage project dependencies', packagesList); +register('packages list', 'List packages with version info', packagesList); +register('packages add', 'Add a dependency', packagesAdd); +register('packages remove', 'Remove a dependency', packagesRemove); +register('packages upgrade', 'Upgrade dependencies', packagesUpgrade); +register('packages minor', 'Find or apply minor/patch updates', packagesMinor); +register('packages audit', 'Audit dependencies for vulnerabilities', packagesAudit); +register('packages peers', 'Check peer dependency compatibility', packagesPeers); +register('packages install', 'Install all dependencies', packagesInstall); +register('packages size', 'Analyze production bundle sizes', packagesSize); +register('packages export', 'Export project-summary.md', packagesExport); diff --git a/cli/src/commands/plugins.ts b/cli/src/commands/plugins.ts new file mode 100644 index 0000000..c59c6fd --- /dev/null +++ b/cli/src/commands/plugins.ts @@ -0,0 +1,122 @@ +import { flagBool, flagString } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { registerCommand } from '../cli/registry'; +import { findBestPluginVersion } from '../packages/peer-dependencies'; +import { fetchPluginCatalog } from '../plugins/catalog'; +import { getPluginInfo } from '../plugins/info'; +import { enterpriseRegisterCommand, hasEnterpriseAuth, planPluginInstall, planPluginRemove } from '../plugins/install'; +import { getPluginPermissions } from '../plugins/permissions'; +import { searchPlugins } from '../plugins/search'; +import { maybeCapSync, requireProject, runProjectCommand } from './helpers'; +import { CliContext } from '../core/context'; + +function runCallback(ctx: CliContext) { + return (command: string, folder: string) => ctx.exec.getOutput({ command, cwd: folder }, { ignoreExitCode: true }); +} + +async function pluginsSearch(ctx: CliContext) { + const query = ctx.args.positionals[0] ?? flagString(ctx.args.flags, 'query'); + if (!query) { + throw new WnError('MISSING_INPUT', 'Search query is required.', { + exitCode: ExitCode.MissingInput, + hint: 'wn plugins search ', + }); + } + const results = await searchPlugins({ + query, + platform: flagString(ctx.args.flags, 'platform'), + official: flagBool(ctx.args.flags, 'official'), + }); + return { results, count: results.length }; +} + +async function pluginsInfo(ctx: CliContext) { + const project = await requireProject(ctx); + const name = ctx.args.positionals[0]; + if (!name) { + throw new WnError('MISSING_INPUT', 'Plugin name is required.', { + exitCode: ExitCode.MissingInput, + hint: 'wn plugins info ', + }); + } + let compatibleVersion: string | undefined; + if (project.isCapacitor) { + compatibleVersion = await findBestPluginVersion(name, project, runCallback(ctx)); + } + const info = await getPluginInfo(name, project, runCallback(ctx), compatibleVersion); + if (!info) { + throw new WnError('UNKNOWN_PLUGIN', `Plugin or package ${name} was not found on npm.`, { + exitCode: ExitCode.CommandFailed, + }); + } + return info; +} + +async function pluginsAdd(ctx: CliContext) { + const project = await requireProject(ctx); + const name = ctx.args.positionals[0]; + if (!name) { + throw new WnError('MISSING_INPUT', 'Plugin name is required.', { + exitCode: ExitCode.MissingInput, + hint: 'wn plugins add ', + }); + } + const version = flagString(ctx.args.flags, 'version'); + const plan = await planPluginInstall(name, project, runCallback(ctx), version); + + if (name.startsWith('@ionic-enterprise/') && !hasEnterpriseAuth(project.projectFolder())) { + const key = flagString(ctx.args.flags, 'enterprise-key'); + if (!key) { + throw new WnError('MISSING_INPUT', 'Ionic Enterprise product key required.', { + exitCode: ExitCode.MissingInput, + input: 'enterprise-key', + hint: 'Re-run with --enterprise-key ', + }); + } + if (ctx.opts.dryRun) return { ...plan, enterprise: enterpriseRegisterCommand(key) }; + await runProjectCommand(ctx, enterpriseRegisterCommand(key)); + } + + if (ctx.opts.dryRun) return plan; + await runProjectCommand(ctx, plan.install); + if (plan.sync) await runProjectCommand(ctx, plan.sync); + return { installed: plan.packageSpec }; +} + +async function pluginsRemove(ctx: CliContext) { + const project = await requireProject(ctx); + const name = ctx.args.positionals[0]; + if (!name) { + throw new WnError('MISSING_INPUT', 'Plugin name is required.', { + exitCode: ExitCode.MissingInput, + hint: 'wn plugins remove ', + }); + } + const plan = planPluginRemove(name, project, !flagBool(ctx.args.flags, 'no-sync')); + if (ctx.opts.dryRun) return plan; + await runProjectCommand(ctx, plan.remove); + if (plan.sync) await runProjectCommand(ctx, plan.sync); + return { removed: name }; +} + +async function pluginsPermissions(ctx: CliContext) { + const project = await requireProject(ctx); + return { plugins: getPluginPermissions(project) }; +} + +async function pluginsRoot(ctx: CliContext) { + const catalog = await fetchPluginCatalog(flagBool(ctx.args.flags, 'refresh')); + return { count: catalog.plugins.length }; +} + +function register(name: string, description: string, run: (ctx: CliContext) => Promise) { + registerCommand({ name, description, run }); +} + +register('plugins', 'Capacitor plugin catalog', pluginsRoot); +register('plugins search', 'Search the Capacitor plugin directory', pluginsSearch); +register('plugins info', 'Show npm metadata for a plugin', pluginsInfo); +register('plugins add', 'Install a Capacitor plugin', pluginsAdd); +register('plugins remove', 'Remove a Capacitor plugin', pluginsRemove); +register('plugins permissions', 'List Android permissions from plugin.xml', pluginsPermissions); diff --git a/cli/src/commands/release.ts b/cli/src/commands/release.ts new file mode 100644 index 0000000..f966e59 --- /dev/null +++ b/cli/src/commands/release.ts @@ -0,0 +1,130 @@ +import { flagBool, flagString } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { registerCommand } from '../cli/registry'; +import { + capacitorBuildCommand, + isCapBuildSupported, + KeyStoreSettings, + listCapBuildTargets, + readKeyStoreSettings, + selectionToPlatformAndArgs, + writeKeyStoreConfig, +} from '../build/capacitor-build'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { ensureProject, positional, runCommandResult } from './helpers'; +import { CliContext } from '../core/context'; + +function readKeystoreFromEnv(): KeyStoreSettings { + return { + keyStorePath: process.env.WN_KEYSTORE_PATH, + keyStorePassword: process.env.WN_KEYSTORE_PASSWORD, + keyAlias: process.env.WN_KEYSTORE_ALIAS, + keyPassword: process.env.WN_KEYSTORE_ALIAS_PASSWORD, + }; +} + +function readKeystoreFromFlags(ctx: CliContext): KeyStoreSettings { + return { + keyStorePath: flagString(ctx.args.flags, 'keystore') ?? flagString(ctx.args.flags, 'keystore-path'), + keyStorePassword: flagString(ctx.args.flags, 'keystore-password'), + keyAlias: flagString(ctx.args.flags, 'keystore-alias'), + keyPassword: flagString(ctx.args.flags, 'keystore-alias-password'), + }; +} + +function mergeKeystoreSettings(project: Awaited>, ctx: CliContext): KeyStoreSettings { + const fromConfig = readKeyStoreSettings(project); + const fromEnv = readKeystoreFromEnv(); + const fromFlags = readKeystoreFromFlags(ctx); + return { + signingType: fromFlags.signingType ?? fromConfig.signingType, + keyStorePath: fromFlags.keyStorePath ?? fromEnv.keyStorePath ?? fromConfig.keyStorePath, + keyStorePassword: fromFlags.keyStorePassword ?? fromEnv.keyStorePassword ?? fromConfig.keyStorePassword, + keyAlias: fromFlags.keyAlias ?? fromEnv.keyAlias ?? fromConfig.keyAlias, + keyPassword: fromFlags.keyPassword ?? fromEnv.keyPassword ?? fromConfig.keyPassword, + }; +} + +function redactSecrets(settings: KeyStoreSettings): string[] { + return [settings.keyStorePassword, settings.keyPassword].filter(Boolean) as string[]; +} + +async function runReleaseAndroid(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + if (!isCapBuildSupported(project)) { + throw new WnError('PRECONDITION_FAILED', '@capacitor/cli 4.4.0+ is required for cap build', { + exitCode: ExitCode.CommandFailed, + }); + } + if (!project.hasCapacitorProject(CapacitorPlatform.android)) { + throw new WnError('PLATFORM_NOT_ADDED', 'Android platform is not added', { exitCode: ExitCode.CommandFailed }); + } + + const type = (flagString(ctx.args.flags, 'type') ?? 'aab').toLowerCase(); + const selection = type === 'apk' ? 'android-apk' : 'android-aab'; + const { platform, args } = selectionToPlatformAndArgs(selection); + const settings = mergeKeystoreSettings(project, ctx); + + if (!settings.keyStorePath || !settings.keyStorePassword || !settings.keyAlias || !settings.keyPassword) { + throw new WnError('MISSING_INPUT', 'Android signing credentials are required', { + exitCode: ExitCode.MissingInput, + hint: 'Pass --keystore flags or set WN_KEYSTORE_* environment variables', + }); + } + + if (flagBool(ctx.args.flags, 'save-config') && !ctx.opts.dryRun) { + writeKeyStoreConfig(project, settings); + } + + const result = capacitorBuildCommand(project, platform, args, settings); + await runCommandResult(ctx, result, redactSecrets(settings)); + return { platform: 'android', type, settings: { ...settings, keyStorePassword: '***', keyPassword: '***' } }; +} + +async function runReleaseIos(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + if (!isCapBuildSupported(project)) { + throw new WnError('PRECONDITION_FAILED', '@capacitor/cli 4.4.0+ is required for cap build', { + exitCode: ExitCode.CommandFailed, + }); + } + if (!project.hasCapacitorProject(CapacitorPlatform.ios)) { + throw new WnError('PLATFORM_NOT_ADDED', 'iOS platform is not added', { exitCode: ExitCode.CommandFailed }); + } + + const { platform, args } = selectionToPlatformAndArgs('ios-ipa'); + const configArg = flagString(ctx.args.flags, 'config'); + const extraArgs = configArg ? `${args} --configuration=${configArg}` : args; + const result = capacitorBuildCommand(project, platform, extraArgs, {}); + await runCommandResult(ctx, result); + return { platform: 'ios', type: 'ipa' }; +} + +async function runRelease(ctx: CliContext): Promise { + const platform = positional(ctx, 0); + if (!platform) { + const project = await ensureProject(ctx); + return { + targets: listCapBuildTargets(project), + hint: 'Usage: wn release ios|android', + }; + } + + switch (platform.toLowerCase()) { + case 'android': + return runReleaseAndroid(ctx); + case 'ios': + return runReleaseIos(ctx); + default: + throw new WnError('USAGE_ERROR', 'Usage: wn release ', { exitCode: ExitCode.UsageError }); + } +} + +export function registerReleaseCommand(): void { + registerCommand({ + name: 'release', + description: 'Produce a signed native release build', + run: runRelease, + }); +} diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts new file mode 100644 index 0000000..2ec2bc5 --- /dev/null +++ b/cli/src/commands/run.ts @@ -0,0 +1,188 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawn } from 'child_process'; +import { registerCommand } from '../cli/registry'; +import { flagBool, flagString } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { serve } from '../build/web-run'; +import { capacitorRun } from '../build/capacitor-run'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { scrapeDevServerUrl } from '../core/exec'; +import { CliContext } from '../core/context'; +import { ensureProject, runCommandResult, usageError } from './helpers'; +import { resolveDeviceId } from './devices'; +import { getExternalAddresses } from '../build/web-run'; + +function pidDir(): string { + return path.join(os.tmpdir(), 'wn'); +} + +function pidFile(projectName: string, kind: string): string { + return path.join(pidDir(), `${projectName}-${kind}.json`); +} + +function logFile(projectName: string, kind: string): string { + return path.join(pidDir(), `${projectName}-${kind}.log`); +} + +function saveManagedProcess(info: Record): void { + fs.mkdirSync(pidDir(), { recursive: true }); + const file = pidFile(String(info.name), String(info.kind)); + fs.writeFileSync(file, JSON.stringify(info, null, 2)); +} + +async function runWeb(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const port = Number(flagString(ctx.args.flags, 'port') || ctx.config.defaultPort || 8100); + const background = flagBool(ctx.args.flags, 'background'); + const open = flagBool(ctx.args.flags, 'open'); + const https = flagBool(ctx.args.flags, 'https'); + const host = flagString(ctx.args.flags, 'host'); + const config = flagString(ctx.args.flags, 'config'); + const strictPort = flagBool(ctx.args.flags, 'strict-port'); + + const result = await serve(project, { + dontOpenBrowser: !open, + httpsForWeb: https, + externalIP: host === 'external' || !!host, + host: host && host !== 'external' ? host : undefined, + defaultPort: port, + runConfiguration: config, + createCerts: https, + }); + + const cmd = typeof result.command === 'string' ? result.command : result.command.command; + const cwd = typeof result.command === 'string' ? project.projectFolder() : result.command.cwd; + const usedPort = result.port || port; + + if (ctx.opts.dryRun) { + await runCommandResult(ctx, result.command); + return { dryRun: true, command: cmd, port: usedPort }; + } + + if (strictPort && result.port && result.port !== port) { + throw new WnError('PORT_IN_USE', `Port ${port} is in use`, { exitCode: ExitCode.CommandFailed }); + } + + const name = path.basename(project.projectFolder()); + const log = logFile(name, 'web'); + fs.mkdirSync(pidDir(), { recursive: true }); + + if (background) { + const out = fs.openSync(log, 'w'); + const child = spawn(cmd, [], { + cwd, + shell: true, + detached: true, + stdio: ['ignore', out, out], + }); + child.unref(); + const url = `http://localhost:${usedPort}`; + const addresses = getExternalAddresses(); + const externalUrl = addresses[0] ? `http://${addresses[0]}:${usedPort}` : undefined; + saveManagedProcess({ + name, + kind: 'web', + pid: child.pid, + url, + externalUrl, + logFile: log, + cwd, + projectRoot: project.folder, + }); + return { url, externalUrl, pid: child.pid, background: true, logFile: log }; + } + + // Foreground: stream until ready then keep running (or return url for agents who shouldn't do this) + let combined = ''; + const runResult = await ctx.exec.run( + { command: cmd, cwd }, + { + onLog: (line) => { + combined += line + '\n'; + }, + }, + ); + const url = scrapeDevServerUrl(combined) || `http://localhost:${usedPort}`; + return { url, exitCode: runResult.exitCode }; +} + +async function runNative(ctx: CliContext, platform: CapacitorPlatform): Promise { + const project = await ensureProject(ctx); + if (!project.hasCapacitorProject(platform)) { + throw new WnError('PLATFORM_NOT_ADDED', `Platform ${platform} is not added`, { + exitCode: ExitCode.CommandFailed, + hint: `Run wn native add ${platform}`, + }); + } + + const deviceFlag = flagString(ctx.args.flags, 'device'); + const deviceName = flagString(ctx.args.flags, 'device-name'); + const liveReload = flagBool(ctx.args.flags, 'live-reload'); + const noSync = flagBool(ctx.args.flags, 'no-sync'); + const flavor = flagString(ctx.args.flags, 'flavor'); + const prod = flagBool(ctx.args.flags, 'prod'); + const ssl = flagBool(ctx.args.flags, 'ssl'); + const config = flagString(ctx.args.flags, 'config'); + + const deviceId = await resolveDeviceId(ctx, platform, deviceFlag, deviceName); + + const result = await capacitorRun(project, platform, { + liveReload, + flavor, + buildForProduction: prod, + target: deviceId, + httpsForWeb: ssl, + syncDone: noSync ? [platform] : [], + internalAddress: ctx.config.internalAddress, + createCerts: ssl, + }); + + if (!result) { + throw new WnError('MISSING_INPUT', 'Unable to build run command (flavor may be required)', { + exitCode: ExitCode.MissingInput, + input: 'flavor', + hint: 'Re-run with --flavor ', + }); + } + + // Substitute target placeholder if present + let command = typeof result === 'string' ? result : result.command; + const cwd = typeof result === 'string' ? project.projectFolder() : result.cwd; + command = command.replace(/\[@target\]/g, deviceId); + + await runCommandResult(ctx, { command, cwd }); + return { platform, device: deviceId, liveReload, config }; +} + +async function runRun(ctx: CliContext): Promise { + const platform = ctx.args.positionals[0] || ctx.args.command[1]; + if (!platform || !['web', 'ios', 'android'].includes(platform)) { + // When registered as "run web" etc, positional may be empty + const name = ctx.commandName; + if (name === 'run web' || platform === 'web') return runWeb(ctx); + if (name === 'run ios' || platform === 'ios') return runNative(ctx, CapacitorPlatform.ios); + if (name === 'run android' || platform === 'android') return runNative(ctx, CapacitorPlatform.android); + throw usageError('Usage: wn run '); + } + if (platform === 'web') return runWeb(ctx); + if (platform === 'ios') return runNative(ctx, CapacitorPlatform.ios); + return runNative(ctx, CapacitorPlatform.android); +} + +registerCommand({ name: 'run', description: 'Serve web or launch on a device', run: runRun }); +registerCommand({ name: 'run web', description: 'Serve the web app', run: async (ctx) => runWeb(ctx) }); +registerCommand({ + name: 'run ios', + description: 'Run on an iOS device or simulator', + run: async (ctx) => runNative(ctx, CapacitorPlatform.ios), +}); +registerCommand({ + name: 'run android', + description: 'Run on an Android device or emulator', + run: async (ctx) => runNative(ctx, CapacitorPlatform.android), +}); + +export { pidDir, pidFile, logFile }; diff --git a/cli/src/commands/scripts.ts b/cli/src/commands/scripts.ts new file mode 100644 index 0000000..3948808 --- /dev/null +++ b/cli/src/commands/scripts.ts @@ -0,0 +1,59 @@ +import { registerCommand } from '../cli/registry'; +import { listScripts } from '../build/scripts'; +import { commandContext, npmRun } from '../build/node-commands'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { CliContext } from '../core/context'; +import { ensureProject, runShell, usageError } from './helpers'; + +async function runScriptsList(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const scripts = listScripts(project, true); + return { + scripts: scripts.map((s) => ({ name: s.name, command: s.command, description: s.description })), + }; +} + +async function runScriptsRun(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + const name = ctx.args.positionals[0]; + if (!name) throw usageError('Usage: wn scripts run '); + + const pkgScripts = project.analyzer.getPackageFile()?.scripts || {}; + // listScripts nice-names scripts; accept either nice name or raw script key + const rawKey = + Object.keys(pkgScripts).find((k) => k === name) || + Object.keys(pkgScripts).find((k) => k.replace(/-/g, ' ').toLowerCase() === name.toLowerCase()); + + if (!rawKey) { + throw new WnError('UNKNOWN_SCRIPT', `Unknown script: ${name}`, { + exitCode: ExitCode.UsageError, + input: 'script', + choices: Object.keys(pkgScripts).map((id) => ({ id, name: id })), + hint: 'Re-run with a script from wn scripts list', + }); + } + + const cmd = npmRun(rawKey, commandContext(project)); + const extra = ctx.args.passthrough.length ? ' -- ' + ctx.args.passthrough.join(' ') : ''; + await runShell(ctx, { command: cmd + extra, cwd: project.projectFolder() }); + return { script: rawKey }; +} + +registerCommand({ + name: 'scripts', + description: 'List package.json scripts', + run: runScriptsList, +}); + +registerCommand({ + name: 'scripts list', + description: 'List package.json scripts', + run: runScriptsList, +}); + +registerCommand({ + name: 'scripts run', + description: 'Run a package.json script', + run: runScriptsRun, +}); diff --git a/cli/src/commands/stop.ts b/cli/src/commands/stop.ts new file mode 100644 index 0000000..6ab879b --- /dev/null +++ b/cli/src/commands/stop.ts @@ -0,0 +1,103 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { registerCommand } from '../cli/registry'; +import { flagBool, flagString } from '../cli/args'; +import { killPid } from '../devices/process-list'; +import { CliContext } from '../core/context'; +import { pidDir } from './run'; +import { ensureProject } from './helpers'; + +interface ManagedProcess { + name: string; + kind: string; + pid: number; + projectRoot?: string; + logFile?: string; +} + +function loadManaged(): ManagedProcess[] { + const dir = pidDir(); + if (!fs.existsSync(dir)) return []; + const results: ManagedProcess[] = []; + for (const file of fs.readdirSync(dir)) { + if (!file.endsWith('.json')) continue; + try { + results.push(JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8'))); + } catch { + /* ignore */ + } + } + return results; +} + +function removeManaged(proc: ManagedProcess): void { + const file = path.join(pidDir(), `${proc.name}-${proc.kind}.json`); + try { + fs.unlinkSync(file); + } catch { + /* ignore */ + } +} + +async function runStop(ctx: CliContext): Promise { + const all = flagBool(ctx.args.flags, 'all'); + const pidFlag = flagString(ctx.args.flags, 'pid'); + const portFlag = flagString(ctx.args.flags, 'port'); + + const stopped: Array<{ pid: number; kind?: string }> = []; + + if (pidFlag) { + await killPid(Number(pidFlag), ctx.cwd); + stopped.push({ pid: Number(pidFlag) }); + return { stopped }; + } + + if (portFlag) { + // Best-effort: kill process listening on port via lsof + try { + const out = await ctx.exec.getOutput({ command: `lsof -ti:${portFlag}` }, { ignoreExitCode: true }); + for (const line of out.split('\n')) { + const pid = Number(line.trim()); + if (pid) { + await killPid(pid, ctx.cwd); + stopped.push({ pid }); + } + } + } catch { + /* ignore */ + } + return { stopped }; + } + + let managed = loadManaged(); + if (!all) { + try { + const project = await ensureProject(ctx); + managed = managed.filter( + (m) => m.projectRoot === project.folder || m.name === path.basename(project.projectFolder()), + ); + } catch { + // no project — stop all for safety when not --all? Prefer only matching cwd + managed = managed.filter((m) => m.projectRoot === ctx.cwd); + } + } + + for (const proc of managed) { + try { + await killPid(proc.pid, proc.projectRoot || ctx.cwd); + stopped.push({ pid: proc.pid, kind: proc.kind }); + } catch { + /* already dead */ + } + removeManaged(proc); + } + + return { stopped }; +} + +registerCommand({ + name: 'stop', + description: 'Stop background processes started by wn run', + run: runStop, + noProject: true, +}); diff --git a/cli/src/commands/sync.ts b/cli/src/commands/sync.ts new file mode 100644 index 0000000..2faead6 --- /dev/null +++ b/cli/src/commands/sync.ts @@ -0,0 +1,35 @@ +import { registerCommand } from '../cli/registry'; +import { flagBool } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { capacitorSync } from '../build/capacitor-sync'; +import { build as buildCommand } from '../build/build'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { CliContext } from '../core/context'; +import { ensureProject, runCommandResult } from './helpers'; + +async function runSync(ctx: CliContext): Promise { + const project = await ensureProject(ctx); + if (!project.isCapacitor) { + throw new WnError('PLATFORM_NOT_ADDED', 'Not a Capacitor project', { exitCode: ExitCode.CommandFailed }); + } + + const platformArg = ctx.args.positionals[0]; + const noBuild = flagBool(ctx.args.flags, 'no-build'); + + if (!noBuild) { + let platform: CapacitorPlatform | undefined; + if (platformArg === 'ios') platform = CapacitorPlatform.ios; + if (platformArg === 'android') platform = CapacitorPlatform.android; + await runCommandResult(ctx, buildCommand(project, { platform })); + } + + await runCommandResult(ctx, capacitorSync(project)); + return { synced: platformArg || 'all' }; +} + +registerCommand({ + name: 'sync', + description: 'Run cap sync (copy web assets and update native deps)', + run: runSync, +}); diff --git a/cli/src/core/cache.ts b/cli/src/core/cache.ts new file mode 100644 index 0000000..99b0fbd --- /dev/null +++ b/cli/src/core/cache.ts @@ -0,0 +1,48 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as crypto from 'crypto'; + +export function cacheDir(): string { + const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'); + return path.join(base, 'wn'); +} + +function ensureDir(dir: string): void { + fs.mkdirSync(dir, { recursive: true }); +} + +function keyPath(namespace: string, key: string): string { + const hash = crypto.createHash('sha1').update(key).digest('hex').slice(0, 16); + const safe = key.replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 40); + return path.join(cacheDir(), namespace, `${safe}-${hash}.json`); +} + +export interface CacheEntry { + value: T; + storedAt: number; +} + +export function getCache(namespace: string, key: string, maxAgeMs: number): T | undefined { + const file = keyPath(namespace, key); + try { + if (!fs.existsSync(file)) return undefined; + const entry = JSON.parse(fs.readFileSync(file, 'utf8')) as CacheEntry; + if (Date.now() - entry.storedAt > maxAgeMs) return undefined; + return entry.value; + } catch { + return undefined; + } +} + +export function setCache(namespace: string, key: string, value: T): void { + const file = keyPath(namespace, key); + ensureDir(path.dirname(file)); + const entry: CacheEntry = { value, storedAt: Date.now() }; + fs.writeFileSync(file, JSON.stringify(entry)); +} + +export function clearCache(namespace?: string): void { + const dir = namespace ? path.join(cacheDir(), namespace) : cacheDir(); + fs.rmSync(dir, { recursive: true, force: true }); +} diff --git a/cli/src/core/config.ts b/cli/src/core/config.ts new file mode 100644 index 0000000..80eadb5 --- /dev/null +++ b/cli/src/core/config.ts @@ -0,0 +1,93 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { GlobalOptions } from '../cli/args'; + +export interface WnConfig { + defaultPort: number; + buildForProduction: boolean; + packageManager?: 'npm' | 'pnpm' | 'yarn' | 'bun'; + javaHome?: string; + androidSdk?: string; + adbPath?: string; + shellPath?: string; + internalAddress: boolean; + debugBrowser: 'chrome' | 'edge'; + androidDebugWebRoot: 'workspace' | 'www'; + telemetry: boolean; + run?: { + ios?: { device?: string }; + android?: { flavor?: string; device?: string }; + }; + check?: { + ignore?: string[]; + errorOn?: 'error' | 'warning' | 'info'; + }; + [key: string]: unknown; +} + +const DEFAULTS: WnConfig = { + defaultPort: 8100, + buildForProduction: false, + internalAddress: false, + debugBrowser: 'chrome', + androidDebugWebRoot: 'www', + telemetry: true, +}; + +export function globalConfigPath(): string { + const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + return path.join(base, 'wn', 'config.json'); +} + +export function projectConfigPath(cwd: string): string { + return path.join(cwd, 'wn.json'); +} + +function readJsonFile(file: string): Partial { + try { + if (!fs.existsSync(file)) return {}; + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch { + return {}; + } +} + +export function loadConfig(cwd: string, globalOpts: GlobalOptions): WnConfig { + const globalCfg = readJsonFile(globalConfigPath()); + const projectCfg = readJsonFile(projectConfigPath(cwd)); + const merged: WnConfig = { + ...DEFAULTS, + ...globalCfg, + ...projectCfg, + }; + + if (globalOpts.packageManager) { + merged.packageManager = globalOpts.packageManager as WnConfig['packageManager']; + } + if (process.env.JAVA_HOME && !merged.javaHome) merged.javaHome = process.env.JAVA_HOME; + if (process.env.ANDROID_HOME && !merged.androidSdk) merged.androidSdk = process.env.ANDROID_HOME; + if (process.env.WN_TELEMETRY === '0') merged.telemetry = false; + + return merged; +} + +export function saveConfig(file: string, config: Partial): void { + const dir = path.dirname(file); + fs.mkdirSync(dir, { recursive: true }); + const existing = readJsonFile(file); + const next = { ...existing, ...config }; + fs.writeFileSync(file, JSON.stringify(next, null, 2) + '\n'); +} + +export function unsetConfigKey(file: string, key: string): void { + const existing = readJsonFile(file) as Record; + delete existing[key]; + const dir = path.dirname(file); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(file, JSON.stringify(existing, null, 2) + '\n'); +} + +export function getConfigDefaults(): WnConfig { + return { ...DEFAULTS }; +} diff --git a/cli/src/core/context.ts b/cli/src/core/context.ts new file mode 100644 index 0000000..63c9988 --- /dev/null +++ b/cli/src/core/context.ts @@ -0,0 +1,35 @@ +import * as path from 'path'; +import { GlobalOptions, ParsedArgs } from '../cli/args'; +import { loadConfig, WnConfig } from './config'; +import { ExecRunner } from './exec'; +import { Logger } from './logger'; +import { Project } from '../project/project'; + +export interface CliContext { + args: ParsedArgs; + opts: GlobalOptions; + config: WnConfig; + logger: Logger; + exec: ExecRunner; + cwd: string; + project?: Project; + warnings: string[]; + commandName: string; +} + +export function createContext(args: ParsedArgs, commandName: string): CliContext { + const cwd = path.resolve(args.global.cwd); + const config = loadConfig(cwd, args.global); + const logger = new Logger(args.global); + const exec = new ExecRunner(args.global, config, logger); + return { + args, + opts: args.global, + config, + logger, + exec, + cwd, + warnings: [], + commandName, + }; +} diff --git a/cli/src/core/exec.ts b/cli/src/core/exec.ts new file mode 100644 index 0000000..d535f32 --- /dev/null +++ b/cli/src/core/exec.ts @@ -0,0 +1,206 @@ +import { spawn, SpawnOptions } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { GlobalOptions } from '../cli/args'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { Logger } from './logger'; +import { WnConfig } from './config'; + +export interface CommandSpec { + command: string; + cwd?: string; + env?: NodeJS.ProcessEnv; + /** When true, passwords/secrets are redacted from dry-run output */ + redact?: string[]; +} + +export interface RunResult { + stdout: string; + stderr: string; + exitCode: number; + command: string; + cwd: string; +} + +export interface ExecOptions { + dryRun?: boolean; + quiet?: boolean; + streamJson?: boolean; + onLog?: (line: string, stream: 'stdout' | 'stderr') => void; + timeoutMs?: number; + ignoreExitCode?: boolean; +} + +function detectNvmPrefix(): string { + const nvmDir = process.env.NVM_DIR || path.join(os.homedir(), '.nvm'); + const nvmSh = path.join(nvmDir, 'nvm.sh'); + if (fs.existsSync(nvmSh)) { + return `export NVM_DIR="${nvmDir}" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && `; + } + return ''; +} + +function buildEnv(config: WnConfig, extra?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env = { ...process.env, ...extra }; + if (config.javaHome) env.JAVA_HOME = config.javaHome; + if (config.androidSdk) { + env.ANDROID_HOME = config.androidSdk; + env.ANDROID_SDK_ROOT = config.androidSdk; + } + if (!env.LANG) env.LANG = 'en_US.UTF-8'; + return env; +} + +function redactCommand(command: string, secrets: string[] = []): string { + let result = command; + for (const s of secrets) { + if (s) result = result.split(s).join('***'); + } + return result; +} + +export class ExecRunner { + constructor( + private readonly opts: GlobalOptions, + private readonly config: WnConfig, + private readonly logger: Logger, + ) {} + + async run(spec: CommandSpec, options: ExecOptions = {}): Promise { + const cwd = spec.cwd || this.opts.cwd; + const dryRun = options.dryRun ?? this.opts.dryRun; + const display = redactCommand(spec.command, spec.redact); + + if (dryRun) { + this.logger.write(`[dry-run] ${display} (cwd: ${cwd})`); + if (this.opts.streamJson) { + this.logger.output.writeStreamEvent({ event: 'dry-run', command: display, cwd }); + } + return { stdout: '', stderr: '', exitCode: 0, command: display, cwd }; + } + + this.logger.verbose(`$ ${display}`); + if (this.opts.streamJson) { + this.logger.output.writeStreamEvent({ event: 'start', command: display, cwd }); + } + + const nvm = detectNvmPrefix(); + const shellCommand = nvm + spec.command; + const env = buildEnv(this.config, spec.env); + + const result = await spawnShell(shellCommand, { + cwd, + env, + timeoutMs: options.timeoutMs ?? (this.opts.timeout ? this.opts.timeout * 1000 : undefined), + onLog: (line, stream) => { + if (!options.quiet && !this.opts.quiet && !this.opts.json) { + process.stderr.write(line + '\n'); + } + if (this.opts.streamJson) { + this.logger.output.writeStreamEvent({ event: 'log', level: 'info', message: line }); + } + options.onLog?.(line, stream); + }, + }); + + if (this.opts.streamJson) { + this.logger.output.writeStreamEvent({ event: 'end', command: display, exitCode: result.exitCode }); + } + + if (result.exitCode !== 0 && !options.ignoreExitCode) { + throw new WnError('COMMAND_FAILED', `Command failed with exit code ${result.exitCode}: ${display}`, { + exitCode: ExitCode.CommandFailed, + details: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }, + }); + } + + return { ...result, command: display, cwd }; + } + + async getOutput(spec: CommandSpec, options: ExecOptions = {}): Promise { + const result = await this.run(spec, { ...options, quiet: true, ignoreExitCode: true }); + if (result.exitCode !== 0 && !options.ignoreExitCode) { + throw new WnError('COMMAND_FAILED', result.stderr || result.stdout || `Command failed: ${spec.command}`, { + exitCode: ExitCode.CommandFailed, + }); + } + return result.stdout; + } +} + +function spawnShell( + command: string, + opts: { + cwd: string; + env: NodeJS.ProcessEnv; + timeoutMs?: number; + onLog?: (line: string, stream: 'stdout' | 'stderr') => void; + }, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve, reject) => { + const spawnOpts: SpawnOptions = { + cwd: opts.cwd, + env: opts.env, + shell: true, + }; + const child = spawn(command, [], spawnOpts); + let stdout = ''; + let stderr = ''; + let killed = false; + + let timer: NodeJS.Timeout | undefined; + if (opts.timeoutMs) { + timer = setTimeout(() => { + killed = true; + child.kill('SIGTERM'); + reject( + new WnError('TIMEOUT', `Command timed out after ${opts.timeoutMs}ms`, { + exitCode: ExitCode.Timeout, + }), + ); + }, opts.timeoutMs); + } + + const handleChunk = (chunk: Buffer, stream: 'stdout' | 'stderr') => { + const text = chunk.toString(); + if (stream === 'stdout') stdout += text; + else stderr += text; + const lines = text.split(/\r?\n/); + for (const line of lines) { + if (line.length) opts.onLog?.(line, stream); + } + }; + + child.stdout?.on('data', (c) => handleChunk(c, 'stdout')); + child.stderr?.on('data', (c) => handleChunk(c, 'stderr')); + child.on('error', (err) => { + if (timer) clearTimeout(timer); + reject(err); + }); + child.on('close', (code) => { + if (timer) clearTimeout(timer); + if (killed) return; + resolve({ stdout, stderr, exitCode: code ?? 1 }); + }); + }); +} + +/** Scrape a local URL from common framework serve banners. */ +export function scrapeDevServerUrl(output: string): string | undefined { + const patterns = [ + /Local:\s+(https?:\/\/[^\s]+)/i, + /➜\s+Local:\s+(https?:\/\/[^\s]+)/i, + /- Local:\s+(https?:\/\/[^\s]+)/i, + /listening on\s+(https?:\/\/[^\s]+)/i, + /Local server:\s+(https?:\/\/[^\s]+)/i, + /http:\/\/localhost:\d+/, + /http:\/\/127\.0\.0\.1:\d+/, + ]; + for (const p of patterns) { + const m = output.match(p); + if (m) return m[1] || m[0]; + } + return undefined; +} diff --git a/cli/src/core/logger.ts b/cli/src/core/logger.ts new file mode 100644 index 0000000..205e429 --- /dev/null +++ b/cli/src/core/logger.ts @@ -0,0 +1,31 @@ +import { Output } from '../cli/output'; +import { GlobalOptions } from '../cli/args'; + +/** Thin wrapper matching the extension's write/writeWN/writeError surface. */ +export class Logger { + readonly output: Output; + + constructor(opts: GlobalOptions) { + this.output = new Output(opts); + } + + write(message: string): void { + this.output.info(message); + } + + writeWN(message: string): void { + this.output.info(`[wn] ${message}`); + } + + writeError(message: string): void { + this.output.error(message); + } + + writeWarning(message: string): void { + this.output.warn(message); + } + + verbose(message: string): void { + this.output.verbose(message); + } +} diff --git a/cli/src/core/strings.ts b/cli/src/core/strings.ts new file mode 100644 index 0000000..b6a2aff --- /dev/null +++ b/cli/src/core/strings.ts @@ -0,0 +1,20 @@ +export function getStringFrom(data: string, start: string, end: string): string { + if (data == undefined) return undefined; + const foundIdx = data.lastIndexOf(start); + if (foundIdx == -1) { + return undefined; + } + const idx = foundIdx + start.length; + const edx = data.indexOf(end, idx); + if (edx == -1) return data.substring(idx); + return data.substring(idx, edx); +} + +export function setStringIn(data: string, start: string, end: string, replacement: string): string { + const foundIdx = data.lastIndexOf(start); + if (foundIdx == -1) { + return data; + } + const idx = foundIdx + start.length; + return data.substring(0, idx) + replacement + data.substring(data.indexOf(end, idx)); +} diff --git a/cli/src/core/text.ts b/cli/src/core/text.ts new file mode 100644 index 0000000..b7a9234 --- /dev/null +++ b/cli/src/core/text.ts @@ -0,0 +1,26 @@ +import { exec } from 'child_process'; + +export function replaceAll(str: string, find: string, replace: string): string { + return str.replace(new RegExp(find.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replace); +} + +export async function getRunOutput(command: string, folder: string, shell?: string): Promise { + return new Promise((resolve, reject) => { + exec( + command, + { + cwd: folder, + encoding: 'utf8', + shell: shell || (process.platform === 'win32' ? 'powershell.exe' : '/bin/sh'), + env: { ...process.env, LANG: process.env.LANG || 'en_US.UTF-8' }, + }, + (error, stdout, stderr) => { + if (error) { + reject(new Error(stderr || error.message)); + return; + } + resolve(stdout); + }, + ); + }); +} diff --git a/cli/src/devices/adb.ts b/cli/src/devices/adb.ts new file mode 100644 index 0000000..d3a2aa8 --- /dev/null +++ b/cli/src/devices/adb.ts @@ -0,0 +1,417 @@ +import { + AdbOptions, + Device, + DeviceState, + ForwardedSocket, + ForwardOptions, + Package, + Process, + ShellOptions, + UnforwardOptions, + WebView, + WebViewType, +} from './models'; +import { existsSync } from 'fs'; +import { join, resolve } from 'path'; +import { homedir } from 'os'; +import { spawn } from 'child_process'; + +export interface AdbConfig { + adbPath?: string; + adbArgs?: string[]; + /** Used to resolve relative adb paths (e.g. `./tools/adb`) */ + workspacePath?: string; +} + +const forwardedSockets: ForwardedSocket[] = []; +let defaultAdbConfig: AdbConfig = {}; + +export function setAdbConfig(config: AdbConfig): void { + defaultAdbConfig = config; +} + +export function getAdbConfig(): AdbConfig { + return defaultAdbConfig; +} + +function mergeConfig(config?: AdbConfig): AdbConfig { + return { ...defaultAdbConfig, ...config }; +} + +export async function androidDebugUnforward(config?: AdbConfig): Promise { + const merged = mergeConfig(config); + const promises: Promise[] = []; + + for (const socket of forwardedSockets) { + const promise = unforward( + { + executable: getAdbExecutable(merged), + arguments: getAdbArguments(merged), + local: socket.local, + }, + merged, + ); + promises.push( + promise.catch(() => { + /* Ignore */ + }), + ); + } + + await Promise.all(promises); + + forwardedSockets.splice(0); +} + +export async function forwardDebugger(application: WebView, port?: number, config?: AdbConfig): Promise { + const merged = mergeConfig(config); + + if (port) { + const idx = forwardedSockets.findIndex((el) => el.local === `tcp:${port}`); + if (idx >= 0) { + forwardedSockets.splice(idx, 1); + + try { + await unforward( + { + executable: getAdbExecutable(merged), + arguments: getAdbArguments(merged), + local: `tcp:${port}`, + }, + merged, + ); + } catch { + // Ignore + } + } + } + + const socket = await forward( + { + executable: getAdbExecutable(merged), + arguments: getAdbArguments(merged), + serial: application.device.serial, + local: `tcp:${port || 0}`, + remote: `localabstract:${application.socket}`, + }, + merged, + ); + + forwardedSockets.push(socket); + + return parseInt(socket.local.substr(4), 10); +} + +export async function findDevices(config?: AdbConfig): Promise { + const merged = mergeConfig(config); + return await devices( + { + executable: getAdbExecutable(merged), + arguments: getAdbArguments(merged), + }, + merged, + ); +} + +export async function verifyAndroidDebugBridge(config?: AdbConfig): Promise { + const merged = mergeConfig(config); + try { + await version( + { + executable: getAdbExecutable(merged), + arguments: getAdbArguments(merged), + }, + merged, + ); + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') { + throw new Error('Cant find ADB executable.'); + } + + throw err; + } +} + +function adb(options: AdbOptions, config: AdbConfig, ...args: string[]): Promise { + return new Promise((resolvePromise, reject) => { + let outBuff = Buffer.alloc(0); + let errBuff = Buffer.alloc(0); + + const proc = spawn(options.executable, [...options.arguments, ...args]); + + proc.stdout.on('data', (data) => { + outBuff = Buffer.concat([outBuff, Buffer.from(data)]); + }); + proc.stderr.on('data', (data) => { + errBuff = Buffer.concat([errBuff, Buffer.from(data)]); + }); + + proc.on('error', (err) => { + reject(err); + }); + proc.on('close', (code) => { + if (code !== 0) { + reject(new Error(errBuff.toString('utf8'))); + } + + resolvePromise(outBuff.toString('utf8')); + }); + }); +} + +async function version(options: AdbOptions, config: AdbConfig): Promise { + return await adb(options, config, 'version'); +} + +async function devices(options: AdbOptions, config: AdbConfig): Promise { + const output = await adb(options, config, 'devices', '-l'); + + const result: Device[] = []; + + const regex = + /^([a-zA-Z0-9_-]+(?:\s?[.a-zA-Z0-9_-]+)?(?::\d{1,})?)\s+(device|connecting|offline|unknown|bootloader|recovery|download|unauthorized|host|no permissions)(?:\s+usb:([^:]+))?(?:\s+product:([^:]+))?(?:\s+model:([\S]+))?(?:\s+device:([\S]+))?(?:\s+features:([^:]+))?(?:\s+transport_id:([^:]+))?$/gim; + let match: any[]; + while ((match = regex.exec(output)) !== null) { + result.push({ + serial: match[1], + state: match[2] as DeviceState, + usb: match[3], + product: match[4], + model: match[5], + device: match[6], + features: match[7], + transportId: match[8], + }); + } + + return result; +} + +export async function findWebViews(device: Device, config?: AdbConfig): Promise { + const merged = mergeConfig(config); + const [sockets, processes, packages] = await Promise.all([ + getSockets(device.serial, merged), + getProcesses(device.serial, merged), + getPackages(device.serial, merged), + ]); + + const result: WebView[] = []; + + for (const socket of sockets) { + let type: WebViewType; + let packageName: string | undefined; + let versionName: string | undefined; + + if (socket === 'chrome_devtools_remote') { + type = WebViewType.chrome; + packageName = 'com.android.chrome'; + } else if (socket.startsWith('webview_devtools_remote_')) { + type = WebViewType.webview; + + const pid = parseInt(socket.substr(24), 10); + if (!isNaN(pid)) { + const proc = processes.find((el) => el.pid === pid); + if (proc) { + packageName = proc.name; + } + } + } else if (socket.endsWith('_devtools_remote')) { + type = WebViewType.crosswalk; + packageName = socket.substring(0, socket.length - 16) || undefined; + } else { + type = WebViewType.unknown; + } + + if (packageName) { + const aPackage = packages.find((el) => el.packageName === packageName); + if (aPackage) { + versionName = aPackage.versionName; + } + } + + result.push({ + device: device, + socket: socket, + type: type, + packageName: packageName, + versionName: versionName, + }); + } + + return result; +} + +async function shell(options: ShellOptions, config: AdbConfig): Promise { + return await adb(options, config, '-s', options.serial, 'shell', options.command); +} + +async function forward(options: ForwardOptions, config: AdbConfig): Promise { + const output = await adb(options, config, '-s', options.serial, 'forward', options.local, options.remote); + + if (options.local === 'tcp:0') { + return { + local: `tcp:${parseInt(output.trim(), 10)}`, + remote: options.remote, + }; + } else { + return { + local: options.local, + remote: options.remote, + }; + } +} + +async function unforward(options: UnforwardOptions, config: AdbConfig): Promise { + await adb(options, config, 'forward', '--remove', options.local); +} + +function getAdbArguments(config: AdbConfig): string[] { + return config.adbArgs ?? []; +} + +function getAdbExecutable(config: AdbConfig): string { + if (config.adbPath) { + return resolvePath(config.adbPath, config.workspacePath); + } + + if (process.platform !== 'win32') { + const adbDefault = '~/Library/Android/sdk/platform-tools/adb'; + if (existsSync(resolvePath(adbDefault, config.workspacePath))) { + return resolvePath(adbDefault, config.workspacePath); + } + } else { + const winAdb = join(process.env['LOCALAPPDATA'], 'Android', 'SDK', 'platform-tools', 'adb.exe'); + if (existsSync(resolvePath(winAdb, config.workspacePath))) { + return resolvePath(winAdb, config.workspacePath); + } + } + + return 'adb'; +} + +function resolvePath(from: string, workspacePath?: string): string { + const substituted = from.replace(/(?:^(~|\.{1,2}))(?=\/)|\$(\w+)/g, (_, tilde?: string, env?: string) => { + if (env) return process.env[env] ?? ''; + + if (tilde === '~') return homedir(); + + const fsPath = workspacePath; + if (!fsPath) return ''; + + if (tilde === '.') return fsPath; + + if (tilde === '..') return fsPath + '/..'; + + return ''; + }); + + if (substituted.includes('/')) { + return resolve(substituted); + } else { + return substituted; + } +} + +async function getSockets(serial: string, config: AdbConfig): Promise { + const output = await shell( + { + executable: getAdbExecutable(config), + arguments: getAdbArguments(config), + serial: serial, + command: 'cat /proc/net/unix', + }, + config, + ); + + const result: string[] = []; + + for (const line of output.split(/[\r\n]+/g)) { + const columns = line.split(/\s+/g); + if (columns.length < 8) { + continue; + } + + if (columns[3] !== '00010000' || columns[5] !== '01') { + continue; + } + + const colPath = columns[7]; + if (!colPath.startsWith('@') || !colPath.includes('_devtools_remote')) { + continue; + } + + result.push(colPath.substr(1)); + } + + return result; +} + +async function getProcesses(serial: string, config: AdbConfig): Promise { + const output = await shell( + { + executable: getAdbExecutable(config), + arguments: getAdbArguments(config), + serial: serial, + command: 'ps', + }, + config, + ); + + const result: Process[] = []; + + for (const line of output.split(/[\r\n]+/g)) { + const columns = line.split(/\s+/g); + if (columns.length < 9) { + continue; + } + + const pid = parseInt(columns[1], 10); + if (isNaN(pid)) { + continue; + } + + result.push({ + pid: pid, + name: columns[8], + }); + } + + return result; +} + +async function getPackages(serial: string, config: AdbConfig): Promise { + const output = await shell( + { + executable: getAdbExecutable(config), + arguments: getAdbArguments(config), + serial: serial, + command: 'dumpsys package packages', + }, + config, + ); + + const result: Package[] = []; + + let packageName: string | undefined; + + for (const line of output.split(/[\r\n]+/g)) { + const columns = line.trim().split(/\s+/g); + + if (!packageName) { + if (columns[0] === 'Package') { + packageName = columns[1].substring(1, columns[1].length - 1); + } + } else { + if (columns[0].startsWith('versionName=')) { + result.push({ + packageName: packageName, + versionName: columns[0].substr(12), + }); + + packageName = undefined; + } + } + } + + return result; +} diff --git a/cli/src/devices/capacitor-device.ts b/cli/src/devices/capacitor-device.ts new file mode 100644 index 0000000..8f06287 --- /dev/null +++ b/cli/src/devices/capacitor-device.ts @@ -0,0 +1,150 @@ +import { replaceAll } from '../core/text'; + +export interface DeviceInfo { + id: string; + name: string; + type: 'device' | 'simulator' | 'emulator'; + os?: string; + api?: number; +} + +export type RunCommandCallback = (command: string, rootPath: string) => Promise; + +/** + * Runs a command callback and parses stdout for connected devices/simulators. + */ +export async function listDevices( + command: string, + rootPath: string, + runCommand: RunCommandCallback, +): Promise { + return getDevices(command, rootPath, runCommand); +} + +/** + * Runs the command and obtains the stdout, parses it for the list of device names and target ids + * @param {string} command Node command which gathers device list + * @param {string} rootPath Path where the node command runs + */ +export async function getDevices( + command: string, + rootPath: string, + runCommand: RunCommandCallback, +): Promise { + const result = await runCommand(command, rootPath); + + const lines = result.split('\n'); + lines.shift(); // Remove the header + const devices: DeviceInfo[] = []; + for (const line of lines) { + const data = line.split('|'); + if (data.length == 3) { + const target = data[2].trim(); + if (target != '?') { + const rawName = data[0].trim() + ' ' + data[1].trim(); + devices.push(toDeviceInfo(friendlyName(rawName), target)); + } + } else { + const device = parseDevice(line); + if (device) { + devices.push(device); + } + } + } + return devices; +} + +export function parseDevice(line: string): DeviceInfo | undefined { + try { + const name = line.substring(0, line.indexOf(' ')).trim(); + line = line.substring(line.indexOf(' ')).trim(); + const args = line.replace(' ', '|').split('|'); + const target = args[1].trim(); + if (target == '?') { + return undefined; + } + const osLabel = replaceSDKLevel(args[0].trim()); + return toDeviceInfo(name + ' ' + osLabel, target); + } catch { + return undefined; + } +} + +export function friendlyName(name: string): string { + function fix(api: string, v: string) { + if (name.includes(`API ${api}`)) { + name = replaceAll(name, `API ${api}`, '').trim() + ` (Android ${v})`; + } + } + fix('35', '15'); + fix('34', '14'); + fix('33', '13'); + fix('32', '12'); + fix('31', '12'); + fix('30', '11'); + fix('29', '10'); + fix('28', '9'); + fix('27', '8'); + fix('26', '8'); + fix('25', '7'); + fix('24', '7'); + fix('23', '6'); + fix('22', '5'); + fix('21', '5'); + name = name.replace(' (emulator)', 'Emulator'); + return name; +} + +function toDeviceInfo(name: string, id: string): DeviceInfo { + const lower = name.toLowerCase(); + let type: DeviceInfo['type'] = 'device'; + if (lower.includes('simulator')) { + type = 'simulator'; + } else if (lower.includes('emulator')) { + type = 'emulator'; + } + + let os: string | undefined; + let api: number | undefined; + + const androidMatch = name.match(/Android\s+([\d.]+)/i); + if (androidMatch) { + os = 'android'; + const apiMatch = name.match(/API\s+(\d+)/i); + if (apiMatch) api = parseInt(apiMatch[1], 10); + } else if (lower.includes('simulator') || lower.includes('iphone') || lower.includes('ipad')) { + os = 'ios'; + } + + const cleanName = name + .replace(/\(simulator\)/gi, '') + .replace(/\(emulator\)/gi, '') + .replace(/\s+/g, ' ') + .trim(); + + return { id, name: cleanName, type, os, api }; +} + +function replaceSDKLevel(sdk: string): string { + switch (sdk) { + case 'API 34': + return 'Android 14'; + case 'API 33': + return 'Android 13'; + case 'API 32': + case 'API 31': + return 'Android 12'; + case 'API 30': + return 'Android 11'; + case 'API 29': + return 'Android 10'; + case 'API 28': + return 'Android 9'; + case 'API 27': + return 'Android 8.1'; + case 'API 26': + return 'Android 8.0'; + default: + return sdk; + } +} diff --git a/cli/src/devices/models.ts b/cli/src/devices/models.ts new file mode 100644 index 0000000..b13e0db --- /dev/null +++ b/cli/src/devices/models.ts @@ -0,0 +1,78 @@ +export interface WebView { + device: Device; + socket: string; + type: WebViewType; + packageName?: string; + versionName?: string; +} + +export enum WebViewType { + chrome = 'chrome', + webview = 'webview', + crosswalk = 'crosswalk', + unknown = 'unknown', +} + +export type DeviceState = + | 'device' + | 'connecting' + | 'offline' + | 'unknown' + | 'bootloader' + | 'recovery' + | 'download' + | 'unauthorized' + | 'host' + | 'no permissions'; + +export interface Device { + serial: string; + state: DeviceState; + usb?: string; + product?: string; + model?: string; + device?: string; + features?: string; + transportId?: string; +} + +export interface ForwardedSocket { + local: string; + remote: string; +} + +export interface AdbOptions { + executable: string; + arguments: string[]; +} + +export interface ShellOptions extends AdbOptions { + serial: string; + command: string; +} + +export interface ForwardOptions extends AdbOptions { + serial: string; + local: string; + remote: string; +} + +export interface UnforwardOptions extends AdbOptions { + local: string; +} + +export interface Process { + pid: number; + name: string; +} + +export interface Package { + packageName: string; + versionName: string; +} + +interface WebViewPage { + url: string; + title: string; + webSocketDebuggerUrl: string; +} diff --git a/cli/src/devices/process-list.ts b/cli/src/devices/process-list.ts new file mode 100644 index 0000000..fe371c3 --- /dev/null +++ b/cli/src/devices/process-list.ts @@ -0,0 +1,96 @@ +import { ChildProcess } from 'child_process'; +import { getRunOutput } from '../core/text'; + +/** + * Given a process find all child process ids + * This is used particularly with Windows which does not end all child processes a process created when the parent is killed + * @param {string} folder + * @param {number} processId + * @returns Promise of an Array of process ids for the children of processId + */ +async function getChildProcessIds(folder: string, processId: number): Promise> { + try { + const lines = process.platform === 'win32' ? await getWindowsProcessList(folder) : await getMacProcessList(folder); + + const pids = []; + let idx: number; + const rel = {}; + for (const line of lines) { + const txt = line.trim(); + idx = txt.indexOf(' '); + const childId: number = parseInt(txt.substring(0, idx).trim(), 10); + const parentId: number = parseInt(txt.substring(idx).trim(), 10); + if (!isNaN(childId)) { + if (!rel[parentId]) { + rel[parentId] = [childId]; + } else { + rel[parentId].push(childId); + } + if (parentId == processId) { + pids.push(childId); + } + } + } + + for (const pid of pids) { + const children: Array = rel[pid]; + if (children) { + for (const child of children) { + pids.push(child); + } + } + } + + return pids; + } catch (err) { + console.error(err); + return []; + } +} + +/** + * Kill a process and all child processes + * @param {ChildProcess} proc + * @param {string} rootPath + * @returns Promise + */ +export async function kill(proc: ChildProcess, rootPath: string): Promise { + const childProcessIds = await getChildProcessIds(rootPath, proc.pid); + + proc.kill('SIGINT'); + + for (const childProcessId of childProcessIds) { + try { + process.kill(childProcessId); + } catch (err) { + // Some child processes will fail (silently) + } + } +} + +/** Kill a process id and its descendants. */ +export async function killPid(pid: number, rootPath = process.cwd()): Promise { + const childProcessIds = await getChildProcessIds(rootPath, pid); + try { + process.kill(pid, 'SIGINT'); + } catch { + /* already dead */ + } + for (const childProcessId of childProcessIds) { + try { + process.kill(childProcessId); + } catch { + /* ignore */ + } + } +} + +async function getWindowsProcessList(folder: string): Promise> { + return (await getRunOutput('gwmi Win32_Process | select ProcessId, ParentProcessId', folder, 'powershell.exe')).split( + '\r\n', + ); +} + +async function getMacProcessList(folder: string): Promise> { + return (await getRunOutput('ps xao pid,ppid', folder)).split('\n'); +} diff --git a/cli/src/migrate/angular.ts b/cli/src/migrate/angular.ts new file mode 100644 index 0000000..6ead03a --- /dev/null +++ b/cli/src/migrate/angular.ts @@ -0,0 +1,115 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { coerce } from 'semver'; +import { npx, npmInstall, commandContext } from '../build/node-commands'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { Project } from '../project/project'; + +export const maxAngularVersion = '22'; + +export interface AngularMigrationPlan { + version: number; + commands: string[]; +} + +export function nextAngularVersion(project: Project): number | undefined { + const current = project.analyzer.getPackageVersion('@angular/core'); + const max = coerce(maxAngularVersion); + if (!current || !max || current.major >= max.major) return undefined; + return current.major + 1; +} + +export function resolveAngularTarget(project: Project, target?: string, all?: boolean): number { + const current = project.analyzer.getPackageVersion('@angular/core'); + if (!current) { + throw new WnError('NOT_APPLICABLE', 'This project does not use Angular.', { exitCode: ExitCode.CommandFailed }); + } + if (all) { + const max = coerce(maxAngularVersion); + if (!max) + throw new WnError('NOT_APPLICABLE', 'Unable to determine target Angular version.', { + exitCode: ExitCode.CommandFailed, + }); + return max.major; + } + if (target) { + const v = Number(target); + if (!Number.isFinite(v) || v <= current.major) { + throw new WnError('MISSING_INPUT', `Angular ${target} is not a valid upgrade target from v${current.major}.`, { + exitCode: ExitCode.MissingInput, + hint: 'Re-run with --to ', + }); + } + return v; + } + const next = nextAngularVersion(project); + if (!next) { + throw new WnError('NOT_APPLICABLE', 'No Angular migration is available for this project.', { + exitCode: ExitCode.CommandFailed, + }); + } + return next; +} + +export function planAngularMigration(project: Project, version: number): AngularMigrationPlan { + const ctx = commandContext(project); + const commands = [`${npx(project)} ng update @angular/cli@${version} @angular/core@${version} --allow-dirty --force`]; + if (project.analyzer.exists('@angular/cdk')) { + commands.push(npmInstall(`@angular/cdk@${version}`, ctx, '--force')); + } + if (project.analyzer.exists('@angular/pwa')) { + commands.push(npmInstall(`@angular/pwa@${version}`, ctx, '--force')); + } + const eslintPackages = project.analyzer + .getAllPackageNames() + .filter((d) => d.startsWith('@angular-eslint/')) + .map((d) => `${d}@${version}`); + if (eslintPackages.length > 0) { + commands.push(npmInstall(eslintPackages.join(' '), ctx, '--force')); + } + return { version, commands }; +} + +export async function ensureValidDependencies( + project: Project, + run: (command: string, folder: string) => Promise, + reinstall: () => Promise, +): Promise { + const folder = project.projectFolder(); + if (!existsSync(join(folder, 'node_modules'))) return; + try { + await run('npm ls --depth=0', folder); + } catch { + await reinstall(); + try { + await run('npm ls --depth=0', folder); + } catch { + throw new WnError( + 'PRECONDITION_FAILED', + 'Dependencies are invalid after reinstall. Fix package.json and try again.', + { + exitCode: ExitCode.CheckFailed, + }, + ); + } + } +} + +export function applyAngularPostFixes(project: Project, version: number): string[] { + const changed: string[] = []; + if (version === 17) { + const polyfills = join(project.projectFolder(), 'src', 'polyfills.ts'); + if (replaceInFile(polyfills, `import 'zone.js/dist/zone';`, `import 'zone.js';`)) changed.push(polyfills); + } + return changed; +} + +function replaceInFile(filename: string, search: string, replace: string): boolean { + if (!existsSync(filename)) return false; + const before = readFileSync(filename, 'utf8'); + const after = before.replace(search, replace); + if (before === after) return false; + writeFileSync(filename, after); + return true; +} diff --git a/cli/src/migrate/capacitor.ts b/cli/src/migrate/capacitor.ts new file mode 100644 index 0000000..e2710e3 --- /dev/null +++ b/cli/src/migrate/capacitor.ts @@ -0,0 +1,199 @@ +import { existsSync, readFileSync, rmSync } from 'fs'; +import { join } from 'path'; +import { capacitorSync } from '../build/capacitor-sync'; +import { commandContext, installForceArgument, npmInstall, npmUpdate, saveDevArgument } from '../build/node-commands'; +import { npx } from '../build/node-commands'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { checkPeerDependencies, RunCallback } from '../packages/peer-dependencies'; +import { packageManagerName } from '../packages/mutate'; +import { Project } from '../project/project'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { migration4To5 } from './versions-4-to-5'; +import { migration5To6 } from './versions-5-to-6'; +import { migration6To7 } from './versions-6-to-7'; +import { migration7To8 } from './versions-7-to-8'; + +export interface CapacitorMigrationOptions { + from: string; + to: string; + coreVersion: string; + versionTitle: string; + versionFull: string; + changesLink: string; + migrateInfo: string; + androidStudioMin: string; + androidStudioName: string; + androidStudioReason: string; + minJavaVersion: number; + minPlugins: Array<{ dep: string; version: string }>; + ignorePeerDependencies: string[]; +} + +export interface MigrationPlan { + description: string; + commands: string[]; + incompatible: string[]; + warnings: string[]; +} + +const ALL_MIGRATIONS = [migration4To5, migration5To6, migration6To7, migration7To8]; + +export function listCapacitorMigrations(project: Project): CapacitorMigrationOptions[] { + if (!project.isCapacitor) return []; + return ALL_MIGRATIONS.filter( + (m) => + project.analyzer.isGreaterOrEqual('@capacitor/core', m.from) && project.analyzer.isLess('@capacitor/core', m.to), + ); +} + +export function resolveCapacitorMigration(project: Project, targetMajor?: string): CapacitorMigrationOptions { + const available = listCapacitorMigrations(project); + if (available.length === 0) { + throw new WnError('NOT_APPLICABLE', 'No Capacitor major migration applies to this project.', { + exitCode: ExitCode.CommandFailed, + }); + } + if (targetMajor) { + const match = available.find((m) => m.versionTitle === targetMajor || m.to.startsWith(`${targetMajor}.`)); + if (!match) { + throw new WnError( + 'MISSING_INPUT', + `Capacitor ${targetMajor} migration is not available from the current version.`, + { + exitCode: ExitCode.MissingInput, + choices: available.map((m) => ({ id: m.versionTitle, name: `Capacitor ${m.versionTitle}` })), + hint: 'Re-run with --to ', + }, + ); + } + return match; + } + return available[0]; +} + +export async function planCapacitorMigration( + project: Project, + options: CapacitorMigrationOptions, + run: RunCallback, +): Promise { + const ctx = commandContext(project); + const report = await checkPeerDependencies( + project.projectFolder(), + project, + [{ name: '@capacitor/core', version: options.versionFull }], + options.ignorePeerDependencies, + run, + ); + + const commands: string[] = [...report.commands]; + for (const minVersion of options.minPlugins) { + if (project.analyzer.exists(minVersion.dep) && project.analyzer.isLess(minVersion.dep, minVersion.version)) { + commands.push( + npmInstall(`${minVersion.dep}@${minVersion.version}`, ctx, installForceArgument(project.packageManager)), + ); + } + } + + const incompatible: string[] = [...report.incompatible]; + if (project.analyzer.exists('phonegap-plugin-barcodescanner')) { + incompatible.push('phonegap-plugin-barcodescanner'); + } + + commands.push( + npmInstall( + `@capacitor/cli@${options.coreVersion}`, + ctx, + saveDevArgument(project.packageManager), + installForceArgument(project.packageManager), + ), + ); + commands.push(`${npx(project)} cap migrate --noprompt --packagemanager=${packageManagerName(project)}`); + + return { + description: options.migrateInfo, + commands, + incompatible, + warnings: incompatible.length + ? [`${incompatible.length} plugin(s) may be incompatible with Capacitor ${options.versionTitle}`] + : [], + }; +} + +export async function runCapacitorMigration( + project: Project, + plan: MigrationPlan, + runCommand: (command: string) => Promise, +): Promise<{ output: string; incompatible: string[] }> { + let output = ''; + for (const command of plan.commands) { + output += (await runCommand(command)) + '\n'; + } + if (output.includes('[error] npm install failed. Try deleting node_modules')) { + output += + (await runCommand(process.platform === 'win32' ? 'rmdir /s /q node_modules' : 'rm -rf node_modules')) + '\n'; + output += (await runCommand(npmUpdate(commandContext(project)))) + '\n'; + } else if (output.includes('Updating iOS native dependencies with pod install - failed!')) { + const podfileLock = join(project.projectFolder(), 'ios', 'App', 'Podfile.lock'); + if (existsSync(podfileLock)) rmSync(podfileLock); + const sync = capacitorSync(project); + const syncCmd = typeof sync === 'string' ? sync : sync.command; + output += (await runCommand(syncCmd)) + '\n'; + } + return { output, incompatible: plan.incompatible }; +} + +export function listAllMigrations(project: Project): Array<{ id: string; title: string; applicable: boolean }> { + const items: Array<{ id: string; title: string; applicable: boolean }> = []; + for (const m of ALL_MIGRATIONS) { + items.push({ + id: `capacitor-${m.versionTitle}`, + title: `Migrate to Capacitor ${m.versionTitle}`, + applicable: + project.isCapacitor && + project.analyzer.isGreaterOrEqual('@capacitor/core', m.from) && + project.analyzer.isLess('@capacitor/core', m.to), + }); + } + items.push({ + id: 'angular', + title: 'Migrate Angular to next major', + applicable: project.analyzer.isGreaterOrEqual('@angular/core', '12.0.0'), + }); + items.push({ + id: 'cordova', + title: 'Convert Cordova project to Capacitor', + applicable: project.isCordova && !project.isCapacitor, + }); + items.push({ + id: 'spm', + title: 'Migrate iOS from CocoaPods to Swift Package Manager', + applicable: project.hasCapacitorProject(CapacitorPlatform.ios), + }); + items.push({ id: 'package-manager', title: 'Convert package manager', applicable: true }); + return items; +} + +function checkAndroidStudio(minVersion: string, project: Project): boolean { + try { + const studioFile = '/Applications/Android Studio.app/Contents/Resources/product-info.json'; + if (!existsSync(studioFile)) return true; + const info = JSON.parse(readFileSync(studioFile, 'utf-8')); + const v = info.buildNumber.split('.'); + const version = `${v[0]}.${v[1]}.${v[2]}`; + return project.analyzer.isVersionGreaterOrEqual(version, minVersion); + } catch { + return true; + } +} + +export async function checkMigrationPreconditions( + project: Project, + options: CapacitorMigrationOptions, +): Promise { + const warnings: string[] = []; + if (project.analyzer.exists('@capacitor/android') && !checkAndroidStudio(options.androidStudioMin, project)) { + warnings.push(`${options.androidStudioName} is recommended for Capacitor ${options.versionTitle}`); + } + return warnings; +} diff --git a/cli/src/migrate/cordova.ts b/cli/src/migrate/cordova.ts new file mode 100644 index 0000000..481a4a0 --- /dev/null +++ b/cli/src/migrate/cordova.ts @@ -0,0 +1,77 @@ +import { npx, npmInstall, npmUninstall, commandContext } from '../build/node-commands'; +import { capacitorAdd } from '../build/capacitor-add'; +import { commandString } from '../build/command-result'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { Project } from '../project/project'; +import { InternalCommand } from '../build/command-name'; + +export interface CordovaMigrationPlan { + integrate: string[]; + remove?: string[]; + notes: string[]; +} + +export function planCordovaMigration(project: Project): CordovaMigrationPlan { + if (project.isCapacitor) { + throw new WnError('NOT_APPLICABLE', 'This project already uses Capacitor.', { exitCode: ExitCode.CommandFailed }); + } + if (!project.isCordova) { + throw new WnError('NOT_APPLICABLE', 'This project is not a Cordova project.', { exitCode: ExitCode.CommandFailed }); + } + + const appId = asAppId(project.name); + const ctx = commandContext(project); + const integrate = [ + npmInstall('@capacitor/core@latest', ctx, '--save', '-E'), + npmInstall('@capacitor/cli@latest', ctx, '-D', '-E'), + npmInstall('@capacitor/app @capacitor/core @capacitor/haptics @capacitor/keyboard @capacitor/status-bar', ctx), + `${npx(project)} capacitor init "${project.name}" "${appId}" --web-dir www`, + ]; + return { + integrate, + notes: [ + 'Review Cordova plugin compatibility before removing Cordova.', + 'Add native platforms with: wn native add ios / wn native add android', + 'See https://capacitorjs.com/docs/cordova/migrating-from-cordova-to-capacitor', + ], + }; +} + +export function planCordovaRemoval(project: Project): CordovaMigrationPlan { + const ctx = commandContext(project); + const movecmd = process.platform === 'win32' ? 'rename config.xml config.xml.bak' : 'mv config.xml config.xml.bak'; + return { + integrate: [], + remove: [ + npmUninstall('cordova-ios', ctx), + npmUninstall('cordova-android', ctx), + movecmd, + InternalCommand.removeCordova, + ], + notes: ['Remove Cordova only after verifying the Capacitor app runs on all target platforms.'], + }; +} + +export function planAddNativePlatforms(project: Project): string[] { + const ctx = commandContext(project); + const commands: string[] = []; + if (!project.hasCapacitorProject(CapacitorPlatform.android)) { + commands.push(npmInstall('@capacitor/android', ctx)); + commands.push(commandString(capacitorAdd(project, CapacitorPlatform.android))); + } + if (!project.hasCapacitorProject(CapacitorPlatform.ios)) { + commands.push(npmInstall('@capacitor/ios', ctx)); + commands.push(commandString(capacitorAdd(project, CapacitorPlatform.ios))); + } + return commands; +} + +function asAppId(name: string): string { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '.') + .replace(/^\.+|\.+$/g, ''); + return `com.example.${slug || 'app'}`; +} diff --git a/cli/src/migrate/package-manager.ts b/cli/src/migrate/package-manager.ts new file mode 100644 index 0000000..df5b0c3 --- /dev/null +++ b/cli/src/migrate/package-manager.ts @@ -0,0 +1,65 @@ +import { InternalCommand } from '../build/command-name'; +import { PackageManager } from '../build/node-commands'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { Project } from '../project/project'; + +export type TargetPackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +export interface PackageManagerMigrationPlan { + target: TargetPackageManager; + commands: string[]; +} + +export function planPackageManagerMigration( + project: Project, + target: TargetPackageManager, +): PackageManagerMigrationPlan { + const current = project.packageManager; + if (current === PackageManager[target]) { + throw new WnError('NOT_APPLICABLE', `Project already uses ${target}.`, { exitCode: ExitCode.CommandFailed }); + } + + switch (target) { + case 'pnpm': + if (current !== PackageManager.npm && current !== PackageManager.bun) { + throw new WnError('NOT_APPLICABLE', 'pnpm migration is supported from npm projects.', { + exitCode: ExitCode.CommandFailed, + }); + } + return { + target, + commands: cwd(['pnpm -v', removeNodeModules(), 'pnpm import', 'pnpm install', 'rm package-lock.json']), + }; + case 'bun': + if (current !== PackageManager.npm) { + throw new WnError('NOT_APPLICABLE', 'bun migration is supported from npm projects.', { + exitCode: ExitCode.CommandFailed, + }); + } + return { + target, + commands: cwd(['bun -v', removeNodeModules(), 'bun install', 'rm package-lock.json']), + }; + case 'npm': + return { + target, + commands: cwd([removeNodeModules(), 'npm install', 'rm pnpm-lock.yaml', 'rm bun.lockb', 'rm yarn.lock']), + }; + case 'yarn': + return { + target, + commands: cwd([removeNodeModules(), 'yarn install', 'rm package-lock.json']), + }; + default: + throw new WnError('USAGE_ERROR', `Unsupported package manager: ${target}`, { exitCode: ExitCode.UsageError }); + } +} + +function removeNodeModules(): string { + return process.platform === 'win32' ? 'del node_modules /S /Q' : 'rm -rf node_modules'; +} + +function cwd(commands: string[]): string[] { + return commands.map((command) => `${InternalCommand.cwd}${command}`); +} diff --git a/cli/src/migrate/spm.ts b/cli/src/migrate/spm.ts new file mode 100644 index 0000000..9a055f8 --- /dev/null +++ b/cli/src/migrate/spm.ts @@ -0,0 +1,30 @@ +import { existsSync } from 'fs'; +import { join } from 'path'; +import { npx } from '../build/node-commands'; +import { WnError } from '../cli/errors'; +import { ExitCode } from '../cli/exit-codes'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { Project } from '../project/project'; + +export interface SpmMigrationPlan { + command: string; + docs: string; +} + +export function planSpmMigration(project: Project): SpmMigrationPlan { + if (!project.hasCapacitorProject(CapacitorPlatform.ios)) { + throw new WnError('NOT_APPLICABLE', 'This project does not have an iOS Capacitor platform.', { + exitCode: ExitCode.CommandFailed, + }); + } + const podfile = join(project.projectFolder(), 'ios', 'App', 'Podfile'); + if (!existsSync(podfile)) { + throw new WnError('NOT_APPLICABLE', 'No Podfile found — this iOS project may already use SPM.', { + exitCode: ExitCode.CommandFailed, + }); + } + return { + command: `${npx(project)} cap spm-migration-assistant`, + docs: 'https://capacitorjs.com/docs/ios/spm', + }; +} diff --git a/cli/src/migrate/versions-4-to-5.ts b/cli/src/migrate/versions-4-to-5.ts new file mode 100644 index 0000000..34e8ec3 --- /dev/null +++ b/cli/src/migrate/versions-4-to-5.ts @@ -0,0 +1,22 @@ +import { CapacitorMigrationOptions } from './capacitor'; + +export const migration4To5: CapacitorMigrationOptions = { + from: '4.0.0', + to: '5.0.0', + coreVersion: '5', + versionTitle: '5', + versionFull: '5.0.0', + changesLink: 'https://capacitorjs.com/docs/updating/5-0', + androidStudioMin: '222.4459.24', + androidStudioName: 'Android Studio Flamingo (2022.2.1)', + androidStudioReason: '(It comes with Java 17 and Gradle 8)', + minJavaVersion: 17, + migrateInfo: 'Capacitor 5 sets a deployment target of iOS 13 and Android 13 (SDK 33).', + ignorePeerDependencies: ['@capacitor/'], + minPlugins: [ + { dep: '@ionic-enterprise/identity-vault', version: '5.10.1' }, + { dep: '@ionic-enterprise/google-pay', version: '2.0.0' }, + { dep: '@ionic-enterprise/apple-pay', version: '2.0.0' }, + { dep: '@ionic-enterprise/zebra-scanner', version: '2.0.0' }, + ], +}; diff --git a/cli/src/migrate/versions-5-to-6.ts b/cli/src/migrate/versions-5-to-6.ts new file mode 100644 index 0000000..6edbdb4 --- /dev/null +++ b/cli/src/migrate/versions-5-to-6.ts @@ -0,0 +1,47 @@ +import { CapacitorMigrationOptions } from './capacitor'; + +export const migration5To6: CapacitorMigrationOptions = { + from: '5.0.0', + to: '6.0.0', + coreVersion: '6.0.0', + versionTitle: '6', + versionFull: '6.0.0', + changesLink: 'https://capacitorjs.com/docs/updating/6-0', + androidStudioMin: '231.9392.1', + androidStudioName: 'Android Studio Hedgehog (2023.1.1)', + androidStudioReason: '(It comes with Gradle 8.2)', + minJavaVersion: 17, + migrateInfo: 'Capacitor 6 sets a deployment target of iOS 13 and Android 14 (SDK 34).', + minPlugins: [ + { dep: '@ionic-enterprise/identity-vault', version: '5.10.1' }, + { dep: '@ionic-enterprise/google-pay', version: '2.0.0' }, + { dep: '@ionic-enterprise/apple-pay', version: '2.0.0' }, + { dep: '@ionic-enterprise/zebra-scanner', version: '2.0.0' }, + ], + ignorePeerDependencies: [ + '@capacitor/action-sheet', + '@capacitor/app', + '@capacitor/app-launcher', + '@capacitor/browser', + '@capacitor/camera', + '@capacitor/clipboard', + '@capacitor/device', + '@capacitor/dialog', + '@capacitor/filesystem', + '@capacitor/geolocation', + '@capacitor/haptics', + '@capacitor/keyboard', + '@capacitor/local-notifications', + '@capacitor/motion', + '@capacitor/network', + '@capacitor/preferences', + '@capacitor/push-notifications', + '@capacitor/screen-reader', + '@capacitor/screen-orientation', + '@capacitor/share', + '@capacitor/splash-screen', + '@capacitor/status-bar', + '@capacitor/text-zoom', + '@capacitor/toast', + ], +}; diff --git a/cli/src/migrate/versions-6-to-7.ts b/cli/src/migrate/versions-6-to-7.ts new file mode 100644 index 0000000..86e0fd7 --- /dev/null +++ b/cli/src/migrate/versions-6-to-7.ts @@ -0,0 +1,47 @@ +import { CapacitorMigrationOptions } from './capacitor'; + +export const migration6To7: CapacitorMigrationOptions = { + from: '6.0.0', + to: '7.0.0', + coreVersion: '7.0.1', + versionTitle: '7', + versionFull: '7.0.0', + changesLink: 'https://capacitorjs.com/docs/updating/7-0', + androidStudioMin: '231.9392.1', + androidStudioName: 'Android Studio Ladybug (2024.2.1)', + androidStudioReason: '(It comes with Gradle 8.7.2)', + minJavaVersion: 21, + migrateInfo: 'Capacitor 7 sets a deployment target of iOS 14 and Android 15 (SDK 35).', + minPlugins: [ + { dep: '@ionic-enterprise/identity-vault', version: '5.10.1' }, + { dep: '@ionic-enterprise/google-pay', version: '2.0.0' }, + { dep: '@ionic-enterprise/apple-pay', version: '2.0.0' }, + { dep: '@ionic-enterprise/zebra-scanner', version: '2.0.0' }, + ], + ignorePeerDependencies: [ + '@capacitor/action-sheet', + '@capacitor/app', + '@capacitor/app-launcher', + '@capacitor/browser', + '@capacitor/camera', + '@capacitor/clipboard', + '@capacitor/device', + '@capacitor/dialog', + '@capacitor/filesystem', + '@capacitor/geolocation', + '@capacitor/haptics', + '@capacitor/keyboard', + '@capacitor/local-notifications', + '@capacitor/motion', + '@capacitor/network', + '@capacitor/preferences', + '@capacitor/push-notifications', + '@capacitor/screen-reader', + '@capacitor/screen-orientation', + '@capacitor/share', + '@capacitor/splash-screen', + '@capacitor/status-bar', + '@capacitor/text-zoom', + '@capacitor/toast', + ], +}; diff --git a/cli/src/migrate/versions-7-to-8.ts b/cli/src/migrate/versions-7-to-8.ts new file mode 100644 index 0000000..ce983bd --- /dev/null +++ b/cli/src/migrate/versions-7-to-8.ts @@ -0,0 +1,59 @@ +import { CapacitorMigrationOptions } from './capacitor'; + +export const migration7To8: CapacitorMigrationOptions = { + from: '7.0.0', + to: '8.0.0', + coreVersion: '8.0.0', + versionTitle: '8', + versionFull: '8.0.0', + changesLink: 'https://capacitorjs.com/docs/updating/8-0', + androidStudioMin: '242.23339.11', + androidStudioName: 'Android Studio Otter (2025.2.1)', + androidStudioReason: '(It comes with Gradle 8.14.3)', + minJavaVersion: 21, + migrateInfo: + 'Capacitor 8 requires NodeJS 22+, xCode 26+, sets a deployment target of iOS 15 and Android 16 (SDK 36), and uses SPM by default for new iOS projects.', + minPlugins: [ + { dep: '@ionic-enterprise/identity-vault', version: '5.10.1' }, + { dep: '@ionic-enterprise/google-pay', version: '2.0.0' }, + { dep: '@ionic-enterprise/apple-pay', version: '2.0.0' }, + { dep: '@ionic-enterprise/zebra-scanner', version: '2.0.0' }, + ], + ignorePeerDependencies: [ + '@capacitor/action-sheet', + '@capacitor/app', + '@capacitor/app-launcher', + '@capacitor/background-runner', + '@capacitor/barcode-scanner', + '@capacitor/browser', + '@capacitor/camera', + '@capacitor/clipboard', + '@capacitor/cookies', + '@capacitor/device', + '@capacitor/dialog', + '@capacitor/file-transfer', + '@capacitor/file-viewer', + '@capacitor/filesystem', + '@capacitor/geolocation', + '@capacitor/google-maps', + '@capacitor/haptics', + '@capacitor/http', + '@capacitor/inappbrowser', + '@capacitor/keyboard', + '@capacitor/local-notifications', + '@capacitor/motion', + '@capacitor/network', + '@capacitor/preferences', + '@capacitor/privacy-screen', + '@capacitor/push-notifications', + '@capacitor/screen-orientation', + '@capacitor/screen-reader', + '@capacitor/share', + '@capacitor/splash-screen', + '@capacitor/status-bar', + '@capacitor/system-bars', + '@capacitor/text-zoom', + '@capacitor/toast', + '@capacitor/watch', + ], +}; diff --git a/cli/src/native/android.ts b/cli/src/native/android.ts new file mode 100644 index 0000000..593348f --- /dev/null +++ b/cli/src/native/android.ts @@ -0,0 +1,207 @@ +import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmdirSync, writeFileSync } from 'fs'; +import { XMLParser } from 'fast-xml-parser'; +import { join } from 'path'; +import { getStringFrom, setStringIn } from '../core/strings'; + +export class AndroidProject { + private _projectPath: string; + constructor(projectPath: string) { + this._projectPath = projectPath; + } + + exists(): boolean { + return existsSync(this._projectPath); + } + + async parse(): Promise {} + + private stringsXmlPath(): string { + return join(this._projectPath, 'app', 'src', 'main', 'res', 'values', 'strings.xml'); + } + + getDisplayName(): string { + return this.getValueFromStringsXml('app_name'); + } + + setDisplayName(newName: string): void { + let data = readFileSync(this.stringsXmlPath(), 'utf-8'); + if (!data) { + throw new Error(`Unable to set Android display name`); + } + data = setStringIn(data as string, ``, ``, newName); + data = setStringIn(data as string, ``, ``, newName); + writeFileSync(this.stringsXmlPath(), data); + } + + getValueFromStringsXml(key: string): string | null { + if (!existsSync(this.stringsXmlPath())) { + console.error('Error: strings.xml not found.'); + return null; + } + + try { + const xmlData = readFileSync(this.stringsXmlPath(), 'utf-8'); + const parser = new XMLParser({ ignoreAttributes: false }); + const parsedXml = parser.parse(xmlData); + + if (parsedXml.resources && parsedXml.resources.string) { + const appNameEntry = parsedXml.resources.string.find((s: any) => s['@_name'] === key); + return appNameEntry ? appNameEntry['#text'] : null; + } + + return null; + } catch (error) { + console.error(`Error reading ${key}:`, error); + return null; + } + } + + private manifestPath(): string { + return join(this._projectPath, 'app', 'src', 'main', 'AndroidManifest.xml'); + } + + getPackageName(): string { + return this.getValueFromStringsXml('package_name'); + } + + getVersionName(): string { + const gradlePath = join(this._projectPath, 'app', 'build.gradle'); + if (!existsSync(gradlePath)) { + console.error('Error: build.gradle not found.'); + return null; + } + + try { + const gradleData = readFileSync(gradlePath, 'utf-8'); + const match = gradleData.match(/versionName\s+"(.+?)"/); + + return match ? match[1] : null; + } catch (error) { + console.error('Error reading versionName:', error); + return null; + } + } + + getVersionCode(): number { + const gradlePath = join(this._projectPath, 'app', 'build.gradle'); + if (!existsSync(gradlePath)) { + console.error('Error: build.gradle not found.'); + return null; + } + + try { + const gradleData = readFileSync(gradlePath, 'utf-8'); + const match = getStringFrom(gradleData, 'versionCode ', '\r\n'); + return match ? parseInt(match) : null; + } catch (error) { + console.error('Error reading versionName:', error); + return null; + } + } + + updateStringsXML(newBundleId: string) { + let data = readFileSync(this.stringsXmlPath(), 'utf-8'); + if (!data) { + throw new Error('Error reading strings.xml'); + } + data = setStringIn(data as string, ``, ``, newBundleId); + data = setStringIn(data as string, ``, ``, newBundleId); + writeFileSync(this.stringsXmlPath(), data); + } + + async setPackageName(packageName: string): Promise { + const dir = join(this._projectPath, 'app', 'src', 'main', 'java'); + const stringsXML = this.stringsXmlPath(); + const currentPackageName = this.getPackageName(); + const gradlePath = join(this._projectPath, 'app', 'build.gradle'); + const currentFolders = currentPackageName.split('.'); + const currentPath = join(dir, ...currentFolders); + const mainActivity = join(currentPath, 'MainActivity.java'); + + if (packageName === currentPackageName) { + return; + } + if (!existsSync(currentPath)) { + throw new Error(`Path ${currentPath} does not exist.`); + } + if (!existsSync(mainActivity)) { + console.error('Error: MainActivity.java not found.'); + return; + } + if (!existsSync(stringsXML)) { + console.error('Error: strings.xml not found.'); + return; + } + if (!existsSync(gradlePath)) { + console.error('Error: build.gradle not found.'); + return; + } + + const data = readFileSync(mainActivity, 'utf-8'); + const newData = data.replace(new RegExp(currentPackageName, 'g'), packageName); + writeFileSync(mainActivity, newData); + + const data2 = readFileSync(stringsXML, 'utf-8'); + const newData2 = data2.replace(new RegExp(currentPackageName, 'g'), packageName); + writeFileSync(stringsXML, newData2); + + const data3 = readFileSync(gradlePath, 'utf-8'); + const newData3 = data3.replace(new RegExp(currentPackageName, 'g'), packageName); + writeFileSync(gradlePath, newData3); + + const folders = packageName.split('.'); + let newPath = dir; + for (const folder of folders) { + newPath = join(newPath, folder); + if (!existsSync(newPath)) { + mkdirSync(newPath); + } + } + + const files = readdirSync(currentPath); + for (const file of files) { + const source = join(currentPath, file); + const destination = join(newPath, file); + renameSync(source, destination); + } + + let count = currentFolders.length; + let pth = currentPath; + while (count > 0) { + try { + rmdirSync(pth); + pth = pth.substring(0, pth.lastIndexOf('/')); + count--; + } catch { + break; + } + } + } + + async setVersionName(versionName: string): Promise { + const currentVersionName = this.getVersionName(); + this.gradleReplace('versionName', `"${versionName}"`, `"${currentVersionName}"`); + } + + private gradleReplace(key: string, value: string, oldvalue: string): void { + const gradlePath = join(this._projectPath, 'app', 'build.gradle'); + if (!existsSync(gradlePath)) { + console.error('Error: build.gradle not found.'); + return null; + } + + try { + const gradleData = readFileSync(gradlePath, 'utf-8'); + + const newData = gradleData.replace(`${key} ${oldvalue}`, `${key} ${value}`); + writeFileSync(gradlePath, newData); + } catch (error) { + throw new Error(`Error setting ${key} to ${value}: ${error.message}`); + } + } + + async setVersionCode(versionCode: number): Promise { + const currentVersionCode = this.getVersionCode(); + this.gradleReplace('versionCode', versionCode.toString(), currentVersionCode.toString()); + } +} diff --git a/cli/src/native/configure.ts b/cli/src/native/configure.ts new file mode 100644 index 0000000..ea3cf83 --- /dev/null +++ b/cli/src/native/configure.ts @@ -0,0 +1,259 @@ +import { join } from 'path'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { AndroidProject } from './android'; +import { IosProject } from './ios'; +import { setStringIn } from '../core/strings'; + +export interface CapacitorProjectState { + iosBundleId?: string; + androidBundleId?: string; + iosVersion?: string; + androidVersion?: string; + iosBuild?: number; + androidBuild?: number; + iosDisplayName?: string; + androidDisplayName?: string; +} + +export enum NativePlatform { + iOSOnly, + AndroidOnly, +} + +export const bundleIdRegex = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+[0-9a-z_]$/i; +export const versionRegex = /^\S+$/; +export const buildRegex = /^\d+$/; + +export function validateBundleId(value: string): string | null { + if (!bundleIdRegex.test(value)) { + return 'You cannot use spaces and some special characters like -. Must contain at least one full stop'; + } + return null; +} + +export function validateVersion(value: string): string | null { + if (!versionRegex.test(value)) { + return 'This version number is not valid'; + } + return null; +} + +export function validateBuild(value: string): string | null { + if (!buildRegex.test(value)) { + return 'You can only use the digits 0 to 9'; + } + return null; +} + +export async function getCapacitorProjectState(projectFolder: string): Promise { + const state: CapacitorProjectState = {}; + + const [androidProject, iosProject] = await Promise.all([ + getAndroidProject(projectFolder), + getIosProject(projectFolder), + ]); + let hasNativeProject = false; + + if (iosProject && iosProject.exists()) { + const appTarget = iosProject.getAppTarget(); + if (appTarget) { + state.iosBundleId = iosProject.getBundleId(appTarget.name); + state.iosDisplayName = await iosProject.getDisplayName(); + for (const buildConfig of iosProject.getBuildConfigurations(appTarget.name)) { + try { + state.iosVersion = iosProject.getVersion(appTarget.name, buildConfig.name); + state.iosBuild = await iosProject.getBuild(appTarget.name, buildConfig.name); + } catch (error) { + console.error(`Unable to getBuild of ios project ${appTarget.name} ${buildConfig.name}`); + } + } + } else { + console.error(`Unable to getAppTarget of ios project`); + } + hasNativeProject = true; + } + + if (androidProject && androidProject.exists()) { + try { + const [androidBundleId, androidVersion, androidBuild, androidDisplayName] = await Promise.all([ + androidProject.getPackageName(), + androidProject.getVersionName(), + androidProject.getVersionCode(), + androidProject.getDisplayName(), + ]); + state.androidBundleId = androidBundleId; + state.androidVersion = androidVersion; + state.androidBuild = androidBuild; + state.androidDisplayName = androidDisplayName; + } catch (error) { + console.error('getCapacitorProjectState', error); + return undefined; + } + hasNativeProject = true; + } + + if (!hasNativeProject) { + return undefined; + } + + return state; +} + +export async function setBundleId( + projectFolder: string, + newBundleId: string, + platform?: NativePlatform, +): Promise { + const err = validateBundleId(newBundleId); + if (err) { + throw new Error(err); + } + + const iosProject = await getIosProject(projectFolder); + + if (iosProject?.exists() && platform != NativePlatform.AndroidOnly) { + const appTarget = iosProject.getAppTarget(); + if (appTarget) { + for (const buildConfig of iosProject.getBuildConfigurations(appTarget.name)) { + await iosProject.setBundleId(appTarget.name, buildConfig.name, newBundleId); + } + } else { + throw new Error(`Unable to update iosProject bundleId`); + } + } + + const androidProject = await getAndroidProject(projectFolder); + if (androidProject?.exists() && platform != NativePlatform.iOSOnly) { + try { + await androidProject.setPackageName(newBundleId); + } catch (error) { + throw new Error(`Unable to setPackageName for android: ${error}`); + } + androidProject.updateStringsXML(newBundleId); + } + + updateCapacitorConfig(projectFolder, newBundleId); +} + +export async function setVersion(projectFolder: string, newVersion: string, platform?: NativePlatform): Promise { + const err = validateVersion(newVersion); + if (err) { + throw new Error(err); + } + + const iosProject = await getIosProject(projectFolder); + const androidProject = await getAndroidProject(projectFolder); + + if (iosProject && iosProject.exists() && platform != NativePlatform.AndroidOnly) { + const appTarget = iosProject.getAppTarget(); + for (const buildConfig of iosProject.getBuildConfigurations(appTarget.name)) { + await iosProject.setVersion(appTarget.name, buildConfig.name, newVersion); + } + } + if (androidProject && androidProject.exists() && platform != NativePlatform.iOSOnly) { + await androidProject.setVersionName(newVersion); + } +} + +export async function setBuild( + projectFolder: string, + newBuild: string | number, + platform?: NativePlatform, +): Promise { + const buildStr = String(newBuild); + const err = validateBuild(buildStr); + if (err) { + throw new Error(err); + } + + const buildNum = parseInt(buildStr, 10); + const iosProject = await getIosProject(projectFolder); + const androidProject = await getAndroidProject(projectFolder); + + if (iosProject?.exists() && platform != NativePlatform.AndroidOnly) { + const appTarget = iosProject.getAppTarget(); + for (const buildConfig of iosProject.getBuildConfigurations(appTarget.name)) { + await iosProject.setBuild(appTarget.name, buildConfig.name, buildNum); + } + } + if (androidProject?.exists() && platform != NativePlatform.iOSOnly) { + await androidProject.setVersionCode(buildNum); + } +} + +export async function setDisplayName( + projectFolder: string, + displayName: string, + platform?: NativePlatform, +): Promise { + const iosProject = await getIosProject(projectFolder); + const androidProject = await getAndroidProject(projectFolder); + + if (iosProject?.exists() && platform != NativePlatform.AndroidOnly) { + const appTarget = iosProject.getAppTarget(); + for (const buildConfig of iosProject.getBuildConfigurations(appTarget.name)) { + await iosProject.setDisplayName(appTarget.name, buildConfig.name, displayName); + } + } + if (androidProject?.exists() && platform != NativePlatform.iOSOnly) { + androidProject.setDisplayName(displayName); + } + updateCapacitorConfig(projectFolder, undefined, displayName); +} + +function getCapacitorConfigureFilename(folder: string): string { + let capConfigFile = join(folder, 'capacitor.config.ts'); + if (!existsSync(capConfigFile)) { + capConfigFile = join(folder, 'capacitor.config.js'); + if (!existsSync(capConfigFile)) { + capConfigFile = join(folder, 'capacitor.config.json'); + } + } + return capConfigFile; +} + +function setValueIn(data: string, key: string, value: string): string { + if (data.includes(`${key}: '`)) { + data = setStringIn(data, `${key}: '`, `'`, value); + } else if (data.includes(`${key}: "`)) { + data = setStringIn(data, `${key}: "`, `"`, value); + } else if (data.includes(`"${key}": "`)) { + data = setStringIn(data, `"${key}": "`, `"`, value); + } + return data; +} + +export function updateCapacitorConfig(projectFolder: string, bundleId?: string, displayName?: string): void { + const filename = getCapacitorConfigureFilename(projectFolder); + if (!filename || !existsSync(filename)) { + return; + } + let data = readFileSync(filename, 'utf-8'); + if (bundleId) { + data = setValueIn(data, 'appId', bundleId); + } + if (displayName) { + data = setValueIn(data, 'appName', displayName); + } + writeFileSync(filename, data); +} + +async function getAndroidProject(projectFolder: string): Promise { + const proj = new AndroidProject(join(projectFolder, 'android')); + await proj.parse(); + return proj; +} + +async function getIosProject(projectFolder: string): Promise { + const proj = new IosProject(join(projectFolder, 'ios', 'App')); + try { + const ok = await proj.parse(); + if (!ok) { + return undefined; + } + return proj; + } catch (error) { + console.error(`Unable to parse ios project: ${error}`); + return undefined; + } +} diff --git a/cli/src/native/gradle-to-json.ts b/cli/src/native/gradle-to-json.ts new file mode 100644 index 0000000..2ca0ee6 --- /dev/null +++ b/cli/src/native/gradle-to-json.ts @@ -0,0 +1,37 @@ +import { existsSync, readFileSync } from 'fs'; +import { replaceAll } from '../core/text'; + +export function gradleToJson(filename: string): any | undefined { + if (!existsSync(filename)) { + return undefined; + } + try { + const lines = readFileSync(filename, 'utf8').split('\n'); + const result = {}; + let at = result; + const stack = [at]; + for (const line of lines) { + if (line.trim().endsWith('{')) { + const key = replaceAll(line, '{', '').trim(); + at[key] = {}; + stack.push(at); + at = at[key]; + } else if (line.trim().endsWith('}')) { + at = stack.pop(); + } else if (line.trim() !== '') { + const kv = line.trim().split(' '); + if (kv.length == 2) { + at[kv[0]] = kv[1]; + } else { + at[kv[0]] = []; + for (let i = 1; i < kv.length; i++) { + at[kv[0]].push(kv[i]); + } + } + } + } + return result; + } catch { + return undefined; + } +} diff --git a/cli/src/native/ios.ts b/cli/src/native/ios.ts new file mode 100644 index 0000000..af09efb --- /dev/null +++ b/cli/src/native/ios.ts @@ -0,0 +1,238 @@ +import { existsSync, writeFileSync } from 'fs'; +import xcode, { XcodeProjectType } from 'xcode'; +import { join } from 'path'; +import { readFileSync, writeFileSync as writePlistFileSync } from '@webnativellc/simple-plist'; + +export class IosProject { + private _projectPath: string; + private _infoPlistPath: string; + private _project: XcodeProjectType; + + constructor(projectPath: string) { + this._projectPath = join(projectPath, 'App.xcodeproj', 'project.pbxproj'); + this._infoPlistPath = join(projectPath, 'App', 'Info.plist'); + } + + exists(): boolean { + return existsSync(this._projectPath); + } + + async parse(): Promise { + if (!this.exists()) { + return false; + } + this._project = xcode.project(this._projectPath); + try { + await this.parseAsync(this._project); + return true; + } catch (error) { + console.error(error); + throw new Error(`Unable to parse project ${this._projectPath}`); + } + } + + parseAsync(project: XcodeProjectType) { + return new Promise((resolve, reject) => { + project.parse((err) => { + if (err) return reject(err); + resolve(undefined); + }); + }); + } + + getAppTarget(): AppTarget { + const targets = this.getAppTargets(); + return targets[0]; + } + + getAppTargets(): AppTarget[] { + const targets = this._project.hash.project.objects.PBXNativeTarget; + const result: AppTarget[] = []; + Object.keys(targets).forEach((key) => { + result.push({ name: targets[key].name, id: key }); + }); + return result; + } + + getBundleId(target: string): string { + const targets = this._project.hash.project.objects.PBXNativeTarget; + let bundleId = ''; + Object.keys(targets).forEach((key) => { + if (targets[key].name === target) { + const buildConfigs = this._project.hash.project.objects.XCBuildConfiguration; + Object.keys(buildConfigs).forEach((configKey) => { + const config = buildConfigs[configKey]; + + if (config.buildSettings && config.buildSettings.PRODUCT_BUNDLE_IDENTIFIER) { + bundleId = config.buildSettings.PRODUCT_BUNDLE_IDENTIFIER; + } + }); + } + }); + if (bundleId == '') { + throw new Error(`getBundleId ${target} failed`); + } + return bundleId; + } + + async getDisplayName(): Promise { + const data: any = readFileSync(this._infoPlistPath); + return data.CFBundleDisplayName; + } + + private async getProdutName(target: string): Promise { + let displayName = ''; + const targets = this._project.hash.project.objects.PBXNativeTarget; + Object.keys(targets).forEach((key) => { + if (targets[key].name === target) { + const buildConfigs = this._project.hash.project.objects.XCBuildConfiguration; + Object.keys(buildConfigs).forEach((configKey) => { + const config = buildConfigs[configKey]; + + if (config.buildSettings && config.buildSettings.PRODUCT_NAME) { + displayName = config.buildSettings.PRODUCT_NAME.replace(/"/g, ''); // Remove quotes if present + } + }); + if (displayName == '') { + throw new Error(`getDisplayName ${target} failed`); + } + } + }); + return displayName; + } + + getBuildConfigurations(target: string): BuildConfiguration[] { + const buildConfigs = this._project.hash.project.objects.XCBuildConfiguration; + const result: BuildConfiguration[] = []; + Object.keys(buildConfigs).forEach((configKey) => { + const config = buildConfigs[configKey]; + if (!config.baseConfigurationReference && config.name) { + result.push({ name: config.name }); + } + }); + return result; + } + + getVersion(target: string, buildConfig: string): string { + const identifier = this.getVariable(this.getInfoPlist().CFBundleShortVersionString); + const buildConfigs = this._project.hash.project.objects.XCBuildConfiguration; + let version = ''; + Object.keys(buildConfigs).forEach((configKey) => { + const config = buildConfigs[configKey]; + if (config.name === buildConfig) { + if (config.buildSettings && config.buildSettings[identifier]) { + version = config.buildSettings[identifier]; + return; + } + } + }); + if (version == '') { + throw Error(`Couldnt find version for ${identifier} in ${buildConfig}`); + } + return version; + } + + getInfoPlist(): any { + return readFileSync(this._infoPlistPath); + } + + async getBuild(target: string, buildConfig: string): Promise { + const identifier = this.getVariable(this.getInfoPlist().CFBundleVersion); + const buildConfigs = this._project.hash.project.objects.XCBuildConfiguration; + let build = ''; + Object.keys(buildConfigs).forEach((configKey) => { + const config = buildConfigs[configKey]; + if (config.name === buildConfig) { + if (config.buildSettings && config.buildSettings[identifier]) { + build = config.buildSettings[identifier]; + return; + } + } + }); + if (build == '') { + throw Error(`Couldnt find build for ${identifier} in ${buildConfig}`); + } + return parseInt(build); + } + + private getVariable(name: string): string { + return name.replace('$(', '').replace(')', ''); + } + + async setBundleId(target: string, buildConfig: string, bundleId: string): Promise { + const targets = this._project.hash.project.objects.PBXNativeTarget; + let set = false; + Object.keys(targets).forEach((key) => { + if (targets[key].name === target) { + const buildConfigs = this._project.hash.project.objects.XCBuildConfiguration; + Object.keys(buildConfigs).forEach((configKey) => { + const config = buildConfigs[configKey]; + + if (config.buildSettings && config.buildSettings.PRODUCT_BUNDLE_IDENTIFIER) { + config.buildSettings.PRODUCT_BUNDLE_IDENTIFIER = bundleId; + writeFileSync(this._projectPath, this._project.writeSync()); + set = true; + } + }); + } + }); + if (!set) { + throw new Error(`setBundleId ${target} failed`); + } + } + + setVersion(target: string, buildConfig: string, version: string): void { + const identifier = this.getVariable(this.getInfoPlist().CFBundleShortVersionString); + let versionSet = false; + const buildConfigs = this._project.hash.project.objects.XCBuildConfiguration; + Object.keys(buildConfigs).forEach((configKey) => { + const config = buildConfigs[configKey]; + if (config.name === buildConfig) { + if (config.buildSettings && config.buildSettings[identifier]) { + config.buildSettings[identifier] = version; + writeFileSync(this._projectPath, this._project.writeSync()); + versionSet = true; + return; + } + } + }); + if (!versionSet) { + throw Error(`Couldnt find version for ${identifier} in ${buildConfig}`); + } + } + + async setBuild(target: string, buildConfig: string, build: number): Promise { + const identifier = this.getVariable(this.getInfoPlist().CFBundleVersion); + let set = false; + const buildConfigs = this._project.hash.project.objects.XCBuildConfiguration; + Object.keys(buildConfigs).forEach((configKey) => { + const config = buildConfigs[configKey]; + if (config.name === buildConfig) { + if (config.buildSettings && config.buildSettings[identifier]) { + config.buildSettings[identifier] = build; + writeFileSync(this._projectPath, this._project.writeSync()); + set = true; + return; + } + } + }); + if (!set) { + throw Error(`Couldnt find build for ${identifier} in ${buildConfig}`); + } + } + + async setDisplayName(target: string, buildConfig: string, displayName: string): Promise { + const data: any = readFileSync(this._infoPlistPath); + data.CFBundleDisplayName = displayName; + writePlistFileSync(this._infoPlistPath, data); + } +} + +export interface BuildConfiguration { + name: string; +} + +export interface AppTarget { + name: string; + id: string; +} diff --git a/cli/src/native/privacy-manifest.ts b/cli/src/native/privacy-manifest.ts new file mode 100644 index 0000000..a5f7366 --- /dev/null +++ b/cli/src/native/privacy-manifest.ts @@ -0,0 +1,189 @@ +export const privacyManifestRules = { + NSPrivacyAccessedAPICategoryUserDefaults: [ + '@aparajita/capacitor-biometric-auth', + '@aparajita/capacitor-dark-mode', + '@aparajita/capacitor-logger', + '@aparajita/capacitor-secure-storage', + '@aparajita/capacitor-splash-screen', + '@capacitor-community/apple-sign-in', + '@capacitor-community/camera-preview', + '@capacitor-community/http', + '@capacitor-community/mdm-appconfig', + '@capacitor-community/twitter', + '@capacitor/live-updates', + '@capacitor/preferences', + '@capacitor/storage', + '@capawesome/capacitor-badge', + '@capawesome/capacitor-managed-configurations', + '@capgo/capacitor-updater', + '@felipeclopes/firebase-remote-config', + '@havesource/cordova-plugin-push', + '@idpass/smartscanner-capacitor', + '@ionic-enterprise/badge', + '@ionic-enterprise/device', + '@ionic-enterprise/identity-vault', + '@ionic-enterprise/intune', + '@ionic-enterprise/nativestorage', + '@joinflux/firebase-remote-config', + '@moodlehq/cordova-plugin-ionic-webview', + '@moodlehq/phonegap-plugin-push', + '@nadavhalfon/firebase-remote-config', + '@nano-sql/adapter-sqlite-cordova', + '@rgarciadelongia/firebase-remote-config', + '@transistorsoft/capacitor-background-geolocation', + '@vinit_poojary/capacitor-intent', + 'blocshop-sockets-for-cordova-plugin', + 'cap-codepush', + 'capacitor-branch-deep-links', + 'capacitor-cloudvoice-meet', + 'capacitor-codepush', + 'capacitor-file-picker', + 'capacitor-file-selector', + 'capacitor-geofencing', + 'capacitor-get-latest-photo', + 'capacitor-google-analytics', + 'capacitor-intercom', + 'capacitor-kindred', + 'capacitor-mapbox', + 'capacitor-plugin-ios-webview-configurator', + 'capacitor-plugin-kommunicate', + 'capacitor-plugin-permissions', + 'capacitor-qrscanner', + 'capacitor-radar', + 'capacitor-share-extension', + 'capacitor-twitter', + 'capacitor-updater', + 'cc.fovea.cordova.purchase', + 'com-infobip-plugins-mobilemessaging', + 'cordova-admobsdk', + 'cordova-background-geolocation-lt', + 'cordova-hot-code-push-plugin', + 'cordova-ios-plugin-userdefaults', + 'cordova-plugin-app-preferences', + 'cordova-plugin-apple-watch', + 'cordova-plugin-attestation', + 'cordova-plugin-autostart', + 'cordova-plugin-awesome-shared-preferences', + 'cordova-plugin-badge', + 'cordova-plugin-badge-fix', + 'cordova-plugin-biometric', + 'cordova-plugin-brother-label-printer', + 'cordova-plugin-cartegraph-cookie-master', + 'cordova-plugin-code-push', + 'cordova-plugin-cookiemaster', + 'cordova-plugin-device', + 'cordova-plugin-document-viewer', + 'cordova-plugin-emm-app-config', + 'cordova-plugin-fcm-with-dependecy-updated', + 'cordova-plugin-fcm-with-dependecy-updated-12', + 'cordova-plugin-firebase', + 'cordova-plugin-firebasex', + 'cordova-plugin-firebasex-fix', + 'cordova-plugin-inapppurchase', + 'cordova-plugin-inapppurchase-2', + 'cordova-plugin-ionic', + 'cordova-plugin-ionic-webview', + 'cordova-plugin-ionic4-crosswalk-webview', + 'cordova-plugin-kakao-sdk', + 'cordova-plugin-keychain-touch-id', + 'cordova-plugin-mixpanel', + 'cordova-plugin-ms-adal', + 'cordova-plugin-ms-adal-is-back', + 'cordova-plugin-ms-adal-is-back-12', + 'cordova-plugin-nativestorage', + 'cordova-plugin-progressindicator', + 'cordova-plugin-purchase', + 'cordova-plugin-rongcloud-im', + 'cordova-plugin-secure-key-store', + 'cordova-plugin-themeablebrowser', + 'cordova-plugin-touch-id', + 'cordova-plugin-tracking-transparency', + 'cordova-plugin-uid', + 'cordova-plugin-unique-device-id2', + 'cordova-plugin-uniquedeviceid', + 'cordova-plugin-update-notifier', + 'cordova-plugin-youtube-video-player', + 'cordova-unique-device-id', + 'cordova.plugins.diagnostic', + 'firefly-cordova-plugin-themeablebrowser', + 'indigitall-capacitor-plugin', + 'phonegap-plugin-push', + 'pushwoosh-cordova-plugin', + 'segment-cordova-plugin', + 'stocknow', + 'urbanairship-cordova', + 'urbanairship-gimbal-bridge-cordova', + ], + NSPrivacyAccessedAPICategoryFileTimestamp: [ + '@capacitor-community/http', + '@capacitor-community/media', + '@capacitor-community/photoviewer', + '@capacitor-firebase/authentication', + '@capacitor/filesystem', + '@capawesome/capacitor-file-picker', + '@mauron85/cordova-plugin-background-geolocation', + '@moodlehq/cordova-plugin-zip', + '@thegrizzlylabs/cordova-plugin-genius-scan', + '@whiteguru/capacitor-plugin-media', + 'blocshop-sockets-for-cordova-plugin', + 'cap-codepush', + 'capacitor-blob-writer', + 'capacitor-branch-deep-links', + 'capacitor-cloudvoice-meet', + 'capacitor-codepush', + 'capacitor-community-media-v2', + 'capacitor-file-selector', + 'capacitor-geofencing', + 'capacitor-get-latest-photo', + 'capacitor-google-analytics', + 'capacitor-intercom', + 'capacitor-plugin-ios-webview-configurator', + 'capacitor-qrscanner', + 'capacitor-share-extension', + 'capacitor-twitter', + 'cordova-background-geolocation-plugin', + 'cordova-plugin-background-upload', + 'cordova-plugin-buildinfo', + 'cordova-plugin-code-push', + 'cordova-plugin-document-viewer', + 'cordova-plugin-esptouch', + 'cordova-plugin-file', + 'cordova-plugin-firebase', + 'cordova-plugin-firebasex', + 'cordova-plugin-httpd', + 'cordova-plugin-ionic-wkwebview-engine', + 'cordova-plugin-iroot', + 'cordova-plugin-local-webserver', + 'cordova-plugin-mauron85-background-geolocation', + 'cordova-plugin-media-capture', + 'cordova-plugin-mediapicker-dmcsdk', + 'cordova-plugin-photo-library', + 'cordova-plugin-photo-library-sism', + 'cordova-plugin-photo-library-wkwebview', + 'cordova-plugin-progressindicator', + 'cordova-plugin-telerik-imagepicker', + 'cordova-plugin-telerik-imagepicker2', + 'cordova-plugin-ths-video-capture-plus', + 'cordova-plugin-tracking-transparency', + 'cordova-plugin-video-capture-plus', + 'cordova-plugin-youtube-video-player', + 'cordova-plugin-zip', + 'indigitall-capacitor-plugin', + 'stocknow', + ], + NSPrivacyAccessedAPICategoryDiskSpace: [ + '@capacitor/device', + 'cap-codepush', + 'capacitor-cloudvoice-meet', + 'capacitor-file-selector', + 'capacitor-geofencing', + 'capacitor-get-latest-photo', + 'capacitor-intercom', + 'capacitor-plugin-ios-webview-configurator', + 'capacitor-qrscanner', + 'capacitor-share-extension', + 'capacitor-twitter', + 'stocknow', + ], + NSPrivacyAccessedAPICategorySystemBootTime: ['cordova-plugin-buildinfo'], +}; diff --git a/cli/src/native/privacy.ts b/cli/src/native/privacy.ts new file mode 100644 index 0000000..6d99f9e --- /dev/null +++ b/cli/src/native/privacy.ts @@ -0,0 +1,341 @@ +import { join } from 'path'; +import { project } from 'xcode'; +import * as plist from '@webnativellc/simple-plist'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { replaceAll } from '../core/text'; +import { privacyManifestRules } from './privacy-manifest'; + +export interface APIUsage { + api: string; + plugin: string; + reasons: string[]; + reasonUrl: string; +} + +export interface MissingPrivacyCategory { + api: string; + plugin: string; + reasons: string[]; + reasonUrl: string; +} + +export interface PrivacyManifestCheckResult { + /** Whether a privacy manifest is required based on installed plugins */ + required: boolean; + apisUsed: APIUsage[]; + hasManifest: boolean; + manifestPath?: string; + missingCategories: MissingPrivacyCategory[]; + needsCreation: boolean; + error?: string; +} + +export interface CreatePrivacyManifestResult { + success: boolean; + path?: string; + error?: string; +} + +export interface SetPrivacyCategoryResult { + success: boolean; + path: string; + category: string; + reason: string; + error?: string; +} + +interface XCProject { + p: any; + projectFolder: string; + projectFilePath: string; +} + +/** + * Check whether the iOS project needs a privacy manifest and whether existing manifest entries are complete. + */ +export async function checkPrivacyManifest( + projectFolder: string, + pluginExists: (plugin: string) => boolean = defaultPluginExists(projectFolder), +): Promise { + if (process.platform !== 'darwin') { + return { + required: false, + apisUsed: [], + hasManifest: false, + missingCategories: [], + needsCreation: false, + }; + } + + const apisUsed: APIUsage[] = []; + for (const api of Object.keys(privacyManifestRules)) { + for (const plugin of privacyManifestRules[api]) { + if (pluginExists(plugin)) { + apisUsed.push({ api, plugin, reasons: getReasons(api), reasonUrl: getReasonUrl(api) }); + } + } + } + + if (apisUsed.length == 0) { + return { + required: false, + apisUsed: [], + hasManifest: false, + missingCategories: [], + needsCreation: false, + }; + } + + try { + const xc = await getXCProject(projectFolder); + if (!xc) { + if (!existsSync(iosFolder(projectFolder))) { + return { + required: true, + apisUsed, + hasManifest: false, + missingCategories: [], + needsCreation: false, + }; + } + return { + required: true, + apisUsed, + hasManifest: false, + missingCategories: [], + needsCreation: false, + error: `XCode project file is missing: ${xCodeProjectFile(projectFolder)}.`, + }; + } + + const pFiles = xc.p.pbxFileReferenceSection(); + const files = Object.keys(pFiles); + const found = files.find((f) => pFiles[f].path?.includes('.xcprivacy')); + if (found) { + const manifestPath = join(iosFolder(projectFolder), replaceAll(pFiles[found].path, '"', '')); + const missingCategories = investigatePrivacyManifest(manifestPath, apisUsed); + return { + required: true, + apisUsed, + hasManifest: true, + manifestPath, + missingCategories, + needsCreation: false, + }; + } + + return { + required: true, + apisUsed, + hasManifest: false, + missingCategories: [], + needsCreation: true, + }; + } catch (err) { + return { + required: true, + apisUsed, + hasManifest: false, + missingCategories: [], + needsCreation: false, + error: `Unable to read privacy manifest of XCode project: ${err}`, + }; + } +} + +/** + * Create a PrivacyInfo.xcprivacy file and add it to the Xcode project. + */ +export async function createPrivacyManifest(projectFolder: string): Promise { + try { + const xc = await getXCProject(projectFolder); + if (!xc) { + return { success: false, error: 'XCode project not found' }; + } + + const filename = 'PrivacyInfo.xcprivacy'; + const path = writeManifestFile(iosFolder(projectFolder), filename); + + const res = xc.p.addPbxGroup([], 'Resources', undefined, undefined); + + const r3 = xc.p.getPBXGroupByKey('504EC2FB1FED79650016851F', 'PBXGroup'); + const r2 = xc.p.addResourceFile(filename, {}, res.uuid); + r3.children.push({ value: r2.fileRef, comment: 'Resources' }); + writeFileSync(xc.projectFilePath, xc.p.writeSync()); + + return { success: true, path }; + } catch (e) { + return { success: false, error: `Unable to create privacy manifest file: ${e}` }; + } +} + +/** + * Add a reason code for a privacy API category in an existing manifest file. + */ +export function setPrivacyCategory( + privacyFilename: string, + category: string, + reason: string, +): SetPrivacyCategoryResult { + try { + const data: any = plist.readFileSync(privacyFilename); + if (!data.NSPrivacyAccessedAPITypes) { + data.NSPrivacyAccessedAPITypes = []; + } + const found = data.NSPrivacyAccessedAPITypes.find((t: any) => t.NSPrivacyAccessedAPIType == category); + if (found) { + if (!found.NSPrivacyAccessedAPITypeReasons) { + found.NSPrivacyAccessedAPITypeReasons = []; + } + if (!found.NSPrivacyAccessedAPITypeReasons.includes(reason)) { + found.NSPrivacyAccessedAPITypeReasons.push(reason); + } + } else { + data.NSPrivacyAccessedAPITypes.push({ + NSPrivacyAccessedAPIType: category, + NSPrivacyAccessedAPITypeReasons: [reason], + }); + } + plist.writeFileSync(privacyFilename, data); + return { success: true, path: privacyFilename, category, reason }; + } catch (e) { + return { success: false, path: privacyFilename, category, reason, error: String(e) }; + } +} + +function investigatePrivacyManifest(manifestPath: string, apisUsages: APIUsage[]): MissingPrivacyCategory[] { + const missing: MissingPrivacyCategory[] = []; + if (!existsSync(manifestPath)) { + return missing; + } + try { + const data: any = plist.readFileSync(manifestPath); + for (const apiUsage of apisUsages) { + const found = data.NSPrivacyAccessedAPITypes + ? data.NSPrivacyAccessedAPITypes.find((a: any) => a.NSPrivacyAccessedAPIType == apiUsage.api) + : undefined; + if (!found || found.NSPrivacyAccessedAPITypeReasons?.length == 0) { + missing.push({ + api: apiUsage.api, + plugin: apiUsage.plugin, + reasons: apiUsage.reasons, + reasonUrl: apiUsage.reasonUrl, + }); + } + } + } catch (e) { + console.error(`Unable to parse plist file: ${manifestPath}: ${e}`); + } + return missing; +} + +function defaultPluginExists(projectFolder: string): (plugin: string) => boolean { + const pkgPath = join(projectFolder, 'package.json'); + let deps: Record = {}; + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')); + deps = { ...pkg.dependencies, ...pkg.devDependencies }; + } catch { + /* ignore */ + } + } + return (plugin: string) => { + if (deps[plugin]) return true; + return existsSync(join(projectFolder, 'node_modules', plugin, 'package.json')); + }; +} + +function XCodeProjFolder(projectFolder: string): string { + return join(iosFolder(projectFolder), 'App.xcodeproj'); +} + +function iosFolder(projectFolder: string): string { + return join(projectFolder, 'ios', 'App'); +} + +function xCodeProjectFile(projectFolder: string): string { + const projectFolderPath = XCodeProjFolder(projectFolder); + return join(projectFolderPath, 'project.pbxproj'); +} + +async function getXCProject(projectFolder: string): Promise { + const xcodeFolder = XCodeProjFolder(projectFolder); + const path = join(xcodeFolder, 'project.pbxproj'); + if (!existsSync(path)) { + return undefined; + } + const p = await parse(path); + return { projectFilePath: path, projectFolder: xcodeFolder, p }; +} + +function writeManifestFile(iosAppFolder: string, filename: string): string { + const content = ` + + + + NSPrivacyTracking + + NSPrivacyAccessedAPITypes + + NSPrivacyCollectedDataTypes + + + `; + const f = join(iosAppFolder, filename); + writeFileSync(f, content); + return f; +} + +async function parse(path: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(`Timeout parsing Xcode project at ${path}`)); + }, 5000); + + const p = project(path); + p.parse((err: any) => { + clearTimeout(timeout); + if (err) { + reject(err); + } else { + resolve(p); + } + }); + }); +} + +function getReasons(api: string): string[] { + switch (api) { + case 'NSPrivacyAccessedAPICategoryUserDefaults': + return ['CA92.1', '1C8F.1']; + case 'NSPrivacyAccessedAPICategoryFileTimestamp': + return ['C617.1', 'DDA9.1', '3B52.1']; + case 'NSPrivacyAccessedAPICategoryDiskSpace': + return ['85F4.1', 'E174.1', '7D9E.1', 'B728.1']; + case 'NSPrivacyAccessedAPICategorySystemBootTime': + return ['35F9.1', '8FFB.1', '3D61.1']; + case 'NSPrivacyAccessedAPICategoryActiveKeyboards': + return ['3EC4.1', '54BD.1']; + default: + console.error(`Unknown api ${api} in getReasons`); + return []; + } +} + +function getReasonUrl(api: string): string { + switch (api) { + case 'NSPrivacyAccessedAPICategoryUserDefaults': + return 'https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api#4278401'; + case 'NSPrivacyAccessedAPICategoryFileTimestamp': + return 'https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api#4278393'; + case 'NSPrivacyAccessedAPICategoryDiskSpace': + return 'https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api#4278397'; + case 'NSPrivacyAccessedAPICategoryActiveKeyboards': + return 'https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api#4278400'; + case 'NSPrivacyAccessedAPICategorySystemBootTime': + return 'https://developer.apple.com/documentation/bundleresources/privacy_manifest_files/describing_use_of_required_reason_api#4278394'; + default: + return ''; + } +} diff --git a/cli/src/packages/audit.ts b/cli/src/packages/audit.ts new file mode 100644 index 0000000..55d16c5 --- /dev/null +++ b/cli/src/packages/audit.ts @@ -0,0 +1,83 @@ +import { stripJsonPrefix } from '../commands/helpers'; + +export interface SecurityVulnerability { + name: string; + severity: string; + url: string; + title: string; +} + +export interface AuditResult { + vulnerabilities: SecurityVulnerability[]; + metadata?: AuditMetadata; + raw?: Audit; +} + +interface Source { + title: string; + url: string; +} + +interface Vulnerability { + severity: string; + via: Array; +} + +interface Audit { + vulnerabilities: Record; + metadata: AuditMetadata; +} + +interface AuditMetadata { + vulnerabilities: { total: number; critical: number; high: number; moderate: number; low: number }; + dependencies: { total: number }; +} + +export function parseAuditOutput(data: string, dependencies: string[]): AuditResult { + try { + const audit: Audit = JSON.parse(stripJsonPrefix(data, '{')); + return { + vulnerabilities: analyzeAudit(dependencies, audit), + metadata: audit.metadata, + raw: audit, + }; + } catch { + throw new Error('npm audit --json returned invalid output'); + } +} + +function analyzeAudit(dependencies: string[], audit: Audit): SecurityVulnerability[] { + const result: SecurityVulnerability[] = []; + for (const name of Object.keys(audit.vulnerabilities ?? {})) { + const v = audit.vulnerabilities[name]; + if (!dependencies.includes(name)) continue; + const source = drillDown(name, audit); + result.push({ + name, + severity: v.severity, + title: source?.title ?? '', + url: source?.url ?? '', + }); + } + return result; +} + +function drillDown(name: string, audit: Audit): Source | undefined { + for (const source of audit.vulnerabilities[name].via) { + if (typeof source === 'string') { + const nested = drillDown(source, audit); + if (nested) return nested; + } else { + return source; + } + } + return undefined; +} + +export function filterBySeverity(vulnerabilities: SecurityVulnerability[], severity?: string): SecurityVulnerability[] { + if (!severity) return vulnerabilities; + const order = ['low', 'moderate', 'high', 'critical']; + const minIdx = order.indexOf(severity.toLowerCase()); + if (minIdx === -1) return vulnerabilities; + return vulnerabilities.filter((v) => order.indexOf(v.severity) >= minIdx); +} diff --git a/cli/src/packages/export.ts b/cli/src/packages/export.ts new file mode 100644 index 0000000..788648a --- /dev/null +++ b/cli/src/packages/export.ts @@ -0,0 +1,176 @@ +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { coerce } from 'semver'; +import { Project } from '../project/project'; +import { getNpmInfo } from './npm-info'; + +export interface ExportPackageInfo { + name: string; + version: string; + latest?: string; + depType?: string; + current?: boolean; +} + +export interface ExportResult { + filename: string; + markdown: string; + score: { good: number; total: number }; +} + +export async function exportProjectSummary( + project: Project, + packages: Record, +): Promise { + const folder = project.projectFolder(); + let txt = ''; + let total = 0; + let good = 0; + + for (const libType of ['Capacitor Plugin', 'Plugin', 'Dependency']) { + txt += `## ${plural(libType)}\n\n`; + for (const library of Object.keys(packages).sort()) { + const pkg = packages[library]; + if (pkg.depType !== libType) continue; + + let lastReleased = 0; + let link: string | undefined; + let message: string | undefined; + const isCustom = pkg.latest === '[custom]'; + let point = '🟩'; + + if (!isCustom) { + try { + const npmInfo = await getNpmInfo(library, false); + const keys = Object.keys(npmInfo.time ?? {}); + const modified = npmInfo.time?.[keys[keys.length - 1]]; + if (modified) lastReleased = daysAgo(new Date(modified)); + link = cleanLink(npmInfo.repository?.url); + } catch { + point = '🟧'; + message = 'Unable to find information on npm.'; + } + } + + if (lastReleased > 730) { + point = '🟥'; + message = `Unmaintained (${timePeriod(lastReleased)} since last release)`; + } else if (lastReleased > 365) { + point = '🟧'; + message = `May be unmaintained (${timePeriod(lastReleased)} since last release)`; + } + + if (isCustom) { + point = '🟧'; + message = 'Requires manual developer maintenance as it is custom / forked.'; + } + + if (!isCustom) { + const current = coerce(pkg.version); + const latest = coerce(pkg.latest); + if (current && latest && latest.major - current.major >= 1) { + point = '🟧'; + const count = latest.major - current.major; + message = `Is behind ${count} major version${count > 1 ? 's' : ''}.`; + } + } + + if (library.startsWith('@ionic-native/')) { + point = '🟧'; + message = 'Is deprecated and replaced with @awesome-cordova-plugins.'; + } + + let name = library; + if (!isCustom) name += `@${pkg.version}`; + if (link) name = `[${name}](${link})`; + + txt += `- ${point} ${name}`; + if (pkg.current) txt += ` - (Latest ${pkg.latest})`; + if (message) txt += ` - ${message}`; + txt += '\n'; + + if (point === '🟩') good++; + total++; + } + } + + txt += `### Maintenance Score\n`; + txt += `${good} out of ${total} dependencies were up to date without issues.\n\n`; + txt += exportNamingStyles(folder); + + const filename = join(folder, 'project-summary.md'); + writeFileSync(filename, txt); + return { filename, markdown: txt, score: { good, total } }; +} + +function plural(word: string): string { + return word.endsWith('y') ? word.slice(0, -1) + 'ies' : word + 's'; +} + +function timePeriod(days: number): string { + if (days < 365) return `${days} days`; + return `${Math.round((days / 365) * 10) / 10} years`; +} + +function daysAgo(d: Date): number { + const oneDay = 24 * 60 * 60 * 1000; + return Math.round(Math.abs((Date.now() - d.getTime()) / oneDay)); +} + +function cleanLink(url?: string): string | undefined { + if (!url) return undefined; + return url + .replace('git+ssh://git@', 'https://') + .replace('git://github.com/', 'https://github.com/') + .replace('git+https://', 'https://') + .replace('git://', ''); +} + +function exportNamingStyles(folder: string): string { + const filenames: string[] = []; + const baseFolder = join(folder, 'src'); + getAllFiles(baseFolder, filenames); + let txt = '\n\n## Nonstandard naming\n'; + txt += 'The following files and folders do not follow the standard naming convention:\n\n'; + for (const filename of filenames) { + const name = filename.replace(baseFolder, ''); + if (name.toLowerCase() !== name) txt += `- ${name}\n`; + } + return txt; +} + +function getAllFiles(folder: string, arrayOfFiles: string[]): void { + if (!existsSync(folder)) return; + for (const file of readdirSync(folder)) { + const full = join(folder, file); + if (statSync(full).isDirectory()) getAllFiles(full, arrayOfFiles); + else arrayOfFiles.push(full); + } +} + +export function packagesForExport(project: Project): Record { + const deps = project.analyzer.getAllDependencies(); + const result: Record = {}; + for (const [name, range] of Object.entries(deps)) { + const version = project.analyzer.getPackageVersion(name)?.version ?? `${range}`; + const depType = classifyPackage(name, project); + result[name] = { + name, + version, + latest: version, + depType, + current: true, + }; + } + return result; +} + +function classifyPackage(name: string, project: Project): string { + if (name.startsWith('@capacitor/') && name !== '@capacitor/core' && name !== '@capacitor/cli') { + return 'Capacitor Plugin'; + } + if (project.isCordova && (name.startsWith('cordova-') || name.startsWith('@ionic-native/'))) { + return 'Plugin'; + } + return 'Dependency'; +} diff --git a/cli/src/packages/list.ts b/cli/src/packages/list.ts new file mode 100644 index 0000000..8afa5ef --- /dev/null +++ b/cli/src/packages/list.ts @@ -0,0 +1,102 @@ +import { coerce } from 'semver'; +import { fixYarnV1Outdated } from '../project/monorepo'; +import { NpmOutdatedDependency } from '../project/npm-model'; +import { PackageType } from '../project/npm-model'; +import { Project } from '../project/project'; +import { listCommand, outdatedCommand } from '../build/node-commands'; +import { stripJsonPrefix } from '../commands/helpers'; + +export interface ListedPackage { + name: string; + current: string; + wanted?: string; + latest?: string; + type: 'dependency' | 'devDependency'; + deprecated?: boolean; + depType?: string; +} + +export interface ListedPlugin extends ListedPackage { + ios?: boolean; + android?: boolean; +} + +export type RunCallback = (command: string, folder: string) => Promise; + +export async function listPackages( + project: Project, + run: RunCallback, + options: { outdated?: boolean; plugins?: boolean } = {}, +): Promise<{ packages: ListedPackage[]; plugins: ListedPlugin[] }> { + const folder = project.projectFolder(); + const deps = project.analyzer.getAllDependencies(); + const devDeps = project.analyzer.getPackageFile().devDependencies ?? {}; + const packages: ListedPackage[] = []; + const plugins: ListedPlugin[] = []; + + let outdated: Record = {}; + if (options.outdated) { + outdated = await fetchOutdated(project, folder, run); + } + + for (const name of Object.keys(deps).sort()) { + const current = project.analyzer.getPackageVersion(name)?.version ?? deps[name]; + const recent = outdated[name]; + const entry: ListedPackage = { + name, + current: recent?.current ?? `${current}`, + wanted: recent?.wanted, + latest: recent?.latest, + type: name in devDeps ? 'devDependency' : 'dependency', + depType: classifyDepType(name, project), + }; + if (options.plugins && isPlugin(name, project)) { + plugins.push({ + ...entry, + ios: name.startsWith('@capacitor/') || name.includes('cordova'), + android: name.startsWith('@capacitor/') || name.includes('cordova'), + }); + } else { + packages.push(entry); + } + } + + return { packages, plugins }; +} + +async function fetchOutdated( + project: Project, + folder: string, + run: RunCallback, +): Promise> { + try { + let data = await run(outdatedCommand(project), folder); + if (project.isYarnV1()) data = fixYarnV1Outdated(data, project.packageManager); + return JSON.parse(stripJsonPrefix(data, '{')); + } catch { + return {}; + } +} + +function classifyDepType(name: string, project: Project): string { + if (name.startsWith('@capacitor/') && !['@capacitor/core', '@capacitor/cli'].includes(name)) { + return PackageType.CapacitorPlugin; + } + if (project.isCordova && (name.startsWith('cordova-') || name.startsWith('@ionic-native/'))) { + return PackageType.CordovaPlugin; + } + return PackageType.Dependency; +} + +function isPlugin(name: string, project: Project): boolean { + const type = classifyDepType(name, project); + return type === PackageType.CapacitorPlugin || type === PackageType.CordovaPlugin; +} + +export function isOutdated(entry: ListedPackage): boolean { + if (!entry.wanted && !entry.latest) return false; + const current = coerce(entry.current); + const wanted = coerce(entry.wanted ?? entry.latest); + if (!current || !wanted) return false; + return wanted.major > current.major || wanted.minor > current.minor || wanted.patch > current.patch; +} diff --git a/cli/src/packages/minor.ts b/cli/src/packages/minor.ts new file mode 100644 index 0000000..58d7ae5 --- /dev/null +++ b/cli/src/packages/minor.ts @@ -0,0 +1,131 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { commandContext, npmInstall, outdatedCommand, PackageManager } from '../build/node-commands'; +import { fixYarnV1Outdated } from '../project/monorepo'; +import { NpmOutdatedDependency } from '../project/npm-model'; +import { Project } from '../project/project'; + +export type RunCallback = (command: string, folder: string) => Promise; + +export interface MinorUpdate { + name: string; + from: string; + to: string; + spec: string; +} + +export async function findMinorUpdates( + project: Project, + packages: Record, + run: RunCallback, +): Promise { + const tmpDir = mkdtempSync(join(tmpdir(), 'wn-minor-')); + const tmpFile = join(tmpDir, 'package.json'); + const pkg = { dependencies: {} as Record, name: 'tmp', license: 'MIT' }; + for (const library of Object.keys(packages).sort()) { + pkg.dependencies[library] = `^${packages[library].version}`; + } + writeFileSync(tmpFile, JSON.stringify(pkg, undefined, 2)); + + try { + if (project.packageManager === PackageManager.yarn && project.isYarnV1()) { + return await addForYarn(packages, tmpDir, run); + } + return await addForPackageManager(project, packages, tmpDir, run); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +} + +async function addForPackageManager( + project: Project, + packages: Record, + tmpDir: string, + run: RunCallback, +): Promise { + if (project.isModernYarn()) return []; + let data = await run(outdatedCommand(project), tmpDir); + if (project.isYarnV1()) { + data = fixYarnV1Outdated(data, project.packageManager); + } + const updates: MinorUpdate[] = []; + try { + const out = JSON.parse(data); + for (const library of Object.keys(packages).sort()) { + const dep: NpmOutdatedDependency = out[library]; + if (dep && packages[library].version !== dep.wanted) { + updates.push({ + name: library, + from: packages[library].version, + to: dep.wanted, + spec: `${library}@${dep.wanted}`, + }); + } + } + } catch { + throw new Error(`${outdatedCommand(project)} returned invalid json`); + } + return updates; +} + +async function addForYarn( + packages: Record, + tmpDir: string, + run: RunCallback, +): Promise { + await run('yarn install', tmpDir); + const data = await run('yarn list --depth=0', tmpDir); + const updates: MinorUpdate[] = []; + for (const line of data.split('\n')) { + if (!line.startsWith('└─ ') && !line.startsWith('├─ ')) continue; + const kv = line.split('@'); + let dependency = kv[0].replace(/^[├└]─\s*/, ''); + let version = kv[1]; + if (kv.length === 3) { + dependency = `@${kv[1]}`; + version = kv[2]; + } + if (packages[dependency] && packages[dependency].version !== version) { + updates.push({ + name: dependency, + from: packages[dependency].version, + to: version, + spec: `${dependency}@${version}`, + }); + } + } + return updates; +} + +export async function applyMinorUpdates( + project: Project, + updates: MinorUpdate[], + runInstall: (spec: string) => Promise, +): Promise<{ updated: string[]; failed: string[] }> { + const updated: string[] = []; + const failed: string[] = []; + for (const update of updates) { + try { + await runInstall(update.spec); + updated.push(update.name); + } catch { + failed.push(update.name); + } + } + return { updated, failed }; +} + +export function packagesFromProject(project: Project): Record { + const deps = project.analyzer.getAllDependencies(); + const result: Record = {}; + for (const [name, range] of Object.entries(deps)) { + const version = project.analyzer.getPackageVersion(name)?.version ?? `${range}`; + result[name] = { version }; + } + return result; +} + +export function installCommand(project: Project, spec: string): string { + return npmInstall(spec, commandContext(project)); +} diff --git a/cli/src/packages/mutate.ts b/cli/src/packages/mutate.ts new file mode 100644 index 0000000..e817d35 --- /dev/null +++ b/cli/src/packages/mutate.ts @@ -0,0 +1,80 @@ +import { + addCommand, + commandContext, + installForceArgument, + npmInstall, + npmInstallAll, + npmUninstall, + npmUpdate, + PackageManager, + saveDevArgument, +} from '../build/node-commands'; +import { Project } from '../project/project'; + +export interface MutateOptions { + dev?: boolean; + force?: boolean; + exact?: boolean; + frozenLockfile?: boolean; +} + +export function addPackageCommand(project: Project, name: string, options: MutateOptions = {}): string { + const ctx = commandContext(project); + const args: string[] = []; + if (options.dev) args.push(saveDevArgument(project.packageManager)); + if (options.force) args.push(installForceArgument(project.packageManager)); + return npmInstall(name, ctx, ...args); +} + +export function removePackageCommand(project: Project, name: string): string { + return npmUninstall(name, commandContext(project)); +} + +export function upgradePackageCommand(project: Project, spec: string, options: MutateOptions = {}): string { + const ctx = commandContext(project); + const args: string[] = []; + if (options.force) args.push(installForceArgument(project.packageManager)); + return npmInstall(spec, ctx, ...args); +} + +export function installAllCommand(project: Project, options: MutateOptions = {}): string { + const ctx = commandContext(project); + if (options.frozenLockfile) { + switch (project.packageManager) { + case PackageManager.pnpm: + return `${addCommand(ctx).replace('add', 'install')} --frozen-lockfile`; + case PackageManager.yarn: + return project.isYarnV1() ? 'yarn install --frozen-lockfile' : 'yarn install --immutable'; + case PackageManager.bun: + return 'bun install --frozen-lockfile'; + default: + return 'npm ci'; + } + } + return npmInstallAll(ctx); +} + +export function updateAllCommand(project: Project): string { + return npmUpdate(commandContext(project)); +} + +export function packageManagerName(project: Project): string { + switch (project.packageManager) { + case PackageManager.npm: + return 'npm'; + case PackageManager.pnpm: + return 'pnpm'; + case PackageManager.yarn: + return 'yarn'; + case PackageManager.bun: + return 'bun'; + default: + return 'npm'; + } +} + +export function buildUpgradeSpec(name: string, version?: string, latest?: boolean): string { + if (latest) return name; + if (version) return `${name}@${version}`; + return name; +} diff --git a/cli/src/packages/npm-info.ts b/cli/src/packages/npm-info.ts new file mode 100644 index 0000000..1a8e532 --- /dev/null +++ b/cli/src/packages/npm-info.ts @@ -0,0 +1,53 @@ +export interface NpmInfo { + name: string; + version: string; + time?: Record; + repository?: { type: string; url: string }; + 'dist-tags'?: { latest?: string; next?: string }; + versions?: Record }>; + bugs?: { url?: string }; + description?: string; + author?: { name: string; email?: string; url?: string } | string; + keywords?: string[]; + license?: string | { type: string; url?: string }; +} + +export async function getNpmInfo(name: string, latest: boolean): Promise { + const url = latest ? `https://registry.npmjs.org/${name}/latest` : `https://registry.npmjs.org/${name}`; + try { + const np = (await httpGet(url, npmHeaders())) as NpmInfo; + if (!np.name) throw new Error(`No name found in ${url}`); + np.version = np['dist-tags']?.latest ?? np.version; + return np; + } catch (error) { + const msg = `${error}`; + if (!msg.includes("'Not found'")) { + console.error(`getNpmInfo failed ${url}`, error); + } + return {} as NpmInfo; + } +} + +function npmHeaders(): RequestInit { + const token = process.env.DATA_SCRIPTS_NPM_TOKEN; + const headers: Record = { + 'User-Agent': 'WebNative CLI', + Accept: '*/*', + }; + if (token) headers.Authorization = `bearer ${token}`; + return { headers }; +} + +async function httpGet(url: string, opts: RequestInit): Promise { + const response = await fetch(url, opts); + const data = await response.json(); + if (rateLimited(data)) { + console.error(`The api call ${url} was rate limited.`); + } + return data; +} + +function rateLimited(a: unknown): boolean { + const msg = (a as { message?: string })?.message; + return !!msg?.startsWith('API rate limit exceeded') || !!msg?.startsWith('You have exceeded a secondary rate limit'); +} diff --git a/cli/src/packages/peer-dependencies.ts b/cli/src/packages/peer-dependencies.ts new file mode 100644 index 0000000..3f8e8bd --- /dev/null +++ b/cli/src/packages/peer-dependencies.ts @@ -0,0 +1,201 @@ +import { satisfies, gt } from 'semver'; +import { commandContext, installForceArgument, npmInstall, PackageManager } from '../build/node-commands'; +import { replaceAll } from '../project/utilities-strings'; +import { Project } from '../project/project'; +import { getNpmInfo } from './npm-info'; + +export interface PeerReport { + dependencies: DependencyConflict[]; + incompatible: string[]; + commands: string[]; +} + +export interface DependencyVersion { + name: string; + version: string; +} + +export interface DependencyConflict { + name: string; + conflict?: DependencyVersion; +} + +const frameworkPrefixes = ['@angular/', '@angular-devkit/']; + +export type RunCallback = (command: string, folder: string) => Promise; + +export async function checkPeerDependencies( + folder: string, + project: Project, + peerDeps: DependencyVersion[], + ignoreDeps: string[], + run: RunCallback, +): Promise { + if (project.packageManager !== PackageManager.npm) { + return { dependencies: [], incompatible: [], commands: [] }; + } + const ignores = mergeIgnoreDeps(ignoreDeps); + const dependencies = await getDependencyConflicts(folder, peerDeps, ignores, run); + const conflicts: string[] = []; + const updates: string[] = []; + const commands: string[] = []; + const reportedErrors = new Set(); + + for (const dependency of dependencies) { + if (isFrameworkPackage(dependency.name)) continue; + const version = await findCompatibleVersion2(dependency, project, run); + if (version === 'latest' || !version) { + if (!version && !reportedErrors.has(dependency.name)) { + reportedErrors.add(dependency.name); + conflicts.push(dependency.name); + } + } else if (!updates.includes(`${dependency.name}@${version}`)) { + updates.push(`${dependency.name}@${version}`); + } + } + + if (updates.length > 0) { + const ctx = commandContext(project); + commands.push(npmInstall(updates.join(' '), ctx, '--force')); + } + + return { dependencies, incompatible: conflicts, commands }; +} + +function mergeIgnoreDeps(ignoreDeps: string[]): string[] { + const ignores = [...frameworkPrefixes]; + for (const ignoreDep of ignoreDeps) { + if (!ignores.includes(ignoreDep)) ignores.push(ignoreDep); + } + return ignores; +} + +function isFrameworkPackage(name: string): boolean { + return shouldIgnoreDependency(name, frameworkPrefixes); +} + +function shouldIgnoreDependency(name: string, ignoreDeps: string[]): boolean { + return ignoreDeps.some((ignoreDep) => name.startsWith(ignoreDep)); +} + +async function getDependencyConflicts( + folder: string, + peerDeps: DependencyVersion[], + ignoreDeps: string[], + run: RunCallback, +): Promise { + try { + const list: DependencyConflict[] = []; + const seen = new Set(); + const data = await run('npm ls --depth=1 --long --json', folder); + const deps = JSON.parse(data); + for (const peerDependency of peerDeps) { + if (shouldIgnoreDependency(peerDependency.name, ignoreDeps)) continue; + for (const key of Object.keys(deps.dependencies ?? {})) { + if (shouldIgnoreDependency(key, ignoreDeps)) continue; + for (const peer of Object.keys(deps.dependencies[key].peerDependencies ?? {})) { + const versionRange = deps.dependencies[key].peerDependencies[peer]; + if (peer === peerDependency.name && !satisfies(peerDependency.version, cleanRange(versionRange))) { + const id = `${key}:${peerDependency.name}`; + if (!seen.has(id)) { + seen.add(id); + list.push({ name: key, conflict: peerDependency }); + } + } + } + } + } + return list; + } catch { + return []; + } +} + +async function getNPMInfoFor(dependency: string, folder: string, run: RunCallback): Promise { + try { + const response = await fetch(`https://registry.npmjs.org/${dependency}`); + const pck = (await response.json()) as Record; + pck.latestVersion = pck['dist-tags']?.latest; + return pck; + } catch { + const data = await run(`npm view ${dependency} --json`, folder); + const pck = JSON.parse(data); + pck.latestVersion = pck.version; + pck.versions = pck.versions ?? {}; + pck.versions[pck.latestVersion] = { peerDependencies: pck.peerDependencies }; + return pck; + } +} + +function cleanRange(range: string): string { + if (range.includes('&&')) return replaceAll(range, '&&', ''); + return range; +} + +function satisfiesPeerVersion(version: string | undefined, range: string): boolean { + if (!version) return false; + return satisfies(version, cleanRange(range)); +} + +export async function findCompatibleVersion2( + dependency: DependencyConflict, + project: Project, + run: RunCallback, +): Promise { + if (isFrameworkPackage(dependency.name)) return undefined; + + let best: string | undefined; + let incompatible = false; + try { + const pck = await getNPMInfoFor(dependency.name, project.projectFolder(), run); + const latestVersion = pck.latestVersion; + for (const version of Object.keys(pck.versions ?? {})) { + const peers = pck.versions[version]?.peerDependencies; + if (!peers) continue; + for (const peerDependency of Object.keys(peers)) { + const peerVersion = peers[peerDependency]; + const current = project.analyzer.getPackageVersion(peerDependency); + let meetsNeeds: boolean; + if (dependency.conflict) { + meetsNeeds = + dependency.conflict.name === peerDependency && + satisfiesPeerVersion(dependency.conflict.version, peerVersion); + } else { + meetsNeeds = !!current && satisfiesPeerVersion(current.version, peerVersion); + } + if (!version.includes('-') && meetsNeeds) { + if (!best || gt(version, best)) best = version; + } else if (dependency.conflict) { + if (dependency.conflict.name === peerDependency && version === latestVersion) incompatible = true; + } else if (version === latestVersion && !best && current) { + incompatible = true; + } + } + } + if (!best) best = incompatible ? 'latest' : latestVersion; + } catch { + best = undefined; + } + return best; +} + +export async function findBestPluginVersion( + plugin: string, + project: Project, + run: RunCallback, +): Promise { + const v = await findCompatibleVersion2({ name: plugin }, project, run); + if (!v || v === 'latest') return v ? plugin : undefined; + return `${plugin}@${v}`; +} + +/** Resolve npm metadata for display (used by plugins info). */ +export async function getNpmPackageView(name: string, folder: string, run: RunCallback): Promise { + try { + const data = await run(`npm view ${name} --json`, folder); + return JSON.parse(data); + } catch { + const info = await getNpmInfo(name, true); + return info.name ? info : undefined; + } +} diff --git a/cli/src/packages/size.ts b/cli/src/packages/size.ts new file mode 100644 index 0000000..11b2230 --- /dev/null +++ b/cli/src/packages/size.ts @@ -0,0 +1,175 @@ +import { basename, extname, join } from 'path'; +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'fs'; +import { build } from '../build/build'; +import { MonoRepoType } from '../project/monorepo'; +import { Project } from '../project/project'; +import { stripJsonPrefix } from '../commands/helpers'; + +export interface FileInfo { + name: string; + path: string; + type: string; + size: number; + bundlename: string; + filename: string; +} + +export interface SizeAnalysis { + bundles: FileInfo[]; + assets: FileInfo[]; + groups: Record; +} + +export type RunCallback = (command: string, cwd: string) => Promise; + +export async function analyzeBundleSize(project: Project, run: RunCallback): Promise { + const dist = project.getDistFolder(); + const previousValue = enableSourceMaps(project); + try { + let args = ''; + if (project.repoType === MonoRepoType.nx || project.frameworkType?.startsWith('angular')) { + args = '--configuration=production'; + } + const buildCmd = build(project, { arguments: args, sourceMaps: true }); + const cmd = typeof buildCmd === 'string' ? buildCmd : buildCmd.command; + const cwd = typeof buildCmd === 'string' ? project.projectFolder() : buildCmd.cwd; + const bumpSize = process.platform !== 'win32' ? 'export NODE_OPTIONS="--max-old-space-size=8192" && ' : ''; + await run(`${bumpSize}${cmd}`, cwd); + + const explorerCmd = `npx source-map-explorer "${dist}/**/*.js" --json --exclude-source-map`; + const output = await run(explorerCmd, project.projectFolder()); + const bundles = analyzeBundles(stripJsonPrefix(output, '{')); + const assets = analyzeAssets(dist, project.projectFolder()); + return { bundles, assets, groups: groupTotals([...bundles, ...assets]) }; + } finally { + revertSourceMaps(project, previousValue); + } +} + +function enableSourceMaps(project: Project): string | undefined { + let filename = join(project.folder, 'angular.json'); + if (!existsSync(filename)) filename = join(project.projectFolder(), 'project.json'); + if (!existsSync(filename)) return undefined; + const json = readFileSync(filename, 'utf-8'); + const config = JSON.parse(json); + const projects = config.projects ?? { app: config }; + let changeMade = false; + for (const prj of Object.keys(projects)) { + const cfg = projects[prj].architect ?? projects[prj].targets; + if (cfg?.build?.configurations?.production?.sourceMap !== true) { + cfg.build.configurations.production.sourceMap = true; + changeMade = true; + } + } + if (changeMade) { + writeFileSync(filename, JSON.stringify(config, null, 2)); + return json; + } + return undefined; +} + +function revertSourceMaps(project: Project, previousValue?: string): void { + if (!previousValue) return; + let filename = join(project.folder, 'angular.json'); + if (!existsSync(filename)) filename = join(project.projectFolder(), 'project.json'); + if (existsSync(filename)) writeFileSync(filename, previousValue, 'utf-8'); +} + +function analyzeBundles(json: string): FileInfo[] { + const data = JSON.parse(json); + const ignoreList = ['[EOLs]']; + const files: FileInfo[] = []; + for (const result of data.results ?? []) { + for (const key of Object.keys(result.files ?? {})) { + if (!ignoreList.includes(key)) { + files.push(getInfo(key, result.files[key].size, result.bundleName)); + } + } + } + return files; +} + +function analyzeAssets(distFolder: string, prjFolder: string): FileInfo[] { + const files = getAllFiles(distFolder); + const excluded = ['.js', '.map']; + const result: FileInfo[] = []; + for (const file of files) { + const ext = extname(file); + if (excluded.includes(ext)) continue; + result.push({ + name: basename(file), + path: file, + bundlename: file, + type: assetType(ext), + size: statSync(file).size, + filename: file.replace(prjFolder, ''), + }); + } + return result; +} + +function groupTotals(files: FileInfo[]): Record { + const groups: Record = {}; + for (const file of files) { + if (!groups[file.type]) groups[file.type] = { total: 0, count: 0, files: [] }; + groups[file.type].total += file.size; + groups[file.type].count += 1; + if (!groups[file.type].files.includes(file.bundlename)) groups[file.type].files.push(file.bundlename); + } + return groups; +} + +function getInfo(fullname: string, size: number, bundlename: string): FileInfo { + let name = fullname; + let type = friendlyType(fullname); + try { + const url = new URL(fullname); + name = url.pathname; + type = friendlyType(name); + } catch { + if (fullname.startsWith('../node_modules')) name = fullname.replace('../node_modules', '/node_modules'); + } + return { name: friendlyName(name), type, path: fullname, size, bundlename, filename: name }; +} + +function friendlyName(name: string): string { + return name.replace(/[-_/]/g, ' ').trim() || name; +} + +function friendlyType(name: string): string { + if (name.includes('polyfills')) return 'Polyfills'; + if (name.startsWith('/node_modules/') || name.includes('node_modules')) return '3rd Party'; + if (name === '[unmapped]' || name === '[no source]') return 'Without Source Code'; + return 'Your Code'; +} + +function assetType(ext: string): string { + switch (ext) { + case '.png': + case '.jpg': + case '.gif': + case '.jpeg': + return 'Images'; + case '.svg': + return 'Vector Images'; + case '.woff': + case '.woff2': + case '.eot': + case '.ttf': + return 'Fonts'; + case '.css': + return 'Style Sheets'; + default: + return 'Other'; + } +} + +function getAllFiles(dirPath: string, arrayOfFiles: string[] = []): string[] { + if (!existsSync(dirPath)) return arrayOfFiles; + for (const file of readdirSync(dirPath)) { + const full = join(dirPath, file); + if (statSync(full).isDirectory()) getAllFiles(full, arrayOfFiles); + else arrayOfFiles.push(full); + } + return arrayOfFiles; +} diff --git a/cli/src/plugins/catalog.ts b/cli/src/plugins/catalog.ts new file mode 100644 index 0000000..d9da014 --- /dev/null +++ b/cli/src/plugins/catalog.ts @@ -0,0 +1,39 @@ +import { getCache, setCache } from '../core/cache'; + +export interface PluginCatalogEntry { + name: string; + description?: string; + repo?: string; + platforms?: string[]; + official?: boolean; + keywords?: string[]; +} + +export interface PluginCatalog { + plugins: PluginCatalogEntry[]; +} + +const CATALOG_URL = 'https://capacitorjs.com/directory/plugin-data-raw.json'; +const CACHE_KEY = 'plugin-catalog'; +const CACHE_TTL_MS = 12 * 60 * 60 * 1000; + +export async function fetchPluginCatalog(force = false): Promise { + if (!force) { + const cached = getCache('plugins', CACHE_KEY, CACHE_TTL_MS); + if (cached) return normalizeCatalog(cached); + } + const response = await fetch(CATALOG_URL, { headers: { Accept: 'application/json', 'User-Agent': 'WebNative CLI' } }); + if (!response.ok) throw new Error(`Failed to fetch plugin catalog: ${response.status}`); + const json = (await response.json()) as PluginCatalog; + const catalog = normalizeCatalog(json); + setCache('plugins', CACHE_KEY, catalog); + return catalog; +} + +function normalizeCatalog(raw: PluginCatalog): PluginCatalog { + const plugins = (raw.plugins ?? []).map((p) => ({ + ...p, + official: p.name?.startsWith('@capacitor/') ?? false, + })); + return { plugins }; +} diff --git a/cli/src/plugins/info.ts b/cli/src/plugins/info.ts new file mode 100644 index 0000000..1e9fc63 --- /dev/null +++ b/cli/src/plugins/info.ts @@ -0,0 +1,77 @@ +import { getNpmPackageView } from '../packages/peer-dependencies'; +import { RunCallback } from '../packages/minor'; +import { Project } from '../project/project'; + +export interface PluginInfo { + name: string; + version?: string; + description?: string; + author?: unknown; + bugs?: string; + keywords?: string[]; + repo?: string; + license?: string; + published?: string; + stars?: number; + image?: string; + fork?: boolean; + compatibleVersion?: string; +} + +export async function getPluginInfo( + name: string, + project: Project, + run: RunCallback, + compatibleVersion?: string, +): Promise { + if (!name) return undefined; + try { + const p = await getNpmPackageView(name, project.projectFolder(), run); + if (!p?.name) return undefined; + const gh = p.repository?.url ? await getGHInfo(p.repository.url) : undefined; + return { + name: p.name, + version: p.version, + description: p.description, + author: p.author, + bugs: p.bugs?.url, + keywords: p.keywords, + repo: cleanRepo(p.repository?.url), + license: p.license, + published: p.time?.modified, + stars: gh?.stargazers_count, + image: gh?.owner?.avatar_url, + fork: gh?.fork, + compatibleVersion, + }; + } catch { + return undefined; + } +} + +async function getGHInfo(repo: string): Promise { + try { + const part = repo + .replace('https://github.com/', '') + .replace('.git', '') + .replace('ssh://git@', '') + .replace('git+', '') + .replace('git://github.com/', ''); + const response = await fetch(`https://api.github.com/repos/${part}`, { + headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'WebNative CLI' }, + }); + if (!response.ok) return undefined; + return response.json(); + } catch { + return undefined; + } +} + +function cleanRepo(url?: string): string | undefined { + if (!url) return undefined; + return url + .replace('git+', '') + .replace('ssh://git@', '') + .replace('.git', '') + .replace('git://github.com/', 'https://github.com/'); +} diff --git a/cli/src/plugins/install.ts b/cli/src/plugins/install.ts new file mode 100644 index 0000000..1c99f55 --- /dev/null +++ b/cli/src/plugins/install.ts @@ -0,0 +1,56 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { capacitorSync } from '../build/capacitor-sync'; +import { findBestPluginVersion } from '../packages/peer-dependencies'; +import { addPackageCommand, removePackageCommand } from '../packages/mutate'; +import { RunCallback } from '../packages/minor'; +import { Project } from '../project/project'; +import { CommandResult } from '../build/command-result'; + +export interface PluginInstallPlan { + install: CommandResult; + sync?: CommandResult; + packageSpec: string; +} + +export async function planPluginInstall( + plugin: string, + project: Project, + run: RunCallback, + version?: string, +): Promise { + let packageSpec = plugin; + if (version) { + packageSpec = `${plugin}@${version}`; + } else { + const best = await findBestPluginVersion(plugin, project, run); + if (best) packageSpec = best; + } + const plan: PluginInstallPlan = { + install: addPackageCommand(project, packageSpec, { force: true }), + packageSpec, + }; + if (project.isCapacitor) plan.sync = capacitorSync(project); + return plan; +} + +export function planPluginRemove( + plugin: string, + project: Project, + sync = true, +): { remove: CommandResult; sync?: CommandResult } { + const result = { remove: removePackageCommand(project, plugin) as CommandResult }; + if (sync && project.isCapacitor) return { ...result, sync: capacitorSync(project) }; + return result; +} + +export function hasEnterpriseAuth(projectFolder: string): boolean { + const npmrc = join(projectFolder, '.npmrc'); + if (!existsSync(npmrc)) return false; + const data = readFileSync(npmrc, 'utf-8'); + return data.includes('@ionic-enterprise') && data.includes('_authToken'); +} + +export function enterpriseRegisterCommand(key: string): string { + return `npx ionic enterprise register --key=${key}`; +} diff --git a/cli/src/plugins/permissions.ts b/cli/src/plugins/permissions.ts new file mode 100644 index 0000000..fb49bc5 --- /dev/null +++ b/cli/src/plugins/permissions.ts @@ -0,0 +1,55 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { setAllStringIn } from '../project/utilities-strings'; +import { Project } from '../project/project'; + +export interface PluginPermissionInfo { + name: string; + androidPermissions: string[]; + androidFeatures: string[]; + dependentPlugins: string[]; + hasHooks: boolean; +} + +export function parsePluginXml(content: string): Omit { + const result = { + androidPermissions: [] as string[], + androidFeatures: [] as string[], + dependentPlugins: [] as string[], + hasHooks: false, + }; + if (!content) return result; + content = setAllStringIn(content, '', '', ''); + content = setAllStringIn(content, '', '', ''); + for (const permission of findAll(content, ' item[1]); +} diff --git a/cli/src/plugins/search.ts b/cli/src/plugins/search.ts new file mode 100644 index 0000000..67e79d7 --- /dev/null +++ b/cli/src/plugins/search.ts @@ -0,0 +1,28 @@ +import { fetchPluginCatalog, PluginCatalogEntry } from './catalog'; + +export interface PluginSearchOptions { + query: string; + platform?: string; + official?: boolean; + limit?: number; +} + +export async function searchPlugins(options: PluginSearchOptions): Promise { + const catalog = await fetchPluginCatalog(); + const q = options.query.toLowerCase(); + let results = catalog.plugins.filter((p) => { + if (options.official && !p.official && !p.name.startsWith('@capacitor/')) return false; + if (options.platform) { + const platforms = (p.platforms ?? []).map((x) => x.toLowerCase()); + if (!platforms.includes(options.platform.toLowerCase())) return false; + } + return ( + p.name.toLowerCase().includes(q) || + (p.description ?? '').toLowerCase().includes(q) || + (p.keywords ?? []).some((k) => k.toLowerCase().includes(q)) + ); + }); + results = results.sort((a, b) => a.name.localeCompare(b.name)); + if (options.limit) results = results.slice(0, options.limit); + return results; +} diff --git a/cli/src/project/analyzer.ts b/cli/src/project/analyzer.ts new file mode 100644 index 0000000..dba6c46 --- /dev/null +++ b/cli/src/project/analyzer.ts @@ -0,0 +1,241 @@ +'use strict'; + +import { coerce, compare, lt, gte, lte, SemVer } from 'semver'; +import { XMLParser, X2jOptions } from 'fast-xml-parser'; +import { processPackages } from './process-packages'; +import { Project } from './project'; +import { PackageManager } from '../build/node-commands'; +import { existsSync, lstatSync, readFileSync, statSync } from 'fs'; +import { execSync } from 'child_process'; + +export class Analyzer { + private packageFile: Record = {}; + private allDependencies: Record = {}; + private cordovaConfig: Record | undefined; + private androidManifest: any; + hasPackageJson = false; + + private createXMLParserConfig(): X2jOptions { + return { + removeNSPrefix: true, + isArray: () => true, + parseTagValue: true, + parseAttributeValue: true, + ignoreAttributes: false, + }; + } + + private processConfigXML(folder: string) { + const configXMLFilename = `${folder}/config.xml`; + const config: Record = { preferences: {}, androidPreferences: {}, iosPreferences: {}, plugins: {} }; + if (existsSync(configXMLFilename)) { + const xml = readFileSync(configXMLFilename, 'utf8'); + try { + const parser = new XMLParser(this.createXMLParserConfig()); + const json = parser.parse(xml); + + const widget = json.widget[0]; + if (widget.preference) { + for (const pref of widget.preference) { + config.preferences[pref['@_name']] = pref['@_value']; + } + } + if (!widget.platform) return config; + for (const platform of widget.platform) { + if (platform['@_name'] == 'android' && platform.preference) { + for (const pref of platform.preference) { + config.androidPreferences[pref['@_name']] = pref['@_value']; + } + } + + if (platform['@_name'] == 'ios' && platform.preference) { + for (const pref of platform.preference) { + config.iosPreferences[pref['@_name']] = pref['@_value']; + } + } + } + if (widget.plugin) { + for (const plugin of widget.plugin) { + config.plugins[plugin['@_name']] = plugin['@_spec']; + } + } + } catch (err) { + console.error(`Unable to parse config.xml`, err); + } + } + return config; + } + + private processAndroidXML(folder: string) { + const androidXMLFilename = `${folder}/android/app/src/main/AndroidManifest.xml`; + if (!existsSync(androidXMLFilename)) { + return undefined; + } + const xml = readFileSync(androidXMLFilename, 'utf8'); + const parser = new XMLParser(this.createXMLParserConfig()); + return parser.parse(xml); + } + + async load(fn: string, project: Project): Promise> { + let packageJsonFilename = fn; + if (lstatSync(fn).isDirectory()) { + packageJsonFilename = fn + '/package.json'; + this.cordovaConfig = this.processConfigXML(fn); + this.androidManifest = this.processAndroidXML(fn); + } + this.hasPackageJson = existsSync(packageJsonFilename); + if (!this.hasPackageJson) { + console.error('This folder does not contain an Ionic application (its missing package.json)'); + this.allDependencies = {}; + this.packageFile = {}; + return undefined; + } + project.modified = statSync(packageJsonFilename).mtime; + try { + this.packageFile = JSON.parse(readFileSync(packageJsonFilename, 'utf8')); + } catch (err) { + throw new Error(`The package.json is malformed: ` + err); + } + project.name = this.packageFile.name; + if (!project.name) { + project.name = project.monoRepo?.name; + } + if (!project.name) { + project.name = 'unnamed'; + } + project.workspaces = this.packageFile.workspaces; + if (!project.yarnVersion) { + if (project.packageManager == PackageManager.yarn) { + project.yarnVersion = await this.getYarnVersion(this.packageFile.packageManager, project.folder); + } + } + this.allDependencies = { + ...this.packageFile.dependencies, + ...this.packageFile.devDependencies, + }; + + project.isCapacitor = !!( + this.packageFile.dependencies && + (this.packageFile.dependencies['@capacitor/core'] || + this.packageFile.dependencies['@capacitor/ios'] || + this.packageFile.dependencies['@capacitor/android']) + ); + + project.isCordova = !!( + this.allDependencies['cordova-ios'] || + this.allDependencies['cordova-android'] || + this.packageFile.cordova + ); + + return await processPackages(fn, this.allDependencies, this.packageFile.devDependencies, project); + } + + exists(library: string): boolean { + return !!this.allDependencies[library]; + } + + matchingBeginingWith(start: string): Array { + const result: string[] = []; + for (const library of Object.keys(this.allDependencies)) { + if (library.startsWith(start)) { + result.push(library); + } + } + return result; + } + + remotePackages(): Array { + const result: string[] = []; + for (const library of Object.keys(this.allDependencies)) { + if (this.allDependencies[library]?.startsWith('git')) { + result.push(library); + } + } + return result; + } + + browsersList(): Array { + try { + return JSON.parse(JSON.stringify(this.packageFile.browserslist)); + } catch { + return []; + } + } + + deprecatedPackages(packages: any): Array { + const result: any[] = []; + if (!packages) return result; + for (const library of Object.keys(packages)) { + if (packages[library].deprecated) { + result.push({ name: library, message: packages[library].deprecated }); + } + } + return result; + } + + getPackageVersion(library: string): SemVer | null { + return coerce(this.allDependencies[library]); + } + + isGreaterOrEqual(library: string, minVersion: string): boolean { + const v = coerce(this.allDependencies[library]); + return v != null && gte(v, minVersion); + } + + isVersionGreaterOrEqual(version: string, minVersion: string): boolean { + const v = coerce(version); + return v != null && gte(v, minVersion); + } + + startsWith(library: string, version: string): boolean { + const v = this.allDependencies[library]; + return v != null && v.startsWith(version); + } + + isLessOrEqual(library: string, minVersion: string): boolean { + const v = coerce(this.allDependencies[library]); + return v != null && lte(v, minVersion); + } + + isLess(library: string, minVersion: string): boolean { + const v = coerce(this.allDependencies[library]); + return v != null && lt(v, minVersion); + } + + checkConsistentVersions(lib1: string, lib2: string): boolean { + const v1 = coerce(this.allDependencies[lib1]); + const v2 = coerce(this.allDependencies[lib2]); + if (v1 && v2 && compare(v1, v2)) { + return v1.major !== v2.major; + } + return false; + } + + getAllPackageNames(): Array { + return Object.keys(this.allDependencies); + } + + getPackageFile(): Record { + return this.packageFile; + } + + getAllDependencies(): Record { + return { ...this.allDependencies }; + } + + getCordovaConfig(): Record | undefined { + return this.cordovaConfig; + } + + private async getYarnVersion(packageManager: string, folder: string): Promise { + if (packageManager) { + return packageManager.replace('yarn@', ''); + } + try { + const v = execSync('yarn --version', { cwd: folder, encoding: 'utf8' }); + return v ? v.replace('\n', '') : ''; + } catch { + return ''; + } + } +} diff --git a/cli/src/project/capacitor-config-file.ts b/cli/src/project/capacitor-config-file.ts new file mode 100644 index 0000000..04973f2 --- /dev/null +++ b/cli/src/project/capacitor-config-file.ts @@ -0,0 +1,100 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { Project } from './project'; +import { getStringFrom, setStringIn } from './utilities-strings'; + +function getCapacitorConfigureFile(folder: string): string | undefined { + const capConfigFile = getCapacitorConfigureFilename(folder); + if (capConfigFile && existsSync(capConfigFile)) { + return readFileSync(capConfigFile, 'utf-8'); + } + return undefined; +} + +export function getCapacitorConfigureFilename(folder: string): string { + let capConfigFile = join(folder, 'capacitor.config.ts'); + if (!existsSync(capConfigFile)) { + capConfigFile = join(folder, 'capacitor.config.js'); + if (!existsSync(capConfigFile)) { + capConfigFile = join(folder, 'capacitor.config.json'); + } + } + + return capConfigFile; +} + +export function getCapacitorConfigDistFolder(folder: string): string { + let result = getCapacitorConfigWebDir(folder); + if (!result) { + if (existsSync(join(folder, 'www'))) { + result = 'www'; + } else if (existsSync(join(folder, 'dist'))) { + result = 'dist'; + } else if (existsSync(join(folder, 'build'))) { + result = 'build'; + } else if (existsSync(join(folder, 'out'))) { + result = 'out'; + } + } + if (!result) { + result = 'www'; + } + return join(folder, result); +} + +export function getCapacitorConfigWebDir(folder: string): string | undefined { + let result: string | undefined; + const config = getCapacitorConfigureFile(folder); + if (config) { + result = getStringFrom(config, `webDir: '`, `'`); + if (!result) { + result = getStringFrom(config, `webDir: "`, `"`); + if (!result) { + result = getStringFrom(config, `"webDir": "`, `"`); + } + } + } + return result; +} + +export interface CapKeyValue { + key: string; + value: string; +} + +export function writeCapacitorConfig(project: Project, keyValues: CapKeyValue[]) { + const filename = getCapacitorConfigureFilename(project.projectFolder()); + if (!filename) { + return; + } + let data = readFileSync(filename, 'utf-8'); + + for (const kv of keyValues) { + data = setValueIn(data, kv.key, kv.value); + } + writeFileSync(filename, data); +} + +export function updateCapacitorConfig(project: Project, bundleId?: string, displayName?: string) { + const filename = getCapacitorConfigureFilename(project.projectFolder()); + if (!filename) { + return; + } + let data = readFileSync(filename, 'utf-8'); + if (bundleId) { + data = setValueIn(data, 'appId', bundleId); + } + if (displayName) { + data = setValueIn(data, 'appName', displayName); + } + writeFileSync(filename, data); +} + +function setValueIn(data: string, key: string, value: string): string { + if (data.includes(`${key}: '`)) { + data = setStringIn(data, `${key}: '`, `'`, value); + } else if (data.includes(`${key}: "`)) { + data = setStringIn(data, `${key}: "`, `"`, value); + } + return data; +} diff --git a/cli/src/project/capacitor-platform.ts b/cli/src/project/capacitor-platform.ts new file mode 100644 index 0000000..f31744f --- /dev/null +++ b/cli/src/project/capacitor-platform.ts @@ -0,0 +1,4 @@ +export enum CapacitorPlatform { + ios = 'ios', + android = 'android', +} diff --git a/cli/src/project/inspect.ts b/cli/src/project/inspect.ts new file mode 100644 index 0000000..c9297c1 --- /dev/null +++ b/cli/src/project/inspect.ts @@ -0,0 +1,24 @@ +import { Project, guessFramework, getPackageManager } from './project'; +import { checkForMonoRepo } from './monorepo'; + +export async function inspectProject(cwd: string, selectedProject?: string): Promise { + const project = new Project('My Project'); + project.folder = cwd; + project.packageManager = getPackageManager(cwd, project.repoType); + + await project.analyzer.load(cwd, project); + project.type = project.isCapacitor ? 'Capacitor' : project.isCordova ? 'Cordova' : 'Other'; + + await checkForMonoRepo(project, selectedProject); + + if (project.monoRepo?.folder) { + project.packageManager = getPackageManager(project.monoRepo.folder, project.repoType); + } + if (project.monoRepo?.localPackageJson) { + await project.analyzer.load(project.monoRepo.folder, project); + } + + guessFramework(project); + + return project; +} diff --git a/cli/src/project/ionic-config.ts b/cli/src/project/ionic-config.ts new file mode 100644 index 0000000..c632649 --- /dev/null +++ b/cli/src/project/ionic-config.ts @@ -0,0 +1,39 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; + +export interface IonicConfig { + npmClient?: string; + 'git.setup'?: boolean; + version?: string; + type: string; + projects?: any; + defaultProject?: string; +} + +/** + * Gets the local folders ionic configuration to override telemetry if needed + */ +export function getIonicConfig(folder: string): IonicConfig { + const config: IonicConfig = { type: 'unknown' }; + const configFile = join(folder, 'ionic.config.json'); + if (existsSync(configFile)) { + const json: any = readFileSync(configFile); + const data: IonicConfig = JSON.parse(json); + if (data.type) { + config.type = data.type; + } else { + config.type = 'unknown'; + if (data.projects) { + const keys = Object.keys(data.projects); + if (keys.length > 0) { + if (data.defaultProject) { + config.type = data.projects[data.defaultProject].type; + } else { + config.type = data.projects[keys[0]].type; + } + } + } + } + } + return config; +} diff --git a/cli/src/project/monorepo-lerna.ts b/cli/src/project/monorepo-lerna.ts new file mode 100644 index 0000000..4f20a5b --- /dev/null +++ b/cli/src/project/monorepo-lerna.ts @@ -0,0 +1,31 @@ +import * as globule from 'globule'; + +import { MonoRepoProject } from './monorepo'; +import { Project } from './project'; +import { existsSync, readFileSync } from 'fs'; +import { basename, join } from 'path'; + +export function getLernaWorkspaces(project: Project): Array { + const lernaFile = join(project.folder, 'lerna.json'); + if (!existsSync(lernaFile)) { + return []; + } + + try { + const json = readFileSync(lernaFile, { encoding: 'utf8' }); + const lerna = JSON.parse(json); + const list: string[] = []; + for (const folder of lerna.packages) { + list.push(folder); + } + const folders = globule.find({ src: list, srcBase: project.folder }); + const repos: Array = []; + for (const folder of folders) { + repos.push({ folder: join(project.folder, folder), name: basename(folder) }); + } + return repos; + } catch (err) { + console.error(err); + return []; + } +} diff --git a/cli/src/project/monorepo-npm.ts b/cli/src/project/monorepo-npm.ts new file mode 100644 index 0000000..198363f --- /dev/null +++ b/cli/src/project/monorepo-npm.ts @@ -0,0 +1,14 @@ +import * as globule from 'globule'; + +import { MonoRepoProject } from './monorepo'; +import { Project } from './project'; +import { basename, join } from 'path'; + +export function getNpmWorkspaceProjects(project: Project): Array { + const result: Array = []; + const folders = globule.find({ src: project.workspaces, srcBase: project.folder }); + for (const folder of folders) { + result.push({ name: basename(folder), folder: join(project.folder, folder) }); + } + return result; +} diff --git a/cli/src/project/monorepo-nx.ts b/cli/src/project/monorepo-nx.ts new file mode 100644 index 0000000..b4c9b29 --- /dev/null +++ b/cli/src/project/monorepo-nx.ts @@ -0,0 +1,95 @@ +import { dirname, join } from 'path'; +import { MonoRepoProject } from './monorepo'; +import { Project } from './project'; +import { stripJsonComments } from './strip-json-comments'; +import { existsSync, readFileSync, readdirSync } from 'fs'; + +let nxProjectFolder: string | undefined; + +export async function getNXProjects(project: Project): Promise> { + if (project.monoRepoProjects?.length > 0 && nxProjectFolder == project.folder) { + return project.monoRepoProjects; + } + + const filename = join(project.folder, 'workspace.json'); + let result: Array = []; + if (existsSync(filename)) { + result = getNXProjectFromWorkspaceJson(filename); + } else { + result = await getNXProjectsFromNX(project); + if (result.length == 0) { + result = getNXProjectsByFolder(project); + } + } + nxProjectFolder = project.folder; + return result; +} + +async function getNXProjectsFromNX(project: Project): Promise { + try { + const result: MonoRepoProject[] = []; + const projects = listProjects(project.folder); + for (const prj of projects) { + try { + const txt = readFileSync(prj, 'utf-8'); + const p = JSON.parse(stripJsonComments(txt)); + if (p.name && p.projectType == 'application') { + result.push({ name: p.name, folder: dirname(prj) }); + } + } catch (err) { + console.error(`Error in project ${prj}: ${err}`); + } + } + return result; + } catch (error) { + console.error(error); + return []; + } +} + +function listProjects(folder: string): string[] { + const result: string[] = []; + const files = readdirSync(folder, { withFileTypes: true }); + for (const file of files) { + const skip = file.name == 'node_modules' || file.name.startsWith('.') || file.name.endsWith('.ts'); + if (!skip) { + if (file.isDirectory()) { + for (const prj of listProjects(join(folder, file.name))) { + result.push(prj); + } + } else if (file.name.toLowerCase() == 'project.json') { + result.push(join(folder, file.name)); + } + } + } + return result; +} + +function getNXProjectFromWorkspaceJson(filename: string): MonoRepoProject[] { + const result: Array = []; + const txt = readFileSync(filename, 'utf-8'); + const projects = JSON.parse(txt).projects; + for (const prj of Object.keys(projects)) { + let folder = projects[prj]; + if (folder?.root) { + folder = folder.root; + } + result.push({ name: prj, folder: folder }); + } + return result; +} + +function getNXProjectsByFolder(project: Project): MonoRepoProject[] { + const result: Array = []; + const folder = join(project.folder, 'apps'); + if (existsSync(folder)) { + const list = readdirSync(folder, { withFileTypes: true }); + for (const item of list) { + if (item.isDirectory() && !item.name.startsWith('.')) { + result.push({ name: item.name, folder: join(folder, item.name) }); + } + } + return result; + } + return result; +} diff --git a/cli/src/project/monorepo-pnpm.ts b/cli/src/project/monorepo-pnpm.ts new file mode 100644 index 0000000..3a6fafc --- /dev/null +++ b/cli/src/project/monorepo-pnpm.ts @@ -0,0 +1,35 @@ +import * as globule from 'globule'; + +import { MonoRepoProject } from './monorepo'; +import { replaceAll } from './utilities-strings'; +import { Project } from './project'; +import { existsSync, readFileSync } from 'fs'; +import { basename, join } from 'path'; + +export function getPnpmWorkspaces(project: Project): Array { + const pw = join(project.folder, 'pnpm-workspace.yaml'); + if (!existsSync(pw)) { + return []; + } + const yaml = readFileSync(pw, { encoding: 'utf8' }); + try { + const list: string[] = []; + for (const line of yaml.split('\n')) { + if (line.trim().startsWith('-')) { + let folder = line.replace('-', '').trim(); + folder = replaceAll(folder, '"', ''); + folder = replaceAll(folder, `'`, ''); + list.push(folder); + } + } + const folders = globule.find({ src: list, srcBase: project.folder }); + const repos: Array = []; + for (const folder of folders) { + repos.push({ folder: join(project.folder, folder), name: basename(folder) }); + } + return repos; + } catch (err) { + console.error(err); + return []; + } +} diff --git a/cli/src/project/monorepo.ts b/cli/src/project/monorepo.ts new file mode 100644 index 0000000..2d02097 --- /dev/null +++ b/cli/src/project/monorepo.ts @@ -0,0 +1,339 @@ +import { getNpmWorkspaceProjects } from './monorepo-npm'; +import { getNXProjects } from './monorepo-nx'; +import { Project } from './project'; +import { getPnpmWorkspaces } from './monorepo-pnpm'; +import { PackageManager } from '../build/node-commands'; +import { getLernaWorkspaces } from './monorepo-lerna'; +import { join } from 'path'; +import { NpmDependency, NpmOutdatedDependency } from './npm-model'; +import { existsSync, readFileSync, readdirSync } from 'fs'; +import { webProjectPackages } from './web-packages'; + +export interface MonoRepoProject { + name: string; + folder: string; + localPackageJson?: boolean; + nodeModulesAtRoot?: boolean; + isIonic?: boolean; + isNXStandalone?: boolean; +} + +interface MonoFolder { + name: string; + packageJson: string; + path: string; +} + +export enum MonoRepoType { + none, + nx, + turboRepo, + pnpm, + lerna, + npm, + yarn, + folder, + bun, +} + +export type FrameworkType = 'angular' | 'react' | 'vue' | 'react-vite' | 'vue-vite' | 'angular-standalone' | 'unknown'; + +/** + * Check to see if this is a monorepo and what type. + */ +export async function checkForMonoRepo(project: Project, selectedProject?: string): Promise { + project.repoType = MonoRepoType.none; + let projects: Array | undefined; + const pw = join(project.folder, 'pnpm-workspace.yaml'); + const isPnpm = existsSync(pw); + + if (project.analyzer.exists('@nrwl/cli') || existsSync(join(project.folder, 'nx.json'))) { + project.repoType = MonoRepoType.nx; + projects = await getNXProjects(project); + if (!projects) { + projects = []; + } + if (projects.length == 0) { + projects.push({ name: 'app', folder: '', nodeModulesAtRoot: true, isNXStandalone: true }); + } + } else if (project.workspaces?.length > 0 && !isPnpm) { + projects = getNpmWorkspaceProjects(project); + project.repoType = MonoRepoType.npm; + if (project.packageManager == PackageManager.yarn) { + project.repoType = MonoRepoType.yarn; + } + if (project.packageManager == PackageManager.bun) { + project.repoType = MonoRepoType.bun; + } + } else { + projects = getFolderBasedProjects(project); + + if (projects?.length > 0 && !isPnpm) { + project.repoType = MonoRepoType.folder; + } else { + if (isPnpm) { + project.repoType = MonoRepoType.pnpm; + projects = getPnpmWorkspaces(project); + } else { + const lerna = join(project.folder, 'lerna.json'); + if (existsSync(lerna)) { + project.repoType = MonoRepoType.lerna; + projects = getLernaWorkspaces(project); + } + } + } + } + + project.monoRepoProjects = projects ?? []; + + if (projects?.length > 0) { + const found = selectedProject ? projects.find((p) => p.name == selectedProject) : undefined; + project.monoRepo = found ? found : projects[0]; + + if (!project.monoRepo) { + project.repoType = MonoRepoType.none; + console.error('No mono repo projects found.'); + } else { + project.monoRepo.localPackageJson = [ + MonoRepoType.npm, + MonoRepoType.bun, + MonoRepoType.folder, + MonoRepoType.yarn, + MonoRepoType.lerna, + MonoRepoType.pnpm, + ].includes(project.repoType); + + project.monoRepo.nodeModulesAtRoot = [ + MonoRepoType.npm, + MonoRepoType.bun, + MonoRepoType.nx, + MonoRepoType.yarn, + ].includes(project.repoType); + } + } +} + +export function isFolderBasedMonoRepo(rootFolder: string): Array { + const folders = readdirSync(rootFolder, { withFileTypes: true }) + .filter((dir) => dir.isDirectory()) + .map((dir) => dir.name); + const result: MonoFolder[] = []; + for (const folder of folders) { + const packageJson = join(rootFolder, folder, 'package.json'); + if (existsSync(packageJson)) { + result.push({ name: folder, packageJson: packageJson, path: join(rootFolder, folder) }); + } + } + if (result.length == 0) { + const configFile = join(rootFolder, 'ionic.config.json'); + if (existsSync(configFile)) { + const json: any = readFileSync(configFile); + const data: any = JSON.parse(json); + if (data.projects) { + for (const key of Object.keys(data.projects)) { + const ionicProject = data.projects[key]; + if (ionicProject.root) { + const packageJson = join(rootFolder, ionicProject.root, 'package.json'); + if (existsSync(packageJson)) { + result.push({ + name: ionicProject.name, + packageJson: packageJson, + path: join(rootFolder, ionicProject.root), + }); + } + } else { + const packageJson = join(rootFolder, 'package.json'); + if (existsSync(packageJson)) { + result.push({ name: ionicProject.name, packageJson: packageJson, path: join(rootFolder) }); + } + } + } + } + } + } + return result; +} + +export function getMonoRepoFolder(name: string, defaultFolder: string, projects: MonoRepoProject[]): string { + const found = projects.find((repo) => repo.name == name); + if (!found) { + return defaultFolder; + } + return found?.folder; +} + +export function getPackageJSONFilename( + rootFolder: string, + repoType: MonoRepoType, + workspaceName: string, + projects: MonoRepoProject[], +): string { + return join(getLocalFolder(rootFolder, repoType, workspaceName, projects), 'package.json'); +} + +export function getLocalFolder( + rootFolder: string, + repoType: MonoRepoType, + workspaceName: string, + projects: MonoRepoProject[], +): string { + switch (repoType) { + case MonoRepoType.npm: + case MonoRepoType.bun: + case MonoRepoType.yarn: + case MonoRepoType.lerna: + case MonoRepoType.folder: + return getMonoRepoFolder(workspaceName, rootFolder, projects); + } + return rootFolder; +} + +function getFolderBasedProjects(prj: Project): Array { + const projects = isFolderBasedMonoRepo(prj.folder); + let result: Array = []; + let likelyFolderBasedMonoRepo = false; + let exampleFolder = ''; + + for (const project of projects) { + const folderType = checkFolder(project.packageJson); + if (folderType != FolderType.unknown) { + result.push({ name: project.name, folder: project.path, isIonic: folderType == FolderType.isKnownWebProject }); + } + if (folderType == FolderType.isKnownWebProject) { + exampleFolder = project.path; + likelyFolderBasedMonoRepo = true; + } + } + + let subFolderWarning = false; + + const rootFolderType = checkFolder(join(prj.folder, 'package.json')); + if (rootFolderType == FolderType.isKnownWebProject) { + if (projects.length == 0 || prj.folder == projects[0].path) { + // Sub folder is the root folder (eg ionic multi-app without a root) + } else { + if (!subFolderWarning && exampleFolder != '') { + console.error( + `This folder has Capacitor/Ionic dependencies but there are subfolders that do too which will be ignored (eg ${exampleFolder})`, + ); + subFolderWarning = true; + } + + return []; + } + } + result = result.sort((a, b) => (a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1)); + if (rootFolderType == FolderType.hasDependencies) { + result.unshift({ name: 'root', folder: prj.folder, isIonic: false }); + } + return result; +} + +export function fixYarnV1Outdated(data: string, packageManager: PackageManager): string { + if (packageManager !== PackageManager.yarn) { + return data; + } + const tmp = data.split('\n'); + if (tmp.length > 1) { + return parseYarnFormat(tmp[1]); + } + return data; +} + +export function fixYarnOutdated(data: string): string | undefined { + if (data.startsWith(`Usage Error: Couldn't find a script named "outdated"`)) { + return undefined; + } + const items = JSON.parse(data); + const result: Record = {}; + for (const item of items) { + const dep: NpmOutdatedDependency = { + current: item.current, + wanted: item.latest, + latest: item.latest, + dependent: '', + location: '', + }; + result[item.name] = dep; + } + return JSON.stringify(result); +} + +export function fixModernYarnList(data: string): string { + const items = data.split('\n'); + if (items.length > 1) { + const result: { dependencies: Record } = { dependencies: {} }; + for (const item of items) { + if (item === '') break; + + const info = JSON.parse(item); + const ldx = info.value.lastIndexOf('@'); + const name = info.value.substring(0, ldx); + const dep: NpmDependency = { + version: info.children.Version, + resolved: '', + }; + result.dependencies[name] = dep; + } + return JSON.stringify(result); + } + return data; +} + +function parseYarnFormat(data: string): string { + try { + const out = JSON.parse(data); + const result: Record = {}; + if (out.data.body) { + for (const item of out.data.body) { + const dep: NpmOutdatedDependency = { + current: item[1], + wanted: item[2], + latest: item[3], + dependent: '', + location: '', + }; + result[item[0]] = dep; + } + } + return JSON.stringify(result); + } catch { + return data; + } +} + +enum FolderType { + hasDependencies, + isKnownWebProject, + unknown, +} + +function checkFolder(filename: string): FolderType { + try { + if (!existsSync(filename)) { + return FolderType.unknown; + } + const pck = JSON.parse(readFileSync(filename, 'utf8')); + let isKnownWebProject = false; + + const packages = [...webProjectPackages, '@capacitor/core', '@capacitor/ios', '@capacitor/android']; + for (const pkg of packages) { + if (pck.dependencies?.[pkg]) { + isKnownWebProject = true; + break; + } + if (pck.devDependencies?.[pkg]) { + isKnownWebProject = true; + break; + } + } + + return isKnownWebProject + ? FolderType.isKnownWebProject + : pck.dependencies || pck.devDependencies + ? FolderType.hasDependencies + : FolderType.unknown; + } catch { + return FolderType.unknown; + } +} diff --git a/cli/src/project/npm-model.ts b/cli/src/project/npm-model.ts new file mode 100644 index 0000000..a730179 --- /dev/null +++ b/cli/src/project/npm-model.ts @@ -0,0 +1,31 @@ +// Used for the data than comes from npm list --json +export interface NpmPackage { + version: string; + name: string; + dependencies: object; +} + +export interface NpmDependency { + version: string; + resolved: string; +} + +// Used from npm outdated --json +export interface NpmOutdatedDependency { + current: string; + wanted: string; + latest: string; + dependent: string; + location: string; +} + +export enum PackageType { + Dependency = 'Dependency', + CapacitorPlugin = 'Capacitor Plugin', + CordovaPlugin = 'Plugin', +} + +export enum PackageVersion { + Unknown = 'Unknown', + Custom = '[custom]', +} diff --git a/cli/src/project/package-lock.ts b/cli/src/project/package-lock.ts new file mode 100644 index 0000000..f5d1206 --- /dev/null +++ b/cli/src/project/package-lock.ts @@ -0,0 +1,28 @@ +import { join } from 'path'; +import { Project } from './project'; +import { PackageManager } from '../build/node-commands'; +import { existsSync, readFileSync } from 'fs'; +import { NpmPackage } from './npm-model'; + +export function hasPackageLock(project: Project): boolean { + return existsSync(join(project.projectFolder(), 'package-lock.json')); +} + +export function getVersionsFromPackageLock(project: Project): NpmPackage | undefined { + if (project.packageManager != PackageManager.npm) return undefined; + const lockFile = join(project.projectFolder(), 'package-lock.json'); + if (!existsSync(lockFile)) return undefined; + const txt = readFileSync(lockFile, { encoding: 'utf8' }); + const data = JSON.parse(txt); + const result: Record = {}; + try { + const packages = data.packages['']; + for (const dep of [...Object.keys(packages.dependencies), ...Object.keys(packages.devDependencies)]) { + const name = `node_modules/${dep}`; + result[dep] = { version: data.packages[name].version }; + } + return { name: project.name, version: '0.0.0', dependencies: result }; + } catch { + return undefined; + } +} diff --git a/cli/src/project/process-packages.ts b/cli/src/project/process-packages.ts new file mode 100644 index 0000000..d9f16f8 --- /dev/null +++ b/cli/src/project/process-packages.ts @@ -0,0 +1,11 @@ +import { Project } from './project'; + +/** Stub — returns an empty package map until package processing is ported. */ +export async function processPackages( + _folder: string, + _allDependencies: Record, + _devDependencies: Record | undefined, + _project: Project, +): Promise> { + return {}; +} diff --git a/cli/src/project/project.ts b/cli/src/project/project.ts new file mode 100644 index 0000000..af2b3c1 --- /dev/null +++ b/cli/src/project/project.ts @@ -0,0 +1,154 @@ +import { Analyzer } from './analyzer'; +import { checkForMonoRepo, FrameworkType, MonoRepoProject, MonoRepoType } from './monorepo'; +import { CapacitorPlatform } from './capacitor-platform'; +import { PackageManager } from '../build/node-commands'; +import { getCapacitorConfigDistFolder } from './capacitor-config-file'; +import { join } from 'path'; +import { existsSync } from 'fs'; +import { getIonicConfig } from './ionic-config'; + +/** Minimal project surface for command builders that only need folder resolution. */ +export interface ProjectLike { + repoType: MonoRepoType; + projectFolder(): string; +} + +export class Project implements ProjectLike { + name: string; + type: string = undefined; + isCapacitor: boolean; + isCordova: boolean; + workspaces: Array; + folder: string; + modified: Date; + repoType: MonoRepoType; + packageManager: PackageManager; + frameworkType: FrameworkType; + monoRepo: MonoRepoProject; + monoRepoProjects: MonoRepoProject[] = []; + isCapacitorPlugin: boolean; + yarnVersion: string; + analyzer: Analyzer = new Analyzer(); + + constructor(_name: string) { + this.name = _name; + this.isCapacitorPlugin = false; + } + + public getNodeModulesFolder(): string { + let nmf = join(this.folder, 'node_modules'); + if (this.monoRepo && !this.monoRepo?.nodeModulesAtRoot) { + nmf = join(this.monoRepo.folder, 'node_modules'); + } + return nmf; + } + + public hasCapacitorProject(platform: CapacitorPlatform): boolean { + return this.analyzer.exists(`@capacitor/${platform}`) && existsSync(join(this.projectFolder(), platform)); + } + + public hasACapacitorProject(): boolean { + return this.hasCapacitorProject(CapacitorPlatform.ios) || this.hasCapacitorProject(CapacitorPlatform.android); + } + + public isYarnV1(): boolean { + return this.yarnVersion?.startsWith('1.'); + } + + public projectFolder(): string { + if (this.repoType == undefined) { + return this.folder; + } + switch (this.repoType) { + case MonoRepoType.none: + return this.folder; + case MonoRepoType.bun: + case MonoRepoType.npm: + case MonoRepoType.yarn: + case MonoRepoType.lerna: + case MonoRepoType.pnpm: + case MonoRepoType.folder: + return this.monoRepo ? this.monoRepo.folder : this.folder; + case MonoRepoType.nx: + return this.monoRepo ? this.monoRepo.folder : this.folder; + default: + return join(this.folder, this.monoRepo.folder); + } + } + + public isModernYarn(): boolean { + const result = !!this.yarnVersion; + if (result && this.yarnVersion.startsWith('pnpm@')) { + return false; + } + if (this.isYarnV1()) { + return false; + } + if (!result && this.packageManager == PackageManager.yarn) { + return true; + } + return result; + } + + public fileExists(filename: string): boolean { + return existsSync(join(this.projectFolder(), filename)); + } + + public getDistFolder(): string { + return getCapacitorConfigDistFolder(this.projectFolder()); + } +} + +export function guessFramework(project: Project): void { + const config = getIonicConfig(project.projectFolder()); + project.frameworkType = config.type as FrameworkType; + if (project.frameworkType && project.frameworkType !== 'unknown') return; + if (project.analyzer.exists('@vue/cli-service')) { + project.frameworkType = 'vue'; + } else if (project.analyzer.exists('@angular/core')) { + project.frameworkType = 'angular'; + } else if (project.analyzer.exists('@angular/cli')) { + project.frameworkType = 'angular-standalone'; + } else if (project.analyzer.exists('@ionic/angular')) { + project.frameworkType = 'angular-standalone'; + } else if (project.analyzer.exists('react-scripts')) { + project.frameworkType = 'react'; + } else if (project.analyzer.exists('vite') && !project.analyzer.exists('@remix-run/react')) { + if (project.analyzer.exists('react')) { + project.frameworkType = 'react-vite'; + } + if (project.analyzer.exists('vue')) { + project.frameworkType = 'vue-vite'; + } + } +} + +export function getPackageManager(folder: string, monoRepoType?: MonoRepoType): PackageManager { + const yarnLock = join(folder, 'yarn.lock'); + const pnpmLock = join(folder, 'pnpm-lock.yaml'); + const bunLockb = join(folder, 'bun.lockb'); + const bunLock = join(folder, 'bun.lock'); + if (existsSync(yarnLock)) { + return PackageManager.yarn; + } else if (existsSync(pnpmLock) || monoRepoType == MonoRepoType.pnpm) { + return PackageManager.pnpm; + } else if (existsSync(bunLockb)) { + return PackageManager.bun; + } else if (existsSync(bunLock)) { + return PackageManager.bun; + } + + if (monoRepoType == MonoRepoType.yarn) { + const packageLock = join(folder, 'package-lock.json'); + if (!existsSync(packageLock)) { + return PackageManager.yarn; + } + } + if (monoRepoType == MonoRepoType.bun) { + const packageLock = join(folder, 'package-lock.json'); + if (!existsSync(packageLock)) { + return PackageManager.bun; + } + } + return PackageManager.npm; +} diff --git a/cli/src/project/strip-json-comments.ts b/cli/src/project/strip-json-comments.ts new file mode 100644 index 0000000..427080f --- /dev/null +++ b/cli/src/project/strip-json-comments.ts @@ -0,0 +1,109 @@ +// Copied from https://github.com/sindresorhus/strip-json-comments/blob/main/index.js +const singleComment = Symbol('singleComment'); +const multiComment = Symbol('multiComment'); + +const stripWithoutWhitespace = () => ''; +const stripWithWhitespace = (string: string, start: number, end: number) => + string.slice(start, end).replace(/\S/g, ' '); + +const isEscaped = (jsonString: string, quotePosition: number) => { + let index = quotePosition - 1; + let backslashCount = 0; + + while (jsonString[index] === '\\') { + index -= 1; + backslashCount += 1; + } + + return Boolean(backslashCount % 2); +}; + +export function stripJsonComments(jsonString: string, { whitespace = true, trailingCommas = false } = {}) { + if (typeof jsonString !== 'string') { + throw new TypeError(`Expected argument \`jsonString\` to be a \`string\`, got \`${typeof jsonString}\``); + } + + const strip = whitespace ? stripWithWhitespace : stripWithoutWhitespace; + + let isInsideString = false; + let isInsideComment: symbol | false = false; + let offset = 0; + let buffer = ''; + let result = ''; + let commaIndex = -1; + + for (let index = 0; index < jsonString.length; index++) { + const currentCharacter = jsonString[index]; + const nextCharacter = jsonString[index + 1]; + + if (!isInsideComment && currentCharacter === '"') { + const escaped = isEscaped(jsonString, index); + if (!escaped) { + isInsideString = !isInsideString; + } + } + + if (isInsideString) { + continue; + } + + if (!isInsideComment && currentCharacter + nextCharacter === '//') { + buffer += jsonString.slice(offset, index); + offset = index; + isInsideComment = singleComment; + index++; + } else if (isInsideComment === singleComment && currentCharacter + nextCharacter === '\r\n') { + index++; + isInsideComment = false; + buffer += strip(jsonString, offset, index); + offset = index; + continue; + } else if (isInsideComment === singleComment && currentCharacter === '\n') { + isInsideComment = false; + buffer += strip(jsonString, offset, index); + offset = index; + } else if (!isInsideComment && currentCharacter + nextCharacter === '/*') { + buffer += jsonString.slice(offset, index); + offset = index; + isInsideComment = multiComment; + index++; + continue; + } else if (isInsideComment === multiComment && currentCharacter + nextCharacter === '*/') { + index++; + isInsideComment = false; + buffer += strip(jsonString, offset, index + 1); + offset = index + 1; + continue; + } else if (trailingCommas && !isInsideComment) { + if (commaIndex !== -1) { + if (currentCharacter === '}' || currentCharacter === ']') { + buffer += jsonString.slice(offset, index); + result += strip(buffer, 0, 1) + buffer.slice(1); + buffer = ''; + offset = index; + commaIndex = -1; + } else if ( + currentCharacter !== ' ' && + currentCharacter !== '\t' && + currentCharacter !== '\r' && + currentCharacter !== '\n' + ) { + buffer += jsonString.slice(offset, index); + offset = index; + commaIndex = -1; + } + } else if (currentCharacter === ',') { + result += buffer + jsonString.slice(offset, index); + buffer = ''; + offset = index; + commaIndex = index; + } + } + } + + return ( + result + + buffer + + (isInsideComment ? strip(jsonString.slice(offset), 0, jsonString.length) : jsonString.slice(offset)) + ); +} diff --git a/cli/src/project/utilities-strings.ts b/cli/src/project/utilities-strings.ts new file mode 100644 index 0000000..54c42db --- /dev/null +++ b/cli/src/project/utilities-strings.ts @@ -0,0 +1,68 @@ +export function getStringFrom(data: string, start: string, end: string): string | undefined { + if (data == undefined) return undefined; + const foundIdx = data.lastIndexOf(start); + if (foundIdx == -1) { + return undefined; + } + const idx = foundIdx + start.length; + const edx = data.indexOf(end, idx); + if (edx == -1) return data.substring(idx); + return data.substring(idx, edx); +} + +export function setStringIn(data: string, start: string, end: string, replacement: string): string { + const foundIdx = data.lastIndexOf(start); + if (foundIdx == -1) { + return data; + } + const idx = foundIdx + start.length; + return data.substring(0, idx) + replacement + data.substring(data.indexOf(end, idx)); +} + +export function setAllStringIn(data: string, start: string, end: string, replacement: string): string { + let position = 0; + let result = data; + let replaced = true; + while (replaced) { + const foundIdx = result.indexOf(start, position); + if (foundIdx == -1) { + replaced = false; + } else { + const idx = foundIdx + start.length; + position = idx + replacement.length; + const ndx = result.indexOf(end, idx); + if (ndx == -1) { + replaced = false; + } else { + result = result.substring(0, idx) + replacement + result.substring(ndx); + } + } + } + return result; +} + +export function replaceAllStringIn(data: string, start: string, end: string, replacement: string): string { + let position = 0; + let result = data; + let replaced = true; + while (replaced) { + const foundIdx = result.indexOf(start, position); + if (foundIdx == -1) { + replaced = false; + } else { + const idx = foundIdx; + position = idx + replacement.length; + result = result.substring(0, idx) + replacement + result.substring(result.indexOf(end, idx) + end.length); + } + } + return result; +} + +export function isEmpty(value: string): boolean { + return value == undefined || value.trim().length == 0; +} + +/** Replace all occurrences of a substring. */ +export function replaceAll(str: string, find: string, replace: string): string { + return str.split(find).join(replace); +} diff --git a/cli/src/project/web-packages.ts b/cli/src/project/web-packages.ts new file mode 100644 index 0000000..694cf33 --- /dev/null +++ b/cli/src/project/web-packages.ts @@ -0,0 +1,11 @@ +export const webProjectPackages = [ + '@ionic/vue', + '@ionic/angular', + '@ionic/react', + '@angular/core', + 'react', + 'astro', + 'vue', + 'vite', + 'svelte', +]; diff --git a/cli/src/rules/engine.ts b/cli/src/rules/engine.ts new file mode 100644 index 0000000..9773652 --- /dev/null +++ b/cli/src/rules/engine.ts @@ -0,0 +1,46 @@ +import { Project } from '../project/project'; +import { createCollector, Finding } from './finding'; +import { recommendRemove } from './factories'; +import { checkCapacitorRules } from './rules-capacitor'; +import { checkCordovaPlugins, checkCordovaRules } from './rules-cordova'; +import { checkDeprecatedPlugins } from './rules-deprecated-plugins'; +import { checkIonicNativePackages } from './rules-ionic-native'; +import { checkPackages, checkRemoteDependencies } from './rules-packages'; +import { checkDeprecatedTsconfig } from './rules-typescript-config'; +import { checkWebProject } from './rules-web-project'; + +export async function runChecks(project: Project, packages: Record = {}): Promise { + const collector = createCollector(); + + checkDeprecatedTsconfig(project, collector); + checkPackages(project, collector); + + for (const deprecated of project.analyzer.deprecatedPackages(packages)) { + recommendRemove( + project, + collector, + deprecated.name, + deprecated.name, + `${deprecated.name} is deprecated: ${deprecated.message}`, + ); + } + + checkRemoteDependencies(project, collector); + checkDeprecatedPlugins(project, collector); + + if (project.isCordova) { + checkCordovaRules(project, collector); + } + + if (project.isCapacitor) { + await checkCapacitorRules(project, collector); + checkIonicNativePackages(packages, project, collector); + checkCordovaPlugins(packages, project, collector); + } + + if (!project.isCapacitor && !project.isCordova) { + checkWebProject(project, collector); + } + + return collector.findings; +} diff --git a/cli/src/rules/factories.ts b/cli/src/rules/factories.ts new file mode 100644 index 0000000..8d57562 --- /dev/null +++ b/cli/src/rules/factories.ts @@ -0,0 +1,467 @@ +import { existsSync } from 'fs'; +import { coerce, compare, lt } from 'semver'; +import { commandContext, npmInstall, npmUninstall, saveDevArgument, PackageManager } from '../build/node-commands'; +import { Project } from '../project/project'; +import { Category, Finding, FindingCollector, Severity } from './finding'; + +function slug(value: string): string { + return value.replace(/^@/, '').replace(/\//g, '-').replace(/_/g, '-'); +} + +function libString(lib: string, ver: string): string { + const vstr = ver ? ` (${ver})` : ''; + return `${lib}${vstr}`; +} + +function pm(project: Project) { + return commandContext(project); +} + +function fixCommand(project: Project, id: string, command: string | string[]): Finding['fix'] { + return { id, command }; +} + +function addFinding(collector: FindingCollector, finding: Finding | undefined): void { + if (finding) collector.add(finding); +} + +function equals(value: unknown, expected: unknown | unknown[]): boolean { + if (value == expected) return true; + if (Array.isArray(expected) && expected.includes(value)) return true; + return false; +} + +export function checkMinVersion( + project: Project, + collector: FindingCollector, + library: string, + minVersion: string, + reason?: string, + url?: string, + category: Category = 'packages', +): void { + const v = project.analyzer.getPackageVersion(library); + if (v && lt(v, minVersion)) { + const id = `min-version-${slug(library)}`; + const reasonText = reason ? ` ${reason}` : ''; + addFinding(collector, { + id, + severity: 'error', + category, + title: `${library} must be upgraded from ${v.version} to at least version ${minVersion}${reasonText}`, + detail: `${library} ${v.version} is below the minimum required version ${minVersion}.`, + url, + fixable: true, + fix: fixCommand(project, id, npmInstall(`${library}@latest`, pm(project))), + }); + } +} + +export function warnMinVersion( + project: Project, + collector: FindingCollector, + library: string, + minVersion: string, + reason?: string, + url?: string, + category: Category = 'packages', +): void { + const v = project.analyzer.getPackageVersion(library); + if (v && lt(v, minVersion)) { + const id = `min-version-warning-${slug(library)}`; + const reasonText = reason ? ` ${reason}` : ''; + addFinding(collector, { + id, + severity: 'info', + category, + title: `Update ${library} to at least ${minVersion}${reasonText}`, + detail: `${library} ${v.version} should be updated to at least ${minVersion}${reasonText}.`, + url, + fixable: true, + fix: fixCommand(project, id, npmInstall(`${library}@latest`, pm(project))), + }); + } +} + +export function checkConsistentVersions( + project: Project, + collector: FindingCollector, + lib1: string, + lib2: string, + category: Category = 'capacitor', +): void { + const deps = project.analyzer.getAllDependencies(); + const v1 = coerce(deps[lib1]); + const v2 = coerce(deps[lib2]); + if (v1 && v2 && compare(v1, v2)) { + const sameMajor = v1.major === v2.major; + const id = + lib1.startsWith('@capacitor/') && lib2.startsWith('@capacitor/') + ? 'capacitor-version-mismatch' + : `${slug(lib2)}-version-mismatch`; + addFinding(collector, { + id, + severity: sameMajor ? 'warning' : 'error', + category, + title: sameMajor + ? `Version of ${libString(lib2, v2.version)} should match ${libString(lib1, v1.version)}` + : `Version of ${libString(lib2, v2.version)} must match ${libString(lib1, v1.version)}`, + detail: `All related packages should share a compatible version. Align ${lib2} with ${lib1} (${v1.version}).`, + fixable: true, + fix: fixCommand(project, id, npmInstall(`${lib2}@${v1.version}`, pm(project))), + }); + } +} + +export function notRequiredPlugin(project: Project, collector: FindingCollector, name: string, message?: string): void { + if (!project.analyzer.exists(name)) return; + const msg = message ? `. ${message}` : ''; + const id = `not-required-plugin-${slug(name)}`; + addFinding(collector, { + id, + severity: 'info', + category: 'capacitor', + title: `${name} is not required with Capacitor`, + detail: `The plugin ${name} is not required with Capacitor${msg}`, + fixable: true, + fix: fixCommand(project, id, npmUninstall(name, pm(project))), + }); +} + +export function replacementPlugin( + project: Project, + collector: FindingCollector, + name: string, + replacement: string, + url?: string, + severity: Severity = 'info', + detail?: string, +): void { + if (!project.analyzer.exists(name)) return; + const reason = replacement.startsWith('@capacitor/') + ? ' as it has official support from the Capacitor team.' + : ' as it offers equivalent functionality.'; + const id = `replace-plugin-${slug(name)}`; + addFinding(collector, { + id, + severity, + category: 'capacitor', + title: `Replace ${name} with ${replacement}`, + detail: + detail ?? + `The plugin ${name} could be replaced with ${replacement}${reason} Replacing the plugin will require manual refactoring in your code.`, + url, + fixable: true, + fix: fixCommand(project, id, `${npmInstall(replacement, pm(project))} && ${npmUninstall(name, pm(project))}`), + }); +} + +export function incompatibleReplacementPlugin( + project: Project, + collector: FindingCollector, + name: string, + replacement: string, + url?: string, +): void { + if (!project.analyzer.exists(name)) return; + const id = `incompatible-replace-plugin-${slug(name)}`; + addFinding(collector, { + id, + severity: 'info', + category: 'capacitor', + title: `Replace ${name} with ${replacement}`, + detail: `The plugin ${name} is incompatible with Capacitor and must be replaced with ${replacement}${url ? ` (${url})` : ''}.`, + url, + fixable: true, + fix: fixCommand(project, id, `${npmInstall(replacement, pm(project))} && ${npmUninstall(name, pm(project))}`), + }); +} + +export function incompatiblePlugin(project: Project, collector: FindingCollector, name: string, url?: string): void { + if (!project.analyzer.exists(name)) return; + const isUrl = url?.startsWith('http'); + const msg = isUrl ? `See ${url}` : url ? url : ''; + const id = `incompatible-plugin-${slug(name)}`; + addFinding(collector, { + id, + severity: 'error', + category: 'capacitor', + title: `${name} is incompatible with Capacitor. ${msg}`, + detail: `The plugin ${name} is incompatible with Capacitor. ${msg}`, + url: isUrl ? url : `https://www.npmjs.com/package/${name}`, + fixable: false, + }); +} + +export function reviewPlugin(project: Project, collector: FindingCollector, name: string): void { + if (!project.analyzer.exists(name)) return; + const id = `review-plugin-${slug(name)}`; + addFinding(collector, { + id, + severity: 'warning', + category: 'capacitor', + title: `${name} requires Capacitor compatibility testing`, + detail: `The plugin ${name} requires testing for Capacitor compatibility.`, + fixable: false, + }); +} + +export function deprecatedPlugin( + project: Project, + collector: FindingCollector, + name: string, + message: string, + url?: string, +): void { + if (!project.analyzer.exists(name)) return; + const id = `deprecated-plugin-${slug(name)}`; + addFinding(collector, { + id, + severity: 'warning', + category: 'packages', + title: `${name} is deprecated`, + detail: `The plugin ${name} is deprecated. ${message}`, + url, + fixable: false, + }); +} + +export function recommendReplace( + project: Project, + collector: FindingCollector, + name: string, + title: string, + message: string, + description: string, + replacement: string, + category: Category = 'packages', +): void { + if (!project.analyzer.exists(name)) return; + const id = `replace-package-${slug(name)}`; + addFinding(collector, { + id, + severity: 'warning', + category, + title: title || message, + detail: description || message, + fixable: true, + fix: fixCommand(project, id, `${npmInstall(replacement, pm(project))} && ${npmUninstall(name, pm(project))}`), + }); +} + +export function recommendRemove( + project: Project, + collector: FindingCollector, + name: string, + title: string, + message: string, + description?: string, + url?: string, + category: Category = 'packages', +): void { + if (!project.analyzer.exists(name)) return; + const id = `remove-package-${slug(name)}`; + addFinding(collector, { + id, + severity: 'warning', + category, + title: title || message, + detail: description || message, + url, + fixable: true, + fix: fixCommand(project, id, npmUninstall(name, pm(project))), + }); +} + +export function recommendAdd( + project: Project, + collector: FindingCollector, + name: string, + title: string, + message: string, + description: string, + devDependency: boolean, + category: Category = 'packages', +): void { + const id = `missing-package-${slug(name)}`; + const flags = devDependency ? saveDevArgument(project.packageManager) : undefined; + addFinding(collector, { + id, + severity: 'warning', + category, + title: title || message, + detail: description || message, + fixable: true, + fix: fixCommand(project, id, npmInstall(name, pm(project), flags ?? '')), + }); +} + +export function recommendUpgrade( + project: Project, + collector: FindingCollector, + name: string, + title: string, + message: string, + fromVersion: string, + toVersion: string, + severity: Severity = 'warning', + category: Category = 'packages', +): void { + if (!project.analyzer.exists(name)) return; + let extra = ''; + if (name === '@capacitor/core') { + if (project.analyzer.exists('@capacitor/ios')) { + extra += ` @capacitor/ios@${toVersion}`; + } + if (project.analyzer.exists('@capacitor/android')) { + extra += ` @capacitor/android@${toVersion}`; + } + } + const id = `upgrade-package-${slug(name)}`; + addFinding(collector, { + id, + severity, + category, + title: title || message, + detail: `Upgrade ${name} from ${fromVersion} to ${toVersion}`, + url: `https://www.npmjs.com/package/${name}`, + fixable: true, + fix: fixCommand(project, id, npmInstall(`${name}@${toVersion}${extra}`, pm(project))), + }); +} + +export function checkNotExists( + project: Project, + collector: FindingCollector, + library: string, + message: string, + category: Category = 'packages', +): void { + if (!project.analyzer.exists(library)) return; + const id = `remove-package-${slug(library)}`; + addFinding(collector, { + id, + severity: 'error', + category, + title: `Remove ${library}`, + detail: `${library} ${message}`, + fixable: true, + fix: fixCommand(project, id, npmUninstall(library, pm(project))), + }); +} + +export function note( + project: Project, + collector: FindingCollector, + title: string, + message: string, + url?: string, + category: Category = 'packages', +): void { + const id = `note-${slug(title)}`; + addFinding(collector, { + id, + severity: 'info', + category, + title, + detail: message, + url, + fixable: false, + }); +} + +export function checkCordovaAndroidPreference( + project: Project, + collector: FindingCollector, + preference: string, + value: string | boolean, +): void { + const cordovaConfig = project.analyzer.getCordovaConfig(); + if (!cordovaConfig) return; + if (equals(cordovaConfig.androidPreferences?.[preference], value)) return; + const id = `cordova-android-preference-${slug(preference)}`; + addFinding(collector, { + id, + severity: 'error', + category: 'cordova', + title: `Set android preference ${preference} to ${value}`, + detail: `The android preference ${preference} should be ${value}. Add to in config.xml`, + fixable: false, + }); +} + +export function checkCordovaAndroidPreferenceMinimum( + project: Project, + collector: FindingCollector, + preference: string, + minVersion: string, +): void { + const cordovaConfig = project.analyzer.getCordovaConfig(); + if (!cordovaConfig) return; + const v = coerce(cordovaConfig.androidPreferences?.[preference]); + if (!v || lt(v, minVersion)) { + const id = `cordova-android-preference-min-${slug(preference)}`; + addFinding(collector, { + id, + severity: 'error', + category: 'cordova', + title: `Set android preference ${preference} to at least ${minVersion}`, + detail: `The android preference ${preference} should be at a minimum ${minVersion}. Add to in config.xml`, + fixable: false, + }); + } +} + +export function checkCordovaIosPreference( + project: Project, + collector: FindingCollector, + preference: string, + value: unknown, + preferredValue?: number, +): void { + const cordovaConfig = project.analyzer.getCordovaConfig(); + if (!cordovaConfig) return; + if (equals(cordovaConfig.iosPreferences?.[preference], value)) return; + const id = `cordova-ios-preference-${slug(preference)}`; + if (preferredValue) { + addFinding(collector, { + id, + severity: 'error', + category: 'cordova', + title: `Fix ios preference ${preference}`, + detail: `The ios preference ${preference} cannot be ${cordovaConfig.iosPreferences?.[preference]}. Add to in config.xml`, + fixable: false, + }); + } else { + addFinding(collector, { + id, + severity: 'error', + category: 'cordova', + title: `Set ios preference ${preference} to ${value}`, + detail: `The ios preference ${preference} should be ${value}. Add to in config.xml`, + fixable: false, + }); + } +} + +export function addRawFinding(collector: FindingCollector, finding: Finding): void { + collector.add(finding); +} + +export function hasNodeModules(project: Project): boolean { + const nmf = project.getNodeModulesFolder(); + return existsSync(nmf) || project.isModernYarn(); +} + +export function packageManagerName(project: Project): string { + switch (project.packageManager) { + case PackageManager.yarn: + return 'yarn'; + case PackageManager.pnpm: + return 'pnpm'; + case PackageManager.bun: + return 'bun'; + default: + return 'npm'; + } +} diff --git a/cli/src/rules/finding.ts b/cli/src/rules/finding.ts new file mode 100644 index 0000000..468b7a8 --- /dev/null +++ b/cli/src/rules/finding.ts @@ -0,0 +1,35 @@ +export type Severity = 'error' | 'warning' | 'info'; +export type Category = + | 'packages' + | 'capacitor' + | 'cordova' + | 'angular' + | 'typescript' + | 'android' + | 'ios' + | 'browserslist' + | 'security' + | 'privacy'; + +export interface Finding { + id: string; + severity: Severity; + category: Category; + title: string; + detail?: string; + url?: string; + fixable: boolean; + fix?: { id: string; command?: string | string[] }; +} + +export type FindingCollector = { add(f: Finding): void; findings: Finding[] }; + +export function createCollector(): FindingCollector { + const findings: Finding[] = []; + return { + findings, + add(f: Finding) { + findings.push(f); + }, + }; +} diff --git a/cli/src/rules/ignore.ts b/cli/src/rules/ignore.ts new file mode 100644 index 0000000..ea4d84f --- /dev/null +++ b/cli/src/rules/ignore.ts @@ -0,0 +1,20 @@ +import { loadConfig, projectConfigPath, saveConfig } from '../core/config'; +import { GlobalOptions } from '../cli/args'; + +export function getIgnoredIds(cwd: string, globalOpts: GlobalOptions = { cwd } as GlobalOptions): string[] { + const config = loadConfig(cwd, globalOpts); + return config.check?.ignore ?? []; +} + +export function addIgnoredId(cwd: string, id: string, globalOpts: GlobalOptions = { cwd } as GlobalOptions): void { + const config = loadConfig(cwd, globalOpts); + const ignore = config.check?.ignore ?? []; + if (!ignore.includes(id)) { + ignore.push(id); + } + saveConfig(projectConfigPath(cwd), { check: { ...config.check, ignore } }); +} + +export function isIgnored(id: string, ignored: string[]): boolean { + return ignored.includes(id); +} diff --git a/cli/src/rules/rules-angular-json.ts b/cli/src/rules/rules-angular-json.ts new file mode 100644 index 0000000..7d109ab --- /dev/null +++ b/cli/src/rules/rules-angular-json.ts @@ -0,0 +1,127 @@ +import { join } from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { Project } from '../project/project'; +import { FindingCollector } from './finding'; +import { MonoRepoType } from '../project/monorepo'; + +function parseAngularJSON(filename: string): any { + try { + return JSON.parse(readFileSync(filename, 'utf8')); + } catch { + try { + const txt = readFileSync(filename, 'utf8'); + const lines = txt.split('\n'); + let tmp = ''; + for (const line of lines) { + if (line && line.trim().startsWith('//')) { + continue; + } + tmp += line; + } + return JSON.parse(tmp); + } catch { + return undefined; + } + } +} + +export function checkAngularJson(project: Project, collector: FindingCollector): void { + try { + const filename = join(project.projectFolder(), 'angular.json'); + if (!existsSync(filename)) return; + + const angular = parseAngularJSON(filename); + if (!angular?.projects) return; + + for (const projectName of Object.keys(angular.projects)) { + checkWebpackToESBuild(angular, project, projectName, collector); + if (fixAOT(angular, project, projectName, collector)) break; + } + checkPackageManager(angular, project, collector); + } catch { + // angular.json may change over time — do not fail checks + } +} + +function checkWebpackToESBuild(angular: any, project: Project, projectName: string, collector: FindingCollector): void { + try { + const builder = angular.projects[projectName].architect?.build?.builder; + if ( + builder === '@angular-devkit/build-angular:browser' || + builder === '@angular-devkit/build-angular:browser-esbuild' + ) { + if (project.analyzer.isGreaterOrEqual('@angular/core', '17.0.0')) { + collector.add({ + id: 'angular-switch-esbuild', + severity: 'info', + category: 'angular', + title: 'Switch to ESBuild', + detail: + 'Angular 17 projects use ESBuild by default but your project is still using WebPack. Consider migrating to the application builder.', + url: 'https://angular.io/guide/esbuild', + fixable: false, + }); + } + } + } catch { + // ignore + } +} + +function checkPackageManager(angular: any, project: Project, collector: FindingCollector): void { + try { + if (project.repoType === MonoRepoType.pnpm) { + if (!angular.cli?.packageManager || angular.cli?.packageManager !== 'pnpm') { + collector.add({ + id: 'angular-cli-package-manager-pnpm', + severity: 'info', + category: 'angular', + title: 'Set Angular CLI to pnpm', + detail: 'It appears you are using pnpm but your Angular CLI is set to the default of npm.', + fixable: false, + }); + } + } else if (project.repoType === MonoRepoType.yarn) { + if (!angular.cli?.packageManager || angular.cli?.packageManager !== 'yarn') { + collector.add({ + id: 'angular-cli-package-manager-yarn', + severity: 'info', + category: 'angular', + title: 'Set Angular CLI to yarn', + detail: 'It appears you are using yarn but your Angular CLI is set to the default of npm.', + fixable: false, + }); + } + } + } catch { + // ignore + } +} + +function fixAOT(angular: any, project: Project, projectName: string, collector: FindingCollector): boolean { + if (angular.projects[projectName].architect?.build?.options?.aot === false) { + collector.add({ + id: 'angular-aot-disabled', + severity: 'error', + category: 'angular', + title: "Use Angular's recommended AOT compilation", + detail: + "The project disables AOT during development. Remove aot: false from angular.json to use Angular's recommended Ahead-of-Time compilation.", + fixable: false, + }); + return true; + } + return false; +} + +export function readAngularJson(project: Project): any { + try { + const filename = join(project.projectFolder(), 'angular.json'); + if (existsSync(filename)) { + return parseAngularJSON(filename); + } + return undefined; + } catch { + return undefined; + } +} diff --git a/cli/src/rules/rules-angular-toolkit.ts b/cli/src/rules/rules-angular-toolkit.ts new file mode 100644 index 0000000..04a2396 --- /dev/null +++ b/cli/src/rules/rules-angular-toolkit.ts @@ -0,0 +1,22 @@ +import { join } from 'path'; +import { existsSync, readFileSync } from 'fs'; +import { Project } from '../project/project'; +import { FindingCollector } from './finding'; + +export function checkMigrationAngularToolkit(project: Project, collector: FindingCollector): void { + const filename = join(project.projectFolder(), 'angular.json'); + if (!existsSync(filename)) return; + + const txt = readFileSync(filename, 'utf8'); + if (txt && txt.includes('ionic-cordova-build')) { + collector.add({ + id: 'angular-toolkit-cordova-config', + severity: 'error', + category: 'angular', + title: 'Migrate angular.json', + detail: + 'When using @ionic/angular-toolkit v6+ the ionic-cordova-build and ionic-cordova-serve sections in angular.json can be removed.', + fixable: false, + }); + } +} diff --git a/cli/src/rules/rules-browserslist.ts b/cli/src/rules/rules-browserslist.ts new file mode 100644 index 0000000..00a661d --- /dev/null +++ b/cli/src/rules/rules-browserslist.ts @@ -0,0 +1,50 @@ +import { join } from 'path'; +import { existsSync } from 'fs'; +import { Project } from '../project/project'; +import { FindingCollector } from './finding'; + +function defaultValues(): string[] { + return ['Chrome >=61', 'ChromeAndroid >=61', 'Firefox >=63', 'Firefox ESR', 'Edge >=79', 'Safari >=13', 'iOS >=13']; +} + +export function checkBrowsersList(project: Project, collector: FindingCollector): void { + try { + const list = project.analyzer.browsersList(); + if (list.length > 0) { + if (list.includes('> 0.5%') || list.includes('last 1 version')) { + collector.add({ + id: 'browserslist-poor-defaults', + severity: 'info', + category: 'browserslist', + title: 'Fix browserslist', + detail: + 'The browserslist in package.json may cause some older devices to show a white screen due to missing polyfills.', + fixable: false, + }); + } + return; + } + + const folder = project.projectFolder(); + let filename = join(folder, 'browserslist'); + if (!existsSync(filename)) { + filename = join(folder, '.browserslistrc'); + } + + if (project.analyzer.exists('@angular/core') && !existsSync(filename)) { + collector.add({ + id: 'browserslist-missing', + severity: 'info', + category: 'browserslist', + title: 'Set browser support', + detail: + 'Some older devices will not be supported. Updating your package.json to include browserslist will fix this.', + fixable: false, + }); + } + } catch { + // ignore + } +} + +export { defaultValues as browsersListDefaults }; diff --git a/cli/src/rules/rules-capacitor-plugins.ts b/cli/src/rules/rules-capacitor-plugins.ts new file mode 100644 index 0000000..e080813 --- /dev/null +++ b/cli/src/rules/rules-capacitor-plugins.ts @@ -0,0 +1,60 @@ +import { Project } from '../project/project'; +import { FindingCollector } from './finding'; +import { npx } from '../build/node-commands'; + +export function checkCapacitorPluginMigration(project: Project, collector: FindingCollector): void { + suggestCapacitorPluginMigration(project, collector, '6.0.0', '7.0.0', { + changesLink: 'https://capacitorjs.com/docs/updating/plugins/7-0', + migrateCommand: '@capacitor/plugin-migration-v6-to-v7@latest', + }); + suggestCapacitorPluginMigration(project, collector, '5.0.0', '6.0.0', { + changesLink: 'https://capacitorjs.com/docs/updating/plugins/6-0', + migrateCommand: '@capacitor/plugin-migration-v5-to-v6@latest', + }); + + if ( + project.analyzer.isGreaterOrEqual('@capacitor/core', '4.0.0') && + project.analyzer.isLess('@capacitor/core', '5.0.0') + ) { + collector.add({ + id: 'capacitor-plugin-migrate-to-5', + severity: 'error', + category: 'capacitor', + title: 'Migrate Capacitor plugin to version 5', + detail: 'Your Capacitor 4 plugin can be migrated to Capacitor 5.', + url: 'https://capacitorjs.com/docs/updating/plugins/5-0', + fixable: true, + fix: { + id: 'capacitor-plugin-migrate-to-5', + command: `${npx(project)} @capacitor/plugin-migration-v4-to-v5@latest`, + }, + }); + } +} + +function suggestCapacitorPluginMigration( + project: Project, + collector: FindingCollector, + minCapacitorCore: string, + maxCapacitorCore: string, + migrateOptions: { changesLink: string; migrateCommand: string }, +): void { + if (project.analyzer.isLess('@capacitor/core', maxCapacitorCore)) { + if (project.analyzer.isGreaterOrEqual('@capacitor/core', minCapacitorCore)) { + const id = `capacitor-plugin-migrate-to-${maxCapacitorCore.replace(/\./g, '-')}`; + collector.add({ + id, + severity: 'info', + category: 'capacitor', + title: `Migrate Capacitor plugin to ${maxCapacitorCore}`, + detail: `This Capacitor plugin can be migrated from ${minCapacitorCore} to version ${maxCapacitorCore}.`, + url: migrateOptions.changesLink, + fixable: true, + fix: { + id, + command: `${npx(project)} ${migrateOptions.migrateCommand}`, + }, + }); + } + } +} diff --git a/cli/src/rules/rules-capacitor.ts b/cli/src/rules/rules-capacitor.ts new file mode 100644 index 0000000..98b00d9 --- /dev/null +++ b/cli/src/rules/rules-capacitor.ts @@ -0,0 +1,965 @@ +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { MonoRepoType } from '../project/monorepo'; +import { Project } from '../project/project'; +import { checkPrivacyManifest } from '../native/privacy'; +import { commandContext, npmInstall } from '../build/node-commands'; +import { FindingCollector } from './finding'; +import { + checkConsistentVersions, + checkMinVersion, + hasNodeModules, + incompatiblePlugin, + incompatibleReplacementPlugin, + notRequiredPlugin, + recommendAdd, + recommendRemove, + recommendReplace, + replacementPlugin, +} from './factories'; +import { checkMigrationAngularToolkit } from './rules-angular-toolkit'; +import { checkAngularJson } from './rules-angular-json'; +import { checkBrowsersList } from './rules-browserslist'; + +function suggestCapacitorMigration( + project: Project, + collector: FindingCollector, + fromVersion: string, + toVersion: string, + url: string, +): void { + if ( + project.analyzer.isLess('@capacitor/core', toVersion) && + project.analyzer.isGreaterOrEqual('@capacitor/core', fromVersion) && + hasNodeModules(project) + ) { + collector.add({ + id: `migrate-capacitor-${toVersion.split('.')[0]}`, + severity: 'info', + category: 'capacitor', + title: `Migrate to Capacitor ${toVersion.split('.')[0]}`, + detail: `Your project is on Capacitor ${project.analyzer.getPackageVersion('@capacitor/core')?.version}. Consider migrating to Capacitor ${toVersion.split('.')[0]}.`, + url, + fixable: false, + }); + } +} + +function checkBuildGradleForMinifyInRelease(project: Project, collector: FindingCollector): void { + const filename = join(project.folder, 'android', 'app', 'build.gradle'); + if (existsSync(filename)) { + const txt = readFileSync(filename, 'utf8'); + if (txt.includes('minifyEnabled true')) { + checkMinVersion( + project, + collector, + '@capacitor/android', + '3.2.3', + 'to ensure Android release builds work when minifyEnabled is true', + 'https://developer.android.com/studio/build/shrink-code', + 'android', + ); + } + } +} + +async function checkPrivacyManifestRules(project: Project, collector: FindingCollector): Promise { + if (!project.analyzer.exists('@capacitor/ios')) return; + + const result = await checkPrivacyManifest(project.projectFolder(), (plugin) => project.analyzer.exists(plugin)); + if (!result.required) return; + + if (result.error) { + collector.add({ + id: 'privacy-manifest-error', + severity: 'warning', + category: 'privacy', + title: 'Privacy manifest check failed', + detail: result.error, + fixable: false, + }); + return; + } + + if (result.needsCreation) { + collector.add({ + id: 'privacy-manifest-missing', + severity: 'warning', + category: 'privacy', + title: 'Add Privacy Manifest', + detail: 'A Privacy Manifest file is required by Apple when submitting your app to the App Store.', + fixable: false, + }); + } + + for (const missing of result.missingCategories) { + collector.add({ + id: `privacy-manifest-category-${missing.api}`, + severity: 'warning', + category: 'privacy', + title: `Privacy manifest missing category ${missing.api}`, + detail: `Plugin ${missing.plugin} requires reason codes for ${missing.api}. See ${missing.reasonUrl}`, + url: missing.reasonUrl, + fixable: false, + }); + } +} + +export async function checkCapacitorRules(project: Project, collector: FindingCollector): Promise { + checkMinVersion(project, collector, '@capacitor/core', '2.2.0', undefined, undefined, 'capacitor'); + checkConsistentVersions(project, collector, '@capacitor/core', '@capacitor/cli'); + checkConsistentVersions(project, collector, '@capacitor/core', '@capacitor/ios'); + checkConsistentVersions(project, collector, '@capacitor/core', '@capacitor/android'); + + if (project.analyzer.exists('@ionic/cli')) { + checkMinVersion(project, collector, '@ionic/cli', '6.0.0', undefined, undefined, 'capacitor'); + } + if (!project.analyzer.exists('@capacitor/cli')) { + recommendAdd( + project, + collector, + '@capacitor/cli', + '@capacitor/cli', + 'Install @capacitor/cli', + 'The Capacitor CLI should be installed locally in your project', + true, + 'capacitor', + ); + } + + recommendReplace( + project, + collector, + 'cordova-plugin-appsflyer-sdk', + 'cordova-plugin-appsflyer-sdk', + 'Replace with appsflyer-capacitor-plugin.', + 'The plugin cordova-plugin-appsflyer-sdk should be replaced with appsflyer-capacitor-plugin.', + 'appsflyer-capacitor-plugin', + 'capacitor', + ); + + recommendReplace( + project, + collector, + '@ionic-enterprise/dialogs', + '@ionic-enterprise/dialogs', + 'Replace with @capacitor/dialog due to official support', + 'The plugin @ionic-enterprise/dialogs should be replaced with @capacitor/dialog as it is an officially supported Capacitor plugin', + '@capacitor/dialog', + 'capacitor', + ); + + recommendReplace( + project, + collector, + '@ionic-enterprise/app-rate', + '@ionic-enterprise/app-rate', + 'Replace with capacitor-rate-app due to Capacitor support', + 'The plugin @ionic-enterprise/app-rate should be replaced with capacitor-rate-app as designed to work with Capacitor', + 'capacitor-rate-app', + 'capacitor', + ); + + recommendReplace( + project, + collector, + '@ionic-enterprise/nativestorage', + '@ionic-enterprise/nativestorage', + 'Replace with @ionic/storage due to support', + 'The plugin @ionic-enterprise/nativestorage should be replaced with @ionic/storage. Consider @ionic-enterprise/secure-storage if encryption is required', + '@ionic/storage', + 'capacitor', + ); + + recommendReplace( + project, + collector, + 'cordova-plugin-advanced-http', + 'cordova-plugin-advanced-http', + 'Replace with @capacitor/core due to official support', + 'The plugin cordova-plugin-advanced-http should be replaced with @capacitor/core. Capacitor now provides the equivalent native http functionality built in.', + '@capacitor/core', + 'capacitor', + ); + + recommendRemove( + project, + collector, + '@ionic-enterprise/promise', + '@ionic-enterprise/promise', + 'This plugin should no longer be required in projects.', + undefined, + undefined, + 'capacitor', + ); + + recommendRemove( + project, + collector, + 'cordova-plugin-appminimize', + 'cordova-plugin-appminimize', + 'This plugin is not required and can be replaced with the minimizeApp method of @capacitor/app', + undefined, + 'https://capacitorjs.com/docs/apis/app#minimizeapp', + 'capacitor', + ); + + recommendRemove( + project, + collector, + 'cordova-plugin-datepicker', + 'cordova-plugin-datepicker', + 'This plugin appears to have been abandoned in 2015. Consider using ion-datetime.', + undefined, + undefined, + 'capacitor', + ); + + recommendRemove( + project, + collector, + '@jcesarmobile/ssl-skip', + '@jcesarmobile/ssl-skip', + 'This plugin should only be used during development. Submitting an app with it included will cause it to be rejected.', + undefined, + undefined, + 'security', + ); + + if (project.analyzer.exists('cordova-plugin-file-transfer') && !project.analyzer.exists('cordova-plugin-whitelist')) { + recommendAdd( + project, + collector, + 'cordova-plugin-whitelist', + 'cordova-plugin-file-transfer', + 'Install cordova-plugin-whitelist for compatibility', + 'The plugin cordova-plugin-file-transfer has a dependency on cordova-plugin-whitelist when used with a Capacitor project', + false, + 'capacitor', + ); + } + + if ( + project.analyzer.exists('@ionic-enterprise/auth') && + project.analyzer.isLessOrEqual('onesignal-cordova-plugin', '5.0.2') + ) { + recommendRemove( + project, + collector, + 'onesignal-cordova-plugin', + 'onesignal-cordova-plugin', + 'This plugin causes build errors on Android when used with Ionic Auth Connect. Upgrade to 5.0.3 or higher.', + undefined, + 'https://github.com/OneSignal/OneSignal-Cordova-SDK/issues/928', + 'capacitor', + ); + } + + if (project.analyzer.exists('@ionic/cordova-builders')) { + recommendRemove( + project, + collector, + '@ionic/cordova-builders', + '@ionic/cordova-builders', + 'This package is only required for Cordova projects.', + undefined, + undefined, + 'capacitor', + ); + } + + if (project.analyzer.isGreaterOrEqual('@ionic/angular-toolkit', '6.0.0')) { + checkMigrationAngularToolkit(project, collector); + } + + if (project.analyzer.exists('@capacitor/ios')) { + await checkPrivacyManifestRules(project, collector); + } + + if (project.analyzer.isGreaterOrEqual('@angular/core', '12.0.0')) { + checkAngularJson(project, collector); + if (project.analyzer.exists('@capacitor/android') || project.analyzer.exists('@capacitor/ios')) { + checkBrowsersList(project, collector); + } + if (project.analyzer.isLess('@ionic/cli', '7.2.0')) { + checkMinVersion(project, collector, '@ionic/cli', '7.2.0', 'to fix live reload support', undefined, 'capacitor'); + } + } + + if (project.analyzer.isLess('@capacitor/android', '3.2.3')) { + checkBuildGradleForMinifyInRelease(project, collector); + } + + if (project.analyzer.isLess('@capacitor/android', '3.0.0')) { + const coreVersion = project.analyzer.getPackageVersion('@capacitor/core')?.version; + collector.add({ + id: 'capacitor-play-store-deadline', + severity: 'error', + category: 'android', + title: 'Your app cannot be submitted to the Play Store after 1st November 2022', + detail: `Capacitor ${coreVersion} must be migrated to Capacitor 4 to meet Play Store requirements of minimum target of SDK 31.`, + url: 'https://capacitorjs.com/docs/updating/3-0', + fixable: false, + }); + } + + if (project.analyzer.isLess('@capacitor/core', '4.0.1') || project.analyzer.startsWith('@capacitor/core', '4.0.0')) { + if (hasNodeModules(project) && project.analyzer.isGreaterOrEqual('@capacitor/core', '3.0.0')) { + collector.add({ + id: 'migrate-capacitor-4', + severity: 'info', + category: 'capacitor', + title: 'Migrate to Capacitor 4', + detail: 'Recommend migration from Capacitor 3 to Capacitor 4.', + url: 'https://capacitorjs.com/docs/updating/4-0', + fixable: false, + }); + } + } + + suggestCapacitorMigration(project, collector, '4.0.0', '5.0.0', 'https://capacitorjs.com/docs/updating/5-0'); + suggestCapacitorMigration(project, collector, '5.0.0', '6.0.0', 'https://capacitorjs.com/docs/updating/6-0'); + suggestCapacitorMigration(project, collector, '6.0.0', '7.0.0', 'https://capacitorjs.com/docs/updating/7-0'); + suggestCapacitorMigration(project, collector, '7.0.0', '8.0.0', 'https://capacitorjs.com/docs/updating/8-0'); + + if (!project.analyzer.isGreaterOrEqual('@ionic-enterprise/identity-vault', '5.1.0')) { + checkMinVersion( + project, + collector, + '@ionic-enterprise/identity-vault', + '5.1.0', + 'as the current version is missing important security fixes.', + 'https://ionic.io/docs/identity-vault', + 'security', + ); + } + + const pluginFindings = await capacitorRecommendations(project, false); + for (const finding of pluginFindings) { + collector.add(finding); + } +} + +export async function capacitorRecommendations( + project: Project, + forMigration: boolean, +): Promise { + const findings: import('./finding').Finding[] = []; + const collector: FindingCollector = { + findings, + add(f: import('./finding').Finding) { + findings.push(f); + }, + }; + + function runCheck(fn: () => void) { + fn(); + } + + function addOptional(fn: () => void) { + if (!forMigration) fn(); + } + + if ( + project.repoType === MonoRepoType.nx && + !project.analyzer.exists('@nxext/capacitor') && + !project.analyzer.exists('@nxtend/capacitor') && + project.analyzer.exists('@nrwl/workspace') + ) { + collector.add({ + id: 'add-nx-capacitor-extension', + severity: 'info', + category: 'capacitor', + title: 'Add Capacitor Extension for NX', + detail: 'Add Capacitor Extension for NX?', + url: 'https://nxext.dev/docs/capacitor/overview.html', + fixable: true, + fix: { + id: 'add-nx-capacitor-extension', + command: npmInstall('@nxext/capacitor', commandContext(project)), + }, + }); + } + + if ( + !project.fileExists('capacitor.config.ts') && + !project.fileExists('capacitor.config.js') && + !project.fileExists('capacitor.config.json') && + !project.isCapacitorPlugin + ) { + collector.add({ + id: 'integrate-capacitor-config', + severity: 'info', + category: 'capacitor', + title: 'Integrate Capacitor', + detail: 'Add the Capacitor integration to this project', + url: 'https://capacitorjs.com/docs/cordova/migrating-from-cordova-to-capacitor', + fixable: false, + }); + } else { + if (!project.hasCapacitorProject(CapacitorPlatform.android) && hasNodeModules(project)) { + collector.add({ + id: 'add-android-project', + severity: 'info', + category: 'android', + title: 'Add Android Project', + detail: 'Add Android support to your Capacitor project?', + fixable: false, + }); + } + if (!project.hasCapacitorProject(CapacitorPlatform.ios) && hasNodeModules(project)) { + collector.add({ + id: 'add-ios-project', + severity: 'info', + category: 'ios', + title: 'Add iOS Project', + detail: 'Add iOS support to your Capacitor project?', + fixable: false, + }); + } + } + + if ( + project.analyzer.exists('@ionic/angular') && + !project.analyzer.exists('@angular/service-worker') && + project.analyzer.isGreaterOrEqual('@angular/core', '17.0.0') + ) { + collector.add({ + id: 'add-pwa-integration', + severity: 'info', + category: 'angular', + title: 'Add PWA Integration', + detail: 'Add @angular/pwa and integrate splash and icon resources', + fixable: false, + }); + } + + runCheck(() => + incompatiblePlugin( + project, + collector, + 'cordova-plugin-admobpro', + 'https://github.com/ionic-team/capacitor/issues/1101', + ), + ); + runCheck(() => + incompatiblePlugin( + project, + collector, + 'cordova-plugin-braintree', + 'https://github.com/ionic-team/capacitor/issues/1415', + ), + ); + runCheck(() => + incompatiblePlugin( + project, + collector, + 'cordova-plugin-code-push', + 'https://github.com/microsoft/code-push/issues/615', + ), + ); + runCheck(() => + incompatiblePlugin(project, collector, 'cordova-plugin-fcm', 'https://github.com/ionic-team/capacitor/issues/584'), + ); + runCheck(() => + incompatiblePlugin( + project, + collector, + 'cordova-plugin-firebase', + 'https://github.com/ionic-team/capacitor/issues/815', + ), + ); + + runCheck(() => notRequiredPlugin(project, collector, 'cordova-support-google-services')); + runCheck(() => incompatiblePlugin(project, collector, 'cordova-plugin-passbook')); + runCheck(() => + incompatibleReplacementPlugin(project, collector, 'cordova-plugin-ionic-keyboard', '@capacitor/keyboard'), + ); + + if ( + project.analyzer.exists('cordova-sqlite-storage') && + project.analyzer.exists('@ionic-enterprise/secure-storage') + ) { + recommendRemove( + project, + collector, + 'cordova-sqlite-storage', + 'Conflict with Secure Storage', + 'cordova-sqlite-storage cannot be used with Secure Storage (@ionic-enterprise/secure-storage) as it will cause compilation errors. cordova-sqlite-storage should be removed.', + undefined, + undefined, + 'capacitor', + ); + } + + runCheck(() => + incompatiblePlugin( + project, + collector, + 'cordova-plugin-firebasex', + 'https://github.com/dpa99c/cordova-plugin-firebasex/issues/610#issuecomment-810236545', + ), + ); + runCheck(() => + incompatiblePlugin(project, collector, 'cordova-plugin-music-controls', 'It causes build failures, skipped'), + ); + runCheck(() => + incompatiblePlugin( + project, + collector, + 'cordova-plugin-qrscanner', + 'https://github.com/ionic-team/capacitor/issues/1213', + ), + ); + runCheck(() => + incompatiblePlugin( + project, + collector, + 'cordova-plugin-swrve', + 'It relies on Cordova specific feature CDVViewController', + ), + ); + runCheck(() => + incompatiblePlugin(project, collector, 'cordova-plugin-ios-keychain', 'It is not compatible with Capacitor'), + ); + + runCheck(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-googlemaps', + '@capacitor/google-maps', + 'It causes build failures on iOS but can be replaced with @capacitor/google-maps and will require code refactoring.', + 'error', + ), + ); + runCheck(() => + replacementPlugin( + project, + collector, + 'capacitor-rate-app', + '@capacitor-community/in-app-review', + 'The capacitor-rate-app plugin has been deprecated in favor of @capacitor-community/in-app-review.', + 'error', + ), + ); + runCheck(() => + incompatiblePlugin( + project, + collector, + 'newrelic-cordova-plugin', + 'It relies on Cordova hooks. https://github.com/newrelic/newrelic-cordova-plugin/issues/15', + ), + ); + runCheck(() => + replacementPlugin( + project, + collector, + 'phonegap-plugin-push', + '@havesource/cordova-plugin-push', + 'It will not compile but can be replaced with the plugin cordova-plugin-push', + ), + ); + runCheck(() => + incompatiblePlugin( + project, + collector, + 'cordova-plugin-appsflyer-sdk', + 'It will not compile but can be replaced with the plugin appsflyer-capacitor-plugin', + ), + ); + + runCheck(() => notRequiredPlugin(project, collector, 'cordova-plugin-compat')); + if (!project.analyzer.exists('cordova-plugin-file-transfer')) { + runCheck(() => + notRequiredPlugin( + project, + collector, + 'cordova-plugin-whitelist', + 'The functionality is built into Capacitors configuration file', + ), + ); + } + runCheck(() => + notRequiredPlugin( + project, + collector, + 'cordova-plugin-crosswalk-webview', + 'Capacitor doesn’t allow to change the webview', + ), + ); + runCheck(() => + notRequiredPlugin( + project, + collector, + 'cordova-plugin-ionic-webview', + 'An App store compliant Webview is built into Capacitor', + ), + ); + runCheck(() => + notRequiredPlugin( + project, + collector, + 'cordova-plugin-wkwebview-engine', + 'An App store compliant Webview is built into Capacitor', + ), + ); + runCheck(() => + notRequiredPlugin( + project, + collector, + 'cordova-plugin-androidx', + 'This was required for Cordova Android 10 support but is not required by Capacitor', + ), + ); + runCheck(() => + notRequiredPlugin( + project, + collector, + 'cordova-android-support-gradle-release', + 'Capacitor provides control to set library versions', + ), + ); + runCheck(() => + notRequiredPlugin( + project, + collector, + 'cordova-plugin-add-swift-support', + 'Swift is supported out-of-the-box with Capacitor', + ), + ); + runCheck(() => + notRequiredPlugin( + project, + collector, + 'cordova-plugin-enable-multidex', + 'Multidex is handled by Android Studio and does not require a plugin', + ), + ); + runCheck(() => + notRequiredPlugin( + project, + collector, + 'cordova-support-android-plugin', + 'This plugin is used to simplify Cordova plugin development and is not required for Capacitor', + ), + ); + runCheck(() => + notRequiredPlugin( + project, + collector, + 'cordova-plugin-androidx-adapter', + 'Android Studio patches plugins for AndroidX without requiring this plugin', + ), + ); + runCheck(() => + notRequiredPlugin(project, collector, 'cordova-custom-config', 'Configuration achieved through native projects'), + ); + runCheck(() => + notRequiredPlugin(project, collector, 'cordova-plugin-cocoapod-support', 'Pod dependencies supported in Capacitor'), + ); + runCheck(() => + notRequiredPlugin(project, collector, 'phonegap-plugin-multidex', 'Android Studio handles compilation'), + ); + + runCheck(() => + checkMinVersion( + project, + collector, + 'cordova-plugin-inappbrowser', + '5.0.0', + 'to compile in a Capacitor project', + undefined, + 'capacitor', + ), + ); + runCheck(() => + checkMinVersion( + project, + collector, + 'cordova-plugin-camera', + '6.0.0', + 'to compile in a Capacitor project', + undefined, + 'capacitor', + ), + ); + runCheck(() => + checkMinVersion( + project, + collector, + 'cordova.plugins.diagnostic', + '6.1.1', + 'to compile in a Capacitor project', + undefined, + 'capacitor', + ), + ); + runCheck(() => + checkMinVersion( + project, + collector, + 'cordova-plugin-file-opener2', + '2.1.1', + 'to compile in a Capacitor project', + undefined, + 'capacitor', + ), + ); + runCheck(() => + checkMinVersion( + project, + collector, + 'cordova-plugin-statusbar', + '3.0.0', + 'to compile in a Capacitor project', + undefined, + 'capacitor', + ), + ); + runCheck(() => + checkMinVersion( + project, + collector, + 'branch-cordova-sdk', + '4.0.0', + 'Requires update. See: https://help.branch.io/developers-hub/docs/capacitor', + 'https://help.branch.io/developers-hub/docs/capacitor', + 'capacitor', + ), + ); + + runCheck(() => incompatibleReplacementPlugin(project, collector, 'sentry-cordova', '@sentry/capacitor')); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-actionsheet', + '@capacitor/action-sheet', + 'https://capacitorjs.com/docs/apis/action-sheet', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-camera', + '@capacitor/camera', + 'https://capacitorjs.com/docs/apis/camera', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'ionic-plugin-deeplinks', + '@capacitor/app', + 'https://capacitorjs.com/docs/guides/deep-links', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-customurlscheme', + '@capacitor/app', + 'https://capacitorjs.com/docs/guides/deep-links', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + '@ionic-enterprise/clipboard', + '@capacitor/clipboard', + 'https://capacitorjs.com/docs/apis/clipboard', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + '@ionic-enterprise/deeplinks', + '@capacitor/app', + 'https://capacitorjs.com/docs/guides/deep-links', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + '@ionic-enterprise/statusbar', + '@capacitor/status-bar', + 'https://capacitorjs.com/docs/apis/status-bar', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-firebase', + '@capacitor-community/fcm', + 'https://github.com/capacitor-community/fcm', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-firebase-messaging', + '@capacitor/push-notifications', + 'https://capacitorjs.com/docs/apis/push-notifications', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-firebase-analytics', + '@capacitor-community/firebase-analytics', + 'https://github.com/capacitor-community/firebase-analytics', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-app-version', + '@capacitor/app', + 'https://capacitorjs.com/docs/apis/app', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-dialogs', + '@capacitor/dialog', + 'https://capacitorjs.com/docs/apis/dialog', + ), + ); + + if (!project.analyzer.exists('cordova-plugin-advanced-http')) { + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-file', + '@capacitor/filesystem', + 'https://capacitorjs.com/docs/apis/filesystem', + ), + ); + } + + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-file-transfer', + '@capacitor/filesystem', + 'https://capacitorjs.com/docs/apis/filesystem', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-datepicker', + '@capacitor-community/date-picker', + 'https://github.com/capacitor-community/date-picker', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-geolocation', + '@capacitor/geolocation', + 'https://capacitorjs.com/docs/apis/geolocation', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-sqlite-storage', + '@capacitor-community/sqlite', + 'https://github.com/capacitor-community/sqlite', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-safariviewcontroller', + '@capacitor/browser', + 'https://capacitorjs.com/docs/apis/browser', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-appavailability', + '@capacitor/app', + 'https://capacitorjs.com/docs/apis/app', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-network-information', + '@capacitor/network', + 'https://capacitorjs.com/docs/apis/network', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-device', + '@capacitor/device', + 'https://capacitorjs.com/docs/apis/device', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-ionic-keyboard', + '@capacitor/keyboard', + 'https://capacitorjs.com/docs/apis/keyboard', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-splashscreen', + '@capacitor/splash-screen', + 'https://capacitorjs.com/docs/apis/splash-screen', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'cordova-plugin-statusbar', + '@capacitor/status-bar', + 'https://capacitorjs.com/docs/apis/status-bar', + ), + ); + addOptional(() => + replacementPlugin( + project, + collector, + 'phonegap-plugin-push', + '@capacitor/push-notifications', + 'https://capacitorjs.com/docs/apis/push-notifications', + ), + ); + + return findings; +} diff --git a/cli/src/rules/rules-cordova.ts b/cli/src/rules/rules-cordova.ts new file mode 100644 index 0000000..a5be8e2 --- /dev/null +++ b/cli/src/rules/rules-cordova.ts @@ -0,0 +1,245 @@ +import { Project } from '../project/project'; +import { FindingCollector } from './finding'; +import { commandContext, npmInstall } from '../build/node-commands'; +import { + checkCordovaAndroidPreference, + checkCordovaAndroidPreferenceMinimum, + checkCordovaIosPreference, + checkMinVersion, + checkNotExists, + recommendAdd, + recommendRemove, + recommendReplace, + warnMinVersion, +} from './factories'; + +export function checkCordovaRules(project: Project, collector: FindingCollector): void { + warnMinVersion( + project, + collector, + 'cordova-android', + '10.0.1', + 'to be able to target Android SDK v30 which is required for all submissions to the Play Store', + undefined, + 'cordova', + ); + warnMinVersion(project, collector, 'cordova-ios', '6.1.0', undefined, undefined, 'cordova'); + + if (project.analyzer.isGreaterOrEqual('cordova-android', '10.0.0')) { + checkNotExists( + project, + collector, + 'cordova-plugin-whitelist', + 'should be removed as its functionality is now built into Cordova', + 'cordova', + ); + checkNotExists( + project, + collector, + 'phonegap-plugin-multidex', + 'is not compatible with cordova-android 10+', + 'cordova', + ); + checkNotExists( + project, + collector, + 'cordova-plugin-androidx', + 'is not required when using cordova-android 10+', + 'cordova', + ); + checkNotExists( + project, + collector, + 'cordova-plugin-androidx-adapter', + 'is not required when using cordova-android 10+', + 'cordova', + ); + checkNotExists( + project, + collector, + 'phonegap-plugin-push', + 'is deprecated and does not support Android X. Migrate to using cordova-plugin-push', + 'cordova', + ); + + checkMinVersion( + project, + collector, + 'cordova-plugin-inappbrowser', + '5.0.0', + 'to support Android 10+', + undefined, + 'cordova', + ); + checkMinVersion( + project, + collector, + 'cordova-plugin-ionic-webview', + '5.0.0', + 'to support Android 10+', + undefined, + 'cordova', + ); + checkMinVersion( + project, + collector, + 'scandit-cordova-datacapture-barcode', + '6.9.1', + 'to support Android 10+', + undefined, + 'cordova', + ); + checkMinVersion( + project, + collector, + 'cordova-plugin-ionic', + '5.5.0', + 'to support Android 10+', + undefined, + 'cordova', + ); + checkCordovaAndroidPreference(project, collector, 'AndroidXEnabled', true); + + if (project.analyzer.exists('cordova-plugin-push') || project.analyzer.exists('@havesource/cordova-plugin-push')) { + checkCordovaAndroidPreference(project, collector, 'GradlePluginGoogleServicesEnabled', true); + checkCordovaAndroidPreferenceMinimum(project, collector, 'GradlePluginGoogleServicesVersion', '4.3.8'); + } + } else { + checkNotExists( + project, + collector, + 'cordova-plugin-whitelist', + 'is deprecated and no longer required with cordova-android v10+', + 'cordova', + ); + } + + if (project.isCapacitor) { + collector.add({ + id: 'cordova-remnants-in-capacitor', + severity: 'error', + category: 'cordova', + title: 'Remove Cordova remnants from package.json', + detail: 'Your project is based on Capacitor but has remnants of cordova in the package.json file.', + fixable: false, + }); + } + + if (project.analyzer.isGreaterOrEqual('@ionic/angular-toolkit', '6.0.0')) { + if (!project.analyzer.exists('@ionic/cordova-builders') && !project.isCapacitor) { + recommendAdd( + project, + collector, + '@ionic/cordova-builders', + '@ionic/cordova-builders', + 'Install @ionic/cordova-builders for compatibility', + 'The package @ionic/cordova-builders is required when @ionic/angular-toolkit is version 6 and higher.', + true, + 'cordova', + ); + } + } + + recommendReplace( + project, + collector, + 'phonegap-plugin-push', + 'phonegap-plugin-push', + 'Replace with cordova-plugin-push due to deprecation', + 'The plugin phonegap-plugin-push should be replaced with cordova-plugin-push as phonegap-plugin-push was deprecated in 2017', + '@havesource/cordova-plugin-push', + 'cordova', + ); + + if (project.analyzer.exists('cordova-plugin-customurlscheme') && project.analyzer.exists('ionic-plugin-deeplinks')) { + recommendRemove( + project, + collector, + 'cordova-plugin-customurlscheme', + 'cordova-plugin-customurlscheme', + 'Remove as the functionality is part of ionic-plugin-deeplinks which is already installed.', + undefined, + undefined, + 'cordova', + ); + } + + if (project.analyzer.exists('@ionic-enterprise/identity-vault')) { + checkCordovaIosPreference(project, collector, 'UseSwiftLanguageVersion', [4.2, 5], 4.2); + if (!project.analyzer.isGreaterOrEqual('@ionic-enterprise/identity-vault', '5.0.0')) { + checkMinVersion( + project, + collector, + '@ionic-enterprise/identity-vault', + '5.0.0', + 'Update to v5 as it contains significant security fixes and broader support for Android security features', + 'https://ionic.io/docs/identity-vault', + 'security', + ); + } else if (!project.analyzer.isGreaterOrEqual('@ionic-enterprise/identity-vault', '5.1.0')) { + checkMinVersion( + project, + collector, + '@ionic-enterprise/identity-vault', + '5.1.0', + 'as the current version is missing important security fixes.', + 'https://ionic.io/docs/identity-vault', + 'security', + ); + } + } + + if ( + project.analyzer.exists('cordova-support-google-services') && + project.analyzer.isGreaterOrEqual('cordova-android', '9.0.0') + ) { + recommendRemove( + project, + collector, + 'cordova-support-google-services', + 'cordova-support-google-services', + 'Remove as the functionality is built into cordova-android 9+. See: https://github.com/chemerisuk/cordova-support-google-services', + undefined, + undefined, + 'cordova', + ); + } +} + +export function checkCordovaPlugins( + packages: Record, + project: Project, + collector: FindingCollector, +): void { + if (Object.keys(packages).length === 0) return; + + const ignorePlugins = ['cordova-plugin-add-swift-support', 'cordova-plugin-ionic-webview']; + const missing: string[] = []; + + for (const library of Object.keys(packages)) { + if (packages[library].depType === 'Plugin') { + for (const dependentPlugin of packages[library].plugin?.dependentPlugins ?? []) { + if ( + !project.analyzer.exists(dependentPlugin) && + !ignorePlugins.includes(dependentPlugin) && + !missing.includes(dependentPlugin) + ) { + missing.push(dependentPlugin); + const id = `missing-package-${dependentPlugin.replace(/^@/, '').replace(/\//g, '-')}`; + collector.add({ + id, + severity: 'warning', + category: 'cordova', + title: `Missing dependency ${dependentPlugin}`, + detail: `The plugin ${library} has a dependency on ${dependentPlugin} but it is missing from your project. It should be installed.`, + fixable: true, + fix: { + id, + command: npmInstall(dependentPlugin, commandContext(project)), + }, + }); + } + } + } + } +} diff --git a/cli/src/rules/rules-deprecated-plugins.ts b/cli/src/rules/rules-deprecated-plugins.ts new file mode 100644 index 0000000..4d6fad3 --- /dev/null +++ b/cli/src/rules/rules-deprecated-plugins.ts @@ -0,0 +1,65 @@ +import { Project } from '../project/project'; +import { FindingCollector } from './finding'; +import { deprecatedPlugin, recommendRemove } from './factories'; + +export function checkDeprecatedPlugins(project: Project, collector: FindingCollector): void { + deprecatedPlugin( + project, + collector, + 'adobe-mobile-services', + 'Mobile Services reaches end-of-life on December 31, 2022', + 'https://experienceleague.adobe.com/docs/mobile-services/using/eol.html?lang=en', + ); + + deprecatedPlugin( + project, + collector, + 'cordova-plugin-crop', + 'cordova-plugin-crop is deprecated and does not support Android 11+', + 'https://github.com/jeduan/cordova-plugin-crop#readme', + ); + + deprecatedPlugin( + project, + collector, + 'cordova-plugin-appcenter-analytics', + 'App Center is deprecating support for Cordova SDK in April 2022', + 'https://devblogs.microsoft.com/appcenter/announcing-apache-cordova-retirement', + ); + deprecatedPlugin( + project, + collector, + 'cordova-plugin-appcenter-crashes', + 'App Center is deprecating support for Cordova SDK in April 2022', + 'https://devblogs.microsoft.com/appcenter/announcing-apache-cordova-retirement', + ); + deprecatedPlugin( + project, + collector, + 'cordova-plugin-appcenter-shared', + 'App Center is deprecating support for Cordova SDK in April 2022', + 'https://devblogs.microsoft.com/appcenter/announcing-apache-cordova-retirement', + ); + + deprecatedPlugin( + project, + collector, + 'cordova-plugin-contacts', + 'Consider migration to @capacitor-community/contacts', + ); + + deprecatedPlugin( + project, + collector, + '@ionic-enterprise/offline-storage', + 'Replace this plugin with @ionic-enterprise/secure-storage', + ); + + recommendRemove( + project, + collector, + 'jetifier', + 'jetifier', + 'This tool was used to transition non-AndroidX libraries. By now though all plugins support Android 10 and this tool should be removed.', + ); +} diff --git a/cli/src/rules/rules-ionic-native.ts b/cli/src/rules/rules-ionic-native.ts new file mode 100644 index 0000000..c8de11e --- /dev/null +++ b/cli/src/rules/rules-ionic-native.ts @@ -0,0 +1,358 @@ +import { commandContext, npmInstall, npmUninstall } from '../build/node-commands'; +import { Project } from '../project/project'; +import { FindingCollector } from './finding'; +import { deprecatedPlugin, recommendRemove } from './factories'; + +export function checkIonicNativePackages( + packages: Record, + project: Project, + collector: FindingCollector, +): void { + const wrappersAndPlugins = getWrappersAndPlugins(); + const deprecatedPackages = getDeprecatedPackages(); + for (const name of Object.keys(packages)) { + if (name.startsWith('@ionic-native/')) { + const replacement = name.replace('@ionic-native', '@awesome-cordova-plugins'); + if (deprecatedPackages.includes(name)) { + deprecatedPlugin(project, collector, name, 'Its support was removed from @awesome-cordova-plugins'); + } else if (project.analyzer.exists(replacement)) { + recommendRemove( + project, + collector, + name, + name, + `You already have a newer version of this package installed (${replacement}) so ${name} can be uninstalled as it is not needed`, + ); + } else { + replacePackage(project, collector, name, replacement); + } + } else if (name.startsWith('@awesome-cordova-plugins')) { + const plugin = wrappersAndPlugins[name]; + if (plugin && !project.analyzer.exists(plugin)) { + recommendRemove( + project, + collector, + name, + name, + `You have the typescript wrapper '${name}' installed but do not have the matching plugin '${plugin}' installed.`, + ); + } + } + } +} + +function replacePackage(project: Project, collector: FindingCollector, name: string, replacement: string): void { + const id = `migrate-ionic-native-${name.replace(/^@/, '').replace(/\//g, '-')}`; + collector.add({ + id, + severity: 'info', + category: 'packages', + title: `Migrate ${name} to @awesome-cordova-plugins`, + detail: `@ionic-native migrated to @awesome-cordova-plugins in 2021. You can safely migrate from ${name} to ${replacement}`, + fixable: true, + fix: { + id, + command: `${npmInstall(replacement, commandContext(project))} && ${npmUninstall(name, commandContext(project))}`, + }, + }); +} + +function getWrappersAndPlugins(): Record { + return { + '@awesome-cordova-plugins/abbyy-rtr': 'cordova-plugin-abbyy-rtr-sdk', + '@awesome-cordova-plugins/action-sheet': 'cordova-plugin-actionsheet', + '@awesome-cordova-plugins/admob-plus': 'cordova-admob-plus', + '@awesome-cordova-plugins/admob-pro': 'cordova-plugin-admobpro', + '@awesome-cordova-plugins/admob': 'cordova-admob', + '@awesome-cordova-plugins/aes-256': 'cordova-plugin-aes256-encryption', + '@awesome-cordova-plugins/all-in-one-sdk': 'cordova-paytm-allinonesdk', + '@awesome-cordova-plugins/analytics-firebase': 'cordova-plugin-analytics', + '@awesome-cordova-plugins/android-exoplayer': 'cordova-plugin-exoplayer', + '@awesome-cordova-plugins/android-full-screen': 'cordova-plugin-fullscreen', + '@awesome-cordova-plugins/android-notch': 'cordova-plugin-android-notch', + '@awesome-cordova-plugins/android-permissions': 'cordova-plugin-android-permissions', + '@awesome-cordova-plugins/anyline': 'io-anyline-cordova', + '@awesome-cordova-plugins/app-availability': 'cordova-plugin-appavailability', + '@awesome-cordova-plugins/app-center-analytics': 'cordova-plugin-appcenter-analytics', + '@awesome-cordova-plugins/app-center-crashes': 'cordova-plugin-appcenter-crashes', + '@awesome-cordova-plugins/app-center-push': 'cordova-plugin-appcenter-push', + '@awesome-cordova-plugins/app-center-shared': 'cordova-plugin-appcenter-shared', + '@awesome-cordova-plugins/app-preferences': 'cordova-plugin-app-preferences', + '@awesome-cordova-plugins/app-rate': 'cordova-plugin-apprate', + '@awesome-cordova-plugins/app-version': 'cordova-plugin-app-version', + '@awesome-cordova-plugins/apple-wallet': 'cordova-apple-wallet', + '@awesome-cordova-plugins/approov-advanced-http': 'cordova-approov-advanced-http', + '@awesome-cordova-plugins/background-fetch': 'cordova-plugin-background-fetch', + '@awesome-cordova-plugins/background-geolocation': '@mauron85/cordova-plugin-background-geolocation', + '@awesome-cordova-plugins/background-mode': 'cordova-plugin-background-mode', + '@awesome-cordova-plugins/background-upload': 'cordova-plugin-background-upload', + '@awesome-cordova-plugins/badge': 'cordova-plugin-badge', + '@awesome-cordova-plugins/barcode-scanner': 'phonegap-plugin-barcodescanner', + '@awesome-cordova-plugins/battery-status': 'cordova-plugin-battery-status', + '@awesome-cordova-plugins/biocatch': 'cordova-plugin-biocatch', + '@awesome-cordova-plugins/biometric-wrapper': 'undefined', + '@awesome-cordova-plugins/ble': 'cordova-plugin-ble-central', + '@awesome-cordova-plugins/blinkid': 'blinkid-cordova', + '@awesome-cordova-plugins/bluetooth-classic-serial-port': 'cordova-plugin-bluetooth-classic-serial-port', + '@awesome-cordova-plugins/bluetooth-le': 'cordova-plugin-bluetoothle', + '@awesome-cordova-plugins/bluetooth-serial': 'cordova-plugin-bluetooth-serial', + '@awesome-cordova-plugins/branch-io': 'branch-cordova-sdk', + '@awesome-cordova-plugins/broadcaster': 'cordova-plugin-broadcaster', + '@awesome-cordova-plugins/browser-tab': 'cordova-plugin-browsertab', + '@awesome-cordova-plugins/build-info': 'cordova-plugin-buildinfo', + '@awesome-cordova-plugins/calendar': 'cordova-plugin-calendar', + '@awesome-cordova-plugins/call-directory': 'cordova-plugin-call-directory', + '@awesome-cordova-plugins/call-number': 'call-number', + '@awesome-cordova-plugins/camera-preview': 'cordova-plugin-camera-preview', + '@awesome-cordova-plugins/camera': 'cordova-plugin-camera', + '@awesome-cordova-plugins/checkout': 'undefined', + '@awesome-cordova-plugins/chooser': 'cordova-plugin-chooser', + '@awesome-cordova-plugins/clevertap': 'clevertap-cordova', + '@awesome-cordova-plugins/clipboard': 'cordova-clipboard', + '@awesome-cordova-plugins/cloud-settings': 'cordova-plugin-cloud-settings', + '@awesome-cordova-plugins/code-push': 'cordova-plugin-code-push', + '@awesome-cordova-plugins/deeplinks': 'ionic-plugin-deeplinks', + '@awesome-cordova-plugins/device-accounts': 'cordova-device-accounts-v2', + '@awesome-cordova-plugins/device-motion': 'cordova-plugin-device-motion', + '@awesome-cordova-plugins/device-orientation': 'cordova-plugin-device-orientation', + '@awesome-cordova-plugins/device': 'cordova-plugin-device', + '@awesome-cordova-plugins/dfu-update': 'cordova-plugin-dfu-update', + '@awesome-cordova-plugins/diagnostic': 'cordova.plugins.diagnostic', + '@awesome-cordova-plugins/dialogs': 'cordova-plugin-dialogs', + '@awesome-cordova-plugins/dns': 'cordova-plugin-dns', + '@awesome-cordova-plugins/document-scanner': 'cordova-plugin-document-scanner', + '@awesome-cordova-plugins/document-viewer': 'cordova-plugin-document-viewer', + '@awesome-cordova-plugins/dynamsoft-barcode-scanner': 'cordova-plugin-dynamsoft-barcode-reader', + '@awesome-cordova-plugins/email-composer': 'cordova-plugin-email-composer', + '@awesome-cordova-plugins/fabric': 'cordova-fabric-plugin', + '@awesome-cordova-plugins/facebook': 'cordova-plugin-facebook-connect', + '@awesome-cordova-plugins/fcm': 'cordova-plugin-fcm-with-dependecy-updated', + '@awesome-cordova-plugins/file-opener': 'cordova-plugin-file-opener2', + '@awesome-cordova-plugins/file-path': 'cordova-plugin-filepath', + '@awesome-cordova-plugins/file-transfer': 'cordova-plugin-file-transfer', + '@awesome-cordova-plugins/file': 'cordova-plugin-file', + '@awesome-cordova-plugins/fingerprint-aio': 'cordova-plugin-fingerprint-aio', + '@awesome-cordova-plugins/firebase-analytics': 'cordova-plugin-firebase-analytics', + '@awesome-cordova-plugins/firebase-authentication': 'cordova-plugin-firebase-authentication', + '@awesome-cordova-plugins/firebase-config': 'cordova-plugin-firebase-config', + '@awesome-cordova-plugins/firebase-crash': 'cordova-plugin-firebase-crash', + '@awesome-cordova-plugins/firebase-crashlytics': 'cordova-plugin-firebase-crashlytics', + '@awesome-cordova-plugins/firebase-dynamic-links': 'cordova-plugin-firebase-dynamiclinks', + '@awesome-cordova-plugins/firebase-messaging': 'cordova-plugin-firebase-messaging', + '@awesome-cordova-plugins/firebase-vision': 'cordova-plugin-firebase-mlvision', + '@awesome-cordova-plugins/firebase-x': 'cordova-plugin-firebasex', + '@awesome-cordova-plugins/firebase': 'cordova-plugin-firebase', + '@awesome-cordova-plugins/flashlight': 'cordova-plugin-flashlight', + '@awesome-cordova-plugins/foreground-service': 'cordova-plugin-foreground-service', + '@awesome-cordova-plugins/ftp': 'cordova-plugin-ftp', + '@awesome-cordova-plugins/gao-de-location': 'cordova-plugin-gaodelocation-chenyu', + '@awesome-cordova-plugins/gcdwebserver': 'cordova-plugin-gcdwebserver', + '@awesome-cordova-plugins/ge-tui-sdk-plugin': 'cordova-plugin-getuisdk', + '@awesome-cordova-plugins/geolocation': 'cordova-plugin-geolocation', + '@awesome-cordova-plugins/globalization': 'cordova-plugin-globalization', + '@awesome-cordova-plugins/google-analytics': 'cordova-plugin-google-analytics', + '@awesome-cordova-plugins/google-nearby': 'cordova-plugin-google-nearby', + '@awesome-cordova-plugins/google-plus': 'cordova-plugin-googleplus', + '@awesome-cordova-plugins/header-color': 'cordova-plugin-headercolor', + '@awesome-cordova-plugins/health-kit': 'com.telerik.plugins.healthkit', + '@awesome-cordova-plugins/health': 'cordova-plugin-health', + '@awesome-cordova-plugins/http': 'cordova-plugin-advanced-http', + '@awesome-cordova-plugins/iamport-cordova': 'iamport-cordova', + '@awesome-cordova-plugins/ibeacon': 'cordova-plugin-ibeacon', + '@awesome-cordova-plugins/image-picker': 'cordova-plugin-telerik-imagepicker', + '@awesome-cordova-plugins/imap': 'cordova-plugin-imap', + '@awesome-cordova-plugins/in-app-browser': 'cordova-plugin-inappbrowser', + '@awesome-cordova-plugins/in-app-purchase-2': 'cordova-plugin-purchase', + '@awesome-cordova-plugins/in-app-review': 'com.omarben.inappreview', + '@awesome-cordova-plugins/in-app-update': 'cordova-in-app-update', + '@awesome-cordova-plugins/insomnia': 'cordova-plugin-insomnia', + '@awesome-cordova-plugins/instagram': 'cordova-instagram-plugin', + '@awesome-cordova-plugins/intercom': 'cordova-plugin-intercom', + '@awesome-cordova-plugins/ionic-webview': 'cordova-plugin-ionic-webview', + '@awesome-cordova-plugins/ios-aswebauthenticationsession-api': 'cordova-plugin-ios-aswebauthenticationsession-api', + '@awesome-cordova-plugins/is-debug': 'cordova-plugin-is-debug', + '@awesome-cordova-plugins/keyboard': 'cordova-plugin-ionic-keyboard', + '@awesome-cordova-plugins/keychain': 'cordova-plugin-ios-keychain', + '@awesome-cordova-plugins/kommunicate': 'kommunicate-cordova-plugin', + '@awesome-cordova-plugins/launch-navigator': 'uk.co.workingedge.phonegap.plugin.launchnavigator', + '@awesome-cordova-plugins/launch-review': 'cordova-launch-review', + '@awesome-cordova-plugins/local-backup': 'cordova-plugin-local-backup', + '@awesome-cordova-plugins/local-notifications': 'cordova-plugin-local-notification', + '@awesome-cordova-plugins/location-accuracy': 'cordova-plugin-request-location-accuracy', + '@awesome-cordova-plugins/lottie-splash-screen': 'undefined', + '@awesome-cordova-plugins/media-capture': 'cordova-plugin-media-capture', + '@awesome-cordova-plugins/media': 'cordova-plugin-media', + '@awesome-cordova-plugins/metrix': 'ir.metrix.sdk', + '@awesome-cordova-plugins/mixpanel': 'cordova-plugin-mixpanel', + '@awesome-cordova-plugins/mlkit-translate': 'cordova-plugin-mlkit-translate', + '@awesome-cordova-plugins/mobile-messaging': 'com-infobip-plugins-mobilemessaging', + '@awesome-cordova-plugins/multiple-document-picker': 'cordova-plugin-multiple-documents-picker', + '@awesome-cordova-plugins/music-controls': 'cordova-plugin-music-controls2', + '@awesome-cordova-plugins/native-audio': 'cordova-plugin-nativeaudio', + '@awesome-cordova-plugins/native-geocoder': 'cordova-plugin-nativegeocoder', + '@awesome-cordova-plugins/native-keyboard': 'cordova-plugin-native-keyboard', + '@awesome-cordova-plugins/native-page-transitions': 'com.telerik.plugins.nativepagetransitions', + '@awesome-cordova-plugins/native-storage': 'cordova-plugin-nativestorage', + '@awesome-cordova-plugins/native-view': 'cordova-plugin-nativeview', + '@awesome-cordova-plugins/network-interface': 'cordova-plugin-networkinterface', + '@awesome-cordova-plugins/network': 'cordova-plugin-network-information', + '@awesome-cordova-plugins/ocr': 'cordova-plugin-mobile-ocr', + '@awesome-cordova-plugins/onesignal': 'onesignal-cordova-plugin', + '@awesome-cordova-plugins/open-native-settings': 'cordova-open-native-settings', + '@awesome-cordova-plugins/openalpr': 'cordova-plugin-openalpr', + '@awesome-cordova-plugins/paytabs': 'com.paytabs.cordova.plugin', + '@awesome-cordova-plugins/pdf-generator': 'cordova-pdf-generator', + '@awesome-cordova-plugins/photo-library': 'cordova-plugin-photo-library', + '@awesome-cordova-plugins/photo-viewer': 'com-sarriaroman-photoviewer', + '@awesome-cordova-plugins/play-install-referrer': 'cordova-plugin-play-installreferrer', + '@awesome-cordova-plugins/pollfish': 'com.pollfish.cordova_plugin', + '@awesome-cordova-plugins/power-management': 'cordova-plugin-powermanagement', + '@awesome-cordova-plugins/power-optimization': 'cordova-plugin-power-optimization', + '@awesome-cordova-plugins/printer': 'cordova-plugin-printer', + '@awesome-cordova-plugins/pspdfkit-cordova': 'pspdfkit-cordova', + '@awesome-cordova-plugins/purchases': 'cordova-plugin-purchases', + '@awesome-cordova-plugins/push': 'phonegap-plugin-push', + '@awesome-cordova-plugins/pushape-push': 'pushape-cordova-push', + '@awesome-cordova-plugins/safari-view-controller': 'cordova-plugin-safariviewcontroller', + '@awesome-cordova-plugins/screen-orientation': 'cordova-plugin-screen-orientation', + '@awesome-cordova-plugins/secure-storage-echo': 'cordova-plugin-secure-storage-echo', + '@awesome-cordova-plugins/secure-storage': 'cordova-plugin-secure-storage-echo', + '@awesome-cordova-plugins/service-discovery': 'cordova-plugin-discovery', + '@awesome-cordova-plugins/shake': 'cordova-plugin-shake', + '@awesome-cordova-plugins/sign-in-with-apple': 'cordova-plugin-sign-in-with-apple', + '@awesome-cordova-plugins/sms-retriever': 'cordova-plugin-sms-retriever-manager', + '@awesome-cordova-plugins/sms': 'cordova-sms-plugin', + '@awesome-cordova-plugins/social-sharing': 'cordova-plugin-x-socialsharing', + '@awesome-cordova-plugins/speech-recognition': 'cordova-plugin-speechrecognition', + '@awesome-cordova-plugins/spinner-dialog': 'cordova-plugin-native-spinner', + '@awesome-cordova-plugins/splash-screen': 'cordova-plugin-splashscreen', + '@awesome-cordova-plugins/spotify-auth': 'cordova-spotify-oauth', + '@awesome-cordova-plugins/sqlite-db-copy': 'cordova-plugin-dbcopy', + '@awesome-cordova-plugins/sqlite-porter': 'uk.co.workingedge.cordova.plugin.sqliteporter', + '@awesome-cordova-plugins/sqlite': 'cordova-sqlite-storage', + '@awesome-cordova-plugins/star-prnt': 'cordova-plugin-starprnt', + '@awesome-cordova-plugins/status-bar': 'cordova-plugin-statusbar', + '@awesome-cordova-plugins/streaming-media': 'cordova-plugin-streaming-media', + '@awesome-cordova-plugins/stripe': 'cordova-plugin-stripe', + '@awesome-cordova-plugins/sum-up': 'cordova-sumup-plugin', + '@awesome-cordova-plugins/system-alert-window-permission': 'cordova-plugin-system-alert-window-permission', + '@awesome-cordova-plugins/taptic-engine': 'cordova-plugin-taptic-engine', + '@awesome-cordova-plugins/text-to-speech-advanced': 'cordova-plugin-tts-advanced', + '@awesome-cordova-plugins/theme-detection': 'cordova-plugin-theme-detection', + '@awesome-cordova-plugins/three-dee-touch': 'cordova-plugin-3dtouch', + '@awesome-cordova-plugins/toast': 'cordova-plugin-x-toast', + '@awesome-cordova-plugins/touch-id': 'cordova-plugin-touch-id', + '@awesome-cordova-plugins/uptime': 'cordova-plugin-uptime', + '@awesome-cordova-plugins/urbanairship': 'urbanairship-cordova', + '@awesome-cordova-plugins/usabilla-cordova-sdk': 'usabilla-cordova', + '@awesome-cordova-plugins/vibes': 'vibes-cordova', + '@awesome-cordova-plugins/vibration': 'cordova-plugin-vibration', + '@awesome-cordova-plugins/video-editor': 'cordova-plugin-video-editor', + '@awesome-cordova-plugins/web-intent': 'com-darryncampbell-cordova-plugin-intent', + '@awesome-cordova-plugins/web-server': 'cordova-plugin-webserver2', + '@awesome-cordova-plugins/web-socket-server': 'cordova-plugin-websocket-server', + '@awesome-cordova-plugins/webengage': 'cordova-plugin-webengage', + '@awesome-cordova-plugins/wechat': 'cordova-plugin-wechat --variable wechatappid=YOUR_WECHAT_APPID', + '@awesome-cordova-plugins/wheel-selector': 'cordova-wheel-selector-plugin', + '@awesome-cordova-plugins/wifi-wizard-2': 'cordova-plugin-wifiwizard2', + '@awesome-cordova-plugins/wonderpush': 'wonderpush-cordova-sdk', + '@awesome-cordova-plugins/youtube-video-player': 'cordova-plugin-youtube-video-player', + '@awesome-cordova-plugins/zbar': 'cordova-plugin-cszbar', + '@awesome-cordova-plugins/zeroconf': 'cordova-plugin-zeroconf', + '@awesome-cordova-plugins/zoom': 'cordova.plugin.zoom', + }; +} + +function getDeprecatedPackages(): string[] { + return [ + '@ionic-native/admob-free', + '@ionic-native/alipay', + '@ionic-native/android-fingerprint-auth', + '@ionic-native/app-launcher', + '@ionic-native/app-minimize', + '@ionic-native/app-update', + '@ionic-native/apple-pay', + '@ionic-native/appodeal', + '@ionic-native/audio-management', + '@ionic-native/autostart', + '@ionic-native/backlight', + '@ionic-native/baidu-push', + '@ionic-native/base64-to-gallery', + '@ionic-native/base64', + '@ionic-native/blinkup', + '@ionic-native/braintree', + '@ionic-native/brightness', + '@ionic-native/browser-tab', + '@ionic-native/call-log', + '@ionic-native/card-io', + '@ionic-native/class-kit', + '@ionic-native/clover-go', + '@ionic-native/colored-browser-tabs', + '@ionic-native/contacts', + '@ionic-native/couchbase-lite', + '@ionic-native/crop', + '@ionic-native/date-picker', + '@ionic-native/db-meter', + '@ionic-native/device-feedback', + '@ionic-native/downloader', + '@ionic-native/emm-app-config', + '@ionic-native/estimote-beacons', + '@ionic-native/extended-device-information', + '@ionic-native/file-encryption', + '@ionic-native/file-picker', + '@ionic-native/flurry-analytics', + '@ionic-native/full-screen-image', + '@ionic-native/geofence', + '@ionic-native/google-play-games-services', + '@ionic-native/gyroscope', + '@ionic-native/hce', + '@ionic-native/hot-code-push', + '@ionic-native/hotspot', + '@ionic-native/httpd', + '@ionic-native/image-resizer', + '@ionic-native/in-app-purchase', + '@ionic-native/index-app-content', + '@ionic-native/janalytics', + '@ionic-native/jumio', + '@ionic-native/keychain-touch-id', + '@ionic-native/last-cam', + '@ionic-native/luxand', + '@ionic-native/magnetometer', + '@ionic-native/market', + '@ionic-native/mobile-accessibility', + '@ionic-native/ms-adal', + '@ionic-native/native-ringtones', + '@ionic-native/navigation-bar', + '@ionic-native/paypal', + '@ionic-native/pedometer', + '@ionic-native/phonegap-local-notification', + '@ionic-native/pin-check', + '@ionic-native/pin-dialog', + '@ionic-native/pinterest', + '@ionic-native/power-management', + '@ionic-native/qqsdk', + '@ionic-native/qr-scanner', + '@ionic-native/quikkly', + '@ionic-native/regula-document-reader', + '@ionic-native/restart', + '@ionic-native/rollbar', + '@ionic-native/screenshot', + '@ionic-native/sensors', + '@ionic-native/serial', + '@ionic-native/shop-checkout', + '@ionic-native/shortcuts-android', + '@ionic-native/sim', + '@ionic-native/siri-shortcuts', + '@ionic-native/speechkit', + '@ionic-native/ssh-connect', + '@ionic-native/stepcounter', + '@ionic-native/text-to-speech', + '@ionic-native/themeable-browser', + '@ionic-native/twitter-connect', + '@ionic-native/uid', + '@ionic-native/unique-device-id', + '@ionic-native/user-agent', + '@ionic-native/video-capture-plus', + '@ionic-native/zip', + ]; +} diff --git a/cli/src/rules/rules-packages.ts b/cli/src/rules/rules-packages.ts new file mode 100644 index 0000000..af8d8f9 --- /dev/null +++ b/cli/src/rules/rules-packages.ts @@ -0,0 +1,166 @@ +import { existsSync } from 'fs'; +import { Project } from '../project/project'; +import { FindingCollector } from './finding'; +import { + checkMinVersion, + checkNotExists, + hasNodeModules, + note, + recommendAdd, + recommendRemove, + recommendReplace, + warnMinVersion, +} from './factories'; +import { npmInstallAll, commandContext } from '../build/node-commands'; + +/** + * General rules for packages like momentjs, jquery, etc. + */ +export function checkPackages(project: Project, collector: FindingCollector): void { + const nmf = project.getNodeModulesFolder(); + const nodeModulesPresent = existsSync(nmf) || project.isModernYarn(); + if (!nodeModulesPresent) { + collector.add({ + id: 'missing-node-modules', + severity: 'info', + category: 'packages', + title: 'Install node modules', + detail: 'node_modules is missing. Install dependencies before building or running the project.', + fixable: true, + fix: { id: 'missing-node-modules', command: npmInstallAll(commandContext(project)) }, + }); + } + + recommendReplace( + project, + collector, + 'moment', + 'momentjs', + 'Migrate the deprecated moment.js to date-fns', + 'Migrate away from the deprecated library moment. A good replacement is date-fns which is significantly smaller and built for modern tooling (https://date-fns.org/)', + 'date-fns', + ); + + recommendRemove( + project, + collector, + 'jquery', + 'jQuery', + 'Refactor your code to remove the dependency on jquery. Much of the API for Jquery is now available in browsers and often Jquery code conflicts with code written in your framework of choice.', + ); + + recommendRemove( + project, + collector, + 'rxjs-compat', + 'rxjs-compat', + 'Migrate your code from rxjs v5 to v6+ so that rxjs-compat is not necessary.', + undefined, + 'https://ncjamieson.com/avoiding-rxjs-compat/', + ); + + if ( + project.analyzer.exists('@ionic-enterprise/identity-vault') && + project.analyzer.exists('cordova-plugin-android-fingerprint-auth') + ) { + recommendRemove( + project, + collector, + 'cordova-plugin-android-fingerprint-auth', + 'cordova-plugin-android-fingerprint-auth', + 'This plugin should be removed as it cannot be used in conjunction with Identity Vault (which provides the same functionality).', + ); + } + + recommendRemove( + project, + collector, + 'node-sass', + 'node-sass', + 'The dependency node-sass is deprecated and should be removed from package.json.', + ); + + if ( + project.analyzer.exists('cordova-plugin-file-opener2') && + project.analyzer.isLessOrEqual('cordova-plugin-file-opener2', '3.0.5') + ) { + recommendRemove( + project, + collector, + 'cordova-plugin-file-opener2', + 'cordova-plugin-file-opener2', + 'Your project uses cordova-plugin-file-opener2 which will be rejected from the Play Store due to use of REQUEST_INSTALL_PACKAGES permission. Upgrade to version 4+', + ); + } + + if (project.analyzer.exists('ionic-angular')) { + if (project.analyzer.exists('@ionic/angular')) { + recommendRemove( + project, + collector, + 'ionic-angular', + 'ionic-angular', + 'Your project has 2 versions of Ionic Angular installed (ionic-angular and @ionic/angular). You should remove ionic-angular', + ); + } else { + note( + project, + collector, + '@ionic/angular', + 'Your Ionic project should be migrated to @ionic/angular version 5 or higher', + 'https://ionicframework.com/docs/reference/migration#migrating-from-ionic-3-0-to-ionic-4-0', + ); + } + } + + warnMinVersion( + project, + collector, + '@angular/core', + '10.0.0', + '. Your version is no longer supported.', + 'https://angular.io/guide/releases#support-policy-and-schedule', + 'angular', + ); + + if (!project.analyzer.exists('@awesome-cordova-plugins/core')) { + const matching = project.analyzer.matchingBeginingWith('@awesome-cordova-plugins'); + if (matching.length > 0) { + recommendAdd( + project, + collector, + '@awesome-cordova-plugins/core', + '@awesome-cordova-plugins/core', + 'Missing @awesome-cordova-plugins/core', + 'You are using awesome-cordova-plugins which require @awesome-cordova-plugins/core.', + false, + ); + } + } + + if (project.analyzer.isGreaterOrEqual('@angular/core', '11.0.0')) { + checkNotExists( + project, + collector, + 'codelyzer', + 'was popular in Angular projects before version 11 but has been superceded by angular-eslint. You can remove this dependency.', + 'angular', + ); + } +} + +export function checkRemoteDependencies(project: Project, collector: FindingCollector): void { + const packages = project.analyzer.remotePackages(); + if (packages.length > 0) { + collector.add({ + id: 'remote-dependencies', + severity: 'warning', + category: 'packages', + title: 'Remote dependencies detected', + detail: `Using dependencies from locations like github or http mean that no 2 builds are guaranteed to produce the same binary. These packages should use versioned dependencies: ${packages.join(', ')}`, + fixable: false, + }); + } +} + +export { hasNodeModules }; diff --git a/cli/src/rules/rules-typescript-config.ts b/cli/src/rules/rules-typescript-config.ts new file mode 100644 index 0000000..387d10b --- /dev/null +++ b/cli/src/rules/rules-typescript-config.ts @@ -0,0 +1,210 @@ +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, join, resolve } from 'path'; +import { stripJsonComments } from '../project/strip-json-comments'; +import { Project } from '../project/project'; +import { FindingCollector } from './finding'; + +interface CompilerOptions { + baseUrl?: string; + downlevelIteration?: boolean; + paths?: Record; + [key: string]: unknown; +} + +interface TsConfig { + extends?: string; + compilerOptions?: CompilerOptions; + [key: string]: unknown; +} + +interface AngularProject { + root?: string; + architect?: Record; +} + +export function joinBaseUrlPath(baseUrl: string, pathValue: string): string { + if (pathValue.startsWith('./') || pathValue.startsWith('../')) { + return pathValue; + } + const base = baseUrl.replace(/\/$/, '') || '.'; + if (base === '.' || base === './') { + return pathValue.startsWith('.') ? pathValue : `./${pathValue}`; + } + const combined = `${base}/${pathValue}`.replace(/\/+/g, '/'); + return combined.startsWith('.') ? combined : `./${combined}`; +} + +export function hasDeprecatedCompilerOptions(compilerOptions?: CompilerOptions): boolean { + if (!compilerOptions) return false; + return compilerOptions.baseUrl !== undefined || compilerOptions.downlevelIteration !== undefined; +} + +export function migrateCompilerOptions(compilerOptions: CompilerOptions): boolean { + let changed = false; + + if (compilerOptions.downlevelIteration !== undefined) { + delete compilerOptions.downlevelIteration; + changed = true; + } + + const baseUrl = compilerOptions.baseUrl; + if (baseUrl !== undefined) { + if (compilerOptions.paths) { + const newPaths: Record = {}; + for (const [key, values] of Object.entries(compilerOptions.paths)) { + newPaths[key] = values.map((value) => joinBaseUrlPath(baseUrl, value)); + } + compilerOptions.paths = newPaths; + } + delete compilerOptions.baseUrl; + changed = true; + } + + return changed; +} + +function readTsconfig(filename: string): TsConfig | undefined { + if (!existsSync(filename)) return undefined; + try { + return JSON.parse(stripJsonComments(readFileSync(filename, 'utf8'))); + } catch { + return undefined; + } +} + +function resolveTsconfigPath(fromFile: string, extendsPath: string): string { + let resolved = resolve(dirname(fromFile), extendsPath); + if (!existsSync(resolved) && !resolved.endsWith('.json')) { + const withJson = `${resolved}.json`; + if (existsSync(withJson)) { + resolved = withJson; + } + } + return resolved; +} + +export function collectTsconfigSearchDirs(project: Project): string[] { + const projectFolder = project.projectFolder(); + const repoRoot = project.folder; + const dirs = [projectFolder]; + + const parent = dirname(projectFolder); + if (parent !== projectFolder && !dirs.includes(parent)) { + dirs.push(parent); + } + + if (repoRoot !== projectFolder && !dirs.includes(repoRoot)) { + dirs.push(repoRoot); + } + + return dirs; +} + +function collectTsconfigFilesInDir(folder: string, files: Set): void { + if (!existsSync(folder)) return; + for (const entry of readdirSync(folder, { withFileTypes: true })) { + if (entry.isFile() && entry.name.startsWith('tsconfig') && entry.name.endsWith('.json')) { + files.add(join(folder, entry.name)); + } + } +} + +function collectExtendedTsconfigs(filename: string, files: Set): void { + const tsconfig = readTsconfig(filename); + const extendsPath = tsconfig?.extends; + if (typeof extendsPath !== 'string') return; + const parent = resolveTsconfigPath(filename, extendsPath); + if (!files.has(parent)) { + files.add(parent); + collectExtendedTsconfigs(parent, files); + } +} + +function findAngularJsonPaths(project: Project): string[] { + const paths: string[] = []; + for (const candidate of [join(project.projectFolder(), 'angular.json'), join(project.folder, 'angular.json')]) { + if (existsSync(candidate) && !paths.includes(candidate)) { + paths.push(candidate); + } + } + return paths; +} + +function collectTsconfigFromAngularJson(angularJsonPath: string, files: Set, projectName?: string): void { + const angular = readTsconfig(angularJsonPath) as { projects?: Record } | undefined; + if (!angular?.projects) return; + const angularDir = dirname(angularJsonPath); + const projects = + projectName && angular.projects[projectName] ? { [projectName]: angular.projects[projectName] } : angular.projects; + + for (const prj of Object.values(projects)) { + const projectRoot = join(angularDir, prj.root ?? ''); + for (const target of Object.values(prj.architect ?? {})) { + const tsConfig = target.options?.tsConfig; + if (typeof tsConfig === 'string') { + files.add(resolve(projectRoot, tsConfig)); + } + } + } +} + +function collectAllTsconfigFiles(project: Project): Set { + const files = new Set(); + + for (const folder of collectTsconfigSearchDirs(project)) { + collectTsconfigFilesInDir(folder, files); + } + for (const angularJson of findAngularJsonPaths(project)) { + collectTsconfigFromAngularJson(angularJson, files, project.monoRepo?.name); + } + + for (const file of [...files]) { + collectExtendedTsconfigs(file, files); + } + + return files; +} + +function fixTsconfigFile(filename: string): boolean { + if (!existsSync(filename)) return false; + const before = readFileSync(filename, 'utf8'); + const tsconfig = readTsconfig(filename); + if (!tsconfig?.compilerOptions || !migrateCompilerOptions(tsconfig.compilerOptions)) { + return false; + } + + const commentMatch = before.match(/^\/\*[\s\S]*?\*\/\s*\n/); + const header = commentMatch ? commentMatch[0] : ''; + writeFileSync(filename, `${header}${JSON.stringify(tsconfig, null, 2)}\n`); + return true; +} + +export function hasDeprecatedTsconfigInProject(project: Project): boolean { + for (const file of collectAllTsconfigFiles(project)) { + const tsconfig = readTsconfig(file); + if (hasDeprecatedCompilerOptions(tsconfig?.compilerOptions)) { + return true; + } + } + return false; +} + +export function fixDeprecatedTsconfigOptions(project: Project): void { + for (const file of collectAllTsconfigFiles(project)) { + fixTsconfigFile(file); + } +} + +export function checkDeprecatedTsconfig(project: Project, collector: FindingCollector): void { + if (!project.analyzer.isGreaterOrEqual('@angular/core', '12.0.0')) return; + if (!hasDeprecatedTsconfigInProject(project)) return; + + collector.add({ + id: 'typescript-deprecated-compiler-options', + severity: 'error', + category: 'typescript', + title: 'Fix deprecated TypeScript compiler options', + detail: 'Remove baseUrl and downlevelIteration from tsconfig files for TypeScript 5.9+ compatibility.', + fixable: false, + }); +} diff --git a/cli/src/rules/rules-web-project.ts b/cli/src/rules/rules-web-project.ts new file mode 100644 index 0000000..44884e8 --- /dev/null +++ b/cli/src/rules/rules-web-project.ts @@ -0,0 +1,40 @@ +import { join } from 'path'; +import { existsSync } from 'fs'; +import { Project } from '../project/project'; +import { FindingCollector } from './finding'; +import { readAngularJson } from './rules-angular-json'; +import { checkCapacitorPluginMigration } from './rules-capacitor-plugins'; + +export function checkWebProject(project: Project, collector: FindingCollector): void { + if (project.isCapacitorPlugin) { + checkCapacitorPluginMigration(project, collector); + } + + if (!project.isCapacitorPlugin) { + collector.add({ + id: 'integrate-capacitor', + severity: 'info', + category: 'capacitor', + title: 'Integrate Capacitor', + detail: 'Integrate Capacitor with this project to make it native mobile?', + url: 'https://capacitorjs.com', + fixable: false, + }); + } +} + +export function guessOutputFolder(project: Project): string { + try { + const angular = readAngularJson(project); + for (const projectName of Object.keys(angular.projects)) { + const outputPath = angular.projects[projectName].architect.build.options.outputPath; + if (outputPath) { + const browser = join(project.projectFolder(), outputPath, 'browser'); + return existsSync(browser) ? browser : outputPath; + } + } + } catch { + return 'dist'; + } + return 'dist'; +} diff --git a/cli/src/templates/new.ts b/cli/src/templates/new.ts new file mode 100644 index 0000000..107f955 --- /dev/null +++ b/cli/src/templates/new.ts @@ -0,0 +1,200 @@ +import { existsSync, readdirSync } from 'fs'; +import { join } from 'path'; +import { CapacitorPlatform } from '../project/capacitor-platform'; +import { MonoRepoType } from '../project/monorepo'; +import { PackageCommandContext, PackageManager, npmInstall } from '../build/node-commands'; +import { replaceAll } from '../core/text'; +import { frameworks, starterTemplates, targets, Template } from './starter-templates'; + +export interface NewProjectInput { + name: string; + type: string; + template: string; + targets: string[]; +} + +export interface ProjectOptions { + noGit: boolean; + folder: string; + packageId: string; + name: string; + noInstall?: boolean; +} + +export function getProjectName(name: string): string { + name = name.toLocaleLowerCase().replace(/ /g, '-'); + return name.replace(/[^a-zA-Z0-9- ]/g, ''); +} + +export function getPackageId(name: string): string { + let packageId = name.replace(/ /g, '.').replace(/-/g, '.'); + if (!packageId.includes('.')) { + packageId = `ionic.${packageId}`; + } + + const parts = packageId.split('.'); + for (const part of parts) { + if (!isNaN(part as any)) { + packageId = packageId.replace(part, `v${part}`); + } + } + return packageId.trim(); +} + +export function asAppId(name: string): string { + if (!name) return 'Unknown'; + name = name.split('-').join('.'); + name = name.split(' ').join('.'); + if (!name.includes('.')) { + name = 'com.' + name; + } + return name; +} + +function toTitleCase(text: string): string { + return text.replace(/\w\S*/g, (txt: string) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()).trim(); +} + +function defaultPmContext(pm?: string): PackageCommandContext { + const map: Record = { + npm: PackageManager.npm, + pnpm: PackageManager.pnpm, + yarn: PackageManager.yarn, + bun: PackageManager.bun, + }; + return { + packageManager: map[pm ?? 'npm'] ?? PackageManager.npm, + repoType: MonoRepoType.none, + }; +} + +export function getCommands(project: NewProjectInput, options: ProjectOptions, commands?: string[]): string[] { + const isIonic = ['angular-standalone', 'angular', 'react', 'vue'].includes(project.type); + if (isIonic) return getIonicTemplateCommands(project, options); + if (project.type == 'plugin') { + return getCapacitorPluginCommands(project, options); + } + const cmds = commands ?? []; + cmds.push('#' + options.folder); + return cmds.map((c) => { + let r = replaceAll(c, '$(project-name)', options.name); + r = replaceAll(r, '$(package-id)', options.packageId); + return r; + }); +} + +function getCapacitorPluginCommands(project: NewProjectInput, options: ProjectOptions): string[] { + const nmt = replaceAll(toTitleCase(replaceAll(options.name, '-', ' ')), ' ', ''); + const nm = replaceAll(options.name, ' ', '').toLowerCase(); + const nmp = replaceAll(nm, '-', '.'); + return [ + `npx @capacitor/create-plugin "${nm}" --name "${nm}" --package-id "com.mycompany.${nmp}" --class-name "${nmt}" --author "me" --license MIT --repo https://github.com --description "${nmt} Capacitor Plugin"`, + ]; +} + +function getIonicTemplateCommands(project: NewProjectInput, options: ProjectOptions): string[] { + const ctx = defaultPmContext(); + const cmds: string[] = []; + cmds.push( + `npm create ionic@beta "${options.name}" -- ${project.template} --type ${project.type} --no-git --capacitor --package-id ${options.packageId}`, + ); + cmds.push('#' + options.folder); + + if (project.targets.includes(CapacitorPlatform.android) || project.targets.includes(CapacitorPlatform.ios)) { + cmds.push(npmInstall(`@capacitor/core`, ctx)); + cmds.push(npmInstall(`@capacitor/cli`, ctx)); + cmds.push(npmInstall(`@capacitor/app @capacitor/haptics @capacitor/keyboard @capacitor/status-bar`, ctx)); + } + + if (project.targets.includes(CapacitorPlatform.android)) { + cmds.push(npmInstall('@capacitor/android', ctx)); + cmds.push('npx cap add android'); + } + if (project.targets.includes(CapacitorPlatform.ios)) { + cmds.push(npmInstall('@capacitor/ios', ctx)); + cmds.push('npx cap add ios'); + } + + if (!options.noGit) { + cmds.push('git init'); + } + return cmds; +} + +export function templateId(t: Template): string { + return `${t.type}:${t.name}`; +} + +export function findTemplate(template?: string, framework?: string): Template | undefined { + if (template) { + const byId = starterTemplates.find((t) => templateId(t) === template || t.name === template); + if (byId) return byId; + if (template.includes(':')) { + const [type, name] = template.split(':'); + return starterTemplates.find((t) => t.type === type && t.name === name); + } + } + if (framework) { + const fw = frameworks.find((f) => f.type === framework || f.name.toLowerCase().includes(framework.toLowerCase())); + if (fw) { + return starterTemplates.find((t) => t.type === fw.type); + } + } + return undefined; +} + +export function listTemplateCatalog() { + return starterTemplates.map((t) => ({ + id: templateId(t), + type: t.type, + typeName: t.typeName, + name: t.name, + description: t.description, + url: t.url, + targets: t.targets, + })); +} + +export function parseTargets(raw?: string): CapacitorPlatform[] { + if (!raw) return []; + const result: CapacitorPlatform[] = []; + for (const part of raw.split(',').map((s) => s.trim().toLowerCase())) { + if (part === 'ios') result.push(CapacitorPlatform.ios); + if (part === 'android') result.push(CapacitorPlatform.android); + } + return result; +} + +export function folderEmpty(folder: string): boolean { + try { + const files = readdirSync(folder); + return !files || files.length === 0; + } catch { + return true; + } +} + +export function resolveProjectFolder(dir: string, name: string): string { + return join(dir, name); +} + +export function buildScaffoldCommands(project: NewProjectInput, template: Template, options: ProjectOptions): string[] { + let cmds = getCommands(project, options, template.commands ? [...template.commands] : undefined); + if (options.noInstall) { + cmds = cmds.map((c) => + c + .replace(/\s--no-install\b/g, '') + .replace(/npm install\b/g, '# skip install') + .replace(/pnpm install\b/g, '# skip install') + .replace(/yarn install\b/g, '# skip install') + .replace(/bun install\b/g, '# skip install'), + ); + } + return cmds.filter((c) => !c.startsWith('# skip')); +} + +export function folderExists(folder: string): boolean { + return existsSync(folder) && !folderEmpty(folder); +} + +export { frameworks, targets, starterTemplates }; diff --git a/cli/src/templates/starter-templates.ts b/cli/src/templates/starter-templates.ts new file mode 100644 index 0000000..0d58391 --- /dev/null +++ b/cli/src/templates/starter-templates.ts @@ -0,0 +1,408 @@ +export interface Template { + type: string; + typeName: string; + name: string; + url?: string; + commands?: string[]; + targets?: string; + description: string; +} + +export const starterTemplates: Template[] = [ + { + type: 'angular-standalone', + typeName: 'New Angular Project', + name: 'list', + description: 'A starting project with a list', + targets: 'ionic-targets', + }, + { + type: 'angular-standalone', + typeName: 'New Angular Project', + name: 'blank', + description: 'A blank starter project', + targets: 'ionic-targets', + }, + { + type: 'angular-standalone', + typeName: 'New Angular Project', + name: 'sidemenu', + description: 'A starting project with a side menu with navigation in the content area', + targets: 'ionic-targets', + }, + { + type: 'angular-standalone', + typeName: 'New Angular Project', + name: 'tabs', + description: 'A starting project with a simple tabbed interface', + targets: 'ionic-targets', + }, + { + type: 'react', + typeName: 'New React Project', + name: 'tabs', + description: 'A starting project with a simple tabbed interface', + targets: 'ionic-targets', + }, + { + type: 'react', + typeName: 'New React Project', + name: 'sidemenu', + description: 'A starting project with a side menu with navigation in the content area', + targets: 'ionic-targets', + }, + { + type: 'react', + typeName: 'New React Project', + name: 'list', + description: 'A starting project with a list', + targets: 'ionic-targets', + }, + { + type: 'react', + typeName: 'New React Project', + name: 'blank', + description: 'A blank starter project', + targets: 'ionic-targets', + }, + { + type: 'vue', + typeName: 'New Vue Project', + name: 'list', + description: 'A starting project with a list', + targets: 'ionic-targets', + }, + { + type: 'vue', + typeName: 'New Vue Project', + name: 'blank', + description: 'The official blank starter project', + targets: 'ionic-targets', + }, + { + type: 'vue', + typeName: 'New Vue Project', + name: 'sidemenu', + description: 'The official starting project with a side menu with navigation in the content area', + targets: 'ionic-targets', + }, + { + type: 'vue', + typeName: 'New Vue Project', + name: 'tabs', + description: 'The official starting project with a simple tabbed interface with Ionic Framework', + targets: 'ionic-targets', + }, + { + type: 'plugin', + typeName: 'New Capacitor Plugin', + name: 'Starter', + url: 'https://capacitorjs.com/docs/plugins/creating-plugins', + description: 'The official Capacitor plugin project', + targets: '', + }, + { + type: 'custom-angular', + typeName: 'New Angular Project', + name: 'Starter', + url: 'https://angular.dev/installation#instructions', + description: 'The official empty starter project for Angular', + commands: ['npx -p @angular/cli ng new $(project-name) --skip-install'], + targets: '', + }, + { + type: 'custom-svelte', + typeName: 'New Svelte Project', + name: 'Minimal', + url: 'https://svelte.dev/docs/svelte/getting-started', + description: 'The official minimal starter project for Svelte', + commands: ['npx sv create $(project-name) --template minimal --types ts --no-add-ons --no-install'], + }, + { + type: 'custom-svelte', + typeName: 'New Svelte Project', + name: 'Demo', + url: 'https://svelte.dev/docs/svelte/getting-started', + description: 'The official demo starter project for SvelteKit', + commands: ['npx sv create $(project-name) --template demo --types ts --no-add-ons --no-install'], + }, + { + type: 'custom-svelte', + typeName: 'New Svelte Project', + name: 'Library', + url: 'https://svelte.dev/docs/svelte/getting-started', + description: 'The official starter project for SvelteKit Library', + commands: ['npx sv create $(project-name) --template library --types ts --no-add-ons --no-install'], + }, + { + type: 'custom-ionic-svelte', + typeName: 'New Svelte Project with Ionic Framework', + name: 'Starter', + description: 'A community starter project for Svelte with Ionic Framework', + url: 'https://github.com/Tommertom/svelte-ionic-app', + commands: ['npm create ionic-svelte-app@latest'], // Would be cool if we could pick package manager for this + }, + { + type: 'tanstack-start', + typeName: 'New TanStack Start Project', + name: 'Basic', + description: 'A basic starting project with TanStack Start', + url: 'https://tanstack.com/start/latest/docs/framework/react/quick-start', + commands: ['npx degit https://github.com/tanstack/router/examples/react/start-basic $(project-name)'], + }, + { + type: 'tanstack-start', + typeName: 'New TanStack Start Project', + name: 'Auth', + description: 'A basic starting project with TanStack Start with auth', + url: 'https://tanstack.com/start/latest/docs/framework/react/quick-start', + commands: ['npx degit https://github.com/tanstack/router/examples/react/start-basic-auth $(project-name)'], + }, + { + type: 'tanstack-start', + typeName: 'New TanStack Start Project', + name: 'Counter', + description: 'A basic starting project with TanStack Start with a counter', + url: 'https://tanstack.com/start/latest/docs/framework/react/quick-start', + commands: ['npx degit https://github.com/tanstack/router/examples/react/start-basic-counter $(project-name)'], + }, + { + type: 'tanstack-start', + typeName: 'New TanStack Start Project', + name: 'React Query', + description: 'A basic starting project with TanStack Start with a React Query', + url: 'https://tanstack.com/start/latest/docs/framework/react/quick-start', + commands: ['npx degit https://github.com/tanstack/router/examples/react/start-basic-react-query $(project-name)'], + }, + { + type: 'tanstack-start', + typeName: 'New TanStack Start Project', + name: 'Clerk Auth', + description: 'A basic starting project with TanStack Start with a Clerk Auth', + url: 'https://tanstack.com/start/latest/docs/framework/react/quick-start', + commands: ['npx degit https://github.com/tanstack/router/examples/react/start-clerk-basic $(project-name)'], + }, + { + type: 'tanstack-start', + typeName: 'New TanStack Start Project', + name: 'Supabase', + description: 'A basic starting project with TanStack Start with a Supabase', + url: 'https://tanstack.com/start/latest/docs/framework/react/quick-start', + commands: ['npx degit https://github.com/tanstack/router/examples/react/start-supabase-basic $(project-name)'], + }, + { + type: 'tanstack-start', + typeName: 'New TanStack Start Project', + name: 'Trellaux', + description: 'A basic starting project with TanStack Start with a Trellaux', + url: 'https://tanstack.com/start/latest/docs/framework/react/quick-start', + commands: ['npx degit https://github.com/tanstack/router/examples/react/start-trellaux $(project-name)'], + }, + { + type: 'tanstack-start', + typeName: 'New TanStack Start Project', + name: 'Material UI', + description: 'A basic starting project with TanStack Start with a Material UI', + url: 'https://tanstack.com/start/latest/docs/framework/react/quick-start', + commands: ['npx degit https://github.com/tanstack/router/examples/react/start-material-ui $(project-name)'], + }, + { + type: 'vite-vue', + typeName: 'New Vite Vue Project', + name: 'Vue', + description: 'A starter Vue project', + url: 'https://vite.dev/guide/', + commands: ['npm create vite@latest $(project-name) -- --template vue'], + }, + { + type: 'vite-react', + typeName: 'New Vite React Project', + name: 'React', + description: 'A starter React project with Typescript', + url: 'https://vite.dev/guide/#trying-vite-online', + commands: ['npm create vite@latest $(project-name) -- --template react-ts'], + }, + { + type: 'vite-preact', + typeName: 'New Vite Preact Project', + name: 'Preact', + description: 'A starter Preact project ', + url: 'https://vite.dev/guide/#trying-vite-online', + commands: ['npm create vite@latest $(project-name) -- --template preact-ts'], + }, + { + type: 'vite-lit', + typeName: 'New Vite Lit Project', + name: 'Lit', + description: 'A starter Lit project with Vite', + url: 'https://vite.dev/guide/#trying-vite-online', + commands: ['npm create vite@latest $(project-name) -- --template lit-ts'], + }, + { + type: 'vite-svelte', + typeName: 'New Vite Svelte Project', + name: 'Svelte', + description: 'A starter Svelte project with Vite', + url: 'https://vite.dev/guide/#trying-vite-online', + commands: ['npm create vite@latest $(project-name) -- --template svelte-ts'], + }, + { + type: 'vite-solid', + typeName: 'New Vite Solid Project', + name: 'Typescript', + description: 'A starter Solid project with Typescript', + url: 'https://vite.dev/guide/#trying-vite-online', + commands: ['npm create vite@latest $(project-name) -- --template solid-ts'], + }, + { + type: 'vite-solid', + typeName: 'New Vite Solid Project', + name: 'Javascript', + description: 'A starter Solid project with Javascript', + url: 'https://vite.dev/guide/#trying-vite-online', + commands: ['npx degit solidjs/templates/js $(project-name)'], + }, + { + type: 'vite-web', + typeName: 'New Web Project', + name: 'Typescript', + description: 'A starter Web project with Typescript', + url: 'https://vite.dev/guide/#trying-vite-online', + commands: ['npm create vite@latest $(project-name) -- --template vanilla-ts'], + }, + { + type: 'vite-web', + typeName: 'New Web Project', + name: 'Javascript', + description: 'A starter Web project with Javascript', + url: 'https://vite.dev/guide/#trying-vite-online', + commands: ['npm create vite@latest $(project-name) -- --template vanilla'], + }, + { + type: 'vite-qwik', + typeName: 'New Vite Solid Project', + name: 'Qwik', + description: 'A starter Qwik project with Qwik', + url: 'https://vite.dev/guide/#trying-vite-online', + commands: ['npm create vite@latest $(project-name) -- --template qwik-ts'], + }, + { + type: 'nuxt', + typeName: 'New Nuxt Project', + name: 'Nuxt', + description: 'A starter Nuxt project', + url: 'https://nuxt.com/docs/getting-started/installation', + commands: [`npm create nuxt $(project-name) -- --packageManager npm --gitInit false --modules '@nuxt/eslint'`], + }, + { + type: 'astro', + typeName: 'New Astro Project', + name: 'Astro', + description: 'A starter Astro project', + url: 'https://astro.build/docs/getting-started/installation', + commands: [ + `npm create astro@latest $(project-name) -- --add react --add tailwind --template basics --skip-houston --no-install --no-git --yes`, + ], + }, + { + type: 'waku', + typeName: 'New Waku Project', + name: 'Waku', + description: 'A starter Waku project', + url: 'https://waku.gg/', + commands: [`npm create waku@latest -- --project-name "$(project-name)"`], + }, + { + type: 'nextjs', + typeName: 'New Next.js Project', + name: 'Next.js', + description: 'A starter Next.js project', + url: 'https://nextjs.org/docs/getting-started', + commands: [`npx create-next-app@latest $(project-name) --skip-install --yes --ts --eslint`], + }, + { + type: 'hydrogen', + typeName: 'New Hydrogen Project', + name: 'Tailwind', + description: 'A starter Hydrogen project', + url: 'https://hydrogen.shopify.dev', + commands: [ + `npm create @shopify/hydrogen@latest -- --path $(project-name) --mock-shop --language ts --shortcut --routes --markets none --styling tailwind --no-install-deps`, + ], + }, + { + type: 'hydrogen', + typeName: 'New Hydrogen Project', + name: 'Vanilla', + description: 'A starter Hydrogen project', + url: 'https://hydrogen.shopify.dev', + commands: [ + `npm create @shopify/hydrogen@latest -- --path $(project-name) --mock-shop --language ts --shortcut --routes --markets none --styling vanilla-extract --no-install-deps`, + ], + }, + { + type: 'hydrogen', + typeName: 'New Hydrogen Project', + name: 'CSS Modules', + description: 'A starter Hydrogen project', + url: 'https://hydrogen.shopify.dev', + commands: [ + `npm create @shopify/hydrogen@latest -- --path $(project-name) --mock-shop --language ts --shortcut --routes --markets none --styling css-modules --no-install-deps`, + ], + }, + { + type: 'hydrogen', + typeName: 'New Hydrogen Project', + name: 'Post CSS', + description: 'A starter Hydrogen project', + url: 'https://hydrogen.shopify.dev', + commands: [ + `npm create @shopify/hydrogen@latest -- --path $(project-name) --mock-shop --language ts --shortcut --routes --markets none --styling postcss --no-install-deps`, + ], + }, + + // Seems to be tied to older vue-cli-service and doesnt run + // { + // type: 'nuxt-ionic', + // typeName: 'New Nuxt Project', + // name: 'Nuxt', + // description: 'A starter Nuxt project with Ionic Framework', + // url: 'https://nuxt.com/docs/getting-started/installation', + // commands: [`npm create nuxt $(project-name) -- --packageManager npm --gitInit false --modules '@nuxt/eslint,@nuxtjs/ionic'`], + // }, +]; + +export const frameworks = [ + { name: 'Angular', icon: 'angular.svg', type: 'custom-angular' }, + { name: 'Angular+Ionic', icon: 'angular.svg', icon2: 'ionic.svg', type: 'angular-standalone' }, + { name: 'Astro', icon: 'astro.svg', type: 'astro' }, + { name: 'Hydrogen', icon: 'hydrogen.svg', type: 'hydrogen' }, + { name: 'Svelte+Ionic', icon: 'svelte.svg', icon2: 'ionic.svg', type: 'custom-ionic-svelte' }, + { name: 'Svelte', icon: 'svelte.svg', icon2: 'vite.svg', type: 'vite-svelte' }, + { name: 'Svelte', icon: 'svelte.svg', type: 'custom-svelte' }, + { name: 'Preact', icon: 'preact.svg', icon2: 'vite.svg', type: 'vite-preact' }, + { name: 'Solid', icon: 'solid.svg', icon2: 'vite.svg', type: 'vite-solid' }, + { name: 'React', icon: 'react.svg', icon2: 'vite.svg', type: 'vite-react' }, + { name: 'Web', icon: 'web.svg', icon2: 'vite.svg', type: 'vite-web' }, + { name: 'Nuxt', icon: 'nuxt.svg', type: 'nuxt' }, + { name: 'Next.js', icon: 'nextjs.svg', type: 'nextjs' }, + { name: 'React+Ionic', icon: 'react.svg', icon2: 'ionic.svg', type: 'react' }, + { name: 'Qwik', icon: 'qwik.svg', icon2: 'vite.svg', type: 'vite-qwik' }, + { name: 'Lit', icon: 'lit.svg', icon2: 'vite.svg', type: 'vite-lit' }, + { name: 'Vue', icon: 'vue.svg', icon2: 'vite.svg', type: 'vite-vue' }, + { name: 'Waku', icon: 'waku.svg', type: 'waku' }, + { name: 'TanStack', icon: 'tanstack.png', type: 'tanstack-start' }, + { name: 'Capacitor Plugin', icon: 'capacitor.svg', type: 'plugin' }, + { name: 'Vue+Ionic', icon: 'vue.svg', icon2: 'ionic.svg', type: 'vue' }, +]; + +export const targets = [ + { + name: 'ionic-targets', + targets: [ + { name: 'Web', icon: 'web', appearance: 'selected' }, + { name: 'iOS', icon: 'apple' }, + { name: 'Android', icon: 'android' }, + ], + }, +]; diff --git a/cli/tests/args.test.ts b/cli/tests/args.test.ts new file mode 100644 index 0000000..52a1d63 --- /dev/null +++ b/cli/tests/args.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { parseArgs, flagBool, flagString } from '../src/cli/args'; + +describe('parseArgs', () => { + it('parses global flags and tokens', () => { + const parsed = parseArgs(['info', '--json', '--cwd', '/tmp/app', '--verbose']); + expect(parsed.tokens).toEqual(['info']); + expect(parsed.global.json).toBe(true); + expect(parsed.global.noInput).toBe(true); + expect(parsed.global.cwd).toBe('/tmp/app'); + expect(parsed.global.verbose).toBe(true); + }); + + it('keeps project name as a positional for new', () => { + const parsed = parseArgs(['new', 'storefront', '--template', 'vite-react']); + expect(parsed.tokens).toEqual(['new', 'storefront']); + expect(flagString(parsed.flags, 'template')).toBe('vite-react'); + }); + + it('supports passthrough after --', () => { + const parsed = parseArgs(['scripts', 'run', 'test', '--', '--coverage']); + expect(parsed.tokens).toEqual(['scripts', 'run', 'test']); + expect(parsed.passthrough).toEqual(['--coverage']); + }); + + it('parses boolean command flags', () => { + const parsed = parseArgs(['build', '--prod', '--no-copy']); + expect(flagBool(parsed.flags, 'prod')).toBe(true); + expect(flagBool(parsed.flags, 'no-copy')).toBe(true); + }); +}); diff --git a/cli/tests/config.test.ts b/cli/tests/config.test.ts new file mode 100644 index 0000000..c1668d8 --- /dev/null +++ b/cli/tests/config.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { loadConfig, saveConfig, projectConfigPath, getConfigDefaults } from '../src/core/config'; +import { createDefaultGlobalOptions } from '../src/cli/args'; + +describe('config precedence', () => { + let tmp: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'wn-config-')); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('uses defaults when no files exist', () => { + const cfg = loadConfig(tmp, createDefaultGlobalOptions()); + expect(cfg.defaultPort).toBe(8100); + expect(cfg.buildForProduction).toBe(false); + }); + + it('project wn.json overrides defaults', () => { + saveConfig(projectConfigPath(tmp), { defaultPort: 4200 }); + const cfg = loadConfig(tmp, createDefaultGlobalOptions()); + expect(cfg.defaultPort).toBe(4200); + }); + + it('CLI packageManager flag overrides wn.json', () => { + saveConfig(projectConfigPath(tmp), { packageManager: 'npm' }); + const opts = createDefaultGlobalOptions(); + opts.packageManager = 'pnpm'; + const cfg = loadConfig(tmp, opts); + expect(cfg.packageManager).toBe('pnpm'); + }); + + it('exposes defaults separately', () => { + expect(getConfigDefaults().defaultPort).toBe(8100); + }); +}); diff --git a/cli/tests/devices.test.ts b/cli/tests/devices.test.ts new file mode 100644 index 0000000..658bfb7 --- /dev/null +++ b/cli/tests/devices.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { parseDevice, friendlyName, getDevices } from '../src/devices/capacitor-device'; + +describe('capacitor device parsers', () => { + it('parses whitespace-delimited device lines', () => { + const line = 'iPhone 16 Pro iOS 18.2 1B2C3D4E-5F60-4A1B-9C2D-3E4F5A6B7C8D'; + const device = parseDevice(line); + expect(device).toBeDefined(); + expect(device!.id).toBe('1B2C3D4E-5F60-4A1B-9C2D-3E4F5A6B7C8D'); + expect(device!.name).toContain('iPhone 16 Pro'); + }); + + it('maps Android API levels in friendly names', () => { + expect(friendlyName('Pixel 8 API 34')).toContain('Android'); + }); + + it('parses pipe-delimited cap --list output', async () => { + const stdout = [ + 'Name API Target id', + 'iPhone 16 Pro iOS 18.2 | simulator | 1B2C3D4E-5F60-4A1B-9C2D-3E4F5A6B7C8D', + 'Damian iPhone iOS 18.3 | device | 00008120-000A1B2C3D4E002E', + ].join('\n'); + const devices = await getDevices('fake', '/tmp', async () => stdout); + expect(devices.length).toBe(2); + expect(devices[0].id).toBe('1B2C3D4E-5F60-4A1B-9C2D-3E4F5A6B7C8D'); + expect(devices[1].type).toBe('device'); + }); +}); diff --git a/cli/tests/error-parsers.test.ts b/cli/tests/error-parsers.test.ts new file mode 100644 index 0000000..866239c --- /dev/null +++ b/cli/tests/error-parsers.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { extractErrors } from '../src/build/error-parsers'; + +describe('extractErrors', () => { + it('parses TypeScript errors', () => { + const output = `src/app.ts(42,9): error TS2322: Type 'string' is not assignable to type 'number'.`; + const errors = extractErrors(output); + expect(errors.length).toBeGreaterThanOrEqual(1); + const err = errors[0]; + expect(err.file).toMatch(/app\.ts/); + expect(err.message).toMatch(/Type 'string'/); + }); + + it('parses ESLint-style errors', () => { + const output = `/Users/me/app/src/main.ts\n 10:5 error 'foo' is defined but never used @typescript-eslint/no-unused-vars`; + const errors = extractErrors(output); + // Parser may or may not catch this format depending on port fidelity + expect(Array.isArray(errors)).toBe(true); + }); + + it('returns empty array for clean output', () => { + expect(extractErrors('Build succeeded')).toEqual([]); + }); +}); diff --git a/cli/tests/finding-ids.test.ts b/cli/tests/finding-ids.test.ts new file mode 100644 index 0000000..5c71b94 --- /dev/null +++ b/cli/tests/finding-ids.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { createCollector, Finding } from '../src/rules/finding'; +import { addRawFinding } from '../src/rules/factories'; + +describe('finding ids', () => { + it('stores findings with stable kebab-case ids', () => { + const c = createCollector(); + const finding: Finding = { + id: 'capacitor-version-mismatch', + severity: 'error', + category: 'capacitor', + title: '@capacitor/android major differs from @capacitor/core', + fixable: true, + fix: { id: 'capacitor-version-mismatch', command: 'pnpm add @capacitor/android@7.2.0' }, + }; + addRawFinding(c, finding); + expect(c.findings).toHaveLength(1); + expect(c.findings[0].id).toMatch(/^[a-z0-9-]+$/); + expect(c.findings[0].fixable).toBe(true); + }); + + it('uses deprecated-plugin prefix convention', () => { + const c = createCollector(); + addRawFinding(c, { + id: 'deprecated-plugin-cordova-plugin-camera', + severity: 'warning', + category: 'packages', + title: 'cordova-plugin-camera is deprecated', + fixable: false, + }); + expect(c.findings[0].id).toBe('deprecated-plugin-cordova-plugin-camera'); + }); +}); diff --git a/cli/tsconfig.json b/cli/tsconfig.json new file mode 100644 index 0000000..fdb5674 --- /dev/null +++ b/cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "sourceMap": true, + "declaration": true, + "skipLibCheck": true, + "strict": false, + "types": ["node"], + "resolveJsonModule": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts new file mode 100644 index 0000000..8996a04 --- /dev/null +++ b/cli/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + }, +}); diff --git a/package.json b/package.json index 97177b5..92fe191 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "webnative", "displayName": "WebNative", "description": "Create and maintain web and native projects", - "version": "2.2.15", + "version": "2.2.16", "whatsNewRevision": 1, "icon": "media/webnative.png", "publisher": "WebNative", @@ -28,12 +28,13 @@ "license": "MIT", "scripts": { "prepare": "husky install", - "install:all": "npm install && cd plugin-explorer && npm install && cd ../preview && npm install && cd ../starter && npm install", + "install:all": "npm install && cd plugin-explorer && npm install && cd ../preview && npm install && cd ../starter && npm install && cd ../cli && npm install", "build:pe": "cd plugin-explorer && npm run build", "build:is": "cd starter && npm run build", "build:preview": "cd preview && npm run build", + "build:cli": "cd cli && npm run build", "clean": "find ./node_modules -name '*.md' -delete && find ./node_modules -name '*.ts' -delete && find ./node_modules -iname 'LICENSE' -delete && find ./node_modules -name '*.map' -delete && find ./node_modules -name '*.txt' -delete && find ./out/*.map -delete", - "build:all": "npm run compile && npm run build:pe && npm run build:preview && npm run build:is", + "build:all": "npm run compile && npm run build:cli && npm run build:pe && npm run build:preview && npm run build:is", "build": "npm run build:all && npm run clean && npm run esbuild && npm run vsix-package && npm run reset", "reset": "rm -rf node_modules && npm install", "vsix-package": "vsce package", diff --git a/src/analyzer.ts b/src/analyzer.ts index f006dd9..8fe7cba 100755 --- a/src/analyzer.ts +++ b/src/analyzer.ts @@ -91,7 +91,11 @@ function processAndroidXML(folder: string) { return parser.parse(xml); } -export async function load(fn: string, project: Project, context: ExtensionContext): Promise { +/** + * Reads package.json metadata into the analyzer/project without running npm outdated. + * Used so monorepo detection can run before the (cached) package processing step. + */ +export async function loadPackageFile(fn: string, project: Project): Promise { let packageJsonFilename = fn; if (lstatSync(fn).isDirectory()) { packageJsonFilename = fn + '/package.json'; @@ -103,7 +107,7 @@ export async function load(fn: string, project: Project, context: ExtensionConte error('package.json', 'This folder does not contain an Ionic application (its missing package.json)'); allDependencies = []; packageFile = {}; - return undefined; + return false; } project.modified = statSync(packageJsonFilename).mtime; try { @@ -138,7 +142,13 @@ export async function load(fn: string, project: Project, context: ExtensionConte ); project.isCordova = !!(allDependencies['cordova-ios'] || allDependencies['cordova-android'] || packageFile.cordova); + return true; +} +export async function load(fn: string, project: Project, context: ExtensionContext): Promise { + if (!(await loadPackageFile(fn, project))) { + return undefined; + } return await processPackages(fn, allDependencies, packageFile.devDependencies, context, project); } diff --git a/src/project.ts b/src/project.ts index 3b98105..3b02e6a 100755 --- a/src/project.ts +++ b/src/project.ts @@ -1,6 +1,6 @@ import { Recommendation } from './recommendation'; import { Tip, TipType } from './tip'; -import { load, exists } from './analyzer'; +import { load, loadPackageFile, exists } from './analyzer'; import { isRunning } from './tasks'; import { exState } from './tree-provider'; import { Context, VSCommand } from './context-variables'; @@ -674,9 +674,11 @@ export async function inspectProject( exState.rootFolder = folder; exState.projectRef = project; - let packages = await load(folder, project, context); + // Read root package.json for monorepo detection only — do not run npm outdated yet. + // Outdated/list data is fetched once below for the selected project folder so that + // switching monorepo projects reuses per-project workspace cache. + await loadPackageFile(folder, project); exState.view.title = project.name; - project.type = project.isCapacitor ? 'Capacitor' : project.isCordova ? 'Cordova' : 'Other'; if (!Features.requireLogin) { exState.skipAuth = true; @@ -692,9 +694,10 @@ export async function inspectProject( exState.packageManager = project.packageManager; } - if (project.monoRepo?.localPackageJson) { - packages = await load(project.monoRepo.folder, project, context); - } + + const packageFolder = project.monoRepo?.localPackageJson ? project.monoRepo.folder : folder; + const packages = await load(packageFolder, project, context); + project.type = project.isCapacitor ? 'Capacitor' : project.isCordova ? 'Cordova' : 'Other'; guessFramework(project); checkNodeVersion(); diff --git a/tsconfig.json b/tsconfig.json index 08b0c61..b628e53 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ "plugin-explorer", "starter", "preview", + "cli", "tests", "vitest.config.ts" ] diff --git a/vitest.config.ts b/vitest.config.ts index f7c90ec..74d5d71 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ globals: true, environment: 'node', setupFiles: ['./tests/setup.ts'], + exclude: ['**/node_modules/**', '**/dist/**', 'cli/**'], }, resolve: { alias: {