diff --git a/.bazelignore b/.bazelignore index 284b0692ec13..ded5d15573b2 100644 --- a/.bazelignore +++ b/.bazelignore @@ -1,3 +1,16 @@ .git dist node_modules +packages/angular/cli/node_modules +packages/angular/create/node_modules +packages/angular/pwa/node_modules +packages/angular/ssr/node_modules +packages/angular_devkit/architect/node_modules +packages/angular_devkit/architect_cli/node_modules +packages/angular_devkit/build_angular/node_modules +packages/angular_devkit/build_webpack/node_modules +packages/angular_devkit/core/node_modules +packages/angular_devkit/schematics/node_modules +packages/angular_devkit/schematics_cli/node_modules +packages/ngtools/webpack/node_modules +packages/schematics/angular/node_modules diff --git a/.bazelrc b/.bazelrc index 3a243dcd8781..126ca0493e54 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,5 +1,5 @@ # Disable NG CLI TTY mode -test --action_env=NG_FORCE_TTY=false +build --action_env=NG_FORCE_TTY=false # Make TypeScript compilation fast, by keeping a few copies of the compiler # running as daemons, and cache SourceFile AST's to reduce parse time. @@ -8,6 +8,10 @@ build --strategy=TypeScriptCompile=worker # Enable debugging tests with --config=debug test:debug --test_arg=--node_options=--inspect-brk --test_output=streamed --test_strategy=exclusive --test_timeout=9999 --nocache_test_results +# Enable debugging tests with --config=no-sharding +# The below is useful to while using `fit` and `fdescribe` to avoid sharing and re-runs of failed flaky tests. +test:no-sharding --flaky_test_attempts=1 --test_sharding_strategy=disabled + ############################### # Filesystem interactions # ############################### @@ -30,8 +34,7 @@ build --symlink_prefix=dist/ build --nowatchfs # Turn off legacy external runfiles -run --nolegacy_external_runfiles -test --nolegacy_external_runfiles +build --nolegacy_external_runfiles # Turn on --incompatible_strict_action_env which was on by default # in Bazel 0.21.0 but turned off again in 0.22.0. Follow @@ -43,6 +46,18 @@ build --incompatible_strict_action_env run --incompatible_strict_action_env test --incompatible_strict_action_env +# Enable remote caching of build/action tree +build --experimental_remote_merkle_tree_cache + +# Ensure that tags applied in BUILDs propagate to actions +common --experimental_allow_tags_propagation + +# Don't check if output files have been modified +build --noexperimental_check_output_files + +# Ensure sandboxing is enabled even for exclusive tests +test --incompatible_exclusive_test_sandboxed + ############################### # Saucelabs support # # Turn on these settings with # @@ -68,11 +83,19 @@ test:saucelabs --define=KARMA_WEB_TEST_MODE=SL_REQUIRED # Releases should always be stamped with version control info # This command assumes node on the path and is a workaround for # https://github.com/bazelbuild/bazel/issues/4802 -build:release --workspace_status_command="yarn -s ng-dev release build-env-stamp --mode=release" +build:release --workspace_status_command="yarn ng-dev release build-env-stamp --mode=release" build:release --stamp -build:snapshot --workspace_status_command="yarn -s ng-dev release build-env-stamp --mode=snapshot" +build:snapshot --workspace_status_command="yarn ng-dev release build-env-stamp --mode=snapshot" build:snapshot --stamp +build:snapshot --//:enable_snapshot_repo_deps + +build:e2e --workspace_status_command="yarn ng-dev release build-env-stamp --mode=release" +build:e2e --stamp +test:e2e --test_timeout=3600 --experimental_ui_max_stdouterr_bytes=2097152 + +# Retry in the event of flakes +test:e2e --flaky_test_attempts=2 build:local --//:enable_package_json_tar_deps @@ -99,7 +122,7 @@ test --test_output=errors # Use the Angular team internal GCP instance for remote execution. build:remote --remote_instance_name=projects/internal-200822/instances/primary_instance -build:remote --project_id=internal-200822 +build:remote --bes_instance_name=internal-200822 # Starting with Bazel 0.27.0 strategies do not need to be explicitly # defined. See https://github.com/bazelbuild/bazel/issues/7480 @@ -113,11 +136,11 @@ build:remote --jobs=150 # Setup the toolchain and platform for the remote build execution. The platform # is provided by the shared dev-infra package and targets k8 remote containers. -build:remote --crosstool_top=@npm//@angular/dev-infra-private/bazel/remote-execution/cpp:cc_toolchain_suite -build:remote --extra_toolchains=@npm//@angular/dev-infra-private/bazel/remote-execution/cpp:cc_toolchain -build:remote --extra_execution_platforms=//tools:rbe_platform_with_network_access -build:remote --host_platform=//tools:rbe_platform_with_network_access -build:remote --platforms=//tools:rbe_platform_with_network_access +build:remote --crosstool_top=@npm//@angular/build-tooling/bazel/remote-execution/cpp:cc_toolchain_suite +build:remote --extra_toolchains=@npm//@angular/build-tooling/bazel/remote-execution/cpp:cc_toolchain +build:remote --extra_execution_platforms=@npm//@angular/build-tooling/bazel/remote-execution:platform_with_network +build:remote --host_platform=@npm//@angular/build-tooling/bazel/remote-execution:platform_with_network +build:remote --platforms=@npm//@angular/build-tooling/bazel/remote-execution:platform_with_network # Set remote caching settings build:remote --remote_accept_cached=true @@ -130,11 +153,20 @@ build:remote --host_cpu=k8 # Set up authentication mechanism for RBE build:remote --google_default_credentials +# Use HTTP remote cache +build:remote-cache --remote_cache=https://storage.googleapis.com/angular-team-cache +build:remote-cache --remote_accept_cached=true +build:remote-cache --remote_upload_local_results=true +build:remote-cache --google_default_credentials + ############################### # NodeJS rules settings # These settings are required for rules_nodejs ############################### +# Fixes use of npm paths with spaces such as some within the puppeteer module +build --experimental_inprocess_symlink_creation + #################################################### # User bazel configuration # NOTE: This needs to be the *last* entry in the config. @@ -146,4 +178,4 @@ try-import .bazelrc.user # Enable runfiles even on Windows. # Architect resolves output files from data files, and this isn't possible without runfile support. -test --enable_runfiles +build --enable_runfiles diff --git a/.bazelversion b/.bazelversion index fcdb2e109f68..03f488b076ae 100644 --- a/.bazelversion +++ b/.bazelversion @@ -1 +1 @@ -4.0.0 +5.3.0 diff --git a/.circleci/bazel.common.rc b/.circleci/bazel.common.rc new file mode 100644 index 000000000000..1e8cad37a5ec --- /dev/null +++ b/.circleci/bazel.common.rc @@ -0,0 +1,35 @@ +# These options are enabled when running on CI +# We do this by copying this file to /etc/bazel.bazelrc at the start of the build. + +# Echo all the configuration settings and their source +build --announce_rc + +# Print extra information for build failures to help with debugging. +build --verbose_failures + +# Show progress so CI doesn't appear to be stuck, but rate limit to avoid +# spamming the log. +build --show_progress_rate_limit 5 + +# Improve the UI for rendering to a CI log. +build --curses yes --color yes --terminal_columns 140 --show_timestamps + +# Workaround https://github.com/bazelbuild/bazel/issues/3645 +# Bazel doesn't calculate the memory ceiling correctly when running under Docker. +# Limit Bazel to consuming resources that fit in CircleCI "xlarge" class +# https://circleci.com/docs/2.0/configuration-reference/#resource_class +build --local_cpu_resources=8 +build --local_ram_resources=14336 + +# More details on failures +build --verbose_failures=true + +# Retry in the event of flakes +test --flaky_test_attempts=2 + +# Run as many tests as possible so we capture all the failures. +test --keep_going + +# Don't build targets not needed for tests. `build_test()` should be used if a +# target should be verified as buildable on CI. +test --build_tests_only diff --git a/.circleci/bazel.linux.rc b/.circleci/bazel.linux.rc new file mode 100644 index 000000000000..6a4d30ed44f8 --- /dev/null +++ b/.circleci/bazel.linux.rc @@ -0,0 +1,5 @@ +# Import config items common to both Linux and Windows setups. +# https://docs.bazel.build/versions/master/guide.html#bazelrc-syntax-and-semantics +import %workspace%/.circleci/bazel.common.rc + +build --config=remote diff --git a/.circleci/bazel.rc b/.circleci/bazel.rc deleted file mode 100644 index 1b89d0bd6424..000000000000 --- a/.circleci/bazel.rc +++ /dev/null @@ -1,24 +0,0 @@ -# These options are enabled when running on CI -# We do this by copying this file to /etc/bazel.bazelrc at the start of the build. - -# Echo all the configuration settings and their source -build --announce_rc - -# Don't be spammy in the logs -build --noshow_progress - -# Don't run manual tests -test --test_tag_filters=-manual - -# Workaround https://github.com/bazelbuild/bazel/issues/3645 -# Bazel doesn't calculate the memory ceiling correctly when running under Docker. -# Limit Bazel to consuming resources that fit in CircleCI "xlarge" class -# https://circleci.com/docs/2.0/configuration-reference/#resource_class -build --local_cpu_resources=8 -build --local_ram_resources=14336 - -# More details on failures -build --verbose_failures=true - -# Retry in the event of flakes -test --flaky_test_attempts=2 diff --git a/.circleci/config.yml b/.circleci/config.yml index 3ba1c49ec78a..9454be12fd03 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,393 +1,16 @@ -# Configuration file for https://circleci.com/gh/angular/angular-cli - -# Note: YAML anchors allow an object to be re-used, reducing duplication. -# The ampersand declares an alias for an object, then later the `<<: *name` -# syntax dereferences it. -# See http://blog.daemonl.com/2016/02/yaml.html -# To validate changes, use an online parser, eg. -# http://yaml-online-parser.appspot.com/ +# This config is remaining in place to prevent pull requests failing because of CircleCI config missing. version: 2.1 -orbs: - browser-tools: circleci/browser-tools@1.1.3 - -# Variables - -## IMPORTANT -# Windows needs its own cache key because binaries in node_modules are different. -# See https://circleci.com/docs/2.0/caching/#restoring-cache for how prefixes work in CircleCI. -var_1: &cache_key v1-angular_devkit-14.17-{{ checksum "yarn.lock" }} -var_1_win: &cache_key_win v1-angular_devkit-win-12.22-{{ checksum "yarn.lock" }} -var_3: &default_nodeversion '14.17' -# Workspace initially persisted by the `setup` job, and then enhanced by `setup-and-build-win`. -# https://circleci.com/docs/2.0/workflows/#using-workspaces-to-share-data-among-jobs -# https://circleci.com/blog/deep-diving-into-circleci-workspaces/ -var_4: &workspace_location . -# Filter to only release branches on a given job. -var_5: &only_release_branches - filters: - branches: - only: - - master - - /\d+\.\d+\.x/ - -# Executor Definitions -# https://circleci.com/docs/2.0/reusing-config/#authoring-reusable-executors -executors: - action-executor: - parameters: - nodeversion: - type: string - default: *default_nodeversion - docker: - - image: cimg/node:<< parameters.nodeversion >> - working_directory: ~/ng - resource_class: small - - test-executor: - parameters: - nodeversion: - type: string - default: *default_nodeversion - docker: - - image: cimg/node:<< parameters.nodeversion >> - working_directory: ~/ng - resource_class: large - - windows-executor: - # Same as https://circleci.com/orbs/registry/orb/circleci/windows, but named. - working_directory: ~/ng - resource_class: windows.medium - shell: powershell.exe -ExecutionPolicy Bypass - machine: - # Contents of this image: - # https://circleci.com/docs/2.0/hello-world-windows/#software-pre-installed-in-the-windows-image - image: windows-server-2019-vs2019:stable - -# Command Definitions -# https://circleci.com/docs/2.0/reusing-config/#authoring-reusable-commands -commands: - fail_fast: - steps: - - run: - name: 'Cancel workflow on fail' - when: on_fail - command: | - curl -X POST --header "Content-Type: application/json" "https://circleci.com/api/v2/workflow/${CIRCLE_WORKFLOW_ID}/cancel?circle-token=${CIRCLE_TOKEN}" - - custom_attach_workspace: - description: Attach workspace at a predefined location - steps: - - attach_workspace: - at: *workspace_location - setup_windows: - steps: - - run: nvm install 12.22.1 - - run: nvm use 12.22.1 - - run: npm install -g yarn@1.22.10 - - run: node --version - - run: yarn --version - - setup_bazel_rbe: - parameters: - key: - type: env_var_name - default: CIRCLE_PROJECT_REPONAME - steps: - - run: - name: 'Setup bazel RBE remote execution' - command: | - touch .bazelrc.user; - # We need ensure that the same default digest is used for encoding and decoding - # with openssl. Openssl versions might have different default digests which can - # cause decryption failures based on the openssl version. https://stackoverflow.com/a/39641378/4317734 - openssl aes-256-cbc -d -in .circleci/gcp_token -md md5 -k "${<< parameters.key >>}" -out /home/circleci/.gcp_credentials; - sudo bash -c "echo -e 'build --google_credentials=/home/circleci/.gcp_credentials' >> .bazelrc.user"; - # Upload/don't upload local results to cache based on environment - if [[ -n "{$CIRCLE_PULL_REQUEST}" ]]; then - sudo bash -c "echo -e 'build:remote --remote_upload_local_results=false\n' >> .bazelrc.user"; - echo "Not uploading local build results to remote cache."; - else - sudo bash -c "echo -e 'build:remote --remote_upload_local_results=true\n' >> .bazelrc.user"; - echo "Uploading local build results to remote cache."; - fi - # Enable remote builds - sudo bash -c "echo -e 'build --config=remote' >> .bazelrc.user"; - echo "Reading from remote cache for bazel remote jobs."; - - install_python: - steps: - - run: - name: 'Install Python 2' - command: | - sudo apt-get update > /dev/null 2>&1 - sudo apt-get install -y python - python --version - -# Job definitions jobs: - setup: - executor: action-executor - resource_class: medium - steps: - - checkout - - run: - name: Rebase PR on target branch - command: > - if [[ -n "${CIRCLE_PR_NUMBER}" ]]; then - # User is required for rebase. - git config user.name "angular-ci" - git config user.email "angular-ci" - # Rebase PR on top of target branch. - node tools/rebase-pr.js angular/angular-cli ${CIRCLE_PR_NUMBER} - else - echo "This build is not over a PR, nothing to do." - fi - - restore_cache: - keys: - - *cache_key - - run: yarn install --frozen-lockfile --cache-folder ~/.cache/yarn - - persist_to_workspace: - root: *workspace_location - paths: - - ./* - - save_cache: - key: *cache_key - paths: - - ~/.cache/yarn - - lint: - executor: action-executor - steps: - - custom_attach_workspace - - run: yarn lint - - validate: - executor: action-executor - steps: - - custom_attach_workspace - - run: - name: Validate Commit Messages - command: > - if [[ -n "${CIRCLE_PR_NUMBER}" ]]; then - yarn ng-dev commit-message validate-range <> <> - else - echo "This build is not over a PR, nothing to do." - fi - - run: - name: Validate Code Formatting - command: yarn -s ng-dev format changed <> --check - - run: - name: Validate NgBot Configuration - command: yarn ng-dev ngbot verify - - run: - name: Validate Circular Dependencies - command: yarn ts-circular-deps:check - - run: yarn -s admin validate - - run: yarn -s check-tooling-setup - - e2e-cli: - parameters: - nodeversion: - type: string - default: *default_nodeversion - snapshots: - type: boolean - default: false - executor: - name: test-executor - nodeversion: << parameters.nodeversion >> - parallelism: 6 - steps: - - custom_attach_workspace - - browser-tools/install-chrome - - run: - name: Initialize Environment - # npm 7 currently does not properly publish the packages locally - command: | - ./.circleci/env.sh - sudo npm install --global npm@6 - - run: - name: Execute CLI E2E Tests - command: | - mkdir /mnt/ramdisk/e2e-main - node ./tests/legacy-cli/run_e2e --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} <<# parameters.snapshots >>--ng-snapshots<> --tmpdir=/mnt/ramdisk/e2e-main - - run: - name: Execute CLI E2E Tests Subset with Yarn - command: | - mkdir /mnt/ramdisk/e2e-yarn - node ./tests/legacy-cli/run_e2e --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} <<# parameters.snapshots >>--ng-snapshots<> --yarn --tmpdir=/mnt/ramdisk/e2e-yarn --glob="{tests/basic/**,tests/update/**,tests/commands/add/**}" - - fail_fast - - test-browsers: - executor: - name: test-executor - environment: - E2E_BROWSERS: true - resource_class: medium - steps: - - custom_attach_workspace - - run: - name: Initialize Environment - command: ./.circleci/env.sh - - run: - name: Initialize Saucelabs - command: setSecretVar SAUCE_ACCESS_KEY $(echo $SAUCE_ACCESS_KEY | rev) - - run: - name: Start Saucelabs Tunnel - command: ./scripts/saucelabs/start-tunnel.sh - background: true - # Waits for the Saucelabs tunnel to be ready. This ensures that we don't run tests - # too early without Saucelabs not being ready. - - run: ./scripts/saucelabs/wait-for-tunnel.sh - - run: node ./tests/legacy-cli/run_e2e ./tests/legacy-cli/e2e/tests/misc/browsers.ts --ve - - run: node ./tests/legacy-cli/run_e2e ./tests/legacy-cli/e2e/tests/misc/browsers.ts - - run: ./scripts/saucelabs/stop-tunnel.sh - - fail_fast - - build: - executor: action-executor - steps: - - custom_attach_workspace - - run: yarn build - - test: - executor: test-executor - resource_class: xlarge - steps: - - custom_attach_workspace - - browser-tools/install-chrome - - setup_bazel_rbe - - run: sudo cp .circleci/bazel.rc /etc/bazel.bazelrc - - run: - command: yarn bazel:test - no_output_timeout: 20m - - fail_fast - - snapshot_publish: - executor: action-executor - resource_class: medium - steps: - - custom_attach_workspace - - run: - name: Decrypt Credentials - # Note: when changing the image, you might have to re-encrypt the credentials with a - # matching version of openssl. - # See https://stackoverflow.com/a/43847627/2116927 for more info. - command: | - openssl aes-256-cbc -d -in .circleci/github_token -k "${KEY}" -out ~/github_token -md md5 - - run: - name: Deployment to Snapshot - command: | - yarn admin snapshots --verbose --githubTokenFile=${HOME}/github_token - - fail_fast - - # Windows jobs - e2e-cli-win: - executor: windows-executor - parallelism: 8 + pass: + docker: + - image: cimg/base:2022.05 steps: - - checkout - - run: - name: Rebase PR on target branch - command: | - if (Test-Path env:CIRCLE_PR_NUMBER) { - # User is required for rebase. - git config user.name "angular-ci" - git config user.email "angular-ci" - # Rebase PR on top of target branch. - node tools/rebase-pr.js angular/angular-cli $env:CIRCLE_PR_NUMBER - } else { - echo "This build is not over a PR, nothing to do." - } - - setup_windows - - restore_cache: - keys: - - *cache_key_win - - run: yarn install --frozen-lockfile --cache-folder ../.cache/yarn - - save_cache: - key: *cache_key_win - paths: - - ~/.cache/yarn - # Run partial e2e suite on PRs only. Release branches will run the full e2e suite. - - run: - name: Execute E2E Tests - command: | - if (Test-Path env:CIRCLE_PULL_REQUEST) { - node tests\legacy-cli\run_e2e.js "--glob={tests/basic/**,tests/i18n/extract-ivy*.ts,tests/build/profile.ts}" --nb-shards=$env:CIRCLE_NODE_TOTAL --shard=$env:CIRCLE_NODE_INDEX - } else { - node tests\legacy-cli\run_e2e.js --nb-shards=$env:CIRCLE_NODE_TOTAL --shard=$env:CIRCLE_NODE_INDEX - } - - fail_fast + - run: echo "This too shall pass (always)" workflows: version: 2 default_workflow: jobs: - # Linux jobs - - setup - - lint: - requires: - - setup - - validate: - requires: - - setup - - build: - requires: - - setup - - e2e-cli: - name: e2e-cli - nodeversion: '14.15' - post-steps: - - store_artifacts: - path: /tmp/dist - destination: cli/new-production - requires: - - build - - e2e-cli: - name: e2e-cli-ng-snapshots - snapshots: true - requires: - - build - filters: - branches: - only: - - renovate/angular - - master - - e2e-cli: - name: e2e-cli-node-12 - nodeversion: '12.20' - <<: *only_release_branches - requires: - - build - - e2e-cli: - name: e2e-cli-node-16 - nodeversion: '16.10' - <<: *only_release_branches - requires: - - build - - test-browsers: - requires: - - build - - # Bazel jobs - # These jobs only really depend on Setup, but the build job is very quick to run (~35s) and - # will catch any build errors before proceeding to the more lengthy and resource intensive - # Bazel jobs. - - test: - requires: - - build - - # Windows jobs - - e2e-cli-win: - requires: - - build - - # Publish jobs - - snapshot_publish: - <<: *only_release_branches - requires: - - build - - test - - e2e-cli + - pass diff --git a/.circleci/env.sh b/.circleci/env.sh index d24334473255..e6ae354a6a7c 100755 --- a/.circleci/env.sh +++ b/.circleci/env.sh @@ -22,7 +22,7 @@ setPublicVar PATH "${HOME}/.npm-global/bin:${PATH}"; # Define SauceLabs environment variables for CircleCI. #################################################################################################### setPublicVar SAUCE_USERNAME "angular-tooling"; -setSecretVar SAUCE_ACCESS_KEY "8c4ffce86ae6-c419-8ef4-0513-54267305"; +setSecretVar SAUCE_ACCESS_KEY "e05dabf6fe0e-2c18-abf4-496d-1d010490"; setPublicVar SAUCE_LOG_FILE /tmp/angular/sauce-connect.log setPublicVar SAUCE_READY_FILE /tmp/angular/sauce-connect-ready-file.lock setPublicVar SAUCE_PID_FILE /tmp/angular/sauce-connect-pid-file.lock @@ -33,3 +33,11 @@ setPublicVar SAUCE_READY_FILE_TIMEOUT 120 # Source `$BASH_ENV` to make the variables available immediately. source $BASH_ENV; + +# Disable husky. +setPublicVar HUSKY 0 + +# Expose the Bazelisk version. We need to run Bazelisk globally since Windows has problems launching +# Bazel from a node modules directoy that might be modified by the Bazel Yarn install then. +setPublicVar BAZELISK_VERSION \ + "$(cd ${PROJECT_ROOT}; node -p 'require("./package.json").devDependencies["@bazel/bazelisk"]')" \ No newline at end of file diff --git a/.circleci/gcp_token b/.circleci/gcp_token deleted file mode 100644 index 06773903e8d8..000000000000 Binary files a/.circleci/gcp_token and /dev/null differ diff --git a/.circleci/github_token b/.circleci/github_token deleted file mode 100644 index 450cb2c93f8c..000000000000 --- a/.circleci/github_token +++ /dev/null @@ -1 +0,0 @@ -Salted__zÈùº¬ö"Bõ¾Y¾’|‚Û¢V”QÖ³UzWò±/G…îR ¡e}j‘% þÿ¦<%öáÉÿ–¼ \ No newline at end of file diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index c3cc98e062af..000000000000 --- a/.eslintignore +++ /dev/null @@ -1,13 +0,0 @@ -/bazel-out/ -/dist-schema/ -/goldens/public-api -/packages/angular_devkit/build_angular/test/ -/packages/angular_devkit/build_webpack/test/ -/packages/angular_devkit/schematics_cli/blank/project-files/ -/packages/angular_devkit/schematics_cli/blank/schematic-files/ -/packages/angular_devkit/schematics_cli/schematic/files/ -/tests/ -.yarn/ -dist/ -node_modules/ -third_party/ \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 954eb0855a7b..116c3d0bc6cc 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -11,6 +11,23 @@ "plugin:@typescript-eslint/recommended-requiring-type-checking", "prettier" ], + "ignorePatterns": [ + "bazel-out", + "dist-schema", + "goldens/public-api", + "modules/testing/builder/projects", + "packages/angular_devkit/build_angular/src/babel-bazel.d.ts", + "packages/angular_devkit/build_angular/test", + "packages/angular_devkit/build_webpack/test", + "packages/angular_devkit/schematics_cli/blank/project-files", + "packages/angular_devkit/schematics_cli/blank/schematic-files", + "packages/angular_devkit/schematics_cli/schematic/files", + "**/tests", + ".yarn", + "dist", + "**/node_modules", + "**/third_party" + ], "parser": "@typescript-eslint/parser", "parserOptions": { "project": "tsconfig.json", @@ -33,7 +50,7 @@ " * Copyright Google LLC All Rights Reserved.", " *", " * Use of this source code is governed by an MIT-style license that can be", - " * found in the LICENSE file at https://angular.io/license", + " * found in the LICENSE file at https://angular.dev/license", " " ], 2 @@ -86,13 +103,9 @@ "no-case-declarations": "off", "no-fallthrough": "off", "no-underscore-dangle": "off", - "@typescript-eslint/await-thenable": "off", "@typescript-eslint/ban-types": "off", - "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-implied-eval": "off", "@typescript-eslint/no-var-requires": "off", - "@typescript-eslint/no-unnecessary-type-assertion": "off", "@typescript-eslint/no-unsafe-argument": "off", "@typescript-eslint/no-unsafe-assignment": "off", "@typescript-eslint/no-unsafe-call": "off", @@ -103,7 +116,10 @@ "@typescript-eslint/require-await": "off", "@typescript-eslint/restrict-plus-operands": "off", "@typescript-eslint/restrict-template-expressions": "off", - "@typescript-eslint/unbound-method": "off" + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/no-unsafe-enum-comparison": "off", + "@typescript-eslint/no-redundant-type-constituents": "off", + "@typescript-eslint/no-base-to-string": "off" }, "overrides": [ { diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index b5135def5125..000000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,10 +0,0 @@ -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 - -Please help us process issues more efficiently by filing an -issue using one of the following templates: - -https://github.com/angular/angular-cli/issues/new/choose - -Thank you! - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.md b/.github/ISSUE_TEMPLATE/1-bug-report.md deleted file mode 100644 index bc0cec42df0e..000000000000 --- a/.github/ISSUE_TEMPLATE/1-bug-report.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: "\U0001F41E Bug report" -about: Report a bug in Angular CLI ---- - - - -# 🞠Bug report - -### Command (mark with an `x`) - - - - -- [ ] new -- [ ] build -- [ ] serve -- [ ] test -- [ ] e2e -- [ ] generate -- [ ] add -- [ ] update -- [ ] lint -- [ ] extract-i18n -- [ ] run -- [ ] config -- [ ] help -- [ ] version -- [ ] doc - -### Is this a regression? - - - Yes, the previous version in which this bug was not present was: .... - -### Description - - A clear and concise description of the problem... - -## 🔬 Minimal Reproduction - - - -## 🔥 Exception or Error - -

-
-
-
-
- -## 🌠Your Environment - -

-
-
-
-
- -**Anything else relevant?** - - - - diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml new file mode 100644 index 000000000000..5c4ea7d2cbdb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -0,0 +1,102 @@ +name: Bug report +description: Report a bug in Angular CLI +body: + - type: markdown + attributes: + value: | + Oh hi there! + + To expedite issue processing please search open and closed issues before submitting a new one. + Existing issues often contain information about workarounds, resolution, or progress updates. + - type: dropdown + id: command + attributes: + label: Command + description: Can you pin-point the command or commands that are effected by this bug? + options: + - add + - build + - config + - doc + - e2e + - extract-i18n + - generate + - help + - lint + - new + - other + - run + - serve + - test + - update + - version + multiple: true + validations: + required: true + - type: checkboxes + id: is-regression + attributes: + label: Is this a regression? + description: Did this behavior use to work in the previous version? + options: + - label: Yes, this behavior used to work in the previous version + - type: input + id: version-bug-was-not-present + attributes: + label: The previous version in which this bug was not present was + validations: + required: false + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of the problem. + validations: + required: true + - type: textarea + id: minimal-reproduction + attributes: + label: Minimal Reproduction + description: | + Simple steps to reproduce this bug. + + **Please include:** + * commands run (including args) + * packages added + * related code changes + + + If reproduction steps are not enough for reproduction of your issue, please create a minimal GitHub repository with the reproduction of the issue. + A good way to make a minimal reproduction is to create a new app via `ng new repro-app` and add the minimum possible code to show the problem. + Share the link to the repo below along with step-by-step instructions to reproduce the problem, as well as expected and actual behavior. + + Issues that don't have enough info and can't be reproduced will be closed. + + You can read more about issue submission guidelines [here](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-submitting-an-issue). + validations: + required: true + - type: textarea + id: exception-or-error + attributes: + label: Exception or Error + description: If the issue is accompanied by an exception or an error, please share it below. + render: text + validations: + required: false + - type: textarea + id: environment + attributes: + label: Your Environment + description: Run `ng version` and paste output below. + render: text + validations: + required: true + - type: textarea + id: other + attributes: + label: Anything else relevant? + description: | + Is this a browser specific issue? If so, please specify the browser and version. + Do any of these matter: operating system, IDE, package manager, HTTP server, ...? If so, please mention it below. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.md b/.github/ISSUE_TEMPLATE/2-feature-request.md deleted file mode 100644 index f129bc107360..000000000000 --- a/.github/ISSUE_TEMPLATE/2-feature-request.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: "\U0001F680 Feature request" -about: Suggest a feature for Angular CLI ---- - - - -# 🚀 Feature request - -### Command (mark with an `x`) - - - - -- [ ] new -- [ ] build -- [ ] serve -- [ ] test -- [ ] e2e -- [ ] generate -- [ ] add -- [ ] update -- [ ] lint -- [ ] extract-i18n -- [ ] run -- [ ] config -- [ ] help -- [ ] version -- [ ] doc - -### Description - - A clear and concise description of the problem or missing capability... - -### Describe the solution you'd like - - If you have a solution in mind, please describe it. - -### Describe alternatives you've considered - - Have you considered any alternative solutions or workarounds? diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.yml b/.github/ISSUE_TEMPLATE/2-feature-request.yml new file mode 100644 index 000000000000..4a01698e0f37 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature-request.yml @@ -0,0 +1,55 @@ +name: Feature request +description: Suggest a feature for Angular CLI +body: + - type: markdown + attributes: + value: | + Oh hi there! + + To expedite issue processing please search open and closed issues before submitting a new one. + Existing issues often contain information about workarounds, resolution, or progress updates. + - type: dropdown + id: command + attributes: + label: Command + description: Can you pin-point the command or commands that are relevant for this feature request? + options: + - add + - build + - config + - doc + - e2e + - extract-i18n + - generate + - help + - lint + - new + - run + - serve + - test + - update + - version + multiple: true + validations: + required: true + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of the problem or missing capability. + validations: + required: true + - type: textarea + id: desired-solution + attributes: + label: Describe the solution you'd like + description: If you have a solution in mind, please describe it. + validations: + required: false + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: Have you considered any alternative solutions or workarounds? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/3-docs-bug.md b/.github/ISSUE_TEMPLATE/3-docs-bug.md deleted file mode 100644 index 7270bb2a963f..000000000000 --- a/.github/ISSUE_TEMPLATE/3-docs-bug.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: '📚 Docs or angular.io issue report' -about: Report an issue in Angular's documentation or angular.io application ---- - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 - -Please file any Docs or angular.io issues at: https://github.com/angular/angular/issues/new/choose - -For the time being, we keep Angular AIO issues in a separate repository. - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 diff --git a/.github/ISSUE_TEMPLATE/4-security-issue-disclosure.md b/.github/ISSUE_TEMPLATE/4-security-issue-disclosure.md deleted file mode 100644 index a5c2c1707fda..000000000000 --- a/.github/ISSUE_TEMPLATE/4-security-issue-disclosure.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: âš ï¸ Security issue disclosure -about: Report a security issue in Angular Framework, Material, or CLI ---- - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 - -Please read https://angular.io/guide/security#report-issues on how to disclose security related issues. - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 diff --git a/.github/ISSUE_TEMPLATE/5-support-request.md b/.github/ISSUE_TEMPLATE/5-support-request.md deleted file mode 100644 index 509f8d4797bc..000000000000 --- a/.github/ISSUE_TEMPLATE/5-support-request.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: 'â“ Support request' -about: Questions and requests for support ---- - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 - -Please do not file questions or support requests on the GitHub issues tracker. - -You can get your questions answered using other communication channels. Please see: -https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md#question - -Thank you! - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 diff --git a/.github/ISSUE_TEMPLATE/6-angular-framework.md b/.github/ISSUE_TEMPLATE/6-angular-framework.md deleted file mode 100644 index 8ab207b2389f..000000000000 --- a/.github/ISSUE_TEMPLATE/6-angular-framework.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: 'âš¡Angular Framework' -about: Issues and feature requests for Angular Framework ---- - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 - -Please file any Angular Framework issues at: https://github.com/angular/angular/issues/new/choose - -For the time being, we keep Angular issues in a separate repository. - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 diff --git a/.github/ISSUE_TEMPLATE/7-angular-material.md b/.github/ISSUE_TEMPLATE/7-angular-material.md deleted file mode 100644 index 10b27db5c86f..000000000000 --- a/.github/ISSUE_TEMPLATE/7-angular-material.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -name: "\U0001F48E Angular Material" -about: Issues and feature requests for Angular Material ---- - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 - -Please file any Angular Material issues at: https://github.com/angular/material2/issues/new - -For the time being, we keep Angular Material issues in a separate repository. - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..898698af3906 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +blank_issues_enabled: false +contact_links: + - name: Docs or angular.dev issue report + url: https://github.com/angular/angular/issues/new + about: Report an issue in Angular's documentation or angular.dev application + - name: Security issue disclosure + url: https://angular.dev/best-practices/security + about: Report a security issue in Angular Framework, Material, or CLI + - name: Support request + url: https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#question + about: Questions and requests for support. + - name: Angular Framework + url: https://github.com/angular/angular/issues/new/choose + about: Issues and feature requests for Angular Framework + - name: Angular Material + url: https://github.com/angular/components/issues/new/choose + about: Issues and feature requests for Angular Material diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000000..3214dde0a4f4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,43 @@ +## PR Checklist + +Please check to confirm your PR fulfills the following requirements: + + + +- [ ] The commit message follows our guidelines: https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-commit-message-guidelines +- [ ] Tests for the changes have been added (for bug fixes / features) +- [ ] Docs have been added / updated (for bug fixes / features) + +## PR Type + +What kind of change does this PR introduce? + + + +- [ ] Bugfix +- [ ] Feature +- [ ] Code style update (formatting, local variables) +- [ ] Refactoring (no functional changes, no api changes) +- [ ] Build related changes +- [ ] CI related changes +- [ ] Documentation content changes +- [ ] Other... Please describe: + +## What is the current behavior? + + + +Issue Number: N/A + +## What is the new behavior? + + + +## Does this PR introduce a breaking change? + +- [ ] Yes +- [ ] No + + + +## Other information diff --git a/.github/SAVED_REPLIES.md b/.github/SAVED_REPLIES.md index 466b8ad5ee52..1237bc279e11 100644 --- a/.github/SAVED_REPLIES.md +++ b/.github/SAVED_REPLIES.md @@ -29,7 +29,7 @@ Thanks for reporting this issue. However, this issue is a duplicate of # - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 28a804d8932a..000000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 6d8c965387b0..000000000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/.idea/runConfigurations/Large_Tests.xml b/.idea/runConfigurations/Large_Tests.xml deleted file mode 100644 index 3d4f25fb3a76..000000000000 --- a/.idea/runConfigurations/Large_Tests.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - `, + ); + }); + + it('should add the anonymous crossorigin attribute when option is set to anonymous', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + crossOrigin: CrossOrigin.Anonymous, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.toEqual( + `` + + `` + + `` + + ``, + ); + }); + + it('should not add a crossorigin attribute when option is set to none', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + crossOrigin: CrossOrigin.None, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.toEqual( + `` + + `` + + `` + + ``, + ); + }); + + it('should not add a crossorigin attribute when option is not present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.toEqual( + `` + + `` + + `` + + ``, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/define_spec.ts b/packages/angular/build/src/builders/application/tests/options/define_spec.ts new file mode 100644 index 000000000000..d4e3319553f2 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/define_spec.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "define"', () => { + it('should replace a value in application code when specified as a number', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + define: { + 'AN_INTEGER': '42', + }, + }); + + await harness.writeFile('./src/types.d.ts', 'declare const AN_INTEGER: number;'); + await harness.writeFile('src/main.ts', 'console.log(AN_INTEGER);'); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.not.toContain('AN_INTEGER'); + harness.expectFile('dist/browser/main.js').content.toContain('(42)'); + }); + + it('should replace a value in application code when specified as a string', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + define: { + 'A_STRING': '"42"', + }, + }); + + await harness.writeFile('./src/types.d.ts', 'declare const A_STRING: string;'); + await harness.writeFile('src/main.ts', 'console.log(A_STRING);'); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.not.toContain('A_STRING'); + harness.expectFile('dist/browser/main.js').content.toContain('("42")'); + }); + + it('should replace a value in application code when specified as a boolean', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + define: { + 'A_BOOLEAN': 'true', + }, + }); + + await harness.writeFile('./src/types.d.ts', 'declare const A_BOOLEAN: boolean;'); + await harness.writeFile('src/main.ts', 'console.log(A_BOOLEAN);'); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.not.toContain('A_BOOLEAN'); + harness.expectFile('dist/browser/main.js').content.toContain('(true)'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/delete-output-path_spec.ts b/packages/angular/build/src/builders/application/tests/options/delete-output-path_spec.ts new file mode 100644 index 000000000000..7c0ceaab7145 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/delete-output-path_spec.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "deleteOutputPath"', () => { + beforeEach(async () => { + // Application code is not needed for asset tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + + // Add files in output + await harness.writeFile('dist/a.txt', 'A'); + await harness.writeFile('dist/browser/b.txt', 'B'); + }); + + it(`should delete the output files when 'deleteOutputPath' is true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + deleteOutputPath: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist').toExist(); + harness.expectFile('dist/a.txt').toNotExist(); + harness.expectDirectory('dist/browser').toExist(); + harness.expectFile('dist/browser/b.txt').toNotExist(); + }); + + it(`should delete the output files when 'deleteOutputPath' is not set`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + deleteOutputPath: undefined, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist').toExist(); + harness.expectFile('dist/a.txt').toNotExist(); + harness.expectDirectory('dist/browser').toExist(); + harness.expectFile('dist/browser/b.txt').toNotExist(); + }); + + it(`should not delete the output files when 'deleteOutputPath' is false`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + deleteOutputPath: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/a.txt').toExist(); + harness.expectFile('dist/browser/b.txt').toExist(); + }); + + it(`should not delete empty only directories when 'deleteOutputPath' is true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + deleteOutputPath: true, + }); + + // Add an error to prevent the build from writing files + await harness.writeFile('src/main.ts', 'INVALID_CODE'); + + const { result } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + harness.expectDirectory('dist').toExist(); + harness.expectDirectory('dist/browser').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts b/packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts new file mode 100644 index 000000000000..a03ca2b026e7 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "deployUrl"', () => { + beforeEach(async () => { + // Add a global stylesheet to test link elements + await harness.writeFile('src/styles.css', '/* Global styles */'); + + // Reduce the input index HTML to a single line to simplify comparing + await harness.writeFile( + 'src/index.html', + '', + ); + }); + + it('should update script src and link href attributes when option is set to relative URL', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + deployUrl: 'deployUrl/', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.toEqual( + `` + + `` + + ``, + ); + }); + + it('should update script src and link href attributes when option is set to absolute URL', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + deployUrl: 'https://example.com/some/path/', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.toEqual( + `` + + `` + + ``, + ); + }); + + it('should update resources component stylesheets to reference deployURL', async () => { + await harness.writeFile('src/app/test.svg', ''); + await harness.writeFile( + 'src/app/app.component.css', + `* { background-image: url('./test.svg'); }`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + deployUrl: 'https://example.com/some/path/', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + .content.toContain('background-image: url("https://example.com/some/path/media/test.svg")'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/external-dependencies_spec.ts b/packages/angular/build/src/builders/application/tests/options/external-dependencies_spec.ts new file mode 100644 index 000000000000..27106874bca6 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/external-dependencies_spec.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "externalDependencies"', () => { + it('should not externalize any dependency when option is not set', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/main.js').content.not.toMatch(/from ['"]@angular\/core['"]/); + harness + .expectFile('dist/browser/main.js') + .content.not.toMatch(/from ['"]@angular\/common['"]/); + }); + + it('should only externalize the listed depedencies when option is set', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + externalDependencies: ['@angular/core'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toMatch(/from ['"]@angular\/core['"]/); + harness + .expectFile('dist/browser/main.js') + .content.not.toMatch(/from ['"]@angular\/common['"]/); + }); + + it('should externalize the listed depedencies in Web Workers when option is set', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + externalDependencies: ['path'], + }); + + // The `path` Node.js builtin is used to cause a failure if not externalized + const workerCodeFile = ` + import path from "path"; + console.log(path); + `; + + // Create a worker file + await harness.writeFile('src/app/worker.ts', workerCodeFile); + + // Create app component that uses the directive + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core' + @Component({ + selector: 'app-root', + template: '

Worker Test

', + }) + export class AppComponent { + worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' }); + } + `, + ); + + const { result } = await harness.executeOnce(); + // If not externalized, build will fail with a Node.js platform builtin error + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts b/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts new file mode 100644 index 000000000000..402200a27f9d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "extractLicenses"', () => { + it(`should generate '3rdpartylicenses.txt' when 'extractLicenses' is true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + extractLicenses: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('MIT'); + }); + + it(`should not generate '3rdpartylicenses.txt' when 'extractLicenses' is false`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + extractLicenses: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/3rdpartylicenses.txt').toNotExist(); + }); + + it(`should generate '3rdpartylicenses.txt' when 'extractLicenses' is not set`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('MIT'); + }); + + it(`should generate '3rdpartylicenses.txt' when 'extractLicenses' and 'localize' are true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + extractLicenses: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/3rdpartylicenses.txt').content.toContain('MIT'); + harness.expectFile('dist/browser/en-US/main.js').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/i18n-missing-translation_spec.ts b/packages/angular/build/src/builders/application/tests/options/i18n-missing-translation_spec.ts new file mode 100644 index 000000000000..d29c0a84adbc --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/i18n-missing-translation_spec.ts @@ -0,0 +1,224 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "i18nMissingTranslation"', () => { + beforeEach(() => { + harness.useProject('test', { + root: '.', + sourceRoot: 'src', + cli: { + cache: { + enabled: false, + }, + }, + i18n: { + locales: { + 'fr': 'src/locales/messages.fr.xlf', + }, + }, + }); + }); + + it('should warn when i18nMissingTranslation is undefined (default)', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + i18nMissingTranslation: undefined, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', MISSING_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should warn when i18nMissingTranslation is set to warning', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'warning' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', MISSING_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should error when i18nMissingTranslation is set to error', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'error' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', MISSING_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should not error or warn when i18nMissingTranslation is set to ignore', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'ignore' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', MISSING_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should not error or warn when i18nMissingTranslation is set to error and all found', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'error' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', GOOD_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + + it('should not error or warn when i18nMissingTranslation is set to warning and all found', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + localize: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + i18nMissingTranslation: 'warning' as any, + }); + + await harness.writeFile( + 'src/app/app.component.html', + ` +

Hello {{ title }}!

+ `, + ); + + await harness.writeFile('src/locales/messages.fr.xlf', GOOD_TRANSLATION_FILE_CONTENT); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('No translation found for'), + }), + ); + }); + }); +}); + +const GOOD_TRANSLATION_FILE_CONTENT = ` + + + + + + Bonjour ! + + src/app/app.component.html + 2,3 + + An introduction header for this sample + + + + +`; + +const MISSING_TRANSLATION_FILE_CONTENT = ` + + + + + + + + +`; diff --git a/packages/angular/build/src/builders/application/tests/options/index_spec.ts b/packages/angular/build/src/builders/application/tests/options/index_spec.ts new file mode 100644 index 000000000000..83e3cc132fe5 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/index_spec.ts @@ -0,0 +1,231 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "index"', () => { + beforeEach(async () => { + // Application code is not needed for index tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + }); + + describe('short form syntax', () => { + it('should not generate an output file when false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/index.html').toNotExist(); + }); + + // TODO: This fails option validation when used in the CLI but not when used directly + xit('should fail build when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: true, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + harness.expectFile('dist/browser/index.html').toNotExist(); + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching('Schema validation failed') }), + ); + }); + + it('should use the provided file path to generate the output file when a string path', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: 'src/index.html', + }); + + await harness.writeFile( + 'src/index.html', + 'TEST_123', + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('TEST_123'); + }); + + // TODO: Build needs to be fixed to not throw an unhandled exception for this case + xit('should fail build when a string path to non-existent file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: 'src/not-here.html', + }); + + const { result } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + harness.expectFile('dist/browser/index.html').toNotExist(); + }); + + it('should generate initial preload link elements', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + preloadInitial: true, + }, + }); + + // Setup an initial chunk usage for JS + await harness.writeFile('src/a.ts', 'console.log("TEST");'); + await harness.writeFile('src/b.ts', 'import "./a";'); + await harness.writeFile('src/main.ts', 'import "./a";\n(() => import("./b"))();'); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('chunk-'); + harness.expectFile('dist/browser/index.html').content.toContain('modulepreload'); + harness.expectFile('dist/browser/index.html').content.toContain('chunk-'); + }); + }); + + describe('long form syntax', () => { + it('should use the provided input path to generate the output file when present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + }, + }); + + await harness.writeFile( + 'src/index.html', + 'TEST_123', + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('TEST_123'); + }); + + it('should use the provided output path to generate the output file when present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + output: 'output.html', + }, + }); + + await harness.writeFile( + 'src/index.html', + 'TEST_123', + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/output.html').content.toContain('TEST_123'); + }); + }); + + it('should generate initial preload link elements when preloadInitial is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + preloadInitial: true, + }, + }); + + // Setup an initial chunk usage for JS + await harness.writeFile('src/a.ts', 'console.log("TEST");'); + await harness.writeFile('src/b.ts', 'import "./a";'); + await harness.writeFile('src/main.ts', 'import "./a";\n(() => import("./b"))();'); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('chunk-'); + harness.expectFile('dist/browser/index.html').content.toContain('modulepreload'); + harness.expectFile('dist/browser/index.html').content.toContain('chunk-'); + }); + + it('should generate initial preload link elements when preloadInitial is undefined', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + preloadInitial: undefined, + }, + }); + + // Setup an initial chunk usage for JS + await harness.writeFile('src/a.ts', 'console.log("TEST");'); + await harness.writeFile('src/b.ts', 'import "./a";'); + await harness.writeFile('src/main.ts', 'import "./a";\n(() => import("./b"))();'); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('chunk-'); + harness.expectFile('dist/browser/index.html').content.toContain('modulepreload'); + harness.expectFile('dist/browser/index.html').content.toContain('chunk-'); + }); + + it('should not generate initial preload link elements when preloadInitial is false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + preloadInitial: false, + }, + }); + + // Setup an initial chunk usage for JS + await harness.writeFile('src/a.ts', 'console.log("TEST");'); + await harness.writeFile('src/b.ts', 'import "./a";'); + await harness.writeFile('src/main.ts', 'import "./a";\n(() => import("./b"))();'); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('chunk-'); + harness.expectFile('dist/browser/index.html').content.not.toContain('modulepreload'); + harness.expectFile('dist/browser/index.html').content.not.toContain('chunk-'); + }); + + it(`should generate 'index.csr.html' instead of 'index.html' by default when ssr is enabled.`, async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toExist(); + harness.expectFile('dist/browser/index.csr.html').toExist(); + harness.expectFile('dist/browser/index.html').toNotExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/inline-style-language_spec.ts b/packages/angular/build/src/builders/application/tests/options/inline-style-language_spec.ts new file mode 100644 index 000000000000..632bc6f1db7b --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/inline-style-language_spec.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { concatMap, count, take, timeout } from 'rxjs'; +import { buildApplication } from '../../index'; +import { InlineStyleLanguage } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "inlineStyleLanguage"', () => { + beforeEach(async () => { + // Setup application component with inline style property + await harness.modifyFile('src/app/app.component.ts', (content) => { + return content + .replace('styleUrls', 'styles') + .replace('./app.component.css', '__STYLE_MARKER__'); + }); + }); + + for (const aot of [true, false]) { + describe(`[${aot ? 'AOT' : 'JIT'}]`, () => { + it('supports SCSS inline component styles when set to "scss"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Scss, + aot, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('__STYLE_MARKER__', '$primary: indianred;\\nh1 { color: $primary; }'), + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('color: indianred'); + }); + + it('supports Sass inline component styles when set to "sass"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Sass, + aot, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('__STYLE_MARKER__', '$primary: indianred\\nh1\\n\\tcolor: $primary'), + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('color: indianred'); + }); + + it('supports Less inline component styles when set to "less"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Less, + aot, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('__STYLE_MARKER__', '@primary: indianred;\\nh1 { color: @primary; }'), + ); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('color: indianred'); + }); + + it('updates produced stylesheet in watch mode', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + inlineStyleLanguage: InlineStyleLanguage.Scss, + aot, + watch: true, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('__STYLE_MARKER__', '$primary: indianred;\\nh1 { color: $primary; }'), + ); + + const buildCount = await harness + .execute() + .pipe( + timeout(30000), + concatMap(async ({ result }, index) => { + expect(result?.success).toBe(true); + + switch (index) { + case 0: + harness + .expectFile('dist/browser/main.js') + .content.toContain('color: indianred'); + harness.expectFile('dist/browser/main.js').content.not.toContain('color: aqua'); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace( + '$primary: indianred;\\nh1 { color: $primary; }', + '$primary: aqua;\\nh1 { color: $primary; }', + ), + ); + break; + case 1: + harness + .expectFile('dist/browser/main.js') + .content.not.toContain('color: indianred'); + harness.expectFile('dist/browser/main.js').content.toContain('color: aqua'); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace( + '$primary: aqua;\\nh1 { color: $primary; }', + '$primary: blue;\\nh1 { color: $primary; }', + ), + ); + break; + case 2: + harness + .expectFile('dist/browser/main.js') + .content.not.toContain('color: indianred'); + harness.expectFile('dist/browser/main.js').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/main.js').content.toContain('color: blue'); + + break; + } + }), + take(3), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(3); + }); + }); + } + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/loader_spec.ts b/packages/angular/build/src/builders/application/tests/options/loader_spec.ts new file mode 100644 index 000000000000..d7a6858d6e4b --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/loader_spec.ts @@ -0,0 +1,257 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "loader"', () => { + it('should error for an unknown file extension', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + 'No loader is configured for ".unknown" files: src/a.unknown', + ), + }), + ); + }); + + it('should not include content for file extension set to "empty"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'empty', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.not.toContain('ABC'); + }); + + it('should inline text content for file extension set to "text"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'text', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('ABC'); + }); + + it('should inline binary content for file extension set to "binary"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'binary', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const content: Uint8Array; export default content; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + // Should contain the binary encoding used esbuild and not the text content + harness.expectFile('dist/browser/main.js').content.toContain('__toBinary("QUJD")'); + harness.expectFile('dist/browser/main.js').content.not.toContain('ABC'); + }); + + it('should emit an output file for file extension set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'file', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const location: string; export default location; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.unknown'); + harness.expectFile('dist/browser/media/a.unknown').toExist(); + }); + + it('should emit an output file with hashing when enabled for file extension set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputHashing: 'media' as any, + loader: { + '.unknown': 'file', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.unknown" { const location: string; export default location; }', + ); + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.unknown";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.unknown'); + expect(harness.hasFileMatch('dist/browser/media', /a-[0-9A-Z]{8}\.unknown$/)).toBeTrue(); + }); + + it('should inline text content for `.txt` by default', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: undefined, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.txt" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.txt";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('ABC'); + }); + + it('should inline text content for `.txt` by default when other extensions are defined', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'binary', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.txt" { const content: string; export default content; }', + ); + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.txt";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('ABC'); + }); + + it('should allow overriding default `.txt` extension behavior', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.txt': 'file', + }, + }); + + await harness.writeFile( + './src/types.d.ts', + 'declare module "*.txt" { const location: string; export default location; }', + ); + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + 'import contents from "./a.txt";\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.txt'); + harness.expectFile('dist/browser/media/a.txt').toExist(); + }); + + // Schema validation will prevent this from happening for supported use-cases. + // This will only happen if used programmatically and the option value is set incorrectly. + it('should ignore entry if an invalid loader name is used', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + '.unknown': 'invalid', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + }); + + // Schema validation will prevent this from happening for supported use-cases. + // This will only happen if used programmatically and the option value is set incorrectly. + it('should ignore entry if an extension does not start with a period', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + loader: { + 'unknown': 'text', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/named-chunks_spec.ts b/packages/angular/build/src/builders/application/tests/options/named-chunks_spec.ts new file mode 100644 index 000000000000..06f72f27c902 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/named-chunks_spec.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +const MAIN_OUTPUT = 'dist/browser/main.js'; +const NAMED_LAZY_OUTPUT = 'dist/browser/lazy-module-7QZXF7K7.js'; +const UNNAMED_LAZY_OUTPUT = 'dist/browser/chunk-OW5RYMPM.js'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "namedChunks"', () => { + beforeEach(async () => { + // Setup a lazy loaded chunk + await harness.writeFiles({ + 'src/lazy-module.ts': 'export const value = 42;', + 'src/main.ts': `import('./lazy-module');`, + }); + }); + + it('generates named files in output when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + namedChunks: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile(MAIN_OUTPUT).toExist(); + harness.expectFile(NAMED_LAZY_OUTPUT).toExist(); + harness.expectFile(UNNAMED_LAZY_OUTPUT).toNotExist(); + }); + + it('does not generate named files in output when false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + namedChunks: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile(MAIN_OUTPUT).toExist(); + harness.expectFile(NAMED_LAZY_OUTPUT).toNotExist(); + harness.expectFile(UNNAMED_LAZY_OUTPUT).toExist(); + }); + + it('does not generates named files in output when not present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile(MAIN_OUTPUT).toExist(); + harness.expectFile(NAMED_LAZY_OUTPUT).toNotExist(); + harness.expectFile(UNNAMED_LAZY_OUTPUT).toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/optimization-fonts-inline_spec.ts b/packages/angular/build/src/builders/application/tests/options/optimization-fonts-inline_spec.ts new file mode 100644 index 000000000000..a84aeeccfdf9 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/optimization-fonts-inline_spec.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "fonts.inline"', () => { + beforeEach(async () => { + await harness.modifyFile('/src/index.html', (content) => + content.replace( + '', + ``, + ), + ); + + await harness.writeFile( + 'src/styles.css', + '@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500);', + ); + + await harness.writeFile( + 'src/app/app.component.css', + '@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500);', + ); + }); + + it(`should not inline fonts when fonts optimization is set to false`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + styles: true, + fonts: false, + }, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + for (const file of ['styles.css', 'index.html', 'main.js']) { + harness + .expectFile(`dist/browser/${file}`) + .content.toContain(`https://fonts.googleapis.com/css?family=Roboto:300,400,500`); + } + }); + + it(`should inline fonts when fonts optimization is unset`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + styles: true, + fonts: undefined, + }, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + for (const file of ['styles.css', 'index.html', 'main.js']) { + harness + .expectFile(`dist/browser/${file}`) + .content.not.toContain(`https://fonts.googleapis.com/css?family=Roboto:300,400,500`); + harness + .expectFile(`dist/browser/${file}`) + .content.toMatch(/@font-face{font-family:'?Roboto/); + } + }); + + it(`should inline fonts when fonts optimization is true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + styles: true, + fonts: true, + }, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + for (const file of ['styles.css', 'index.html', 'main.js']) { + harness + .expectFile(`dist/browser/${file}`) + .content.not.toContain(`https://fonts.googleapis.com/css?family=Roboto:300,400,500`); + harness + .expectFile(`dist/browser/${file}`) + .content.toMatch(/@font-face{font-family:'?Roboto/); + } + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/optimization-inline-critical_spec.ts b/packages/angular/build/src/builders/application/tests/options/optimization-inline-critical_spec.ts new file mode 100644 index 000000000000..ab56a9bc84dd --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/optimization-inline-critical_spec.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "inlineCritical"', () => { + beforeEach(async () => { + await harness.writeFile('src/styles.css', 'body { color: #000 }'); + }); + + it(`should extract critical css when 'inlineCritical' is true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: false, + styles: { + minify: true, + inlineCritical: true, + }, + fonts: false, + }, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/browser/index.html').content.toContain(`body{color:#000}`); + }); + + it(`should extract critical css when 'optimization' is unset`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + optimization: undefined, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/browser/index.html').content.toContain(`body{color:#000}`); + }); + + it(`should extract critical css when 'optimization' is true`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + optimization: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/browser/index.html').content.toContain(`body{color:#000}`); + }); + + it(`should not extract critical css when 'optimization' is false`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + optimization: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.not.toContain(` { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + optimization: { + scripts: false, + styles: { + minify: false, + inlineCritical: false, + }, + fonts: false, + }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.not.toContain(` { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + optimization: { + scripts: false, + styles: { + minify: true, + inlineCritical: true, + }, + fonts: false, + }, + }); + + await harness.writeFile('src/styles.css', '@media all { body { color: #000 } }'); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.toContain( + ``, + ); + harness.expectFile('dist/browser/index.html').content.toContain(`body{color:#000}`); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/optimization-remove-special-comments_spec.ts b/packages/angular/build/src/builders/application/tests/options/optimization-remove-special-comments_spec.ts new file mode 100644 index 000000000000..0ce1c6dc92b5 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/optimization-remove-special-comments_spec.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "removeSpecialComments"', () => { + beforeEach(async () => { + await harness.writeFile( + 'src/styles.css', + ` + /* normal-comment */ + /*! important-comment */ + div { flex: 1 } + `, + ); + }); + + it(`should retain special comments when 'removeSpecialComments' is set to 'false'`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + extractLicenses: true, + styles: ['src/styles.css'], + optimization: { + styles: { + removeSpecialComments: false, + }, + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/\/\*! important-comment \*\/[\s\S]*div{flex:1}/); + }); + + it(`should not retain special comments when 'removeSpecialComments' is set to 'true'`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + extractLicenses: true, + styles: ['src/styles.css'], + optimization: { + styles: { + removeSpecialComments: true, + }, + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.not.toContain('important-comment'); + }); + + it(`should not retain special comments when 'removeSpecialComments' is not set`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + extractLicenses: true, + styles: ['src/styles.css'], + optimization: { + styles: {}, + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.not.toContain('important-comment'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/output-hashing_spec.ts b/packages/angular/build/src/builders/application/tests/options/output-hashing_spec.ts new file mode 100644 index 000000000000..a4988148c879 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/output-hashing_spec.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { OutputHashing } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "outputHashing"', () => { + beforeEach(async () => { + // Application code is not needed for asset tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + await harness.writeFile('src/polyfills.ts', 'console.log("TEST-POLYFILLS");'); + }); + + it('hashes all filenames when set to "all"', async () => { + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + polyfills: ['src/polyfills.ts'], + outputHashing: OutputHashing.All, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + expect(harness.hasFileMatch('dist/browser', /main-[0-9A-Z]{8}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist/browser', /polyfills-[0-9A-Z]{8}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css$/)).toBeTrue(); + expect(harness.hasFileMatch('dist/browser/media', /spectrum-[0-9A-Z]{8}\.png$/)).toBeTrue(); + }); + + it(`doesn't hash any filenames when not set`, async () => { + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['src/polyfills.ts'], + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + expect(harness.hasFileMatch('dist/browser', /main-[0-9A-Z]{8}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /polyfills-[0-9A-Z]{8}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser/media', /spectrum-[0-9A-Z]{8}\.png$/)).toBeFalse(); + }); + + it(`doesn't hash any filenames when set to "none"`, async () => { + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + polyfills: ['src/polyfills.ts'], + outputHashing: OutputHashing.None, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + expect(harness.hasFileMatch('dist/browser', /main-[0-9A-Z]{8}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /polyfills-[0-9A-Z]{8}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser/media', /spectrum-[0-9A-Z]{8}\.png$/)).toBeFalse(); + }); + + it(`hashes CSS resources filenames only when set to "media"`, async () => { + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + polyfills: ['src/polyfills.ts'], + outputHashing: OutputHashing.Media, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + expect(harness.hasFileMatch('dist/browser', /main-[0-9A-Z]{8}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /polyfills-[0-9A-Z]{8}\.js$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser/media', /spectrum-[0-9A-Z]{8}\.png$/)).toBeTrue(); + }); + + it(`hashes bundles filenames only when set to "bundles"`, async () => { + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + polyfills: ['src/polyfills.ts'], + outputHashing: OutputHashing.Bundles, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + expect(harness.hasFileMatch('dist/browser', /main-[0-9A-Z]{8}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist/browser', /polyfills-[0-9A-Z]{8}\.js$/)).toBeTrue(); + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css$/)).toBeTrue(); + expect(harness.hasFileMatch('dist/browser/media', /spectrum-[0-9A-Z]{8}\.png$/)).toBeFalse(); + }); + + it('does not hash non injected styles', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.All, + sourceMap: true, + styles: [ + { + input: 'src/styles.css', + inject: false, + }, + ], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css$/)).toBeFalse(); + expect(harness.hasFileMatch('dist/browser', /styles-[0-9A-Z]{8}\.css.map$/)).toBeFalse(); + harness.expectFile('dist/browser/styles.css').toExist(); + harness.expectFile('dist/browser/styles.css.map').toExist(); + }); + + // TODO: Re-enable once implemented in the esbuild builder + xit('does not override different files which has the same filenames when hashing is "none"', async () => { + await harness.writeFiles({ + 'src/styles.css': ` + h1 { background: url('./test.svg')} + h2 { background: url('./small/test.svg')} + `, + './src/test.svg': ` + Hello World + `, + './src/small/test.svg': ` + Hello World + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + outputHashing: OutputHashing.None, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/media/test.svg').toExist(); + harness.expectFile('dist/browser/media/small-test.svg').toExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/output-path_spec.ts b/packages/angular/build/src/builders/application/tests/options/output-path_spec.ts new file mode 100644 index 000000000000..b08314b53427 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/output-path_spec.ts @@ -0,0 +1,360 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + beforeEach(async () => { + // Add a global stylesheet media file + await harness.writeFile('src/styles.css', `h1 { background: url('./spectrum.png')}`); + // Add a component stylesheet media file + await harness.writeFile('src/app/abc.svg', ''); + await harness.writeFile('src/app/app.component.css', `h2 { background: url('./abc.svg')}`); + + // Enable SSR + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts', 'server.ts'); + + return JSON.stringify(tsConfig); + }); + + // Application server code is not needed in this test + await harness.writeFile('src/main.server.ts', `console.log('Hello!');`); + await harness.writeFile('src/server.ts', `console.log('Hello!');`); + }); + + describe('Option: "outputPath"', () => { + describe(`when option value is is a string`, () => { + beforeEach(() => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + outputPath: 'dist', + styles: ['src/styles.css'], + server: 'src/main.server.ts', + ssr: { + entry: 'src/server.ts', + }, + }); + }); + + it(`should emit browser bundles in 'browser' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + }); + + it(`should emit media files in 'browser/media' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/media/spectrum.png').toExist(); + harness.expectFile('dist/browser/media/abc.svg').toExist(); + }); + + it(`should emit server bundles in 'server' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/server.mjs').toExist(); + }); + }); + + describe(`when option value is an object`, () => { + describe(`'media' is set to 'resources'`, () => { + beforeEach(() => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + media: 'resource', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + }); + + it(`should emit browser bundles in 'browser' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + }); + + it(`should emit media files in 'browser/resource' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/resource/spectrum.png').toExist(); + harness.expectFile('dist/browser/resource/abc.svg').toExist(); + }); + + it(`should emit server bundles in 'server' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/server.mjs').toExist(); + }); + }); + + describe(`'media' is set to ''`, () => { + beforeEach(() => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + media: '', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + }); + + it(`should emit browser bundles in 'browser' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + }); + + it(`should emit media files in 'browser' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/spectrum.png').toExist(); + harness.expectFile('dist/browser/abc.svg').toExist(); + + // Component CSS should not be considered media + harness.expectFile('dist/browser/app.component.css').toNotExist(); + }); + + it(`should emit server bundles in 'server' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/server.mjs').toExist(); + }); + }); + + describe(`'server' is set to 'node-server'`, () => { + beforeEach(() => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + server: 'node-server', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + }); + + it(`should emit browser bundles in 'browser' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + }); + + it(`should emit media files in 'browser/media' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/media/spectrum.png').toExist(); + harness.expectFile('dist/browser/media/abc.svg').toExist(); + }); + + it(`should emit server bundles in 'node-server' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/node-server/server.mjs').toExist(); + }); + }); + + describe(`'browser' is set to 'public'`, () => { + beforeEach(() => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: 'public', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + }); + + it(`should emit browser bundles in 'public' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/public/main.js').toExist(); + }); + + it(`should emit media files in 'public/media' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/public/media/spectrum.png').toExist(); + harness.expectFile('dist/public/media/abc.svg').toExist(); + }); + + it(`should emit server bundles in 'server' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/server.mjs').toExist(); + }); + }); + + describe(`'browser' is set to ''`, () => { + it(`should emit browser bundles in '' directory`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: '', + }, + ssr: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/main.js').toExist(); + }); + + it(`should emit media files in 'media' directory`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: '', + }, + ssr: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/media/spectrum.png').toExist(); + harness.expectFile('dist/media/abc.svg').toExist(); + }); + + it(`should error when ssr is enabled`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: '', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + expect(result?.error).toContain( + `'outputPath.browser' cannot be configured to an empty string when SSR is enabled`, + ); + }); + }); + + describe(`'server' is set ''`, () => { + beforeEach(() => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + styles: ['src/styles.css'], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + server: '', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + }); + + it(`should emit browser bundles in 'browser' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + }); + + it(`should emit media files in 'browser/media' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/media/spectrum.png').toExist(); + harness.expectFile('dist/browser/media/abc.svg').toExist(); + }); + + it(`should emit server bundles in '' directory`, async () => { + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server.mjs').toExist(); + }); + }); + + it(`should error when ssr is enabled and 'browser' and 'server' are identical`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + server: 'src/main.server.ts', + outputPath: { + base: 'dist', + browser: 'public', + server: 'public', + }, + ssr: { + entry: 'src/server.ts', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeFalse(); + expect(result?.error).toContain( + `'outputPath.browser' and 'outputPath.server' cannot be configured to the same value`, + ); + }); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/polyfills_spec.ts b/packages/angular/build/src/builders/application/tests/options/polyfills_spec.ts new file mode 100644 index 000000000000..290ea281208d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/polyfills_spec.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +const testsVariants: [suitName: string, baseUrl: string | undefined][] = [ + ['When "baseUrl" is set to "./"', './'], + [`When "baseUrl" is not set`, undefined], + [`When "baseUrl" is set to non root path`, './project/foo'], +]; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + for (const [suitName, baseUrl] of testsVariants) { + describe(suitName, () => { + beforeEach(async () => { + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.baseUrl = baseUrl; + + return JSON.stringify(tsconfig); + }); + }); + + it('uses a provided TypeScript file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['src/polyfills.ts'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/polyfills.js').toExist(); + }); + + it('uses a provided JavaScript file', async () => { + await harness.writeFile('src/polyfills.js', `console.log('main');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['src/polyfills.js'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/polyfills.js').content.toContain(`console.log("main")`); + }); + + it('fails and shows an error when file does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['src/missing.ts'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching('Could not resolve') }), + ); + + harness.expectFile('dist/browser/polyfills.js').toNotExist(); + }); + + it('resolves module specifiers in array', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['zone.js', 'zone.js/testing'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/polyfills.js').toExist(); + }); + }); + } +}); diff --git a/packages/angular/build/src/builders/application/tests/options/scripts_spec.ts b/packages/angular/build/src/builders/application/tests/options/scripts_spec.ts new file mode 100644 index 000000000000..757ff81acbac --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/scripts_spec.ts @@ -0,0 +1,438 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "scripts"', () => { + beforeEach(async () => { + // Application code is not needed for scripts tests + await harness.writeFile('src/main.ts', 'console.log("TESTING");'); + }); + + it('supports an empty array value', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('processes an empty script when optimizing', async () => { + await harness.writeFile('src/test-script-a.js', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: { + scripts: true, + }, + scripts: ['src/test-script-a.js'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/scripts.js').toExist(); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + describe('shorthand syntax', () => { + it('processes a single script into a single output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: ['src/test-script-a.js'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/scripts.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('processes multiple scripts into a single output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + await harness.writeFile('src/test-script-b.js', 'console.log("b");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: ['src/test-script-a.js', 'src/test-script-b.js'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/scripts.js').content.toContain('console.log("a")'); + harness.expectFile('dist/browser/scripts.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('preserves order of multiple scripts in single output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + await harness.writeFile('src/test-script-b.js', 'console.log("b");'); + await harness.writeFile('src/test-script-c.js', 'console.log("c");'); + await harness.writeFile('src/test-script-d.js', 'console.log("d");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + 'src/test-script-c.js', + 'src/test-script-d.js', + 'src/test-script-b.js', + 'src/test-script-a.js', + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/scripts.js') + .content.toMatch( + /console\.log\("c"\)[;\s]+console\.log\("d"\)[;\s]+console\.log\("b"\)[;\s]+console\.log\("a"\)/, + ); + }); + + it('fails and shows an error if script does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: ['src/test-script-a.js'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching(`Could not resolve "src/test-script-a.js"`), + }), + ); + + harness.expectFile('dist/browser/scripts.js').toNotExist(); + }); + + it('shows the output script as a chunk entry in the logging output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: ['src/test-script-a.js'], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/scripts\.js.+\d+ bytes/) }), + ); + }); + }); + + describe('longhand syntax', () => { + it('processes a single script into a single output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/scripts.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('processes a single script into a single output named with bundleName', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', bundleName: 'extra' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/extra.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('uses default bundleName when bundleName is empty string', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', bundleName: '' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/scripts.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('processes multiple scripts with different bundleNames into separate outputs', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + { input: 'src/test-script-a.js', bundleName: 'extra' }, + { input: 'src/test-script-b.js', bundleName: 'other' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/extra.js').content.toContain('console.log("a")'); + harness.expectFile('dist/browser/other.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('processes multiple scripts with no bundleName into a single output', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js' }, { input: 'src/test-script-b.js' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/scripts.js').content.toContain('console.log("a")'); + harness.expectFile('dist/browser/scripts.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('processes multiple scripts with same bundleName into a single output', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + { input: 'src/test-script-a.js', bundleName: 'extra' }, + { input: 'src/test-script-b.js', bundleName: 'extra' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/extra.js').content.toContain('console.log("a")'); + harness.expectFile('dist/browser/extra.js').content.toContain('console.log("b")'); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('preserves order of multiple scripts in single output', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + 'src/test-script-c.js': 'console.log("c");', + 'src/test-script-d.js': 'console.log("d");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + { input: 'src/test-script-c.js' }, + { input: 'src/test-script-d.js' }, + { input: 'src/test-script-b.js' }, + { input: 'src/test-script-a.js' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/scripts.js') + .content.toMatch( + /console\.log\("c"\)[;\s]+console\.log\("d"\)[;\s]+console\.log\("b"\)[;\s]+console\.log\("a"\)/, + ); + }); + + it('preserves order of multiple scripts with different bundleNames', async () => { + await harness.writeFiles({ + 'src/test-script-a.js': 'console.log("a");', + 'src/test-script-b.js': 'console.log("b");', + 'src/test-script-c.js': 'console.log("c");', + 'src/test-script-d.js': 'console.log("d");', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [ + { input: 'src/test-script-c.js', bundleName: 'other' }, + { input: 'src/test-script-d.js', bundleName: 'extra' }, + { input: 'src/test-script-b.js', bundleName: 'extra' }, + { input: 'src/test-script-a.js', bundleName: 'other' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/other.js') + .content.toMatch(/console\.log\("c"\)[;\s]+console\.log\("a"\)/); + harness + .expectFile('dist/browser/extra.js') + .content.toMatch(/console\.log\("d"\)[;\s]+console\.log\("b"\)/); + harness + .expectFile('dist/browser/index.html') + .content.toMatch( + /'); + }); + + it('does not add script element to index when inject is false', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', inject: false }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + // `inject: false` causes the bundleName to be the input file name + harness.expectFile('dist/browser/test-script-a.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/browser/index.html') + .content.not.toContain(''); + }); + + it('does not add script element to index with bundleName when inject is false', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', bundleName: 'extra', inject: false }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/extra.js').content.toContain('console.log("a")'); + harness + .expectFile('dist/browser/index.html') + .content.not.toContain(''); + }); + + it('shows the output script as a chunk entry in the logging output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js' }], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/scripts\.js.+\d+ bytes/) }), + ); + }); + + it('shows the output script as a chunk entry with bundleName in the logging output', async () => { + await harness.writeFile('src/test-script-a.js', 'console.log("a");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + scripts: [{ input: 'src/test-script-a.js', bundleName: 'extra' }], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/extra\.js.+\d+ bytes/) }), + ); + }); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/server_spec.ts b/packages/angular/build/src/builders/application/tests/options/server_spec.ts new file mode 100644 index 000000000000..a01a4eef73e2 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/server_spec.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + }); + + describe('Option: "server"', () => { + it('uses a provided TypeScript file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + ssr: true, + server: 'src/main.server.ts', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + }); + + it('does not write file to disk when "ssr" is "false"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + ssr: true, + server: 'src/main.server.ts', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + }); + + it('uses a provided JavaScript file', async () => { + await harness.writeFile('src/server.js', `console.log('server'); export default {};`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + ssr: true, + server: 'src/server.js', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + + it('fails and shows an error when file does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + ssr: true, + server: 'src/missing.ts', + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching('Could not resolve "') }), + ); + + harness.expectFile('dist/browser/main.js').toNotExist(); + }); + + it('throws an error when given an empty string', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + ssr: true, + server: '', + }); + + const { result, error } = await harness.executeOnce({ outputLogsOnException: false }); + expect(result).toBeUndefined(); + + expect(error?.message).toContain('cannot be an empty string'); + }); + + it('resolves an absolute path as relative inside the workspace root', async () => { + await harness.writeFile('file.mjs', `console.log('Hello!'); export default {};`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + ssr: true, + server: '/file.mjs', + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/service-worker_spec.ts b/packages/angular/build/src/builders/application/tests/options/service-worker_spec.ts new file mode 100644 index 000000000000..958cd5007960 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/service-worker_spec.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "serviceWorker"', () => { + beforeEach(async () => { + const manifest = { + index: '/index.html', + assetGroups: [ + { + name: 'app', + installMode: 'prefetch', + resources: { + files: ['/favicon.ico', '/index.html'], + }, + }, + { + name: 'assets', + installMode: 'lazy', + updateMode: 'prefetch', + resources: { + files: [ + '/assets/**', + '/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)', + ], + }, + }, + ], + }; + + await harness.writeFile('src/ngsw-config.json', JSON.stringify(manifest)); + }); + + it('should not generate SW config when option is unset', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + serviceWorker: undefined, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/ngsw.json').toNotExist(); + }); + + it('should not generate SW config when option is false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + serviceWorker: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/ngsw.json').toNotExist(); + }); + + it('should generate SW config when option is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + serviceWorker: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/ngsw.json').toExist(); + }); + + it('should generate SW config referencing index output', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + serviceWorker: true, + index: { + input: 'src/index.html', + output: 'index.csr.html', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + const config = await harness.readFile('dist/browser/ngsw.json'); + expect(JSON.parse(config)).toEqual(jasmine.objectContaining({ index: '/index.csr.html' })); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/sourcemap_spec.ts b/packages/angular/build/src/builders/application/tests/options/sourcemap_spec.ts new file mode 100644 index 000000000000..ddd36477bd99 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/sourcemap_spec.ts @@ -0,0 +1,244 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "sourceMap"', () => { + it('should not generate script sourcemap files by default', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: undefined, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').toNotExist(); + }); + + it('should not generate script sourcemap files when false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').toNotExist(); + }); + + it('should not generate script sourcemap files when scripts suboption is false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: { scripts: false }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').toNotExist(); + }); + + it('should generate script sourcemap files when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').toExist(); + }); + + it('should generate script sourcemap files when scripts suboption is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: { scripts: true }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').toExist(); + }); + + it('should not include third-party sourcemaps when true', async () => { + await harness.writeFile('src/polyfills.js', `console.log('main');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').content.not.toContain('/core/index.ts'); + harness.expectFile('dist/browser/main.js.map').content.not.toContain('/common/index.ts'); + }); + + it('should not include third-party sourcemaps when vendor suboption is false', async () => { + await harness.writeFile('src/polyfills.js', `console.log('main');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: { scripts: true, vendor: false }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').content.not.toContain('/core/index.ts'); + harness.expectFile('dist/browser/main.js.map').content.not.toContain('/common/index.ts'); + }); + + it('should include third-party sourcemaps when vendor suboption is true', async () => { + await harness.writeFile('src/polyfills.js', `console.log('main');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: { scripts: true, vendor: true }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').content.toContain('/core/index.ts'); + harness.expectFile('dist/browser/main.js.map').content.toContain('/common/index.ts'); + }); + + it(`should not include 'sourceMappingURL' sourcemaps when hidden suboption is true`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: { scripts: true, styles: true, hidden: true }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').toExist(); + harness + .expectFile('dist/browser/main.js') + .content.not.toContain('sourceMappingURL=main.js.map'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness + .expectFile('dist/browser/styles.css') + .content.not.toContain('sourceMappingURL=styles.css.map'); + }); + + it(`should include 'sourceMappingURL' sourcemaps when hidden suboption is false`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: { scripts: true, styles: true, hidden: false }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').toExist(); + harness.expectFile('dist/browser/main.js').content.toContain('sourceMappingURL=main.js.map'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness + .expectFile('dist/browser/styles.css') + .content.toContain('sourceMappingURL=styles.css.map'); + }); + + it(`should include 'sourceMappingURL' sourcemaps when hidden suboption is not set`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: { scripts: true, styles: true }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').toExist(); + harness.expectFile('dist/browser/main.js').content.toContain('sourceMappingURL=main.js.map'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness + .expectFile('dist/browser/styles.css') + .content.toContain('sourceMappingURL=styles.css.map'); + }); + + it('should add "x_google_ignoreList" extension to script sourcemap files when true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js.map').content.toContain('"x_google_ignoreList"'); + }); + + it('should generate component sourcemaps when sourcemaps when true', async () => { + await harness.writeFile('src/app/app.component.css', `* { color: red}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + .content.toContain('sourceMappingURL=app.component.css.map'); + harness.expectFile('dist/browser/app.component.css.map').toExist(); + }); + + it('should not generate component sourcemaps when sourcemaps when false', async () => { + await harness.writeFile('src/app/app.component.css', `* { color: red}`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + sourceMap: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + .content.not.toContain('sourceMappingURL=app.component.css.map'); + harness.expectFile('dist/browser/app.component.css.map').toNotExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/ssr_spec.ts b/packages/angular/build/src/builders/application/tests/options/ssr_spec.ts new file mode 100644 index 000000000000..e5e31e1a408f --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/ssr_spec.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts', 'server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFile('src/server.ts', `console.log('Hello!');`); + }); + + describe('Option: "ssr"', () => { + it('uses a provided TypeScript file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: { entry: 'src/server.ts' }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/main.server.mjs').toExist(); + harness.expectFile('dist/server/server.mjs').toExist(); + }); + + it('resolves an absolute path as relative inside the workspace root', async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: { entry: '/file.mjs' }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/server/server.mjs').toExist(); + }); + + it(`should emit 'server' directory when 'ssr' is 'true'`, async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toExist(); + }); + + it(`should not emit 'server' directory when 'ssr' is 'false'`, async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toNotExist(); + }); + + it(`should not emit 'server' directory when 'ssr' is not set`, async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: undefined, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toNotExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/styles_spec.ts b/packages/angular/build/src/builders/application/tests/options/styles_spec.ts new file mode 100644 index 000000000000..eb8d973ae904 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/styles_spec.ts @@ -0,0 +1,466 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "styles"', () => { + beforeEach(async () => { + // Application code is not needed for styles tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + }); + + it('supports an empty array value', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/styles.css').toNotExist(); + }); + + it('does not create an output styles file when option is not present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/styles.css').toNotExist(); + }); + + describe('shorthand syntax', () => { + it('processes a single style into a single output', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/test-style-a.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('processes multiple styles into a single output', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/test-style-a.css', 'src/test-style-b.css'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/\.test-b {\s*color: green;?\s*}/); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('preserves order of multiple styles in single output', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + 'src/test-style-c.css': '.test-c {color: blue}', + 'src/test-style-d.css': '.test-d {color: yellow}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [ + 'src/test-style-c.css', + 'src/test-style-d.css', + 'src/test-style-b.css', + 'src/test-style-a.css', + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/styles.css').content.toMatch( + // eslint-disable-next-line max-len + /\.test-c {\s*color: blue;?\s*}[\s|\S]+\.test-d {\s*color: yellow;?\s*}[\s|\S]+\.test-b {\s*color: green;?\s*}[\s|\S]+\.test-a {\s*color: red;?\s*}/m, + ); + }); + + it('fails and shows an error if style does not exist', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/test-style-a.css'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringMatching('Could not resolve "src/test-style-a.css"'), + }), + ); + + harness.expectFile('dist/browser/styles.css').toNotExist(); + }); + + it('shows the output style as a chunk entry in the logging output', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/test-style-a.css'], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/styles\.css.+\d+ bytes/) }), + ); + }); + }); + + describe('longhand syntax', () => { + it('processes a single style into a single output', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('processes a single style into a single output named with bundleName', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css', bundleName: 'extra' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/extra.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('uses default bundleName when bundleName is empty string', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css', bundleName: '' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('processes multiple styles with no bundleName into a single output', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css' }, { input: 'src/test-style-b.css' }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/\.test-b {\s*color: green;?\s*}/); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('processes multiple styles with same bundleName into a single output', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [ + { input: 'src/test-style-a.css', bundleName: 'extra' }, + { input: 'src/test-style-b.css', bundleName: 'extra' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/extra.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/extra.css') + .content.toMatch(/\.test-b {\s*color: green;?\s*}/); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('processes multiple styles with different bundleNames into separate outputs', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [ + { input: 'src/test-style-a.css', bundleName: 'extra' }, + { input: 'src/test-style-b.css', bundleName: 'other' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/extra.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/other.css') + .content.toMatch(/\.test-b {\s*color: green;?\s*}/); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('preserves order of multiple styles in single output', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + 'src/test-style-c.css': '.test-c {color: blue}', + 'src/test-style-d.css': '.test-d {color: yellow}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [ + { input: 'src/test-style-c.css' }, + { input: 'src/test-style-d.css' }, + { input: 'src/test-style-b.css' }, + { input: 'src/test-style-a.css' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/styles.css').content.toMatch( + // eslint-disable-next-line max-len + /\.test-c {\s*color: blue;?\s*}[\s|\S]+\.test-d {\s*color: yellow;?\s*}[\s|\S]+\.test-b {\s*color: green;?\s*}[\s|\S]+\.test-a {\s*color: red;?\s*}/, + ); + }); + + it('preserves order of multiple styles with different bundleNames', async () => { + await harness.writeFiles({ + 'src/test-style-a.css': '.test-a {color: red}', + 'src/test-style-b.css': '.test-b {color: green}', + 'src/test-style-c.css': '.test-c {color: blue}', + 'src/test-style-d.css': '.test-d {color: yellow}', + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [ + { input: 'src/test-style-c.css', bundleName: 'other' }, + { input: 'src/test-style-d.css', bundleName: 'extra' }, + { input: 'src/test-style-b.css', bundleName: 'extra' }, + { input: 'src/test-style-a.css', bundleName: 'other' }, + ], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/other.css') + .content.toMatch(/\.test-c {\s*color: blue;?\s*}[\s|\S]+\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/extra.css') + .content.toMatch( + /\.test-d {\s*color: yellow;?\s*}[\s|\S]+\.test-b {\s*color: green;?\s*}/, + ); + harness + .expectFile('dist/browser/index.html') + .content.toMatch( + /\s*/, + ); + }); + + it('adds link element to index when inject is true', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css', inject: true }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/index.html') + .content.toContain(''); + }); + + it('does not add link element to index when inject is false', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css', inject: false }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + // `inject: false` causes the bundleName to be the input file name + harness + .expectFile('dist/browser/test-style-a.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + harness + .expectFile('dist/browser/index.html') + .content.not.toContain(''); + }); + + it('does not add link element to index with bundleName when inject is false', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css', bundleName: 'extra', inject: false }], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/extra.css') + .content.toMatch(/\.test-a {\s*color: red;?\s*}/); + + harness + .expectFile('dist/browser/index.html') + .content.not.toContain(''); + }); + + it('shows the output style as a chunk entry in the logging output', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css' }], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/styles\.css.+\d+ bytes/) }), + ); + }); + + it('shows the output style as a chunk entry with bundleName in the logging output', async () => { + await harness.writeFile('src/test-style-a.css', '.test-a {color: red}'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [{ input: 'src/test-style-a.css', bundleName: 'extra' }], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + + expect(logs).toContain( + jasmine.objectContaining({ message: jasmine.stringMatching(/extra\.css.+\d+ bytes/) }), + ); + }); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts new file mode 100644 index 000000000000..4afb87ebaed3 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "subresourceIntegrity"', () => { + it(`does not add integrity attribute when not present`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.not.toContain('integrity='); + }); + + it(`does not add integrity attribute when 'false'`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + subresourceIntegrity: false, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.not.toContain('integrity='); + }); + + it(`does add integrity attribute when 'true'`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + subresourceIntegrity: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.toMatch(/integrity="\w+-[A-Za-z0-9/+=]+"/); + }); + + it(`does not issue a warning when 'true' and 'scripts' is set.`, async () => { + await harness.writeFile('src/script.js', ''); + + harness.useTarget('build', { + ...BASE_OPTIONS, + subresourceIntegrity: true, + scripts: ['src/script.js'], + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.toMatch(/integrity="\w+-[A-Za-z0-9/+=]+"/); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(/subresource-integrity/), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/setup.ts b/packages/angular/build/src/builders/application/tests/setup.ts new file mode 100644 index 000000000000..93a5cda2a1df --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/setup.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Schema } from '../schema'; + +// TODO: Consider using package.json imports field instead of relative path +// after the switch to rules_js. +export * from '../../../../../../../modules/testing/builder/src'; + +export const APPLICATION_BUILDER_INFO = Object.freeze({ + name: '@angular-devkit/build-angular:application', + schemaPath: __dirname + '/../schema.json', +}); + +/** + * Contains all required browser builder fields. + * Also disables progress reporting to minimize logging output. + */ +export const BASE_OPTIONS = Object.freeze({ + index: 'src/index.html', + browser: 'src/main.ts', + outputPath: 'dist', + tsConfig: 'src/tsconfig.app.json', + progress: false, + + // Disable optimizations + optimization: false, + + // Enable polling (if a test enables watch mode). + // This is a workaround for bazel isolation file watch not triggering in tests. + poll: 100, +}); diff --git a/packages/angular/build/src/builders/dev-server/builder.ts b/packages/angular/build/src/builders/dev-server/builder.ts new file mode 100644 index 000000000000..41b7cac60960 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/builder.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { BuilderContext } from '@angular-devkit/architect'; +import type { Plugin } from 'esbuild'; +import type http from 'node:http'; +import { checkPort } from '../../utils/check-port'; +import { + type IndexHtmlTransform, + buildApplicationInternal, + purgeStaleBuildCache, +} from './internal'; +import { normalizeOptions } from './options'; +import type { DevServerBuilderOutput } from './output'; +import type { Schema as DevServerBuilderOptions } from './schema'; +import { serveWithVite } from './vite-server'; + +/** + * A Builder that executes a development server based on the provided browser target option. + * + * Usage of the `transforms` and/or `extensions` parameters is NOT supported and may cause + * unexpected build output or build failures. + * + * @param options Dev Server options. + * @param context The build context. + * @param extensions An optional object containing an array of build plugins (esbuild-based) + * and/or HTTP request middleware. + * + * @experimental Direct usage of this function is considered experimental. + */ +export async function* execute( + options: DevServerBuilderOptions, + context: BuilderContext, + extensions?: { + buildPlugins?: Plugin[]; + middleware?: (( + req: http.IncomingMessage, + res: http.ServerResponse, + next: (err?: unknown) => void, + ) => void)[]; + indexHtmlTransformer?: IndexHtmlTransform; + }, +): AsyncIterable { + // Determine project name from builder context target + const projectName = context.target?.project; + if (!projectName) { + context.logger.error(`The "dev-server" builder requires a target to be specified.`); + + return; + } + + const { builderName, normalizedOptions } = await initialize(options, projectName, context); + + // Warn if the initial options provided by the user enable prebundling but caching is disabled + if (options.prebundle && !normalizedOptions.cacheOptions.enabled) { + context.logger.warn( + `Prebundling has been configured but will not be used because caching has been disabled.`, + ); + } + + yield* serveWithVite( + normalizedOptions, + builderName, + (options, context, plugins) => + buildApplicationInternal(options, context, { write: false }, { codePlugins: plugins }), + context, + { indexHtml: extensions?.indexHtmlTransformer }, + extensions, + ); +} + +async function initialize( + initialOptions: DevServerBuilderOptions, + projectName: string, + context: BuilderContext, +) { + // Purge old build disk cache. + await purgeStaleBuildCache(context); + + const normalizedOptions = await normalizeOptions(context, projectName, initialOptions); + const builderName = await context.getBuilderNameForTarget(normalizedOptions.buildTarget); + + if ( + !/^127\.\d+\.\d+\.\d+/g.test(normalizedOptions.host) && + normalizedOptions.host !== '::1' && + normalizedOptions.host !== 'localhost' + ) { + context.logger.warn(` +Warning: This is a simple server for use in testing or debugging Angular applications +locally. It hasn't been reviewed for security issues. + +Binding this server to an open connection can result in compromising your application or +computer. Using a different host than the one passed to the "--host" flag might result in +websocket connection issues. + `); + } + + normalizedOptions.port = await checkPort(normalizedOptions.port, normalizedOptions.host); + + return { + builderName, + normalizedOptions, + }; +} diff --git a/packages/angular/build/src/builders/dev-server/index.ts b/packages/angular/build/src/builders/dev-server/index.ts new file mode 100644 index 000000000000..0410d1b0f2d8 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/index.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createBuilder } from '@angular-devkit/architect'; +import { execute } from './builder'; +import type { DevServerBuilderOutput } from './output'; +import type { Schema as DevServerBuilderOptions } from './schema'; + +export { + type DevServerBuilderOptions, + type DevServerBuilderOutput, + execute as executeDevServerBuilder, +}; +export default createBuilder(execute); + +// Temporary export to support specs +export { execute as executeDevServer }; diff --git a/packages/angular/build/src/builders/dev-server/internal.ts b/packages/angular/build/src/builders/dev-server/internal.ts new file mode 100644 index 000000000000..d3681f90cc00 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/internal.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export { type BuildOutputFile, BuildOutputFileType } from '@angular/build'; +export { createRxjsEsmResolutionPlugin } from '../../tools/esbuild/rxjs-esm-resolution-plugin'; +export { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer'; +export { getFeatureSupport, isZonelessApp } from '../../tools/esbuild/utils'; +export { renderPage } from '../../utils/server-rendering/render-page'; +export { type IndexHtmlTransform } from '../../utils/index-file/index-html-generator'; +export { purgeStaleBuildCache } from '../../utils/purge-cache'; +export { getSupportedBrowsers } from '../../utils/supported-browsers'; +export { transformSupportedBrowsersToTargets } from '../../tools/esbuild/utils'; +export { buildApplicationInternal } from '../../builders/application'; +export type { ApplicationBuilderInternalOptions } from '../../builders/application/options'; +export type { ExternalResultMetadata } from '../../tools/esbuild/bundler-execution-result'; diff --git a/packages/angular/build/src/builders/dev-server/options.ts b/packages/angular/build/src/builders/dev-server/options.ts new file mode 100644 index 000000000000..080e168699bc --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/options.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext, targetFromTargetString } from '@angular-devkit/architect'; +import path from 'node:path'; +import { normalizeOptimization } from '../../utils'; +import { normalizeCacheOptions } from '../../utils/normalize-cache'; +import { ApplicationBuilderOptions } from '../application'; +import { Schema as DevServerOptions } from './schema'; + +export type NormalizedDevServerOptions = Awaited>; + +/** + * Normalize the user provided options by creating full paths for all path based options + * and converting multi-form options into a single form that can be directly used + * by the build process. + * + * @param context The context for current builder execution. + * @param projectName The name of the project for the current execution. + * @param options An object containing the options to use for the build. + * @returns An object containing normalized options required to perform the build. + */ +export async function normalizeOptions( + context: BuilderContext, + projectName: string, + options: DevServerOptions, +) { + const { workspaceRoot, logger } = context; + const projectMetadata = await context.getProjectMetadata(projectName); + const projectRoot = path.join(workspaceRoot, (projectMetadata.root as string | undefined) ?? ''); + + const cacheOptions = normalizeCacheOptions(projectMetadata, workspaceRoot); + + // Target specifier defaults to the current project's build target using a development configuration + const buildTargetSpecifier = options.buildTarget ?? `::development`; + const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build'); + + // Get the application builder options. + const browserBuilderName = await context.getBuilderNameForTarget(buildTarget); + const rawBuildOptions = await context.getTargetOptions(buildTarget); + const buildOptions = (await context.validateOptions( + rawBuildOptions, + browserBuilderName, + )) as unknown as ApplicationBuilderOptions; + const optimization = normalizeOptimization(buildOptions.optimization); + + if (options.prebundle) { + if (!cacheOptions.enabled) { + // Warn if the initial options provided by the user enable prebundling but caching is disabled + logger.warn( + 'Prebundling has been configured but will not be used because caching has been disabled.', + ); + } else if (optimization.scripts) { + // Warn if the initial options provided by the user enable prebundling but script optimization is enabled. + logger.warn( + 'Prebundling has been configured but will not be used because scripts optimization is enabled.', + ); + } + } + + let inspect: false | { host?: string; port?: number } = false; + const inspectRaw = options.inspect; + if (inspectRaw === true || inspectRaw === '' || inspectRaw === 'true') { + inspect = { + host: undefined, + port: undefined, + }; + } else if (typeof inspectRaw === 'string' && inspectRaw !== 'false') { + const port = +inspectRaw; + if (isFinite(port)) { + inspect = { + host: undefined, + port, + }; + } else { + const [host, port] = inspectRaw.split(':'); + inspect = { + host, + port: isNaN(+port) ? undefined : +port, + }; + } + } + + // Initial options to keep + const { + host, + port, + poll, + open, + verbose, + watch, + liveReload, + hmr, + headers, + proxyConfig, + servePath, + ssl, + sslCert, + sslKey, + prebundle, + } = options; + + // Return all the normalized options + return { + buildTarget, + host: host ?? 'localhost', + port: port ?? 4200, + poll, + open, + verbose, + watch, + liveReload, + hmr, + headers, + workspaceRoot, + projectRoot, + cacheOptions, + proxyConfig, + servePath, + ssl, + sslCert, + sslKey, + // Prebundling defaults to true but requires caching to function + prebundle: cacheOptions.enabled && !optimization.scripts && prebundle, + inspect, + }; +} diff --git a/packages/angular/build/src/builders/dev-server/output.ts b/packages/angular/build/src/builders/dev-server/output.ts new file mode 100644 index 000000000000..c166994a429b --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/output.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderOutput } from '@angular-devkit/architect'; + +/** + * @experimental Direct usage of this type is considered experimental. + */ +export interface DevServerBuilderOutput extends BuilderOutput { + baseUrl: string; + port?: number; + address?: string; +} diff --git a/packages/angular/build/src/builders/dev-server/schema.json b/packages/angular/build/src/builders/dev-server/schema.json new file mode 100644 index 000000000000..3adce45eb71a --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Dev Server Target", + "description": "Dev Server target options for Build Facade.", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "A build builder target to serve in the format of `project:target[:configuration]`. You can also pass in more than one configuration name as a comma-separated list. Example: `project:target:production,staging`.", + "pattern": "^[^:\\s]*:[^:\\s]*(:[^\\s]+)?$" + }, + "port": { + "type": "number", + "description": "Port to listen on.", + "default": 4200 + }, + "host": { + "type": "string", + "description": "Host to listen on.", + "default": "localhost" + }, + "proxyConfig": { + "type": "string", + "description": "Proxy configuration file. For more information, see https://angular.dev/tools/cli/serve#proxying-to-a-backend-server." + }, + "ssl": { + "type": "boolean", + "description": "Serve using HTTPS.", + "default": false + }, + "sslKey": { + "type": "string", + "description": "SSL key to use for serving HTTPS." + }, + "sslCert": { + "type": "string", + "description": "SSL certificate to use for serving HTTPS." + }, + "headers": { + "type": "object", + "description": "Custom HTTP headers to be added to all responses.", + "propertyNames": { + "pattern": "^[-_A-Za-z0-9]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "open": { + "type": "boolean", + "description": "Opens the url in default browser.", + "default": false, + "alias": "o" + }, + "verbose": { + "type": "boolean", + "description": "Adds more details to output logging." + }, + "liveReload": { + "type": "boolean", + "description": "Whether to reload the page on change, using live-reload.", + "default": true + }, + "servePath": { + "type": "string", + "description": "The pathname where the application will be served." + }, + "hmr": { + "type": "boolean", + "description": "Enable hot module replacement.", + "default": false + }, + "watch": { + "type": "boolean", + "description": "Rebuild on change.", + "default": true + }, + "poll": { + "type": "number", + "description": "Enable and define the file watching poll time period in milliseconds." + }, + "inspect": { + "default": false, + "description": "Activate debugging inspector. This option only has an effect when 'SSR' or 'SSG' are enabled.", + "oneOf": [ + { + "type": "string", + "description": "Activate the inspector on host and port in the format of `[[host:]port]`. See the security warning in https://nodejs.org/docs/latest-v22.x/api/cli.html#warning-binding-inspector-to-a-public-ipport-combination-is-insecure regarding the host parameter usage." + }, + { "type": "boolean" } + ] + }, + "prebundle": { + "description": "Enable and control the Vite-based development server's prebundling capabilities. To enable prebundling, the Angular CLI cache must also be enabled.", + "default": true, + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "properties": { + "exclude": { + "description": "List of package imports that should not be prebundled by the development server. The packages will be bundled into the application code itself.", + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false, + "required": ["exclude"] + } + ] + } + }, + "additionalProperties": false, + "required": ["buildTarget"] +} diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts new file mode 100644 index 000000000000..57679680ddb6 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { executeDevServer } from '../../index'; +import { executeOnceAndFetch } from '../execute-fetch'; +import { describeServeBuilder } from '../jasmine-helpers'; +import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; + +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => { + const javascriptFileContent = + "import {foo} from 'unresolved'; /* a comment */const foo = `bar`;\n\n\n"; + + describe('Behavior: "browser builder assets"', () => { + it('serves a project JavaScript asset unmodified', async () => { + await harness.writeFile('src/extra.js', javascriptFileContent); + + setupTarget(harness, { + assets: ['src/extra.js'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'extra.js'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain(javascriptFileContent); + }); + + it('serves a project TypeScript asset unmodified', async () => { + await harness.writeFile('src/extra.ts', javascriptFileContent); + + setupTarget(harness, { + assets: ['src/extra.ts'], + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'extra.ts'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain(javascriptFileContent); + }); + + it('should return 404 for non existing assets', async () => { + setupTarget(harness, { + assets: [], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'does-not-exist.js'); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(404); + }); + + it(`should return the asset that matches 'index.html' when path has a trailing '/'`, async () => { + await harness.writeFile( + 'src/login/index.html', + '

Login page

', + ); + + setupTarget(harness, { + assets: ['src/login'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'login/'); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(200); + expect(await response?.text()).toContain('

Login page

'); + }); + + it(`should return the asset that matches '.html' when path has no trailing '/'`, async () => { + await harness.writeFile('src/login/new.html', '

Login page

'); + + setupTarget(harness, { + assets: ['src/login'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'login/new'); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(200); + expect(await response?.text()).toContain('

Login page

'); + }); + + it(`should return a redirect when an asset directory is accessed without a trailing '/'`, async () => { + await harness.writeFile( + 'src/login/index.html', + '

Login page

', + ); + + setupTarget(harness, { + assets: ['src/login'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'login', { + request: { redirect: 'manual' }, + }); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(301); + expect(await response?.headers.get('Location')).toBe('/login/'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/build-base-href_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/build-base-href_spec.ts new file mode 100644 index 000000000000..813796079b17 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/tests/behavior/build-base-href_spec.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { executeDevServer } from '../../index'; +import { executeOnceAndFetch } from '../execute-fetch'; +import { describeServeBuilder } from '../jasmine-helpers'; +import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; + +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => { + describe('Behavior: "buildTarget baseHref"', () => { + beforeEach(async () => { + setupTarget(harness, { + baseHref: '/test/', + }); + + // Application code is not needed for these tests + await harness.writeFile('src/main.ts', 'console.log("foo");'); + }); + + it('uses the baseHref defined in the "buildTarget" options as the serve path', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/test/main.js'); + + expect(result?.success).toBeTrue(); + const baseUrl = new URL(`${result?.baseUrl}/`); + expect(baseUrl.pathname).toBe('/test/'); + expect(await response?.text()).toContain('console.log'); + }); + + it('serves the application from baseHref location without trailing slash', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/test'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain(''; + + rewriter.on('startTag', (tag) => { + rewriter.emitStartTag(tag); + + if (tag.tagName === 'body') { + rewriter.emitRaw(jsActionContractScript); + } + }); + + return transformedContent(); +} diff --git a/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts new file mode 100644 index 000000000000..6c0747730c29 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { addEventDispatchContract } from './add-event-dispatch-contract'; + +describe('addEventDispatchContract', () => { + it('should inline event dispatcher script', async () => { + const result = await addEventDispatchContract(` + + + +

Hello World!

+ + + `); + + expect(result).toMatch( + /\s*`); + } + + let headerLinkTags: string[] = []; + let bodyLinkTags: string[] = []; + for (const src of stylesheets) { + const attrs = [`rel="stylesheet"`, `href="${deployUrl}${src}"`]; + + if (crossOrigin !== 'none') { + attrs.push(`crossorigin="${crossOrigin}"`); + } + + if (sri) { + const content = await loadOutputFile(src); + attrs.push(generateSriAttributes(content)); + } + + headerLinkTags.push(``); + } + + if (params.hints?.length) { + for (const hint of params.hints) { + const attrs = [`rel="${hint.mode}"`, `href="${deployUrl}${hint.url}"`]; + + if (hint.mode !== 'modulepreload' && crossOrigin !== 'none') { + // Value is considered anonymous by the browser when not present or empty + attrs.push(crossOrigin === 'anonymous' ? 'crossorigin' : `crossorigin="${crossOrigin}"`); + } + + if (hint.mode === 'preload' || hint.mode === 'prefetch') { + switch (extname(hint.url)) { + case '.js': + attrs.push('as="script"'); + break; + case '.css': + attrs.push('as="style"'); + break; + default: + if (hint.as) { + attrs.push(`as="${hint.as}"`); + } + break; + } + } + + if ( + sri && + (hint.mode === 'preload' || hint.mode === 'prefetch' || hint.mode === 'modulepreload') + ) { + const content = await loadOutputFile(hint.url); + attrs.push(generateSriAttributes(content)); + } + + const tag = ``; + if (hint.mode === 'modulepreload') { + // Module preloads should be placed by the inserted script elements in the body since + // they are only useful in combination with the scripts. + bodyLinkTags.push(tag); + } else { + headerLinkTags.push(tag); + } + } + } + + const dir = lang ? await getLanguageDirection(lang, warnings) : undefined; + const { rewriter, transformedContent } = await htmlRewritingStream(html); + const baseTagExists = html.includes('(); + + rewriter + .on('startTag', (tag, rawTagHtml) => { + switch (tag.tagName) { + case 'html': + // Adjust document locale if specified + if (isString(lang)) { + updateAttribute(tag, 'lang', lang); + } + + if (dir) { + updateAttribute(tag, 'dir', dir); + } + break; + case 'head': + // Base href should be added before any link, meta tags + if (!baseTagExists && isString(baseHref)) { + rewriter.emitStartTag(tag); + rewriter.emitRaw(``); + + return; + } + break; + case 'base': + // Adjust base href if specified + if (isString(baseHref)) { + updateAttribute(tag, 'href', baseHref); + } + break; + case 'link': + if (readAttribute(tag, 'rel') === 'preconnect') { + const href = readAttribute(tag, 'href'); + if (href) { + foundPreconnects.add(href); + } + } + break; + default: + if (tag.selfClosing && !VALID_SELF_CLOSING_TAGS.has(tag.tagName)) { + errors.push(`Invalid self-closing element in index HTML file: '${rawTagHtml}'.`); + + return; + } + } + + rewriter.emitStartTag(tag); + }) + .on('endTag', (tag) => { + switch (tag.tagName) { + case 'head': + for (const linkTag of headerLinkTags) { + rewriter.emitRaw(linkTag); + } + if (imageDomains) { + for (const imageDomain of imageDomains) { + if (!foundPreconnects.has(imageDomain)) { + rewriter.emitRaw(``); + } + } + } + headerLinkTags = []; + break; + case 'body': + for (const linkTag of bodyLinkTags) { + rewriter.emitRaw(linkTag); + } + bodyLinkTags = []; + + // Add script tags + for (const scriptTag of scriptTags) { + rewriter.emitRaw(scriptTag); + } + + scriptTags = []; + break; + } + + rewriter.emitEndTag(tag); + }); + + const content = await transformedContent(); + + return { + content: + headerLinkTags.length || scriptTags.length + ? // In case no body/head tags are not present (dotnet partial templates) + headerLinkTags.join('') + scriptTags.join('') + content + : content, + warnings, + errors, + }; +} + +function generateSriAttributes(content: string): string { + const algo = 'sha384'; + const hash = createHash(algo).update(content, 'utf8').digest('base64'); + + return `integrity="${algo}-${hash}"`; +} + +function updateAttribute( + tag: { attrs: { name: string; value: string }[] }, + name: string, + value: string, +): void { + const index = tag.attrs.findIndex((a) => a.name === name); + const newValue = { name, value }; + + if (index === -1) { + tag.attrs.push(newValue); + } else { + tag.attrs[index] = newValue; + } +} + +function readAttribute( + tag: { attrs: { name: string; value: string }[] }, + name: string, +): string | undefined { + const targetAttr = tag.attrs.find((attr) => attr.name === name); + + return targetAttr ? targetAttr.value : undefined; +} + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +async function getLanguageDirection( + locale: string, + warnings: string[], +): Promise { + const dir = await getLanguageDirectionFromLocales(locale); + + if (!dir) { + warnings.push( + `Locale data for '${locale}' cannot be found. 'dir' attribute will not be set for this locale.`, + ); + } + + return dir; +} + +async function getLanguageDirectionFromLocales(locale: string): Promise { + try { + const localeData = ( + await loadEsmModule( + `@angular/common/locales/${locale}`, + ) + ).default; + + const dir = localeData[localeData.length - 2]; + + return isString(dir) ? dir : undefined; + } catch { + // In some cases certain locales might map to files which are named only with language id. + // Example: `en-US` -> `en`. + const [languageId] = locale.split('-', 1); + if (languageId !== locale) { + return getLanguageDirectionFromLocales(languageId); + } + } + + return undefined; +} diff --git a/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts new file mode 100644 index 000000000000..61aaa0674ed8 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts @@ -0,0 +1,537 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { tags } from '@angular-devkit/core'; +import { AugmentIndexHtmlOptions, augmentIndexHtml } from './augment-index-html'; + +describe('augment-index-html', () => { + const indexGeneratorOptions: AugmentIndexHtmlOptions = { + html: '', + baseHref: '/', + sri: false, + files: [], + loadOutputFile: async (_fileName: string) => '', + entrypoints: [ + ['scripts', false], + ['polyfills', true], + ['main', true], + ['styles', false], + ], + }; + + const oneLineHtml = (html: TemplateStringsArray) => + tags.stripIndents`${html}`.replace(/(>\s+)/g, '>'); + + it('can generate index.html', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + files: [ + { file: 'styles.css', extension: '.css', name: 'styles' }, + { file: 'runtime.js', extension: '.js', name: 'main' }, + { file: 'main.js', extension: '.js', name: 'main' }, + { file: 'runtime.js', extension: '.js', name: 'polyfills' }, + { file: 'polyfills.js', extension: '.js', name: 'polyfills' }, + ], + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + + + + `); + }); + + it('should replace base href value', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: '', + baseHref: '/Apps/', + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + `); + }); + + it('should add lang and dir LTR attribute for French (fr)', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + lang: 'fr', + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it('should add lang and dir RTL attribute for Pashto (ps)', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + lang: 'ps', + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it(`should fallback to use language ID to set the dir attribute (en-US)`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + lang: 'en-US', + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it(`should work when lang (locale) is not provided by '@angular/common'`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + lang: 'xx-XX', + }); + + expect(warnings).toEqual([ + `Locale data for 'xx-XX' cannot be found. 'dir' attribute will not be set for this locale.`, + ]); + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it(`should add script and link tags even when body and head element doesn't exist`, async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: ``, + files: [ + { file: 'styles.css', extension: '.css', name: 'styles' }, + { file: 'runtime.js', extension: '.js', name: 'main' }, + { file: 'main.js', extension: '.js', name: 'main' }, + { file: 'runtime.js', extension: '.js', name: 'polyfills' }, + { file: 'polyfills.js', extension: '.js', name: 'polyfills' }, + ], + }); + + expect(content).toEqual(oneLineHtml` + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with "use-credentials" cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + crossOrigin: 'use-credentials', + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with "anonymous" cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + crossOrigin: 'anonymous', + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with "none" cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + crossOrigin: 'none', + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with no cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add modulepreload hint when provided`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'modulepreload', url: 'x.js' }, + { mode: 'modulepreload', url: 'y/z.js' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add modulepreload hint with no crossorigin attribute when provided with cross origin set`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + crossOrigin: 'anonymous', + hints: [ + { mode: 'modulepreload', url: 'x.js' }, + { mode: 'modulepreload', url: 'y/z.js' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add prefetch/preload hints with as=script when specified with a JS url`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'prefetch', url: 'x.js' }, + { mode: 'preload', url: 'y/z.js' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add prefetch/preload hints with as=style when specified with a CSS url`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'prefetch', url: 'x.css' }, + { mode: 'preload', url: 'y/z.css' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add prefetch/preload hints with as=style when specified with a URL and an 'as' option`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'prefetch', url: 'https://example.com/x?a=1', as: 'style' }, + { mode: 'preload', url: 'http://example.com/y?b=2', as: 'style' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it('should add `.mjs` script tags', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + files: [{ file: 'main.mjs', extension: '.mjs', name: 'main' }], + entrypoints: [['main', true /* isModule */]], + }); + + expect(content).toContain(''); + }); + + it('should reject non-module `.mjs` scripts', async () => { + const options: AugmentIndexHtmlOptions = { + ...indexGeneratorOptions, + files: [{ file: 'main.mjs', extension: '.mjs', name: 'main' }], + entrypoints: [['main', false /* isModule */]], + }; + + await expectAsync(augmentIndexHtml(options)).toBeRejectedWithError( + '`.mjs` files *must* set `isModule` to `true`.', + ); + }); + + it('should add image domain preload tags', async () => { + const imageDomains = ['https://www.example.com', 'https://www.example2.com']; + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + imageDomains, + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it('should add no image preconnects if provided empty domain list', async () => { + const imageDomains: Array = []; + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + imageDomains, + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it('should not add duplicate preconnects', async () => { + const imageDomains = ['https://www.example1.com', 'https://www.example2.com']; + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: '', + imageDomains, + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it('should add image preconnects if it encounters preconnect elements for other resources', async () => { + const imageDomains = ['https://www.example2.com', 'https://www.example3.com']; + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: '', + imageDomains, + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + + + + `); + }); + + describe('self-closing tags', () => { + it('should return an error when used on a not supported element', async () => { + const { errors } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: ` + + + + + ' + `, + }); + + expect(errors.length).toEqual(1); + expect(errors).toEqual([`Invalid self-closing element in index HTML file: ''.`]); + }); + + it('should not return an error when used on a supported element', async () => { + const { errors } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: ` + + +
+ + + ' + `, + }); + + expect(errors.length).toEqual(0); + }); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/html-rewriting-stream.ts b/packages/angular/build/src/utils/index-file/html-rewriting-stream.ts new file mode 100644 index 000000000000..5ae7c397904d --- /dev/null +++ b/packages/angular/build/src/utils/index-file/html-rewriting-stream.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { loadEsmModule } from '../load-esm'; + +export async function htmlRewritingStream(content: string): Promise<{ + rewriter: import('parse5-html-rewriting-stream').RewritingStream; + transformedContent: () => Promise; +}> { + const { RewritingStream } = await loadEsmModule( + 'parse5-html-rewriting-stream', + ); + const rewriter = new RewritingStream(); + + return { + rewriter, + transformedContent: () => + pipeline(Readable.from(content), rewriter, async function (source) { + const chunks = []; + for await (const chunk of source) { + chunks.push(Buffer.from(chunk)); + } + + return Buffer.concat(chunks).toString('utf-8'); + }), + }; +} diff --git a/packages/angular/build/src/utils/index-file/index-html-generator.ts b/packages/angular/build/src/utils/index-file/index-html-generator.ts new file mode 100644 index 000000000000..b0303e4ae41c --- /dev/null +++ b/packages/angular/build/src/utils/index-file/index-html-generator.ts @@ -0,0 +1,203 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { NormalizedCachedOptions } from '../normalize-cache'; +import { NormalizedOptimizationOptions } from '../normalize-optimization'; +import { addEventDispatchContract } from './add-event-dispatch-contract'; +import { CrossOriginValue, Entrypoint, FileInfo, augmentIndexHtml } from './augment-index-html'; +import { InlineCriticalCssProcessor } from './inline-critical-css'; +import { InlineFontsProcessor } from './inline-fonts'; +import { addNonce } from './nonce'; + +type IndexHtmlGeneratorPlugin = ( + html: string, + options: IndexHtmlGeneratorProcessOptions, +) => Promise | string; + +export type HintMode = 'prefetch' | 'preload' | 'modulepreload' | 'preconnect' | 'dns-prefetch'; + +export interface IndexHtmlGeneratorProcessOptions { + lang: string | undefined; + baseHref: string | undefined; + outputPath: string; + files: FileInfo[]; + hints?: { url: string; mode: HintMode; as?: string }[]; +} + +export interface IndexHtmlGeneratorOptions { + indexPath: string; + deployUrl?: string; + sri?: boolean; + entrypoints: Entrypoint[]; + postTransform?: IndexHtmlTransform; + crossOrigin?: CrossOriginValue; + optimization?: NormalizedOptimizationOptions; + cache?: NormalizedCachedOptions; + imageDomains?: string[]; + generateDedicatedSSRContent?: boolean; +} + +export type IndexHtmlTransform = (content: string) => Promise; + +export interface IndexHtmlPluginTransformResult { + content: string; + warnings: string[]; + errors: string[]; +} + +export interface IndexHtmlProcessResult { + csrContent: string; + ssrContent?: string; + warnings: string[]; + errors: string[]; +} + +export class IndexHtmlGenerator { + private readonly plugins: IndexHtmlGeneratorPlugin[]; + private readonly csrPlugins: IndexHtmlGeneratorPlugin[] = []; + private readonly ssrPlugins: IndexHtmlGeneratorPlugin[] = []; + + constructor(readonly options: IndexHtmlGeneratorOptions) { + const extraCommonPlugins: IndexHtmlGeneratorPlugin[] = []; + if (options?.optimization?.fonts.inline) { + extraCommonPlugins.push(inlineFontsPlugin(this), addNonce); + } + + // Common plugins + this.plugins = [augmentIndexHtmlPlugin(this), ...extraCommonPlugins, postTransformPlugin(this)]; + + // CSR plugins + if (options?.optimization?.styles?.inlineCritical) { + this.csrPlugins.push(inlineCriticalCssPlugin(this)); + } + + // SSR plugins + if (options.generateDedicatedSSRContent) { + this.ssrPlugins.push(addEventDispatchContractPlugin(), addNoncePlugin()); + } + } + + async process(options: IndexHtmlGeneratorProcessOptions): Promise { + let content = await this.readIndex(this.options.indexPath); + const warnings: string[] = []; + const errors: string[] = []; + + content = await this.runPlugins(content, this.plugins, options, warnings, errors); + const [csrContent, ssrContent] = await Promise.all([ + this.runPlugins(content, this.csrPlugins, options, warnings, errors), + this.ssrPlugins.length + ? this.runPlugins(content, this.ssrPlugins, options, warnings, errors) + : undefined, + ]); + + return { + ssrContent, + csrContent, + warnings, + errors, + }; + } + + private async runPlugins( + content: string, + plugins: IndexHtmlGeneratorPlugin[], + options: IndexHtmlGeneratorProcessOptions, + warnings: string[], + errors: string[], + ): Promise { + for (const plugin of plugins) { + const result = await plugin(content, options); + if (typeof result === 'string') { + content = result; + } else { + content = result.content; + + if (result.warnings.length) { + warnings.push(...result.warnings); + } + + if (result.errors.length) { + errors.push(...result.errors); + } + } + } + + return content; + } + + async readAsset(path: string): Promise { + try { + return await readFile(path, 'utf-8'); + } catch { + throw new Error(`Failed to read asset "${path}".`); + } + } + + protected async readIndex(path: string): Promise { + try { + return new TextDecoder('utf-8').decode(await readFile(path)); + } catch (cause) { + throw new Error(`Failed to read index HTML file "${path}".`, { cause }); + } + } +} + +function augmentIndexHtmlPlugin(generator: IndexHtmlGenerator): IndexHtmlGeneratorPlugin { + const { deployUrl, crossOrigin, sri = false, entrypoints, imageDomains } = generator.options; + + return async (html, options) => { + const { lang, baseHref, outputPath = '', files, hints } = options; + + return augmentIndexHtml({ + html, + baseHref, + deployUrl, + crossOrigin, + sri, + lang, + entrypoints, + loadOutputFile: (filePath) => generator.readAsset(join(outputPath, filePath)), + imageDomains, + files, + hints, + }); + }; +} + +function inlineFontsPlugin({ options }: IndexHtmlGenerator): IndexHtmlGeneratorPlugin { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: options.optimization?.styles.minify, + }); + + return async (html) => inlineFontsProcessor.process(html); +} + +function inlineCriticalCssPlugin(generator: IndexHtmlGenerator): IndexHtmlGeneratorPlugin { + const inlineCriticalCssProcessor = new InlineCriticalCssProcessor({ + minify: generator.options.optimization?.styles.minify, + deployUrl: generator.options.deployUrl, + readAsset: (filePath) => generator.readAsset(filePath), + }); + + return async (html, options) => + inlineCriticalCssProcessor.process(html, { outputPath: options.outputPath }); +} + +function addNoncePlugin(): IndexHtmlGeneratorPlugin { + return (html) => addNonce(html); +} + +function postTransformPlugin({ options }: IndexHtmlGenerator): IndexHtmlGeneratorPlugin { + return async (html) => (options.postTransform ? options.postTransform(html) : html); +} + +function addEventDispatchContractPlugin(): IndexHtmlGeneratorPlugin { + return (html) => addEventDispatchContract(html); +} diff --git a/packages/angular/build/src/utils/index-file/inline-critical-css.ts b/packages/angular/build/src/utils/index-file/inline-critical-css.ts new file mode 100644 index 000000000000..fe68c8abe105 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/inline-critical-css.ts @@ -0,0 +1,248 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import Critters from 'critters'; +import { readFile } from 'node:fs/promises'; + +/** + * Pattern used to extract the media query set by Critters in an `onload` handler. + */ +const MEDIA_SET_HANDLER_PATTERN = /^this\.media=["'](.*)["'];?$/; + +/** + * Name of the attribute used to save the Critters media query so it can be re-assigned on load. + */ +const CSP_MEDIA_ATTR = 'ngCspMedia'; + +/** + * Script text used to change the media value of the link tags. + * + * NOTE: + * We do not use `document.querySelectorAll('link').forEach((s) => s.addEventListener('load', ...)` + * because this does not always fire on Chome. + * See: https://github.com/angular/angular-cli/issues/26932 and https://crbug.com/1521256 + */ +const LINK_LOAD_SCRIPT_CONTENT = [ + '(() => {', + ` const CSP_MEDIA_ATTR = '${CSP_MEDIA_ATTR}';`, + ' const documentElement = document.documentElement;', + ' const listener = (e) => {', + ' const target = e.target;', + ` if (!target || target.tagName !== 'LINK' || !target.hasAttribute(CSP_MEDIA_ATTR)) {`, + ' return;', + ' }', + + ' target.media = target.getAttribute(CSP_MEDIA_ATTR);', + ' target.removeAttribute(CSP_MEDIA_ATTR);', + + // Remove onload listener when there are no longer styles that need to be loaded. + ' if (!document.head.querySelector(`link[${CSP_MEDIA_ATTR}]`)) {', + ` documentElement.removeEventListener('load', listener);`, + ' }', + ' };', + + // We use an event with capturing (the true parameter) because load events don't bubble. + ` documentElement.addEventListener('load', listener, true);`, + '})();', +].join('\n'); + +export interface InlineCriticalCssProcessOptions { + outputPath: string; +} + +export interface InlineCriticalCssProcessorOptions { + minify?: boolean; + deployUrl?: string; + readAsset?: (path: string) => Promise; +} + +/** Partial representation of an `HTMLElement`. */ +interface PartialHTMLElement { + getAttribute(name: string): string | null; + setAttribute(name: string, value: string): void; + hasAttribute(name: string): boolean; + removeAttribute(name: string): void; + appendChild(child: PartialHTMLElement): void; + insertBefore(newNode: PartialHTMLElement, referenceNode?: PartialHTMLElement): void; + remove(): void; + name: string; + textContent: string; + tagName: string | null; + children: PartialHTMLElement[]; + next: PartialHTMLElement | null; + prev: PartialHTMLElement | null; +} + +/** Partial representation of an HTML `Document`. */ +interface PartialDocument { + head: PartialHTMLElement; + createElement(tagName: string): PartialHTMLElement; + querySelector(selector: string): PartialHTMLElement | null; +} + +/** Signature of the `Critters.embedLinkedStylesheet` method. */ +type EmbedLinkedStylesheetFn = ( + link: PartialHTMLElement, + document: PartialDocument, +) => Promise; + +class CrittersExtended extends Critters { + readonly warnings: string[] = []; + readonly errors: string[] = []; + private initialEmbedLinkedStylesheet: EmbedLinkedStylesheetFn; + private addedCspScriptsDocuments = new WeakSet(); + private documentNonces = new WeakMap(); + + // Inherited from `Critters`, but not exposed in the typings. + protected declare embedLinkedStylesheet: EmbedLinkedStylesheetFn; + + constructor( + private readonly optionsExtended: InlineCriticalCssProcessorOptions & + InlineCriticalCssProcessOptions, + ) { + super({ + logger: { + warn: (s: string) => this.warnings.push(s), + error: (s: string) => this.errors.push(s), + info: () => {}, + }, + logLevel: 'warn', + path: optionsExtended.outputPath, + publicPath: optionsExtended.deployUrl, + compress: !!optionsExtended.minify, + pruneSource: false, + reduceInlineStyles: false, + mergeStylesheets: false, + // Note: if `preload` changes to anything other than `media`, the logic in + // `embedLinkedStylesheetOverride` will have to be updated. + preload: 'media', + noscriptFallback: true, + inlineFonts: true, + }); + + // We can't use inheritance to override `embedLinkedStylesheet`, because it's not declared in + // the `Critters` .d.ts which means that we can't call the `super` implementation. TS doesn't + // allow for `super` to be cast to a different type. + this.initialEmbedLinkedStylesheet = this.embedLinkedStylesheet; + this.embedLinkedStylesheet = this.embedLinkedStylesheetOverride; + } + + public override readFile(path: string): Promise { + const readAsset = this.optionsExtended.readAsset; + + return readAsset ? readAsset(path) : readFile(path, 'utf-8'); + } + + /** + * Override of the Critters `embedLinkedStylesheet` method + * that makes it work with Angular's CSP APIs. + */ + private embedLinkedStylesheetOverride: EmbedLinkedStylesheetFn = async (link, document) => { + if (link.getAttribute('media') === 'print' && link.next?.name === 'noscript') { + // Workaround for https://github.com/GoogleChromeLabs/critters/issues/64 + // NB: this is only needed for the webpack based builders. + const media = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); + if (media) { + link.removeAttribute('onload'); + link.setAttribute('media', media[1]); + link?.next?.remove(); + } + } + + const returnValue = await this.initialEmbedLinkedStylesheet(link, document); + const cspNonce = this.findCspNonce(document); + + if (cspNonce) { + const crittersMedia = link.getAttribute('onload')?.match(MEDIA_SET_HANDLER_PATTERN); + + if (crittersMedia) { + // If there's a Critters-generated `onload` handler and the file has an Angular CSP nonce, + // we have to remove the handler, because it's incompatible with CSP. We save the value + // in a different attribute and we generate a script tag with the nonce that uses + // `addEventListener` to apply the media query instead. + link.removeAttribute('onload'); + link.setAttribute(CSP_MEDIA_ATTR, crittersMedia[1]); + this.conditionallyInsertCspLoadingScript(document, cspNonce, link); + } + + // Ideally we would hook in at the time Critters inserts the `style` tags, but there isn't + // a way of doing that at the moment so we fall back to doing it any time a `link` tag is + // inserted. We mitigate it by only iterating the direct children of the `` which + // should be pretty shallow. + document.head.children.forEach((child) => { + if (child.tagName === 'style' && !child.hasAttribute('nonce')) { + child.setAttribute('nonce', cspNonce); + } + }); + } + + return returnValue; + }; + + /** + * Finds the CSP nonce for a specific document. + */ + private findCspNonce(document: PartialDocument): string | null { + if (this.documentNonces.has(document)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this.documentNonces.get(document)!; + } + + // HTML attribute are case-insensitive, but the parser used by Critters is case-sensitive. + const nonceElement = document.querySelector('[ngCspNonce], [ngcspnonce]'); + const cspNonce = + nonceElement?.getAttribute('ngCspNonce') || nonceElement?.getAttribute('ngcspnonce') || null; + + this.documentNonces.set(document, cspNonce); + + return cspNonce; + } + + /** + * Inserts the `script` tag that swaps the critical CSS at runtime, + * if one hasn't been inserted into the document already. + */ + private conditionallyInsertCspLoadingScript( + document: PartialDocument, + nonce: string, + link: PartialHTMLElement, + ): void { + if (this.addedCspScriptsDocuments.has(document)) { + return; + } + + const script = document.createElement('script'); + script.setAttribute('nonce', nonce); + script.textContent = LINK_LOAD_SCRIPT_CONTENT; + // Prepend the script to the head since it needs to + // run as early as possible, before the `link` tags. + document.head.insertBefore(script, link); + this.addedCspScriptsDocuments.add(document); + } +} + +export class InlineCriticalCssProcessor { + constructor(protected readonly options: InlineCriticalCssProcessorOptions) {} + + async process( + html: string, + options: InlineCriticalCssProcessOptions, + ): Promise<{ content: string; warnings: string[]; errors: string[] }> { + const critters = new CrittersExtended({ ...this.options, ...options }); + const content = await critters.process(html); + + return { + // Clean up value from value less attributes. + // This is caused because parse5 always requires attributes to have a string value. + // nomodule="" defer="" -> nomodule defer. + content: content.replace(/(\s(?:defer|nomodule))=""/g, '$1'), + errors: critters.errors, + warnings: critters.warnings, + }; + } +} diff --git a/packages/angular/build/src/utils/index-file/inline-critical-css_spec.ts b/packages/angular/build/src/utils/index-file/inline-critical-css_spec.ts new file mode 100644 index 000000000000..4c68304cd9d6 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/inline-critical-css_spec.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { tags } from '@angular-devkit/core'; +import { InlineCriticalCssProcessor } from './inline-critical-css'; + +describe('InlineCriticalCssProcessor', () => { + const styles: Record = { + '/dist/styles.css': ` + body { margin: 0; } + html { color: white; } + `, + '/dist/theme.css': ` + span { color: blue; } + p { color: blue; } + `, + }; + + const readAsset = async (file: string): Promise => { + const content = styles[file]; + if (content) { + return content; + } + + throw new Error('Cannot read asset.'); + }; + + const getContent = (deployUrl: string, bodyContent = ''): string => { + return ` + + + + + + ${bodyContent} + `; + }; + + it('should inline critical css', async () => { + const inlineFontsProcessor = new InlineCriticalCssProcessor({ + readAsset, + }); + + const { content } = await inlineFontsProcessor.process(getContent(''), { + outputPath: '/dist/', + }); + + expect(content).toContain( + ``, + ); + expect(content).toContain( + ``, + ); + expect(content).not.toContain('color: blue'); + expect(tags.stripIndents`${content}`).toContain(tags.stripIndents` + `); + }); + + it('should inline critical css when using deployUrl', async () => { + const inlineFontsProcessor = new InlineCriticalCssProcessor({ + readAsset, + deployUrl: 'http://cdn.com', + }); + + const { content } = await inlineFontsProcessor.process(getContent('http://cdn.com/'), { + outputPath: '/dist/', + }); + + expect(content).toContain( + ``, + ); + expect(content).toContain( + ``, + ); + expect(tags.stripIndents`${content}`).toContain(tags.stripIndents` + `); + }); + + it('should compress inline critical css when minify is enabled', async () => { + const inlineFontsProcessor = new InlineCriticalCssProcessor({ + readAsset, + minify: true, + }); + + const { content } = await inlineFontsProcessor.process(getContent(''), { + outputPath: '/dist/', + }); + + expect(content).toContain( + ``, + ); + expect(content).toContain( + ``, + ); + expect(content).toContain(''); + }); + + it('should process the inline `onload` handlers if a CSP nonce is specified', async () => { + const inlineCssProcessor = new InlineCriticalCssProcessor({ + readAsset, + }); + + const { content } = await inlineCssProcessor.process( + getContent('', ''), + { + outputPath: '/dist/', + }, + ); + + expect(content).toContain( + '', + ); + expect(content).toContain( + '', + ); + // Nonces shouldn't be added inside the `noscript` tags. + expect(content).toContain(''); + expect(content).toContain(' + + + + + `); + + expect(result).toContain(``); + expect(result).toContain(''); + expect(result).toContain(``); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts b/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts new file mode 100644 index 000000000000..bd85b6ee00dd --- /dev/null +++ b/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** A list of valid self closing HTML elements */ +export const VALID_SELF_CLOSING_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', + /** SVG tags */ + 'circle', + 'ellipse', + 'line', + 'path', + 'polygon', + 'polyline', + 'rect', + 'text', + 'tspan', + 'linearGradient', + 'radialGradient', + 'stop', + 'image', + 'pattern', + 'defs', + 'g', + 'marker', + 'mask', + 'style', + 'symbol', + 'use', + 'view', + /** MathML tags */ + 'mspace', + 'mphantom', + 'mrow', + 'mfrac', + 'msqrt', + 'mroot', + 'mstyle', + 'merror', + 'mpadded', + 'mtable', +]); diff --git a/packages/angular/build/src/utils/index.ts b/packages/angular/build/src/utils/index.ts new file mode 100644 index 000000000000..1a7cb15cd9c3 --- /dev/null +++ b/packages/angular/build/src/utils/index.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './normalize-asset-patterns'; +export * from './normalize-optimization'; +export * from './normalize-source-maps'; +export * from './load-proxy-config'; diff --git a/packages/angular/build/src/utils/load-esm.ts b/packages/angular/build/src/utils/load-esm.ts new file mode 100644 index 000000000000..6a6220f66288 --- /dev/null +++ b/packages/angular/build/src/utils/load-esm.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Lazily compiled dynamic import loader function. + */ +let load: ((modulePath: string | URL) => Promise) | undefined; + +/** + * This uses a dynamic import to load a module which may be ESM. + * CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript + * will currently, unconditionally downlevel dynamic import into a require call. + * require calls cannot load ESM code and will result in a runtime error. To workaround + * this, a Function constructor is used to prevent TypeScript from changing the dynamic import. + * Once TypeScript provides support for keeping the dynamic import this workaround can + * be dropped. + * + * @param modulePath The path of the module to load. + * @returns A Promise that resolves to the dynamically imported module. + */ +export function loadEsmModule(modulePath: string | URL): Promise { + load ??= new Function('modulePath', `return import(modulePath);`) as Exclude< + typeof load, + undefined + >; + + return load(modulePath); +} diff --git a/packages/angular/build/src/utils/load-proxy-config.ts b/packages/angular/build/src/utils/load-proxy-config.ts new file mode 100644 index 000000000000..2ed21c05ba2a --- /dev/null +++ b/packages/angular/build/src/utils/load-proxy-config.ts @@ -0,0 +1,198 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { isDynamicPattern } from 'fast-glob'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { extname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { makeRe as makeRegExpFromGlob } from 'picomatch'; +import { assertIsError } from './error'; +import { loadEsmModule } from './load-esm'; + +export async function loadProxyConfiguration( + root: string, + proxyConfig: string | undefined, +): Promise | undefined> { + if (!proxyConfig) { + return undefined; + } + + const proxyPath = resolve(root, proxyConfig); + + if (!existsSync(proxyPath)) { + throw new Error(`Proxy configuration file ${proxyPath} does not exist.`); + } + + let proxyConfiguration; + switch (extname(proxyPath)) { + case '.json': { + const content = await readFile(proxyPath, 'utf-8'); + + const { parse, printParseErrorCode } = await import('jsonc-parser'); + const parseErrors: import('jsonc-parser').ParseError[] = []; + proxyConfiguration = parse(content, parseErrors, { allowTrailingComma: true }); + + if (parseErrors.length > 0) { + let errorMessage = `Proxy configuration file ${proxyPath} contains parse errors:`; + for (const parseError of parseErrors) { + const { line, column } = getJsonErrorLineColumn(parseError.offset, content); + errorMessage += `\n[${line}, ${column}] ${printParseErrorCode(parseError.error)}`; + } + throw new Error(errorMessage); + } + + break; + } + case '.mjs': + // Load the ESM configuration file using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + proxyConfiguration = (await loadEsmModule<{ default: unknown }>(pathToFileURL(proxyPath))) + .default; + break; + case '.cjs': + proxyConfiguration = require(proxyPath); + break; + default: + // The file could be either CommonJS or ESM. + // CommonJS is tried first then ESM if loading fails. + try { + proxyConfiguration = require(proxyPath); + break; + } catch (e) { + assertIsError(e); + if (e.code === 'ERR_REQUIRE_ESM') { + // Load the ESM configuration file using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + proxyConfiguration = (await loadEsmModule<{ default: unknown }>(pathToFileURL(proxyPath))) + .default; + break; + } + + throw e; + } + } + + return normalizeProxyConfiguration(proxyConfiguration); +} + +/** + * Converts glob patterns to regular expressions to support Vite's proxy option. + * Also converts the Webpack supported array form to an object form supported by both. + * + * @param proxy A proxy configuration object. + */ +function normalizeProxyConfiguration( + proxy: Record | object[], +): Record { + let normalizedProxy: Record | undefined; + + if (Array.isArray(proxy)) { + // Construct an object-form proxy configuration from the array + normalizedProxy = {}; + for (const proxyEntry of proxy) { + if (!('context' in proxyEntry)) { + continue; + } + if (!Array.isArray(proxyEntry.context)) { + continue; + } + + // Array-form entries contain a context string array with the path(s) + // to use for the configuration entry. + const context = proxyEntry.context; + delete proxyEntry.context; + for (const contextEntry of context) { + if (typeof contextEntry !== 'string') { + continue; + } + + normalizedProxy[contextEntry] = proxyEntry; + } + } + } else { + normalizedProxy = proxy; + } + + // TODO: Consider upstreaming glob support + for (const key of Object.keys(normalizedProxy)) { + if (key[0] !== '^' && isDynamicPattern(key)) { + const pattern = makeRegExpFromGlob(key).source; + normalizedProxy[pattern] = normalizedProxy[key]; + delete normalizedProxy[key]; + } + } + + // Replace `pathRewrite` field with a `rewrite` function + for (const proxyEntry of Object.values(normalizedProxy)) { + if ( + typeof proxyEntry === 'object' && + 'pathRewrite' in proxyEntry && + proxyEntry.pathRewrite && + typeof proxyEntry.pathRewrite === 'object' + ) { + // Preprocess path rewrite entries + const pathRewriteEntries: [RegExp, string][] = []; + for (const [pattern, value] of Object.entries( + proxyEntry.pathRewrite as Record, + )) { + pathRewriteEntries.push([new RegExp(pattern), value]); + } + + (proxyEntry as Record).rewrite = pathRewriter.bind( + undefined, + pathRewriteEntries, + ); + + delete proxyEntry.pathRewrite; + } + } + + return normalizedProxy; +} + +function pathRewriter(pathRewriteEntries: [RegExp, string][], path: string): string { + for (const [pattern, value] of pathRewriteEntries) { + const updated = path.replace(pattern, value); + if (path !== updated) { + return updated; + } + } + + return path; +} + +/** + * Calculates the line and column for an error offset in the content of a JSON file. + * @param location The offset error location from the beginning of the content. + * @param content The full content of the file containing the error. + * @returns An object containing the line and column + */ +function getJsonErrorLineColumn(offset: number, content: string) { + if (offset === 0) { + return { line: 1, column: 1 }; + } + + let line = 0; + let position = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + ++line; + + const nextNewline = content.indexOf('\n', position); + if (nextNewline === -1 || nextNewline > offset) { + break; + } + + position = nextNewline + 1; + } + + return { line, column: offset - position + 1 }; +} diff --git a/packages/angular_devkit/build_angular/src/utils/load-translations.ts b/packages/angular/build/src/utils/load-translations.ts similarity index 80% rename from packages/angular_devkit/build_angular/src/utils/load-translations.ts rename to packages/angular/build/src/utils/load-translations.ts index 1bef4e0b49f4..c6afe9a1ecd9 100644 --- a/packages/angular_devkit/build_angular/src/utils/load-translations.ts +++ b/packages/angular/build/src/utils/load-translations.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import type { Diagnostics } from '@angular/localize/tools'; @@ -26,9 +26,11 @@ export async function createTranslationLoader(): Promise { const content = fs.readFileSync(path, 'utf8'); const unusedParsers = new Map(); for (const [format, parser] of Object.entries(parsers)) { - const analysis = analyze(parser, path, content); + const analysis = parser.analyze(path, content); if (analysis.canParse) { - const { locale, translations } = parser.parse(path, content, analysis.hint); + // Types don't overlap here so we need to use any. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { locale, translations } = parser.parse(path, content, analysis.hint as any); const integrity = 'sha256-' + createHash('sha256').update(content).digest('base64'); return { format, locale, translations, diagnostics, integrity }; @@ -46,18 +48,6 @@ export async function createTranslationLoader(): Promise { messages.join('\n'), ); }; - - // TODO: `parser.canParse()` is deprecated; remove this polyfill once we are sure all parsers provide the `parser.analyze()` method. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function analyze(parser: any, path: string, content: string) { - if (parser.analyze !== undefined) { - return parser.analyze(path, content); - } else { - const hint = parser.canParse(path, content); - - return { canParse: hint !== false, hint, diagnostics }; - } - } } async function importParsers() { diff --git a/packages/angular/build/src/utils/normalize-asset-patterns.ts b/packages/angular/build/src/utils/normalize-asset-patterns.ts new file mode 100644 index 000000000000..246b6190fdf8 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-asset-patterns.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { statSync } from 'fs'; +import assert from 'node:assert'; +import * as path from 'path'; +import { AssetPattern, AssetPatternClass } from '../builders/application/schema'; + +export class MissingAssetSourceRootException extends Error { + constructor(path: string) { + super(`The ${path} asset path must start with the project source root.`); + } +} + +export function normalizeAssetPatterns( + assetPatterns: AssetPattern[], + workspaceRoot: string, + projectRoot: string, + projectSourceRoot: string | undefined, +): (AssetPatternClass & { output: string })[] { + if (assetPatterns.length === 0) { + return []; + } + + // When sourceRoot is not available, we default to ${projectRoot}/src. + const sourceRoot = projectSourceRoot || path.join(projectRoot, 'src'); + const resolvedSourceRoot = path.resolve(workspaceRoot, sourceRoot); + + return assetPatterns.map((assetPattern) => { + // Normalize string asset patterns to objects. + if (typeof assetPattern === 'string') { + const assetPath = path.normalize(assetPattern); + const resolvedAssetPath = path.resolve(workspaceRoot, assetPath); + + // Check if the string asset is within sourceRoot. + if (!resolvedAssetPath.startsWith(resolvedSourceRoot)) { + throw new MissingAssetSourceRootException(assetPattern); + } + + let glob: string, input: string; + let isDirectory = false; + + try { + isDirectory = statSync(resolvedAssetPath).isDirectory(); + } catch { + isDirectory = true; + } + + if (isDirectory) { + // Folders get a recursive star glob. + glob = '**/*'; + // Input directory is their original path. + input = assetPath; + } else { + // Files are their own glob. + glob = path.basename(assetPath); + // Input directory is their original dirname. + input = path.dirname(assetPath); + } + + // Output directory for both is the relative path from source root to input. + const output = path.relative(resolvedSourceRoot, path.resolve(workspaceRoot, input)); + + assetPattern = { glob, input, output }; + } else { + assetPattern.output = path.join('.', assetPattern.output ?? ''); + } + + assert(assetPattern.output !== undefined); + + if (assetPattern.output.startsWith('..')) { + throw new Error('An asset cannot be written to a location outside of the output path.'); + } + + return assetPattern as AssetPatternClass & { output: string }; + }); +} diff --git a/packages/angular/build/src/utils/normalize-cache.ts b/packages/angular/build/src/utils/normalize-cache.ts new file mode 100644 index 000000000000..9dc7ba6ae2a6 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-cache.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join, resolve } from 'node:path'; + +/** Version placeholder is replaced during the build process with actual package version */ +const VERSION = '0.0.0-PLACEHOLDER'; + +export interface NormalizedCachedOptions { + /** Whether disk cache is enabled. */ + enabled: boolean; + /** Disk cache path. Example: `/.angular/cache/v12.0.0`. */ + path: string; + /** Disk cache base path. Example: `/.angular/cache`. */ + basePath: string; +} + +interface CacheMetadata { + enabled?: boolean; + environment?: 'local' | 'ci' | 'all'; + path?: string; +} + +function hasCacheMetadata(value: unknown): value is { cli: { cache: CacheMetadata } } { + return ( + !!value && + typeof value === 'object' && + 'cli' in value && + !!value['cli'] && + typeof value['cli'] === 'object' && + 'cache' in value['cli'] + ); +} + +export function normalizeCacheOptions( + projectMetadata: unknown, + worspaceRoot: string, +): NormalizedCachedOptions { + const cacheMetadata = hasCacheMetadata(projectMetadata) ? projectMetadata.cli.cache : {}; + + const { + // Webcontainers do not currently benefit from persistent disk caching and can lead to increased browser memory usage + enabled = !process.versions.webcontainer, + environment = 'local', + path = '.angular/cache', + } = cacheMetadata; + const isCI = process.env['CI'] === '1' || process.env['CI']?.toLowerCase() === 'true'; + + let cacheEnabled = enabled; + if (cacheEnabled) { + switch (environment) { + case 'ci': + cacheEnabled = isCI; + break; + case 'local': + cacheEnabled = !isCI; + break; + } + } + + const cacheBasePath = resolve(worspaceRoot, path); + + return { + enabled: cacheEnabled, + basePath: cacheBasePath, + path: join(cacheBasePath, VERSION), + }; +} diff --git a/packages/angular/build/src/utils/normalize-optimization.ts b/packages/angular/build/src/utils/normalize-optimization.ts new file mode 100644 index 000000000000..fcd5b556f27f --- /dev/null +++ b/packages/angular/build/src/utils/normalize-optimization.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + FontsClass, + OptimizationClass, + OptimizationUnion, + StylesClass, +} from '../builders/application/schema'; + +export type NormalizedOptimizationOptions = Required< + Omit +> & { + fonts: FontsClass; + styles: StylesClass; +}; + +export function normalizeOptimization( + optimization: OptimizationUnion = true, +): NormalizedOptimizationOptions { + if (typeof optimization === 'object') { + const styleOptimization = !!optimization.styles; + + return { + scripts: !!optimization.scripts, + styles: + typeof optimization.styles === 'object' + ? optimization.styles + : { + minify: styleOptimization, + removeSpecialComments: styleOptimization, + inlineCritical: styleOptimization, + }, + fonts: + typeof optimization.fonts === 'object' + ? optimization.fonts + : { + inline: !!optimization.fonts, + }, + }; + } + + return { + scripts: optimization, + styles: { + minify: optimization, + inlineCritical: optimization, + removeSpecialComments: optimization, + }, + fonts: { + inline: optimization, + }, + }; +} diff --git a/packages/angular/build/src/utils/normalize-source-maps.ts b/packages/angular/build/src/utils/normalize-source-maps.ts new file mode 100644 index 000000000000..ddeb3e5322d4 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-source-maps.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { SourceMapClass, SourceMapUnion } from '../builders/application/schema'; + +export function normalizeSourceMaps(sourceMap: SourceMapUnion): SourceMapClass { + const scripts = typeof sourceMap === 'object' ? sourceMap.scripts : sourceMap; + const styles = typeof sourceMap === 'object' ? sourceMap.styles : sourceMap; + const hidden = (typeof sourceMap === 'object' && sourceMap.hidden) || false; + const vendor = (typeof sourceMap === 'object' && sourceMap.vendor) || false; + + return { + vendor, + hidden, + scripts, + styles, + }; +} diff --git a/packages/angular/build/src/utils/postcss-configuration.ts b/packages/angular/build/src/utils/postcss-configuration.ts new file mode 100644 index 000000000000..1861f9f2b1db --- /dev/null +++ b/packages/angular/build/src/utils/postcss-configuration.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; + +export interface PostcssConfiguration { + plugins: [name: string, options?: object | string][]; +} + +interface RawPostcssConfiguration { + plugins?: Record | (string | [string, object])[]; +} + +const postcssConfigurationFiles: string[] = ['postcss.config.json', '.postcssrc.json']; +const tailwindConfigFiles: string[] = [ + 'tailwind.config.js', + 'tailwind.config.cjs', + 'tailwind.config.mjs', + 'tailwind.config.ts', +]; + +export interface SearchDirectory { + root: string; + files: Set; +} + +export async function generateSearchDirectories(roots: string[]): Promise { + return await Promise.all( + roots.map((root) => + readdir(root, { withFileTypes: true }).then((entries) => ({ + root, + files: new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name)), + })), + ), + ); +} + +function findFile( + searchDirectories: SearchDirectory[], + potentialFiles: string[], +): string | undefined { + for (const { root, files } of searchDirectories) { + for (const potential of potentialFiles) { + if (files.has(potential)) { + return join(root, potential); + } + } + } + + return undefined; +} + +export function findTailwindConfiguration( + searchDirectories: SearchDirectory[], +): string | undefined { + return findFile(searchDirectories, tailwindConfigFiles); +} + +async function readPostcssConfiguration( + configurationFile: string, +): Promise { + const data = await readFile(configurationFile, 'utf-8'); + const config = JSON.parse(data) as RawPostcssConfiguration; + + return config; +} + +export async function loadPostcssConfiguration( + searchDirectories: SearchDirectory[], +): Promise { + const configPath = findFile(searchDirectories, postcssConfigurationFiles); + if (!configPath) { + return undefined; + } + + const raw = await readPostcssConfiguration(configPath); + + // If no plugins are defined, consider it equivalent to no configuration + if (!raw.plugins || typeof raw.plugins !== 'object') { + return undefined; + } + + // Normalize plugin array form + if (Array.isArray(raw.plugins)) { + if (raw.plugins.length < 1) { + return undefined; + } + + const config: PostcssConfiguration = { plugins: [] }; + for (const element of raw.plugins) { + if (typeof element === 'string') { + config.plugins.push([element]); + } else { + config.plugins.push(element); + } + } + + return config; + } + + // Normalize plugin object map form + const entries = Object.entries(raw.plugins); + if (entries.length < 1) { + return undefined; + } + + const config: PostcssConfiguration = { plugins: [] }; + for (const [name, options] of entries) { + if (!options || (typeof options !== 'object' && typeof options !== 'string')) { + continue; + } + + config.plugins.push([name, options]); + } + + return config; +} diff --git a/packages/angular/build/src/utils/purge-cache.ts b/packages/angular/build/src/utils/purge-cache.ts new file mode 100644 index 000000000000..5851d052d54a --- /dev/null +++ b/packages/angular/build/src/utils/purge-cache.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import { readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { normalizeCacheOptions } from './normalize-cache'; + +/** Delete stale cache directories used by previous versions of build-angular. */ +export async function purgeStaleBuildCache(context: BuilderContext): Promise { + const projectName = context.target?.project; + if (!projectName) { + return; + } + + const metadata = await context.getProjectMetadata(projectName); + const { basePath, path, enabled } = normalizeCacheOptions(metadata, context.workspaceRoot); + + if (!enabled) { + return; + } + + let baseEntries; + try { + baseEntries = await readdir(basePath, { withFileTypes: true }); + } catch { + // No purging possible if base path does not exist or cannot otherwise be accessed + return; + } + + const entriesToDelete = baseEntries + .filter((d) => d.isDirectory()) + .map((d) => join(basePath, d.name)) + .filter((cachePath) => cachePath !== path) + .map((stalePath) => rm(stalePath, { force: true, recursive: true, maxRetries: 3 })); + + await Promise.allSettled(entriesToDelete); +} diff --git a/packages/angular/build/src/utils/resolve-assets.ts b/packages/angular/build/src/utils/resolve-assets.ts new file mode 100644 index 000000000000..c9732501ce29 --- /dev/null +++ b/packages/angular/build/src/utils/resolve-assets.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import glob from 'fast-glob'; +import path from 'node:path'; + +export async function resolveAssets( + entries: { + glob: string; + ignore?: string[]; + input: string; + output: string; + flatten?: boolean; + followSymlinks?: boolean; + }[], + root: string, +): Promise<{ source: string; destination: string }[]> { + const defaultIgnore = ['.gitkeep', '**/.DS_Store', '**/Thumbs.db']; + + const outputFiles: { source: string; destination: string }[] = []; + + for (const entry of entries) { + const cwd = path.resolve(root, entry.input); + const files = await glob(entry.glob, { + cwd, + dot: true, + ignore: entry.ignore ? defaultIgnore.concat(entry.ignore) : defaultIgnore, + followSymbolicLinks: entry.followSymlinks, + }); + + for (const file of files) { + const src = path.join(cwd, file); + const filePath = entry.flatten ? path.basename(file) : file; + + outputFiles.push({ source: src, destination: path.join(entry.output, filePath) }); + } + } + + return outputFiles; +} diff --git a/packages/angular/build/src/utils/routes-extractor/BUILD.bazel b/packages/angular/build/src/utils/routes-extractor/BUILD.bazel new file mode 100644 index 000000000000..f9c6f8827f1f --- /dev/null +++ b/packages/angular/build/src/utils/routes-extractor/BUILD.bazel @@ -0,0 +1,26 @@ +# Copyright Google Inc. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license + +load("//tools:defaults.bzl", "ts_library") + +# NOTE This is built as ESM as this is included in the users server bundle. +licenses(["notice"]) + +package(default_visibility = ["//packages/angular/build:__subpackages__"]) + +ts_library( + name = "routes-extractor", + srcs = [ + "extractor.ts", + ], + devmode_module = "es2015", + prodmode_module = "es2015", + deps = [ + "@npm//@angular/core", + "@npm//@angular/platform-server", + "@npm//@angular/router", + "@npm//@types/node", + ], +) diff --git a/packages/angular/build/src/utils/routes-extractor/extractor.ts b/packages/angular/build/src/utils/routes-extractor/extractor.ts new file mode 100644 index 000000000000..14708c05f705 --- /dev/null +++ b/packages/angular/build/src/utils/routes-extractor/extractor.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + ApplicationRef, + Compiler, + Injector, + Type, + createPlatformFactory, + platformCore, + ɵwhenStable as whenStable, + ɵConsole, +} from '@angular/core'; +import { + INITIAL_CONFIG, + ɵINTERNAL_SERVER_PLATFORM_PROVIDERS as INTERNAL_SERVER_PLATFORM_PROVIDERS, +} from '@angular/platform-server'; +import { Route, Router, ɵloadChildren as loadChildrenHelper } from '@angular/router'; + +interface RouterResult { + route: string; + success: boolean; + redirect: boolean; +} + +async function* getRoutesFromRouterConfig( + routes: Route[], + compiler: Compiler, + parentInjector: Injector, + parentRoute = '', +): AsyncIterableIterator { + for (const route of routes) { + const { path, redirectTo, loadChildren, children } = route; + if (path === undefined) { + continue; + } + + const currentRoutePath = buildRoutePath(parentRoute, path); + + if (redirectTo !== undefined) { + // TODO: handle `redirectTo`. + yield { route: currentRoutePath, success: false, redirect: true }; + continue; + } + + if (/[:*]/.test(path)) { + // TODO: handle parameterized routes population. + yield { route: currentRoutePath, success: false, redirect: false }; + continue; + } + + yield { route: currentRoutePath, success: true, redirect: false }; + + if (children?.length) { + yield* getRoutesFromRouterConfig(children, compiler, parentInjector, currentRoutePath); + } + + if (loadChildren) { + const loadedChildRoutes = await loadChildrenHelper( + route, + compiler, + parentInjector, + ).toPromise(); + + if (loadedChildRoutes) { + const { routes: childRoutes, injector = parentInjector } = loadedChildRoutes; + yield* getRoutesFromRouterConfig(childRoutes, compiler, injector, currentRoutePath); + } + } + } +} + +export async function* extractRoutes( + bootstrapAppFnOrModule: (() => Promise) | Type, + document: string, +): AsyncIterableIterator { + const platformRef = createPlatformFactory(platformCore, 'server', [ + { + provide: INITIAL_CONFIG, + useValue: { document, url: '' }, + }, + { + provide: ɵConsole, + /** An Angular Console Provider that does not print a set of predefined logs. */ + useFactory: () => { + class Console extends ɵConsole { + private readonly ignoredLogs = new Set(['Angular is running in development mode.']); + override log(message: string): void { + if (!this.ignoredLogs.has(message)) { + super.log(message); + } + } + } + + return new Console(); + }, + }, + ...INTERNAL_SERVER_PLATFORM_PROVIDERS, + ])(); + + try { + let applicationRef: ApplicationRef; + if (isBootstrapFn(bootstrapAppFnOrModule)) { + applicationRef = await bootstrapAppFnOrModule(); + } else { + const moduleRef = await platformRef.bootstrapModule(bootstrapAppFnOrModule); + applicationRef = moduleRef.injector.get(ApplicationRef); + } + + // Wait until the application is stable. + await whenStable(applicationRef); + + const injector = applicationRef.injector; + const router = injector.get(Router); + + if (router.config.length === 0) { + // In case there are no routes available + yield { route: '', success: true, redirect: false }; + } else { + const compiler = injector.get(Compiler); + // Extract all the routes from the config. + yield* getRoutesFromRouterConfig(router.config, compiler, injector); + } + } finally { + platformRef.destroy(); + } +} + +function isBootstrapFn(value: unknown): value is () => Promise { + // We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property: + return typeof value === 'function' && !('ɵmod' in value); +} + +function buildRoutePath(...routeParts: string[]): string { + return routeParts.filter(Boolean).join('/'); +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts new file mode 100644 index 000000000000..ca9e986bbb89 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'url'; +import { JavaScriptTransformer } from '../../../tools/esbuild/javascript-transformer'; + +/** + * Node.js ESM loader to redirect imports to in memory files. + * @see: https://nodejs.org/api/esm.html#loaders for more information about loaders. + */ + +const MEMORY_URL_SCHEME = 'memory://'; + +export interface ESMInMemoryFileLoaderWorkerData { + outputFiles: Record; + workspaceRoot: string; +} + +let memoryVirtualRootUrl: string; +let outputFiles: Record; + +const javascriptTransformer = new JavaScriptTransformer( + // Always enable JIT linking to support applications built with and without AOT. + // In a development environment the additional scope information does not + // have a negative effect unlike production where final output size is relevant. + { sourcemap: true, jit: true }, + 1, +); + +export function initialize(data: ESMInMemoryFileLoaderWorkerData) { + // This path does not actually exist but is used to overlay the in memory files with the + // actual filesystem for resolution purposes. + // A custom URL schema (such as `memory://`) cannot be used for the resolve output because + // the in-memory files may use `import.meta.url` in ways that assume a file URL. + // `createRequire` is one example of this usage. + memoryVirtualRootUrl = pathToFileURL( + join(data.workspaceRoot, `.angular/prerender-root/${randomUUID()}/`), + ).href; + outputFiles = data.outputFiles; +} + +export function resolve( + specifier: string, + context: { parentURL: undefined | string }, + nextResolve: Function, +) { + // In-memory files loaded from external code will contain a memory scheme + if (specifier.startsWith(MEMORY_URL_SCHEME)) { + let memoryUrl; + try { + memoryUrl = new URL(specifier); + } catch { + assert.fail('External code attempted to use malformed memory scheme: ' + specifier); + } + + // Resolve with a URL based from the virtual filesystem root + return { + format: 'module', + shortCircuit: true, + url: new URL(memoryUrl.pathname.slice(1), memoryVirtualRootUrl).href, + }; + } + + // Use next/default resolve if the parent is not from the virtual root + if (!context.parentURL?.startsWith(memoryVirtualRootUrl)) { + return nextResolve(specifier, context); + } + + // Check for `./` and `../` relative specifiers + const isRelative = + specifier[0] === '.' && + (specifier[1] === '/' || (specifier[1] === '.' && specifier[2] === '/')); + + // Relative specifiers from memory file should be based from the parent memory location + if (isRelative) { + let specifierUrl; + try { + specifierUrl = new URL(specifier, context.parentURL); + } catch {} + + if ( + specifierUrl?.pathname && + Object.hasOwn(outputFiles, specifierUrl.href.slice(memoryVirtualRootUrl.length)) + ) { + return { + format: 'module', + shortCircuit: true, + url: specifierUrl.href, + }; + } + + assert.fail( + `In-memory ESM relative file should always exist: '${context.parentURL}' --> '${specifier}'`, + ); + } + + // Update the parent URL to allow for module resolution for the workspace. + // This handles bare specifiers (npm packages) and absolute paths. + // Defer to the next hook in the chain, which would be the + // Node.js default resolve if this is the last user-specified loader. + return nextResolve(specifier, { + ...context, + parentURL: new URL('index.js', memoryVirtualRootUrl).href, + }); +} + +export async function load(url: string, context: { format?: string | null }, nextLoad: Function) { + const { format } = context; + + // Load the file from memory if the URL is based in the virtual root + if (url.startsWith(memoryVirtualRootUrl)) { + const source = outputFiles[url.slice(memoryVirtualRootUrl.length)]; + assert(source !== undefined, 'Resolved in-memory ESM file should always exist: ' + url); + + // In-memory files have already been transformer during bundling and can be returned directly + return { + format, + shortCircuit: true, + source, + }; + } + + // Only module files potentially require transformation. Angular libraries that would + // need linking are ESM only. + if (format === 'module' && isFileProtocol(url)) { + const filePath = fileURLToPath(url); + const source = await javascriptTransformer.transformFile(filePath); + + return { + format, + shortCircuit: true, + source, + }; + } + + // Let Node.js handle all other URLs. + return nextLoad(url); +} + +function isFileProtocol(url: string): boolean { + return url.startsWith('file://'); +} + +function handleProcessExit(): void { + void javascriptTransformer.close(); +} + +process.once('exit', handleProcessExit); +process.once('SIGINT', handleProcessExit); +process.once('uncaughtException', handleProcessExit); diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts new file mode 100644 index 000000000000..b23fe297bc19 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { register } from 'node:module'; +import { pathToFileURL } from 'node:url'; +import { workerData } from 'node:worker_threads'; + +register('./loader-hooks.js', { parentURL: pathToFileURL(__filename), data: workerData }); diff --git a/packages/angular/build/src/utils/server-rendering/fetch-patch.ts b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts new file mode 100644 index 000000000000..6777d9a5aa28 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { lookup as lookupMimeType } from 'mrmime'; +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; +import { workerData } from 'node:worker_threads'; +import { Response, fetch } from 'undici'; + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { assetFiles } = workerData as { + assetFiles: Record; +}; + +const assetsCache: Map; content: Buffer }> = + new Map(); + +const RESOLVE_PROTOCOL = 'resolve:'; + +export function patchFetchToLoadInMemoryAssets(): void { + const global = globalThis as unknown as { fetch: typeof fetch }; + const originalFetch = global.fetch; + const patchedFetch: typeof fetch = async (input, init) => { + let url: URL; + if (input instanceof URL) { + url = input; + } else if (typeof input === 'string') { + url = new URL(input, RESOLVE_PROTOCOL + '//'); + } else if (typeof input === 'object' && 'url' in input) { + url = new URL(input.url, RESOLVE_PROTOCOL + '//'); + } else { + return originalFetch(input, init); + } + + const { protocol } = url; + const pathname = decodeURIComponent(url.pathname); + + if (protocol !== RESOLVE_PROTOCOL || !assetFiles[pathname]) { + // Only handle relative requests or files that are in assets. + return originalFetch(input, init); + } + + const cachedAsset = assetsCache.get(pathname); + if (cachedAsset) { + const { content, headers } = cachedAsset; + + return new Response(content, { + headers, + }); + } + + const extension = extname(pathname); + const mimeType = lookupMimeType(extension); + const content = await readFile(assetFiles[pathname]); + const headers = mimeType + ? { + 'Content-Type': mimeType, + } + : undefined; + + assetsCache.set(pathname, { headers, content }); + + return new Response(content, { + headers, + }); + }; + + global.fetch = patchedFetch; +} diff --git a/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts new file mode 100644 index 000000000000..a3a3384545a4 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { assertIsError } from '../error'; +import { loadEsmModule } from '../load-esm'; +import { MainServerBundleExports, RenderUtilsServerBundleExports } from './main-bundle-exports'; + +export function loadEsmModuleFromMemory( + path: './main.server.mjs', +): Promise; +export function loadEsmModuleFromMemory( + path: './render-utils.server.mjs', +): Promise; +export function loadEsmModuleFromMemory(path: string): Promise { + return loadEsmModule(new URL(path, 'memory://')).catch((e) => { + assertIsError(e); + + // While the error is an 'instanceof Error', it is extended with non transferable properties + // and cannot be transferred from a worker when using `--import`. This results in the error object + // displaying as '[Object object]' when read outside of the worker. Therefore, we reconstruct the error message here. + const error: Error & { code?: string } = new Error(e.message); + error.stack = e.stack; + error.name = e.name; + error.code = e.code; + + throw error; + }); +} diff --git a/packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts b/packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts new file mode 100644 index 000000000000..eb6f0f0dfb8c --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ApplicationRef, Type, ɵConsole } from '@angular/core'; +import type { renderApplication, renderModule, ɵSERVER_CONTEXT } from '@angular/platform-server'; +import type { extractRoutes } from '../routes-extractor/extractor'; + +export interface MainServerBundleExports { + /** Standalone application bootstrapping function. */ + default: (() => Promise) | Type; +} + +export interface RenderUtilsServerBundleExports { + /** An internal token that allows providing extra information about the server context. */ + ɵSERVER_CONTEXT: typeof ɵSERVER_CONTEXT; + + /** Render an NgModule application. */ + renderModule: typeof renderModule; + + /** Method to render a standalone application. */ + renderApplication: typeof renderApplication; + + /** Method to extract routes from the router config. */ + extractRoutes: typeof extractRoutes; + + ɵresetCompiledComponents?: () => void; + + /** Angular Console token/class. */ + ɵConsole: typeof ɵConsole; +} diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts new file mode 100644 index 000000000000..5b42f8c739ef --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -0,0 +1,297 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { extname, join, posix } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import Piscina from 'piscina'; +import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; +import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; +import type { RenderResult, ServerContext } from './render-page'; +import type { RenderWorkerData } from './render-worker'; +import type { + RoutersExtractorWorkerResult, + RoutesExtractorWorkerData, +} from './routes-extractor-worker'; + +interface PrerenderOptions { + routesFile?: string; + discoverRoutes?: boolean; +} + +interface AppShellOptions { + route?: string; +} + +export async function prerenderPages( + workspaceRoot: string, + appShellOptions: AppShellOptions = {}, + prerenderOptions: PrerenderOptions = {}, + outputFiles: Readonly, + assets: Readonly, + document: string, + sourcemap = false, + inlineCriticalCss = false, + maxThreads = 1, + verbose = false, +): Promise<{ + output: Record; + warnings: string[]; + errors: string[]; + prerenderedRoutes: Set; +}> { + const outputFilesForWorker: Record = {}; + const serverBundlesSourceMaps = new Map(); + const warnings: string[] = []; + const errors: string[] = []; + + for (const { text, path, type } of outputFiles) { + const fileExt = extname(path); + if (type === BuildOutputFileType.Server && fileExt === '.map') { + serverBundlesSourceMaps.set(path.slice(0, -4), text); + } else if ( + type === BuildOutputFileType.Server || // Contains the server runnable application code + (type === BuildOutputFileType.Browser && fileExt === '.css') // Global styles for critical CSS inlining. + ) { + outputFilesForWorker[path] = text; + } + } + + // Inline sourcemap into JS file. This is needed to make Node.js resolve sourcemaps + // when using `--enable-source-maps` when using in memory files. + for (const [filePath, map] of serverBundlesSourceMaps) { + const jsContent = outputFilesForWorker[filePath]; + if (jsContent) { + outputFilesForWorker[filePath] = + jsContent + + `\n//# sourceMappingURL=` + + `data:application/json;base64,${Buffer.from(map).toString('base64')}`; + } + } + serverBundlesSourceMaps.clear(); + + const assetsReversed: Record = {}; + for (const { source, destination } of assets) { + assetsReversed[addLeadingSlash(destination.replace(/\\/g, posix.sep))] = source; + } + + // Get routes to prerender + const { routes: allRoutes, warnings: routesWarnings } = await getAllRoutes( + workspaceRoot, + outputFilesForWorker, + assetsReversed, + document, + appShellOptions, + prerenderOptions, + sourcemap, + verbose, + ); + + if (routesWarnings?.length) { + warnings.push(...routesWarnings); + } + + if (allRoutes.size < 1) { + return { + errors, + warnings, + output: {}, + prerenderedRoutes: allRoutes, + }; + } + + // Render routes + const { + warnings: renderingWarnings, + errors: renderingErrors, + output, + } = await renderPages( + sourcemap, + allRoutes, + maxThreads, + workspaceRoot, + outputFilesForWorker, + assetsReversed, + inlineCriticalCss, + document, + appShellOptions, + ); + + errors.push(...renderingErrors); + warnings.push(...renderingWarnings); + + return { + errors, + warnings, + output, + prerenderedRoutes: allRoutes, + }; +} + +class RoutesSet extends Set { + override add(value: string): this { + return super.add(addLeadingSlash(value)); + } +} + +async function renderPages( + sourcemap: boolean, + allRoutes: Set, + maxThreads: number, + workspaceRoot: string, + outputFilesForWorker: Record, + assetFilesForWorker: Record, + inlineCriticalCss: boolean, + document: string, + appShellOptions: AppShellOptions, +): Promise<{ + output: Record; + warnings: string[]; + errors: string[]; +}> { + const output: Record = {}; + const warnings: string[] = []; + const errors: string[] = []; + + const workerExecArgv = [ + '--import', + // Loader cannot be an absolute path on Windows. + pathToFileURL(join(__dirname, 'esm-in-memory-loader/register-hooks.js')).href, + ]; + + if (sourcemap) { + workerExecArgv.push('--enable-source-maps'); + } + + const renderWorker = new Piscina({ + filename: require.resolve('./render-worker'), + maxThreads: Math.min(allRoutes.size, maxThreads), + workerData: { + workspaceRoot, + outputFiles: outputFilesForWorker, + assetFiles: assetFilesForWorker, + inlineCriticalCss, + document, + } as RenderWorkerData, + execArgv: workerExecArgv, + recordTiming: false, + }); + + try { + const renderingPromises: Promise[] = []; + const appShellRoute = appShellOptions.route && addLeadingSlash(appShellOptions.route); + + for (const route of allRoutes) { + const isAppShellRoute = appShellRoute === route; + const serverContext: ServerContext = isAppShellRoute ? 'app-shell' : 'ssg'; + const render: Promise = renderWorker.run({ route, serverContext }); + const renderResult: Promise = render.then(({ content, warnings, errors }) => { + if (content !== undefined) { + const outPath = isAppShellRoute + ? 'index.html' + : posix.join(removeLeadingSlash(route), 'index.html'); + output[outPath] = content; + } + + if (warnings) { + warnings.push(...warnings); + } + + if (errors) { + errors.push(...errors); + } + }); + + renderingPromises.push(renderResult); + } + + await Promise.all(renderingPromises); + } finally { + void renderWorker.destroy(); + } + + return { + errors, + warnings, + output, + }; +} + +async function getAllRoutes( + workspaceRoot: string, + outputFilesForWorker: Record, + assetFilesForWorker: Record, + document: string, + appShellOptions: AppShellOptions, + prerenderOptions: PrerenderOptions, + sourcemap: boolean, + verbose: boolean, +): Promise<{ routes: Set; warnings?: string[] }> { + const { routesFile, discoverRoutes } = prerenderOptions; + const routes = new RoutesSet(); + const { route: appShellRoute } = appShellOptions; + + if (appShellRoute !== undefined) { + routes.add(appShellRoute); + } + + if (routesFile) { + const routesFromFile = (await readFile(routesFile, 'utf8')).split(/\r?\n/); + for (const route of routesFromFile) { + routes.add(route.trim()); + } + } + + if (!discoverRoutes) { + return { routes }; + } + + const workerExecArgv = [ + '--import', + // Loader cannot be an absolute path on Windows. + pathToFileURL(join(__dirname, 'esm-in-memory-loader/register-hooks.js')).href, + ]; + + if (sourcemap) { + workerExecArgv.push('--enable-source-maps'); + } + + const renderWorker = new Piscina({ + filename: require.resolve('./routes-extractor-worker'), + maxThreads: 1, + workerData: { + workspaceRoot, + outputFiles: outputFilesForWorker, + assetFiles: assetFilesForWorker, + document, + verbose, + } as RoutesExtractorWorkerData, + execArgv: workerExecArgv, + recordTiming: false, + }); + + const { routes: extractedRoutes, warnings }: RoutersExtractorWorkerResult = await renderWorker + .run({}) + .finally(() => { + void renderWorker.destroy(); + }); + + for (const route of extractedRoutes) { + routes.add(route); + } + + return { routes, warnings }; +} + +function addLeadingSlash(value: string): string { + return value.charAt(0) === '/' ? value : '/' + value; +} + +function removeLeadingSlash(value: string): string { + return value.charAt(0) === '/' ? value.slice(1) : value; +} diff --git a/packages/angular/build/src/utils/server-rendering/render-page.ts b/packages/angular/build/src/utils/server-rendering/render-page.ts new file mode 100644 index 000000000000..aaf4509c35a2 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/render-page.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ApplicationRef, StaticProvider } from '@angular/core'; +import assert from 'node:assert'; +import { basename } from 'node:path'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; +import { MainServerBundleExports, RenderUtilsServerBundleExports } from './main-bundle-exports'; + +export interface RenderOptions { + route: string; + serverContext: ServerContext; + outputFiles: Record; + document: string; + inlineCriticalCss?: boolean; + loadBundle?: ((path: './main.server.mjs') => Promise) & + ((path: './render-utils.server.mjs') => Promise); +} + +export interface RenderResult { + errors?: string[]; + warnings?: string[]; + content?: string; +} + +export type ServerContext = 'app-shell' | 'ssg' | 'ssr'; + +/** + * Renders each route in routes and writes them to //index.html. + */ +export async function renderPage({ + route, + serverContext, + document, + inlineCriticalCss, + outputFiles, + loadBundle = loadEsmModuleFromMemory, +}: RenderOptions): Promise { + const { default: bootstrapAppFnOrModule } = await loadBundle('./main.server.mjs'); + const { ɵSERVER_CONTEXT, renderModule, renderApplication, ɵresetCompiledComponents, ɵConsole } = + await loadBundle('./render-utils.server.mjs'); + + // Need to clean up GENERATED_COMP_IDS map in `@angular/core`. + // Otherwise an incorrect component ID generation collision detected warning will be displayed in development. + // See: https://github.com/angular/angular-cli/issues/25924 + ɵresetCompiledComponents?.(); + + const platformProviders: StaticProvider[] = [ + { + provide: ɵSERVER_CONTEXT, + useValue: serverContext, + }, + { + provide: ɵConsole, + /** An Angular Console Provider that does not print a set of predefined logs. */ + useFactory: () => { + class Console extends ɵConsole { + private readonly ignoredLogs = new Set(['Angular is running in development mode.']); + override log(message: string): void { + if (!this.ignoredLogs.has(message)) { + super.log(message); + } + } + } + + return new Console(); + }, + }, + ]; + + assert( + bootstrapAppFnOrModule, + 'The file "./main.server.mjs" does not have a default export for an AppServerModule or a bootstrapping function.', + ); + + let renderAppPromise: Promise; + if (isBootstrapFn(bootstrapAppFnOrModule)) { + renderAppPromise = renderApplication(bootstrapAppFnOrModule, { + document, + url: route, + platformProviders, + }); + } else { + renderAppPromise = renderModule(bootstrapAppFnOrModule, { + document, + url: route, + extraProviders: platformProviders, + }); + } + + // The below should really handled by the framework!!!. + // See: https://github.com/angular/angular/issues/51549 + let timer: NodeJS.Timeout; + const renderingTimeout = new Promise( + (_, reject) => + (timer = setTimeout( + () => + reject( + new Error( + `Page ${new URL(route, 'resolve://').pathname} did not render in 30 seconds.`, + ), + ), + 30_000, + )), + ); + + const html = await Promise.race([renderAppPromise, renderingTimeout]).finally(() => + clearTimeout(timer), + ); + + if (inlineCriticalCss) { + const { InlineCriticalCssProcessor } = await import( + '../../utils/index-file/inline-critical-css' + ); + + const inlineCriticalCssProcessor = new InlineCriticalCssProcessor({ + minify: false, // CSS has already been minified during the build. + readAsset: async (filePath) => { + filePath = basename(filePath); + const content = outputFiles[filePath]; + if (content === undefined) { + throw new Error(`Output file does not exist: ${filePath}`); + } + + return content; + }, + }); + + return inlineCriticalCssProcessor.process(html, { outputPath: '' }); + } + + return { + content: html, + }; +} + +function isBootstrapFn(value: unknown): value is () => Promise { + // We can differentiate between a module and a bootstrap function by reading compiler-generated `ɵmod` static property: + return typeof value === 'function' && !('ɵmod' in value); +} diff --git a/packages/angular/build/src/utils/server-rendering/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts new file mode 100644 index 000000000000..e7e439838a21 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { workerData } from 'node:worker_threads'; +import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; +import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; +import { RenderResult, ServerContext, renderPage } from './render-page'; + +export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { + document: string; + inlineCriticalCss?: boolean; + assetFiles: Record; +} + +export interface RenderOptions { + route: string; + serverContext: ServerContext; +} + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { outputFiles, document, inlineCriticalCss } = workerData as RenderWorkerData; + +/** Renders an application based on a provided options. */ +function render(options: RenderOptions): Promise { + return renderPage({ + ...options, + outputFiles, + document, + inlineCriticalCss, + }); +} + +function initialize() { + patchFetchToLoadInMemoryAssets(); + + return render; +} + +export default initialize(); diff --git a/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts new file mode 100644 index 000000000000..44dbfb3cb2e3 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { workerData } from 'node:worker_threads'; +import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; +import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; + +export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { + document: string; + verbose: boolean; + assetFiles: Record; +} + +export interface RoutersExtractorWorkerResult { + routes: string[]; + warnings?: string[]; +} + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { document, verbose } = workerData as RoutesExtractorWorkerData; + +/** Renders an application based on a provided options. */ +async function extractRoutes(): Promise { + const { extractRoutes } = await loadEsmModuleFromMemory('./render-utils.server.mjs'); + const { default: bootstrapAppFnOrModule } = await loadEsmModuleFromMemory('./main.server.mjs'); + + const skippedRedirects: string[] = []; + const skippedOthers: string[] = []; + const routes: string[] = []; + + for await (const { route, success, redirect } of extractRoutes( + bootstrapAppFnOrModule, + document, + )) { + if (success) { + routes.push(route); + continue; + } + + if (redirect) { + skippedRedirects.push(route); + } else { + skippedOthers.push(route); + } + } + + if (!verbose) { + return { routes }; + } + + let warnings: string[] | undefined; + if (skippedOthers.length) { + (warnings ??= []).push( + 'The following routes were skipped from prerendering because they contain routes with dynamic parameters:\n' + + skippedOthers.join('\n'), + ); + } + + if (skippedRedirects.length) { + (warnings ??= []).push( + 'The following routes were skipped from prerendering because they contain redirects:\n', + skippedRedirects.join('\n'), + ); + } + + return { routes, warnings }; +} + +function initialize() { + patchFetchToLoadInMemoryAssets(); + + return extractRoutes; +} + +export default initialize(); diff --git a/packages/angular/build/src/utils/service-worker.ts b/packages/angular/build/src/utils/service-worker.ts new file mode 100644 index 000000000000..96447012652f --- /dev/null +++ b/packages/angular/build/src/utils/service-worker.ts @@ -0,0 +1,255 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Config, Filesystem } from '@angular/service-worker/config'; +import * as crypto from 'crypto'; +import { existsSync, constants as fsConstants, promises as fsPromises } from 'node:fs'; +import * as path from 'path'; +import { BuildOutputFile, BuildOutputFileType } from '../tools/esbuild/bundler-context'; +import { BuildOutputAsset } from '../tools/esbuild/bundler-execution-result'; +import { assertIsError } from './error'; +import { loadEsmModule } from './load-esm'; + +class CliFilesystem implements Filesystem { + constructor( + private fs: typeof fsPromises, + private base: string, + ) {} + + list(dir: string): Promise { + return this._recursiveList(this._resolve(dir), []); + } + + read(file: string): Promise { + return this.fs.readFile(this._resolve(file), 'utf-8'); + } + + async hash(file: string): Promise { + return crypto + .createHash('sha1') + .update(await this.fs.readFile(this._resolve(file))) + .digest('hex'); + } + + write(_file: string, _content: string): never { + throw new Error('This should never happen.'); + } + + private _resolve(file: string): string { + return path.join(this.base, file); + } + + private async _recursiveList(dir: string, items: string[]): Promise { + const subdirectories = []; + for (const entry of await this.fs.readdir(dir)) { + const entryPath = path.join(dir, entry); + const stats = await this.fs.stat(entryPath); + + if (stats.isFile()) { + // Uses posix paths since the service worker expects URLs + items.push('/' + path.relative(this.base, entryPath).replace(/\\/g, '/')); + } else if (stats.isDirectory()) { + subdirectories.push(entryPath); + } + } + + for (const subdirectory of subdirectories) { + await this._recursiveList(subdirectory, items); + } + + return items; + } +} + +class ResultFilesystem implements Filesystem { + private readonly fileReaders = new Map Promise>(); + + constructor( + outputFiles: BuildOutputFile[], + assetFiles: { source: string; destination: string }[], + ) { + for (const file of outputFiles) { + if (file.type === BuildOutputFileType.Media || file.type === BuildOutputFileType.Browser) { + this.fileReaders.set('/' + file.path.replace(/\\/g, '/'), async () => file.contents); + } + } + for (const file of assetFiles) { + this.fileReaders.set('/' + file.destination.replace(/\\/g, '/'), () => + fsPromises.readFile(file.source), + ); + } + } + + async list(dir: string): Promise { + if (dir !== '/') { + throw new Error('Serviceworker manifest generator should only list files from root.'); + } + + return [...this.fileReaders.keys()]; + } + + async read(file: string): Promise { + const reader = this.fileReaders.get(file); + if (reader === undefined) { + throw new Error('File does not exist.'); + } + const contents = await reader(); + + return Buffer.from(contents.buffer, contents.byteOffset, contents.byteLength).toString('utf-8'); + } + + async hash(file: string): Promise { + const reader = this.fileReaders.get(file); + if (reader === undefined) { + throw new Error('File does not exist.'); + } + + return crypto + .createHash('sha1') + .update(await reader()) + .digest('hex'); + } + + write(): never { + throw new Error('Serviceworker manifest generator should not attempted to write.'); + } +} + +export async function augmentAppWithServiceWorker( + appRoot: string, + workspaceRoot: string, + outputPath: string, + baseHref: string, + ngswConfigPath?: string, + inputputFileSystem = fsPromises, + outputFileSystem = fsPromises, +): Promise { + // Determine the configuration file path + const configPath = ngswConfigPath + ? path.join(workspaceRoot, ngswConfigPath) + : path.join(appRoot, 'ngsw-config.json'); + + // Read the configuration file + let config: Config | undefined; + try { + const configurationData = await inputputFileSystem.readFile(configPath, 'utf-8'); + config = JSON.parse(configurationData) as Config; + } catch (error) { + assertIsError(error); + if (error.code === 'ENOENT') { + throw new Error( + 'Error: Expected to find an ngsw-config.json configuration file' + + ` in the ${appRoot} folder. Either provide one or` + + ' disable Service Worker in the angular.json configuration file.', + ); + } else { + throw error; + } + } + + const result = await augmentAppWithServiceWorkerCore( + config, + new CliFilesystem(outputFileSystem, outputPath), + baseHref, + ); + + const copy = async (src: string, dest: string): Promise => { + const resolvedDest = path.join(outputPath, dest); + + return inputputFileSystem === outputFileSystem + ? // Native FS (Builder). + inputputFileSystem.copyFile(src, resolvedDest, fsConstants.COPYFILE_FICLONE) + : // memfs (Webpack): Read the file from the input FS (disk) and write it to the output FS (memory). + outputFileSystem.writeFile(resolvedDest, await inputputFileSystem.readFile(src)); + }; + + await outputFileSystem.writeFile(path.join(outputPath, 'ngsw.json'), result.manifest); + + for (const { source, destination } of result.assetFiles) { + await copy(source, destination); + } +} + +// This is currently used by the esbuild-based builder +export async function augmentAppWithServiceWorkerEsbuild( + workspaceRoot: string, + configPath: string, + baseHref: string, + indexHtml: string | undefined, + outputFiles: BuildOutputFile[], + assetFiles: BuildOutputAsset[], +): Promise<{ manifest: string; assetFiles: BuildOutputAsset[] }> { + // Read the configuration file + let config: Config | undefined; + try { + const configurationData = await fsPromises.readFile(configPath, 'utf-8'); + config = JSON.parse(configurationData) as Config; + + if (indexHtml) { + config.index = indexHtml; + } + } catch (error) { + assertIsError(error); + if (error.code === 'ENOENT') { + // TODO: Generate an error object that can be consumed by the esbuild-based builder + const message = `Service worker configuration file "${path.relative( + workspaceRoot, + configPath, + )}" could not be found.`; + throw new Error(message); + } else { + throw error; + } + } + + return augmentAppWithServiceWorkerCore( + config, + new ResultFilesystem(outputFiles, assetFiles), + baseHref, + ); +} + +export async function augmentAppWithServiceWorkerCore( + config: Config, + serviceWorkerFilesystem: Filesystem, + baseHref: string, +): Promise<{ manifest: string; assetFiles: { source: string; destination: string }[] }> { + // Load ESM `@angular/service-worker/config` using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + const GeneratorConstructor = ( + await loadEsmModule( + '@angular/service-worker/config', + ) + ).Generator; + + // Generate the manifest + const generator = new GeneratorConstructor(serviceWorkerFilesystem, baseHref); + const output = await generator.process(config); + + // Write the manifest + const manifest = JSON.stringify(output, null, 2); + + // Find the service worker package + const workerPath = require.resolve('@angular/service-worker/ngsw-worker.js'); + + const result = { + manifest, + // Main worker code + assetFiles: [{ source: workerPath, destination: 'ngsw-worker.js' }], + }; + + // If present, write the safety worker code + const safetyPath = path.join(path.dirname(workerPath), 'safety-worker.js'); + if (existsSync(safetyPath)) { + result.assetFiles.push({ source: safetyPath, destination: 'worker-basic.min.js' }); + result.assetFiles.push({ source: safetyPath, destination: 'safety-worker.js' }); + } + + return result; +} diff --git a/packages/angular/cli/utilities/spinner.ts b/packages/angular/build/src/utils/spinner.ts similarity index 77% rename from packages/angular/cli/utilities/spinner.ts rename to packages/angular/build/src/utils/spinner.ts index 3deda119aee5..f567cac09fdc 100644 --- a/packages/angular/cli/utilities/spinner.ts +++ b/packages/angular/build/src/utils/spinner.ts @@ -3,25 +3,28 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import ora from 'ora'; import { colors } from './color'; +import { isTTY } from './tty'; export class Spinner { private readonly spinner: ora.Ora; /** When false, only fail messages will be displayed. */ enabled = true; + readonly #isTTY = isTTY(); constructor(text?: string) { this.spinner = ora({ - text, + text: text === undefined ? undefined : text + '\n', // The below 2 options are needed because otherwise CTRL+C will be delayed // when the underlying process is sync. hideCursor: false, discardStdin: false, + isEnabled: this.#isTTY, }); } @@ -29,24 +32,20 @@ export class Spinner { this.spinner.text = text; } + get isSpinning(): boolean { + return this.spinner.isSpinning || !this.#isTTY; + } + succeed(text?: string): void { if (this.enabled) { this.spinner.succeed(text); } } - info(text?: string): void { - this.spinner.info(text); - } - fail(text?: string): void { this.spinner.fail(text && colors.redBright(text)); } - warn(text?: string): void { - this.spinner.warn(text && colors.yellowBright(text)); - } - stop(): void { this.spinner.stop(); } diff --git a/packages/angular/build/src/utils/stats-table.ts b/packages/angular/build/src/utils/stats-table.ts new file mode 100644 index 000000000000..b007fd7a4aa5 --- /dev/null +++ b/packages/angular/build/src/utils/stats-table.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { stripVTControlCharacters } from 'node:util'; +import { BudgetCalculatorResult } from './bundle-calculator'; +import { colors as ansiColors } from './color'; +import { formatSize } from './format-bytes'; + +export type BundleStatsData = [ + files: string, + names: string, + rawSize: number | string, + estimatedTransferSize: number | string, +]; +export interface BundleStats { + initial: boolean; + stats: BundleStatsData; +} + +export function generateEsbuildBuildStatsTable( + [browserStats, serverStats]: [browserStats: BundleStats[], serverStats: BundleStats[]], + colors: boolean, + showTotalSize: boolean, + showEstimatedTransferSize: boolean, + budgetFailures?: BudgetCalculatorResult[], + verbose?: boolean, +): string { + const bundleInfo = generateBuildStatsData( + browserStats, + colors, + showTotalSize, + showEstimatedTransferSize, + budgetFailures, + verbose, + ); + + if (serverStats.length) { + const m = (x: string) => (colors ? ansiColors.magenta(x) : x); + if (browserStats.length) { + bundleInfo.unshift([m('Browser bundles')]); + // Add seperators between browser and server logs + bundleInfo.push([], []); + } + + bundleInfo.push( + [m('Server bundles')], + ...generateBuildStatsData(serverStats, colors, false, false, undefined, verbose), + ); + } + + return generateTableText(bundleInfo, colors); +} + +export function generateBuildStatsTable( + data: BundleStats[], + colors: boolean, + showTotalSize: boolean, + showEstimatedTransferSize: boolean, + budgetFailures?: BudgetCalculatorResult[], +): string { + const bundleInfo = generateBuildStatsData( + data, + colors, + showTotalSize, + showEstimatedTransferSize, + budgetFailures, + true, + ); + + return generateTableText(bundleInfo, colors); +} + +function generateBuildStatsData( + data: BundleStats[], + colors: boolean, + showTotalSize: boolean, + showEstimatedTransferSize: boolean, + budgetFailures?: BudgetCalculatorResult[], + verbose?: boolean, +): (string | number)[][] { + if (data.length === 0) { + return []; + } + + const g = (x: string) => (colors ? ansiColors.green(x) : x); + const c = (x: string) => (colors ? ansiColors.cyan(x) : x); + const r = (x: string) => (colors ? ansiColors.redBright(x) : x); + const y = (x: string) => (colors ? ansiColors.yellowBright(x) : x); + const bold = (x: string) => (colors ? ansiColors.bold(x) : x); + const dim = (x: string) => (colors ? ansiColors.dim(x) : x); + + const getSizeColor = (name: string, file?: string, defaultColor = c) => { + const severity = budgets.get(name) || (file && budgets.get(file)); + switch (severity) { + case 'warning': + return y; + case 'error': + return r; + default: + return defaultColor; + } + }; + + const changedEntryChunksStats: BundleStatsData[] = []; + const changedLazyChunksStats: BundleStatsData[] = []; + + let initialTotalRawSize = 0; + let changedLazyChunksCount = 0; + let initialTotalEstimatedTransferSize; + const maxLazyChunksWithoutBudgetFailures = 15; + + const budgets = new Map(); + if (budgetFailures) { + for (const { label, severity } of budgetFailures) { + // In some cases a file can have multiple budget failures. + // Favor error. + if (label && (!budgets.has(label) || budgets.get(label) === 'warning')) { + budgets.set(label, severity); + } + } + } + + // Sort descending by raw size + data.sort((a, b) => { + if (a.stats[2] > b.stats[2]) { + return -1; + } + + if (a.stats[2] < b.stats[2]) { + return 1; + } + + return 0; + }); + + for (const { initial, stats } of data) { + const [files, names, rawSize, estimatedTransferSize] = stats; + if ( + !initial && + !verbose && + changedLazyChunksStats.length >= maxLazyChunksWithoutBudgetFailures && + !budgets.has(names) && + !budgets.has(files) + ) { + // Limit the number of lazy chunks displayed in the stats table when there is no budget failure and not in verbose mode. + changedLazyChunksCount++; + continue; + } + + const getRawSizeColor = getSizeColor(names, files); + let data: BundleStatsData; + if (showEstimatedTransferSize) { + data = [ + g(files), + dim(names), + getRawSizeColor(typeof rawSize === 'number' ? formatSize(rawSize) : rawSize), + c( + typeof estimatedTransferSize === 'number' + ? formatSize(estimatedTransferSize) + : estimatedTransferSize, + ), + ]; + } else { + data = [ + g(files), + dim(names), + getRawSizeColor(typeof rawSize === 'number' ? formatSize(rawSize) : rawSize), + '', + ]; + } + + if (initial) { + changedEntryChunksStats.push(data); + if (typeof rawSize === 'number') { + initialTotalRawSize += rawSize; + } + if (showEstimatedTransferSize && typeof estimatedTransferSize === 'number') { + if (initialTotalEstimatedTransferSize === undefined) { + initialTotalEstimatedTransferSize = 0; + } + initialTotalEstimatedTransferSize += estimatedTransferSize; + } + } else { + changedLazyChunksStats.push(data); + changedLazyChunksCount++; + } + } + + const bundleInfo: (string | number)[][] = []; + const baseTitles = ['Names', 'Raw size']; + + if (showEstimatedTransferSize) { + baseTitles.push('Estimated transfer size'); + } + + // Entry chunks + if (changedEntryChunksStats.length) { + bundleInfo.push(['Initial chunk files', ...baseTitles].map(bold), ...changedEntryChunksStats); + + if (showTotalSize) { + const initialSizeTotalColor = getSizeColor('bundle initial', undefined, (x) => x); + const totalSizeElements = [ + ' ', + 'Initial total', + initialSizeTotalColor(formatSize(initialTotalRawSize)), + ]; + if (showEstimatedTransferSize) { + totalSizeElements.push( + typeof initialTotalEstimatedTransferSize === 'number' + ? formatSize(initialTotalEstimatedTransferSize) + : '-', + ); + } + bundleInfo.push([], totalSizeElements.map(bold)); + } + } + + // Seperator + if (changedEntryChunksStats.length && changedLazyChunksStats.length) { + bundleInfo.push([]); + } + + // Lazy chunks + if (changedLazyChunksStats.length) { + bundleInfo.push(['Lazy chunk files', ...baseTitles].map(bold), ...changedLazyChunksStats); + + if (changedLazyChunksCount > changedLazyChunksStats.length) { + bundleInfo.push([ + dim( + `...and ${changedLazyChunksCount - changedLazyChunksStats.length} more lazy chunks files. ` + + 'Use "--verbose" to show all the files.', + ), + ]); + } + } + + return bundleInfo; +} + +function generateTableText(bundleInfo: (string | number)[][], colors: boolean): string { + const skipText = (value: string) => value.includes('...and '); + const longest: number[] = []; + for (const item of bundleInfo) { + for (let i = 0; i < item.length; i++) { + if (item[i] === undefined) { + continue; + } + + const currentItem = item[i].toString(); + if (skipText(currentItem)) { + continue; + } + + const currentLongest = (longest[i] ??= 0); + const currentItemLength = stripVTControlCharacters(currentItem).length; + if (currentLongest < currentItemLength) { + longest[i] = currentItemLength; + } + } + } + + const seperator = colors ? ansiColors.dim(' | ') : ' | '; + const outputTable: string[] = []; + for (const item of bundleInfo) { + for (let i = 0; i < longest.length; i++) { + if (item[i] === undefined) { + continue; + } + + const currentItem = item[i].toString(); + if (skipText(currentItem)) { + continue; + } + + const currentItemLength = stripVTControlCharacters(currentItem).length; + const stringPad = ' '.repeat(longest[i] - currentItemLength); + // Values in columns at index 2 and 3 (Raw and Estimated sizes) are always right aligned. + item[i] = i >= 2 ? stringPad + currentItem : currentItem + stringPad; + } + + outputTable.push(item.join(seperator)); + } + + return outputTable.join('\n'); +} diff --git a/packages/angular/build/src/utils/supported-browsers.ts b/packages/angular/build/src/utils/supported-browsers.ts new file mode 100644 index 000000000000..79674a62beae --- /dev/null +++ b/packages/angular/build/src/utils/supported-browsers.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import browserslist from 'browserslist'; + +export function getSupportedBrowsers( + projectRoot: string, + logger: { warn(message: string): void }, +): string[] { + browserslist.defaults = [ + 'last 2 Chrome versions', + 'last 1 Firefox version', + 'last 2 Edge major versions', + 'last 2 Safari major versions', + 'last 2 iOS major versions', + 'Firefox ESR', + ]; + + // Get browsers from config or default. + const browsersFromConfigOrDefault = new Set(browserslist(undefined, { path: projectRoot })); + + // Get browsers that support ES6 modules. + const browsersThatSupportEs6 = new Set(browserslist('supports es6-module')); + + const unsupportedBrowsers: string[] = []; + for (const browser of browsersFromConfigOrDefault) { + if (!browsersThatSupportEs6.has(browser)) { + browsersFromConfigOrDefault.delete(browser); + unsupportedBrowsers.push(browser); + } + } + + if (unsupportedBrowsers.length) { + logger.warn( + `One or more browsers which are configured in the project's Browserslist configuration ` + + 'will be ignored as ES5 output is not supported by the Angular CLI.\n' + + `Ignored browsers: ${unsupportedBrowsers.join(', ')}`, + ); + } + + return Array.from(browsersFromConfigOrDefault); +} diff --git a/packages/angular/build/src/utils/tty.ts b/packages/angular/build/src/utils/tty.ts new file mode 100644 index 000000000000..0d669c0301e3 --- /dev/null +++ b/packages/angular/build/src/utils/tty.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +function _isTruthy(value: undefined | string): boolean { + // Returns true if value is a string that is anything but 0 or false. + return value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE'; +} + +export function isTTY(): boolean { + // If we force TTY, we always return true. + const force = process.env['NG_FORCE_TTY']; + if (force !== undefined) { + return _isTruthy(force); + } + + return !!process.stdout.isTTY && !_isTruthy(process.env['CI']); +} diff --git a/packages/angular/build/src/utils/url.ts b/packages/angular/build/src/utils/url.ts new file mode 100644 index 000000000000..d3f1e5791276 --- /dev/null +++ b/packages/angular/build/src/utils/url.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export function urlJoin(...parts: string[]): string { + const [p, ...rest] = parts; + + // Remove trailing slash from first part + // Join all parts with `/` + // Dedupe double slashes from path names + return p.replace(/\/$/, '') + ('/' + rest.join('/')).replace(/\/\/+/g, '/'); +} diff --git a/packages/angular/build/src/utils/version.ts b/packages/angular/build/src/utils/version.ts new file mode 100644 index 000000000000..80c531336bcb --- /dev/null +++ b/packages/angular/build/src/utils/version.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* eslint-disable no-console */ + +import { createRequire } from 'node:module'; +import { SemVer, satisfies } from 'semver'; + +export function assertCompatibleAngularVersion(projectRoot: string): void | never { + let angularCliPkgJson; + let angularPkgJson; + + // Create a custom require function for ESM compliance. + // NOTE: The trailing slash is significant. + const projectRequire = createRequire(projectRoot + '/'); + + try { + const angularPackagePath = projectRequire.resolve('@angular/core/package.json'); + + angularPkgJson = projectRequire(angularPackagePath); + } catch { + console.error('You seem to not be depending on "@angular/core". This is an error.'); + + process.exit(2); + } + + if (!(angularPkgJson && angularPkgJson['version'])) { + console.error( + 'Cannot determine versions of "@angular/core".\n' + + 'This likely means your local installation is broken. Please reinstall your packages.', + ); + + process.exit(2); + } + + try { + const angularCliPkgPath = projectRequire.resolve('@angular/cli/package.json'); + angularCliPkgJson = projectRequire(angularCliPkgPath); + if (!(angularCliPkgJson && angularCliPkgJson['version'])) { + return; + } + } catch { + // Not using @angular-devkit/build-angular with @angular/cli is ok too. + // In this case we don't provide as many version checks. + return; + } + + if (angularCliPkgJson['version'] === '0.0.0' || angularPkgJson['version'] === '0.0.0') { + // Internal CLI testing version or integration testing in the angular/angular + // repository with the generated development @angular/core npm package which is versioned "0.0.0". + return; + } + + let supportedAngularSemver; + try { + supportedAngularSemver = projectRequire('@angular/build/package.json')['peerDependencies'][ + '@angular/compiler-cli' + ]; + } catch { + supportedAngularSemver = projectRequire('@angular-devkit/build-angular/package.json')[ + 'peerDependencies' + ]['@angular/compiler-cli']; + } + + const angularVersion = new SemVer(angularPkgJson['version']); + + if (!satisfies(angularVersion, supportedAngularSemver, { includePrerelease: true })) { + console.error( + `This version of CLI is only compatible with Angular versions ${supportedAngularSemver},\n` + + `but Angular version ${angularVersion} was found instead.\n` + + 'Please visit the link below to find instructions on how to update Angular.\nhttps://update.angular.dev/', + ); + + process.exit(3); + } +} diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index ba4a82bd9839..252cd6c77df3 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -1,14 +1,14 @@ # Copyright Google Inc. All Rights Reserved. # # Use of this source code is governed by an MIT-style license that can be -# found in the LICENSE file at https://angular.io/license +# found in the LICENSE file at https://angular.dev/license load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test") -load("//tools:ts_json_schema.bzl", "ts_json_schema") -load("//tools:ng_cli_schema_generator.bzl", "cli_json_schema") load("//tools:defaults.bzl", "pkg_npm", "ts_library") +load("//tools:ng_cli_schema_generator.bzl", "cli_json_schema") +load("//tools:ts_json_schema.bzl", "ts_json_schema") -licenses(["notice"]) # MIT +licenses(["notice"]) package(default_visibility = ["//visibility:public"]) @@ -16,53 +16,35 @@ ts_library( name = "angular-cli", package_name = "@angular/cli", srcs = glob( - include = ["**/*.ts"], + include = [ + "lib/**/*.ts", + "src/**/*.ts", + ], exclude = [ "**/*_spec.ts", - # NB: we need to exclude the nested node_modules that is laid out by yarn workspaces - "node_modules/**", ], ) + [ # @external_begin # These files are generated from the JSON schema "//packages/angular/cli:lib/config/workspace-schema.ts", - "//packages/angular/cli:commands/analytics.ts", - "//packages/angular/cli:commands/add.ts", - "//packages/angular/cli:commands/build.ts", - "//packages/angular/cli:commands/deploy.ts", - "//packages/angular/cli:commands/config.ts", - "//packages/angular/cli:commands/doc.ts", - "//packages/angular/cli:commands/e2e.ts", - "//packages/angular/cli:commands/easter-egg.ts", - "//packages/angular/cli:commands/generate.ts", - "//packages/angular/cli:commands/help.ts", - "//packages/angular/cli:commands/lint.ts", - "//packages/angular/cli:commands/new.ts", - "//packages/angular/cli:commands/serve.ts", - "//packages/angular/cli:commands/test.ts", - "//packages/angular/cli:commands/update.ts", - "//packages/angular/cli:commands/version.ts", - "//packages/angular/cli:commands/run.ts", - "//packages/angular/cli:commands/extract-i18n.ts", "//packages/angular/cli:src/commands/update/schematic/schema.ts", # @external_end ], data = glob( include = [ "bin/**/*", - "**/*.json", - "**/*.md", + "src/**/*.md", + "src/**/*.json", ], exclude = [ - # NB: we need to exclude the nested node_modules that is laid out by yarn workspaces - "node_modules/**", "lib/config/workspace-schema.json", ], ) + [ + # @external_begin "//packages/angular/cli:lib/config/schema.json", + # @external_end ], module_name = "@angular/cli", - # strict_checks = False, deps = [ "//packages/angular_devkit/architect", "//packages/angular_devkit/architect/node", @@ -72,32 +54,43 @@ ts_library( "//packages/angular_devkit/schematics/tasks", "//packages/angular_devkit/schematics/tools", "@npm//@angular/core", - "@npm//@types/debug", - "@npm//@types/inquirer", + "@npm//@inquirer/prompts", + "@npm//@listr2/prompt-adapter-inquirer", + "@npm//@types/ini", "@npm//@types/node", "@npm//@types/npm-package-arg", + "@npm//@types/pacote", "@npm//@types/resolve", "@npm//@types/semver", - "@npm//@types/uuid", + "@npm//@types/yargs", + "@npm//@types/yarnpkg__lockfile", "@npm//@yarnpkg/lockfile", - "@npm//ansi-colors", "@npm//ini", "@npm//jsonc-parser", + "@npm//listr2", "@npm//npm-package-arg", - "@npm//open", - "@npm//ora", "@npm//pacote", "@npm//semver", + "@npm//yargs", ], ) +# @external_begin CLI_SCHEMA_DATA = [ + "//packages/angular/build:src/builders/application/schema.json", + "//packages/angular/build:src/builders/dev-server/schema.json", + "//packages/angular/build:src/builders/extract-i18n/schema.json", "//packages/angular_devkit/build_angular:src/builders/app-shell/schema.json", "//packages/angular_devkit/build_angular:src/builders/browser/schema.json", + "//packages/angular_devkit/build_angular:src/builders/browser-esbuild/schema.json", "//packages/angular_devkit/build_angular:src/builders/dev-server/schema.json", "//packages/angular_devkit/build_angular:src/builders/extract-i18n/schema.json", + "//packages/angular_devkit/build_angular:src/builders/jest/schema.json", + "//packages/angular_devkit/build_angular:src/builders/web-test-runner/schema.json", "//packages/angular_devkit/build_angular:src/builders/karma/schema.json", "//packages/angular_devkit/build_angular:src/builders/ng-packagr/schema.json", + "//packages/angular_devkit/build_angular:src/builders/prerender/schema.json", + "//packages/angular_devkit/build_angular:src/builders/ssr-dev-server/schema.json", "//packages/angular_devkit/build_angular:src/builders/protractor/schema.json", "//packages/angular_devkit/build_angular:src/builders/server/schema.json", "//packages/schematics/angular:app-shell/schema.json", @@ -132,150 +125,6 @@ ts_json_schema( data = CLI_SCHEMA_DATA, ) -ts_json_schema( - name = "analytics_schema", - src = "commands/analytics.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "add_schema", - src = "commands/add.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "build_schema", - src = "commands/build.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "deploy_schema", - src = "commands/deploy.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "config_schema", - src = "commands/config.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "doc_schema", - src = "commands/doc.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "e2e_schema", - src = "commands/e2e.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "easter_egg_schema", - src = "commands/easter-egg.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "generate_schema", - src = "commands/generate.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "help_schema", - src = "commands/help.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "lint_schema", - src = "commands/lint.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "new_schema", - src = "commands/new.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "run_schema", - src = "commands/run.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "serve_schema", - src = "commands/serve.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "test_schema", - src = "commands/test.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "update_schema", - src = "commands/update.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "version_schema", - src = "commands/version.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "extract-i18n_schema", - src = "commands/extract-i18n.json", - data = [ - "commands/definitions.json", - ], -) - ts_json_schema( name = "update_schematic_schema", src = "src/commands/update/schematic/schema.json", @@ -291,14 +140,12 @@ ts_library( "node_modules/**", ], ), - # strict_checks = False, deps = [ ":angular-cli", "//packages/angular_devkit/core", "//packages/angular_devkit/schematics", "//packages/angular_devkit/schematics/testing", "@npm//@types/semver", - "@npm//rxjs", ], ) @@ -324,12 +171,13 @@ pkg_npm( "//packages/angular_devkit/schematics:package.json", "//packages/schematics/angular:package.json", ], + tags = ["release-package"], deps = [ ":README.md", ":angular-cli", ":license", ":src/commands/update/schematic/collection.json", ":src/commands/update/schematic/schema.json", - ":utilities/INITIAL_COMMIT_MESSAGE.txt", ], ) +# @external_end diff --git a/packages/angular/cli/README.md b/packages/angular/cli/README.md index ced76bc505d0..07b498c785dc 100644 --- a/packages/angular/cli/README.md +++ b/packages/angular/cli/README.md @@ -1,273 +1,5 @@ -## Angular CLI +# Angular CLI - The CLI tool for Angular. - +The sources for this package are in the [Angular CLI](https://github.com/angular/angular-cli) repository. Please file issues and pull requests against that repository. -[![Dependency Status][david-badge]][david-badge-url] -[![devDependency Status][david-dev-badge]][david-dev-badge-url] - -[![npm](https://img.shields.io/npm/v/%40angular/cli.svg)][npm-badge-url] -[![npm](https://img.shields.io/npm/v/%40angular/cli/next.svg)][npm-badge-url] -[![npm](https://img.shields.io/npm/l/@angular/cli.svg)][license-url] -[![npm](https://img.shields.io/npm/dm/@angular/cli.svg)][npm-badge-url] - -[![Join the chat at https://gitter.im/angular/angular-cli](https://img.shields.io/gitter/room/nwjs/nw.js.svg)](https://gitter.im/angular/angular-cli?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) - -[![GitHub forks](https://img.shields.io/github/forks/angular/angular-cli.svg?style=social&label=Fork)](https://github.com/angular/angular-cli/fork) -[![GitHub stars](https://img.shields.io/github/stars/angular/angular-cli.svg?style=social&label=Star)](https://github.com/angular/angular-cli) - -## Note - -If you are updating from a beta or RC version, check out our [1.0 Update Guide](https://github.com/angular/angular-cli/wiki/stories-1.0-update). - -If you wish to collaborate, check out [our issue list](https://github.com/angular/angular-cli/issues). - -Before submitting new issues, have a look at [issues marked with the `type: faq` label](https://github.com/angular/angular-cli/issues?utf8=%E2%9C%93&q=is%3Aissue%20label%3A%22type%3A%20faq%22%20). - -## Prerequisites - -Both the CLI and generated project have dependencies that require Node 8.9 or higher, together -with NPM 5.5.1 or higher. - -## Table of Contents - -- [Installation](#installation) -- [Usage](#usage) -- [Generating a New Project](#generating-and-serving-an-angular-project-via-a-development-server) -- [Generating Components, Directives, Pipes and Services](#generating-components-directives-pipes-and-services) -- [Updating Angular CLI](#updating-angular-cli) -- [Development Hints for working on Angular CLI](#development-hints-for-working-on-angular-cli) -- [Documentation](#documentation) -- [License](#license) - -## Installation - -**BEFORE YOU INSTALL:** please read the [prerequisites](#prerequisites) - -### Install Globally - -```bash -npm install -g @angular/cli -``` - -### Install Locally - -```bash -npm install @angular/cli -``` - -To run a locally installed version of the angular-cli, you can call `ng` commands directly by adding the `.bin` folder within your local `node_modules` folder to your PATH. The `node_modules` and `.bin` folders are created in the directory where `npm install @angular/cli` was run upon completion of the install command. - -Alternatively, you can install [npx](https://www.npmjs.com/package/npx) and run `npx ng ` within the local directory where `npm install @angular/cli` was run, which will use the locally installed angular-cli. - -### Install Specific Version (Example: 6.1.1) - -```bash -npm install -g @angular/cli@6.1.1 -``` - -## Usage - -```bash -ng help -``` - -### Generating and serving an Angular project via a development server - -```bash -ng new PROJECT-NAME -cd PROJECT-NAME -ng serve -``` - -Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. - -You can configure the default HTTP host and port used by the development server with two command-line options : - -```bash -ng serve --host 0.0.0.0 --port 4201 -``` - -### Generating Components, Directives, Pipes and Services - -You can use the `ng generate` (or just `ng g`) command to generate Angular components: - -```bash -ng generate component my-new-component -ng g component my-new-component # using the alias - -# components support relative path generation -# if in the directory src/app/feature/ and you run -ng g component new-cmp -# your component will be generated in src/app/feature/new-cmp -# but if you were to run -ng g component ./newer-cmp -# your component will be generated in src/app/newer-cmp -# if in the directory src/app you can also run -ng g component feature/new-cmp -# and your component will be generated in src/app/feature/new-cmp -``` - -You can find all possible blueprints in the table below: - -| Scaffold | Usage | -| ------------------------------------------------------ | --------------------------------- | -| [Component](https://angular.io/cli/generate#component) | `ng g component my-new-component` | -| [Directive](https://angular.io/cli/generate#directive) | `ng g directive my-new-directive` | -| [Pipe](https://angular.io/cli/generate#pipe) | `ng g pipe my-new-pipe` | -| [Service](https://angular.io/cli/generate#service) | `ng g service my-new-service` | -| [Class](https://angular.io/cli/generate#class) | `ng g class my-new-class` | -| [Guard](https://angular.io/cli/generate#guard) | `ng g guard my-new-guard` | -| [Interface](https://angular.io/cli/generate#interface) | `ng g interface my-new-interface` | -| [Enum](https://angular.io/cli/generate#enum) | `ng g enum my-new-enum` | -| [Module](https://angular.io/cli/generate#module) | `ng g module my-module` | - -angular-cli will add reference to `components`, `directives` and `pipes` automatically in the `app.module.ts`. If you need to add this references to another custom module, follow these steps: - -1. `ng g module new-module` to create a new module -2. call `ng g component new-module/new-component` - -This should add the new `component`, `directive` or `pipe` reference to the `new-module` you've created. - -### Updating Angular CLI - -If you're using Angular CLI `1.0.0-beta.28` or less, you need to uninstall `angular-cli` package. It should be done due to changing of package's name and scope from `angular-cli` to `@angular/cli`: - -```bash -npm uninstall -g angular-cli -npm uninstall --save-dev angular-cli -``` - -To update Angular CLI to a new version, you must update both the global package and your project's local package. - -Global package: - -```bash -npm uninstall -g @angular/cli -npm cache verify -# if npm version is < 5 then use `npm cache clean` -npm install -g @angular/cli@latest -``` - -Local project package: - -```bash -rm -rf node_modules dist # use rmdir /S/Q node_modules dist in Windows Command Prompt; use rm -r -fo node_modules,dist in Windows PowerShell -npm install --save-dev @angular/cli@latest -npm install -``` - -If you are updating to 1.0 from a beta or RC version, check out our [1.0 Update Guide](https://github.com/angular/angular-cli/wiki/stories-1.0-update). - -You can find more details about changes between versions in [the Releases tab on GitHub](https://github.com/angular/angular-cli/releases). - -## Development Hints for working on Angular CLI - -### Working with master - -```bash -git clone https://github.com/angular/angular-cli.git -yarn -npm run build -cd dist/@angular/cli -npm link -``` - -`npm link` is very similar to `npm install -g` except that instead of downloading the package -from the repo, the just built `dist/@angular/cli/` folder becomes the global package. -Additionally, this repository publishes several packages and we use special logic to load all of them -on development setups. - -Any changes to the files in the `angular-cli/` folder will immediately affect the global `@angular/cli` package, -meaning that, in order to quickly test any changes you make to the cli project, you should simply just run `npm run build` -again. - -Now you can use `@angular/cli` via the command line: - -```bash -ng new foo -cd foo -npm link @angular/cli -ng serve -``` - -`npm link @angular/cli` is needed because by default the globally installed `@angular/cli` just loads -the local `@angular/cli` from the project which was fetched remotely from npm. -`npm link @angular/cli` symlinks the global `@angular/cli` package to the local `@angular/cli` package. -Now the `angular-cli` you cloned before is in three places: -The folder you cloned it into, npm's folder where it stores global packages and the Angular CLI project you just created. - -You can also use `ng new foo --link-cli` to automatically link the `@angular/cli` package. - -Please read the official [npm-link documentation](https://docs.npmjs.com/cli/link) -and the [npm-link cheatsheet](http://browsenpm.org/help#linkinganynpmpackagelocally) for more information. - -To run the Angular CLI E2E test suite, use the `node ./tests/legacy-cli/run_e2e` command. -It can also receive a filename to only run that test (e.g. `node ./tests/legacy-cli/run_e2e tests/legacy-cli/e2e/tests/build/dev-build.ts`). - -As part of the test procedure, all packages will be built and linked. -You will need to re-run `npm link` to re-link the development Angular CLI environment after tests finish. - -### Debugging with VS Code - -In order to debug some Angular CLI behaviour using Visual Studio Code, you can run `npm run build`, and then use a launch configuration like the following: - -```json -{ - "type": "node", - "request": "launch", - "name": "ng serve", - "cwd": "", - "program": "${workspaceFolder}/dist/@angular/cli/bin/ng", - "args": [ - "", - ...other arguments - ], - "console": "integratedTerminal" -} -``` - -Then you can add breakpoints in `dist/@angular` files. - -For more informations about Node.js debugging in VS Code, see the related [VS Code Documentation](https://code.visualstudio.com/docs/nodejs/nodejs-debugging). - -### CPU Profiling - -In order to investigate performance issues, CPU profiling is often useful. - -#### Creating a profile - -Node.js 16+ users can use the Node.js command line argument `--cpu-prof` to create a CPU profile. - -```bash -node --cpu-prof node_modules/.bin/ng build -``` - -In addition to this one, another, more elaborated way to capture a CPU profile using the Chrome Devtools is detailed in https://github.com/angular/angular-cli/issues/8259#issue-269908550. - -#### Opening a profile - -You can use the Chrome Devtools to process it. To do so: - -1. open `chrome://inspect` in Chrome -1. click on "Open dedicated DevTools for Node" -1. go to the "profiler" tab -1. click on the "Load" button and select the generated `.cpuprofile` file -1. on the left panel, select the associated file - -## Documentation - -The documentation for the Angular CLI is located on our [documentation website](https://angular.io/cli). - -## License - -[MIT](https://github.com/angular/angular-cli/blob/master/LICENSE) - -[travis-badge]: https://travis-ci.org/angular/angular-cli.svg?branch=master -[travis-badge-url]: https://travis-ci.org/angular/angular-cli -[david-badge]: https://david-dm.org/angular/angular-cli.svg -[david-badge-url]: https://david-dm.org/angular/angular-cli -[david-dev-badge]: https://david-dm.org/angular/angular-cli/dev-status.svg -[david-dev-badge-url]: https://david-dm.org/angular/angular-cli?type=dev -[npm-badge]: https://img.shields.io/npm/v/@angular/cli.svg -[npm-badge-url]: https://www.npmjs.com/package/@angular/cli -[license-url]: https://github.com/angular/angular-cli/blob/master/LICENSE +Usage information and reference details can be found in repository [README](../../../README.md) file. diff --git a/packages/angular/cli/bin/bootstrap.js b/packages/angular/cli/bin/bootstrap.js index 75e454ee74ff..96b978296dcc 100644 --- a/packages/angular/cli/bin/bootstrap.js +++ b/packages/angular/cli/bin/bootstrap.js @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ /** diff --git a/packages/angular/cli/bin/ng.js b/packages/angular/cli/bin/ng.js index c90124127248..8c39f94f8408 100755 --- a/packages/angular/cli/bin/ng.js +++ b/packages/angular/cli/bin/ng.js @@ -4,13 +4,23 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ /* eslint-disable no-console */ /* eslint-disable import/no-unassigned-import */ 'use strict'; +const path = require('path'); + +// Error if the external CLI appears to be used inside a google3 context. +if (process.cwd().split(path.sep).includes('google3')) { + console.error( + 'This is the external Angular CLI, but you appear to be running in google3. There is a separate, internal version of the CLI which should be used instead. See http://go/angular/cli.', + ); + process.exit(); +} + // Provide a title to the process in `ps`. // Due to an obscure Mac bug, do not start this title with any symbol. try { @@ -20,35 +30,38 @@ try { process.title = 'ng'; } +const rawCommandName = process.argv[2]; + +if (rawCommandName === '--get-yargs-completions' || rawCommandName === 'completion') { + // Skip Node.js supported checks when running ng completion. + // A warning at this stage could cause a broken source action (`source <(ng completion script)`) when in the shell init script. + require('./bootstrap'); + + return; +} + // This node version check ensures that extremely old versions of node are not used. // These may not support ES2015 features such as const/let/async/await/etc. // These would then crash with a hard to diagnose error message. var version = process.versions.node.split('.').map((part) => Number(part)); -if (version[0] % 2 === 1 && version[0] > 16) { +if (version[0] % 2 === 1) { // Allow new odd numbered releases with a warning (currently v17+) console.warn( 'Node.js version ' + process.version + ' detected.\n' + 'Odd numbered Node.js versions will not enter LTS status and should not be used for production.' + - ' For more information, please see https://nodejs.org/en/about/releases/.', + ' For more information, please see https://nodejs.org/en/about/previous-releases/.', ); require('./bootstrap'); -} else if ( - version[0] < 12 || - version[0] === 13 || - version[0] === 15 || - (version[0] === 12 && version[1] < 20) || - (version[0] === 14 && version[1] < 15) || - (version[0] === 16 && version[1] < 10) -) { - // Error and exit if less than 12.20 or 13.x or less than 14.15 or 15.x or less than 16.10 +} else if (version[0] < 18 || (version[0] === 18 && version[1] < 19)) { + // Error and exit if less than 18.19 console.error( 'Node.js version ' + process.version + ' detected.\n' + - 'The Angular CLI requires a minimum Node.js version of either v12.20, v14.15, or v16.10.\n\n' + + 'The Angular CLI requires a minimum Node.js version of v18.19.\n\n' + 'Please update your Node.js version or visit https://nodejs.org/ for additional instructions.\n', ); diff --git a/packages/angular/cli/bin/postinstall/analytics-prompt.js b/packages/angular/cli/bin/postinstall/analytics-prompt.js deleted file mode 100644 index d9e0b4873878..000000000000 --- a/packages/angular/cli/bin/postinstall/analytics-prompt.js +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -'use strict'; -// This file is ES5 because it needs to be executed as is. - -if ('NG_CLI_ANALYTICS' in process.env) { - return; -} - -try { - var analytics = require('../../models/analytics'); - - analytics - .hasGlobalAnalyticsConfiguration() - .then((hasGlobalConfig) => { - if (!hasGlobalConfig) { - return analytics.promptGlobalAnalytics(); - } - }) - .catch(() => {}); -} catch (_) {} diff --git a/packages/angular/cli/bin/postinstall/script.js b/packages/angular/cli/bin/postinstall/script.js deleted file mode 100644 index cbca12955ec0..000000000000 --- a/packages/angular/cli/bin/postinstall/script.js +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env node -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -'use strict'; - -// These should not fail but if they do they should not block installation of the package -try { - // eslint-disable-next-line import/no-unassigned-import - require('./analytics-prompt'); -} catch (_) {} diff --git a/packages/angular/cli/commands.json b/packages/angular/cli/commands.json deleted file mode 100644 index 0b65947a0647..000000000000 --- a/packages/angular/cli/commands.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "add": "./commands/add.json", - "analytics": "./commands/analytics.json", - "build": "./commands/build.json", - "config": "./commands/config.json", - "deploy": "./commands/deploy.json", - "doc": "./commands/doc.json", - "e2e": "./commands/e2e.json", - "extract-i18n": "./commands/extract-i18n.json", - "make-this-awesome": "./commands/easter-egg.json", - "generate": "./commands/generate.json", - "help": "./commands/help.json", - "lint": "./commands/lint.json", - "new": "./commands/new.json", - "run": "./commands/run.json", - "serve": "./commands/serve.json", - "test": "./commands/test.json", - "update": "./commands/update.json", - "version": "./commands/version.json" -} diff --git a/packages/angular/cli/commands/add-impl.ts b/packages/angular/cli/commands/add-impl.ts deleted file mode 100644 index a2cd46602e22..000000000000 --- a/packages/angular/cli/commands/add-impl.ts +++ /dev/null @@ -1,415 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { analytics, tags } from '@angular-devkit/core'; -import { NodePackageDoesNotSupportSchematics } from '@angular-devkit/schematics/tools'; -import npa from 'npm-package-arg'; -import { dirname, join } from 'path'; -import { intersects, prerelease, rcompare, satisfies, valid, validRange } from 'semver'; -import { PackageManager } from '../lib/config/workspace-schema'; -import { isPackageNameSafeForAnalytics } from '../models/analytics'; -import { Arguments } from '../models/interface'; -import { RunSchematicOptions, SchematicCommand } from '../models/schematic-command'; -import { colors } from '../utilities/color'; -import { installPackage, installTempPackage } from '../utilities/install-package'; -import { ensureCompatibleNpm, getPackageManager } from '../utilities/package-manager'; -import { - NgAddSaveDepedency, - PackageManifest, - fetchPackageManifest, - fetchPackageMetadata, -} from '../utilities/package-metadata'; -import { askConfirmation } from '../utilities/prompt'; -import { Spinner } from '../utilities/spinner'; -import { isTTY } from '../utilities/tty'; -import { Schema as AddCommandSchema } from './add'; - -/** - * The set of packages that should have certain versions excluded from consideration - * when attempting to find a compatible version for a package. - * The key is a package name and the value is a SemVer range of versions to exclude. - */ -const packageVersionExclusions: Record = { - // @angular/localize@9.x versions do not have peer dependencies setup - '@angular/localize': '9.x', -}; - -export class AddCommand extends SchematicCommand { - override readonly allowPrivateSchematics = true; - - override async initialize(options: AddCommandSchema & Arguments) { - if (options.registry) { - return super.initialize({ ...options, packageRegistry: options.registry }); - } else { - return super.initialize(options); - } - } - - // eslint-disable-next-line max-lines-per-function - async run(options: AddCommandSchema & Arguments) { - await ensureCompatibleNpm(this.context.root); - - if (!options.collection) { - this.logger.fatal( - `The "ng add" command requires a name argument to be specified eg. ` + - `${colors.yellow('ng add [name] ')}. For more details, use "ng help".`, - ); - - return 1; - } - - let packageIdentifier; - try { - packageIdentifier = npa(options.collection); - } catch (e) { - this.logger.error(e.message); - - return 1; - } - - if ( - packageIdentifier.name && - packageIdentifier.registry && - this.isPackageInstalled(packageIdentifier.name) - ) { - const validVersion = await this.isProjectVersionValid(packageIdentifier); - if (validVersion) { - // Already installed so just run schematic - this.logger.info('Skipping installation: Package already installed'); - - return this.executeSchematic(packageIdentifier.name, options['--']); - } - } - - const spinner = new Spinner(); - - spinner.start('Determining package manager...'); - const packageManager = await getPackageManager(this.context.root); - const usingYarn = packageManager === PackageManager.Yarn; - spinner.info(`Using package manager: ${colors.grey(packageManager)}`); - - if (packageIdentifier.name && packageIdentifier.type === 'tag' && !packageIdentifier.rawSpec) { - // only package name provided; search for viable version - // plus special cases for packages that did not have peer deps setup - spinner.start('Searching for compatible package version...'); - - let packageMetadata; - try { - packageMetadata = await fetchPackageMetadata(packageIdentifier.name, this.logger, { - registry: options.registry, - usingYarn, - verbose: options.verbose, - }); - } catch (e) { - spinner.fail('Unable to load package information from registry: ' + e.message); - - return 1; - } - - // Start with the version tagged as `latest` if it exists - const latestManifest = packageMetadata.tags['latest']; - if (latestManifest) { - packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version); - } - - // Adjust the version based on name and peer dependencies - if (latestManifest && Object.keys(latestManifest.peerDependencies).length === 0) { - if (latestManifest.name === '@angular/pwa') { - const version = await this.findProjectVersion('@angular/cli'); - const semverOptions = { includePrerelease: true }; - - if ( - version && - ((validRange(version) && intersects(version, '7', semverOptions)) || - (valid(version) && satisfies(version, '7', semverOptions))) - ) { - packageIdentifier = npa.resolve('@angular/pwa', '0.12'); - } - } - - spinner.succeed( - `Found compatible package version: ${colors.grey(packageIdentifier.toString())}.`, - ); - } else if (!latestManifest || (await this.hasMismatchedPeer(latestManifest))) { - // 'latest' is invalid so search for most recent matching package - const versionExclusions = packageVersionExclusions[packageMetadata.name]; - const versionManifests = Object.values(packageMetadata.versions).filter( - (value: PackageManifest) => { - // Prerelease versions are not stable and should not be considered by default - if (prerelease(value.version)) { - return false; - } - // Deprecated versions should not be used or considered - if (value.deprecated) { - return false; - } - // Excluded package versions should not be considered - if (versionExclusions && satisfies(value.version, versionExclusions)) { - return false; - } - - return true; - }, - ); - - versionManifests.sort((a, b) => rcompare(a.version, b.version, true)); - - let newIdentifier; - for (const versionManifest of versionManifests) { - if (!(await this.hasMismatchedPeer(versionManifest))) { - newIdentifier = npa.resolve(versionManifest.name, versionManifest.version); - break; - } - } - - if (!newIdentifier) { - spinner.warn("Unable to find compatible package. Using 'latest' tag."); - } else { - packageIdentifier = newIdentifier; - spinner.succeed( - `Found compatible package version: ${colors.grey(packageIdentifier.toString())}.`, - ); - } - } else { - spinner.succeed( - `Found compatible package version: ${colors.grey(packageIdentifier.toString())}.`, - ); - } - } - - let collectionName = packageIdentifier.name; - let savePackage: NgAddSaveDepedency | undefined; - - try { - spinner.start('Loading package information from registry...'); - const manifest = await fetchPackageManifest(packageIdentifier.toString(), this.logger, { - registry: options.registry, - verbose: options.verbose, - usingYarn, - }); - - savePackage = manifest['ng-add']?.save; - collectionName = manifest.name; - - if (await this.hasMismatchedPeer(manifest)) { - spinner.warn('Package has unmet peer dependencies. Adding the package may not succeed.'); - } else { - spinner.succeed(`Package information loaded.`); - } - } catch (e) { - spinner.fail(`Unable to fetch package information for '${packageIdentifier}': ${e.message}`); - - return 1; - } - - if (!options.skipConfirmation) { - const confirmationResponse = await askConfirmation( - `\nThe package ${colors.blue(packageIdentifier.raw)} will be installed and executed.\n` + - 'Would you like to proceed?', - true, - false, - ); - - if (!confirmationResponse) { - if (!isTTY()) { - this.logger.error( - 'No terminal detected. ' + - `'--skip-confirmation' can be used to bypass installation confirmation. ` + - `Ensure package name is correct prior to '--skip-confirmation' option usage.`, - ); - } - this.logger.error('Command aborted.'); - - return 1; - } - } - - if (savePackage === false) { - // Temporary packages are located in a different directory - // Hence we need to resolve them using the temp path - const { status, tempNodeModules } = await installTempPackage( - packageIdentifier.raw, - packageManager, - options.registry ? [`--registry="${options.registry}"`] : undefined, - ); - const resolvedCollectionPath = require.resolve(join(collectionName, 'package.json'), { - paths: [tempNodeModules], - }); - - if (status !== 0) { - return status; - } - - collectionName = dirname(resolvedCollectionPath); - } else { - const status = await installPackage( - packageIdentifier.raw, - packageManager, - savePackage, - options.registry ? [`--registry="${options.registry}"`] : undefined, - ); - - if (status !== 0) { - return status; - } - } - - return this.executeSchematic(collectionName, options['--']); - } - - private async isProjectVersionValid(packageIdentifier: npa.Result): Promise { - if (!packageIdentifier.name) { - return false; - } - - let validVersion = false; - const installedVersion = await this.findProjectVersion(packageIdentifier.name); - if (installedVersion) { - if (packageIdentifier.type === 'range' && packageIdentifier.fetchSpec) { - validVersion = satisfies(installedVersion, packageIdentifier.fetchSpec); - } else if (packageIdentifier.type === 'version') { - const v1 = valid(packageIdentifier.fetchSpec); - const v2 = valid(installedVersion); - validVersion = v1 !== null && v1 === v2; - } else if (!packageIdentifier.rawSpec) { - validVersion = true; - } - } - - return validVersion; - } - - override async reportAnalytics( - paths: string[], - options: AddCommandSchema & Arguments, - dimensions: (boolean | number | string)[] = [], - metrics: (boolean | number | string)[] = [], - ): Promise { - const collection = options.collection; - - // Add the collection if it's safe listed. - if (collection && isPackageNameSafeForAnalytics(collection)) { - dimensions[analytics.NgCliAnalyticsDimensions.NgAddCollection] = collection; - } else { - delete dimensions[analytics.NgCliAnalyticsDimensions.NgAddCollection]; - } - - return super.reportAnalytics(paths, options, dimensions, metrics); - } - - private isPackageInstalled(name: string): boolean { - try { - require.resolve(join(name, 'package.json'), { paths: [this.context.root] }); - - return true; - } catch (e) { - if (e.code !== 'MODULE_NOT_FOUND') { - throw e; - } - } - - return false; - } - - private async executeSchematic( - collectionName: string, - options: string[] = [], - ): Promise { - const runOptions: RunSchematicOptions = { - schematicOptions: options, - collectionName, - schematicName: 'ng-add', - dryRun: false, - force: false, - }; - - try { - return await this.runSchematic(runOptions); - } catch (e) { - if (e instanceof NodePackageDoesNotSupportSchematics) { - this.logger.error(tags.oneLine` - The package that you are trying to add does not support schematics. You can try using - a different version of the package or contact the package author to add ng-add support. - `); - - return 1; - } - - throw e; - } - } - - private async findProjectVersion(name: string): Promise { - let installedPackage; - try { - installedPackage = require.resolve(join(name, 'package.json'), { - paths: [this.context.root], - }); - } catch {} - - if (installedPackage) { - try { - const installed = await fetchPackageManifest(dirname(installedPackage), this.logger); - - return installed.version; - } catch {} - } - - let projectManifest; - try { - projectManifest = await fetchPackageManifest(this.context.root, this.logger); - } catch {} - - if (projectManifest) { - const version = projectManifest.dependencies[name] || projectManifest.devDependencies[name]; - if (version) { - return version; - } - } - - return null; - } - - private async hasMismatchedPeer(manifest: PackageManifest): Promise { - for (const peer in manifest.peerDependencies) { - let peerIdentifier; - try { - peerIdentifier = npa.resolve(peer, manifest.peerDependencies[peer]); - } catch { - this.logger.warn(`Invalid peer dependency ${peer} found in package.`); - continue; - } - - if (peerIdentifier.type === 'version' || peerIdentifier.type === 'range') { - try { - const version = await this.findProjectVersion(peer); - if (!version) { - continue; - } - - const options = { includePrerelease: true }; - - if ( - !intersects(version, peerIdentifier.rawSpec, options) && - !satisfies(version, peerIdentifier.rawSpec, options) - ) { - return true; - } - } catch { - // Not found or invalid so ignore - continue; - } - } else { - // type === 'tag' | 'file' | 'directory' | 'remote' | 'git' - // Cannot accurately compare these as the tag/location may have changed since install - } - } - - return false; - } -} diff --git a/packages/angular/cli/commands/add.json b/packages/angular/cli/commands/add.json deleted file mode 100644 index 99cd82d897fb..000000000000 --- a/packages/angular/cli/commands/add.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/add.json", - "description": "Adds support for an external library to your project.", - "$longDescription": "./add.md", - - "$scope": "in", - "$impl": "./add-impl#AddCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "collection": { - "type": "string", - "description": "The package to be added.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "registry": { - "description": "The NPM registry to use.", - "type": "string", - "oneOf": [ - { - "format": "uri" - }, - { - "format": "hostname" - } - ] - }, - "verbose": { - "description": "Display additional details about internal operations during execution.", - "type": "boolean", - "default": false - }, - "skipConfirmation": { - "description": "Skip asking a confirmation prompt before installing and executing the package. Ensure package name is correct prior to using this option.", - "type": "boolean", - "default": false - } - }, - "required": [] - }, - { - "$ref": "./definitions.json#/definitions/interactive" - }, - { - "$ref": "./definitions.json#/definitions/base" - } - ] -} diff --git a/packages/angular/cli/commands/add.md b/packages/angular/cli/commands/add.md deleted file mode 100644 index 09cd2e239d76..000000000000 --- a/packages/angular/cli/commands/add.md +++ /dev/null @@ -1,10 +0,0 @@ -Adds the npm package for a published library to your workspace, and configures -the project in the current working directory (or the default project if you are -not in a project directory) to use that library, as specified by the library's schematic. -For example, adding `@angular/pwa` configures your project for PWA support: - -```bash -ng add @angular/pwa -``` - -The default project is the value of `defaultProject` in `angular.json`. diff --git a/packages/angular/cli/commands/analytics-impl.ts b/packages/angular/cli/commands/analytics-impl.ts deleted file mode 100644 index b0cc575ad173..000000000000 --- a/packages/angular/cli/commands/analytics-impl.ts +++ /dev/null @@ -1,100 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { - promptGlobalAnalytics, - promptProjectAnalytics, - setAnalyticsConfig, -} from '../models/analytics'; -import { Command } from '../models/command'; -import { Arguments } from '../models/interface'; -import { Schema as AnalyticsCommandSchema, ProjectSetting, SettingOrProject } from './analytics'; - -export class AnalyticsCommand extends Command { - public async run(options: AnalyticsCommandSchema & Arguments) { - // Our parser does not support positional enums (won't report invalid parameters). Do the - // validation manually. - // TODO(hansl): fix parser to better support positionals. This would be a breaking change. - if (options.settingOrProject === undefined) { - if (options['--']) { - // The user passed positional arguments but they didn't validate. - this.logger.error(`Argument ${JSON.stringify(options['--'][0])} is invalid.`); - this.logger.error(`Please provide one of the following value: on, off, ci or project.`); - - return 1; - } else { - // No argument were passed. - await this.printHelp(); - - return 2; - } - } else if ( - options.settingOrProject == SettingOrProject.Project && - options.projectSetting === undefined - ) { - this.logger.error( - `Argument ${JSON.stringify(options.settingOrProject)} requires a second ` + - `argument of one of the following value: on, off.`, - ); - - return 2; - } - - try { - switch (options.settingOrProject) { - case SettingOrProject.Off: - setAnalyticsConfig('global', false); - break; - - case SettingOrProject.On: - setAnalyticsConfig('global', true); - break; - - case SettingOrProject.Ci: - setAnalyticsConfig('global', 'ci'); - break; - - case SettingOrProject.Project: - switch (options.projectSetting) { - case ProjectSetting.Off: - setAnalyticsConfig('local', false); - break; - - case ProjectSetting.On: - setAnalyticsConfig('local', true); - break; - - case ProjectSetting.Prompt: - await promptProjectAnalytics(true); - break; - - default: - await this.printHelp(); - - return 3; - } - break; - - case SettingOrProject.Prompt: - await promptGlobalAnalytics(true); - break; - - default: - await this.printHelp(); - - return 4; - } - } catch (err) { - this.logger.fatal(err.message); - - return 1; - } - - return 0; - } -} diff --git a/packages/angular/cli/commands/analytics-long.md b/packages/angular/cli/commands/analytics-long.md deleted file mode 100644 index 87b9925d1473..000000000000 --- a/packages/angular/cli/commands/analytics-long.md +++ /dev/null @@ -1,8 +0,0 @@ -The value of _settingOrProject_ is one of the following. - -- "on" : Enables analytics gathering and reporting for the user. -- "off" : Disables analytics gathering and reporting for the user. -- "ci" : Enables analytics and configures reporting for use with Continuous Integration, - which uses a common CI user. -- "prompt" : Prompts the user to set the status interactively. -- "project" : Sets the default status for the project to the _projectSetting_ value, which can be any of the other values. The _projectSetting_ argument is ignored for all other values of _settingOrProject_. diff --git a/packages/angular/cli/commands/analytics.json b/packages/angular/cli/commands/analytics.json deleted file mode 100644 index ee2612b20399..000000000000 --- a/packages/angular/cli/commands/analytics.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/analytics.json", - "description": "Configures the gathering of Angular CLI usage metrics. See https://angular.io/cli/usage-analytics-gathering.", - "$longDescription": "./analytics-long.md", - - "$aliases": [], - "$scope": "all", - "$type": "native", - "$impl": "./analytics-impl#AnalyticsCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "settingOrProject": { - "enum": ["on", "off", "ci", "project", "prompt"], - "description": "Directly enables or disables all usage analytics for the user, or prompts the user to set the status interactively, or sets the default status for the project.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "projectSetting": { - "enum": ["on", "off", "prompt"], - "description": "Sets the default analytics enablement status for the project.", - "$default": { - "$source": "argv", - "index": 1 - } - } - }, - "required": ["settingOrProject"] - }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/build-impl.ts b/packages/angular/cli/commands/build-impl.ts deleted file mode 100644 index 2d983a7514b1..000000000000 --- a/packages/angular/cli/commands/build-impl.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { ArchitectCommand, ArchitectCommandOptions } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as BuildCommandSchema } from './build'; - -export class BuildCommand extends ArchitectCommand { - public override readonly target = 'build'; - - public override async run(options: ArchitectCommandOptions & Arguments) { - return this.runArchitectTarget(options); - } -} diff --git a/packages/angular/cli/commands/build-long.md b/packages/angular/cli/commands/build-long.md deleted file mode 100644 index 57bf9a16edd4..000000000000 --- a/packages/angular/cli/commands/build-long.md +++ /dev/null @@ -1,18 +0,0 @@ -The command can be used to build a project of type "application" or "library". -When used to build a library, a different builder is invoked, and only the `ts-config`, `configuration`, and `watch` options are applied. -All other options apply only to building applications. - -The application builder uses the [webpack](https://webpack.js.org/) build tool, with default configuration options specified in the workspace configuration file (`angular.json`) or with a named alternative configuration. -A "development" configuration is created by default when you use the CLI to create the project, and you can use that configuration by specifying the `--configuration development`. - -The configuration options generally correspond to the command options. -You can override individual configuration defaults by specifying the corresponding options on the command line. -The command can accept option names given in either dash-case or camelCase. -Note that in the configuration file, you must specify names in camelCase. - -Some additional options can only be set through the configuration file, -either by direct editing or with the `ng config` command. -These include `assets`, `styles`, and `scripts` objects that provide runtime-global resources to include in the project. -Resources in CSS, such as images and fonts, are automatically written and fingerprinted at the root of the output folder. - -For further details, see [Workspace Configuration](guide/workspace-config). diff --git a/packages/angular/cli/commands/build.json b/packages/angular/cli/commands/build.json deleted file mode 100644 index df9d93b85a19..000000000000 --- a/packages/angular/cli/commands/build.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/build.json", - "description": "Compiles an Angular app into an output directory named dist/ at the given output path. Must be executed from within a workspace directory.", - "$longDescription": "./build-long.md", - - "$aliases": ["b"], - "$scope": "in", - "$type": "architect", - "$impl": "./build-impl#BuildCommand", - - "allOf": [ - { "$ref": "./definitions.json#/definitions/architect" }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/config-impl.ts b/packages/angular/cli/commands/config-impl.ts deleted file mode 100644 index 2b6393e5bea8..000000000000 --- a/packages/angular/cli/commands/config-impl.ts +++ /dev/null @@ -1,195 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { JsonValue, tags } from '@angular-devkit/core'; -import { v4 as uuidV4 } from 'uuid'; -import { Command } from '../models/command'; -import { Arguments, CommandScope } from '../models/interface'; -import { getWorkspaceRaw, migrateLegacyGlobalConfig, validateWorkspace } from '../utilities/config'; -import { JSONFile, parseJson } from '../utilities/json-file'; -import { Schema as ConfigCommandSchema } from './config'; - -const validCliPaths = new Map< - string, - ((arg: string | number | boolean | undefined) => string) | undefined ->([ - ['cli.warnings.versionMismatch', undefined], - ['cli.defaultCollection', undefined], - ['cli.packageManager', undefined], - ['cli.analytics', undefined], - - ['cli.analyticsSharing.tracking', undefined], - ['cli.analyticsSharing.uuid', (v) => (v === '' ? uuidV4() : `${v}`)], - - ['cli.cache.enabled', undefined], - ['cli.cache.environment', undefined], - ['cli.cache.path', undefined], -]); - -/** - * Splits a JSON path string into fragments. Fragments can be used to get the value referenced - * by the path. For example, a path of "a[3].foo.bar[2]" would give you a fragment array of - * ["a", 3, "foo", "bar", 2]. - * @param path The JSON string to parse. - * @returns {(string|number)[]} The fragments for the string. - * @private - */ -function parseJsonPath(path: string): (string | number)[] { - const fragments = (path || '').split(/\./g); - const result: (string | number)[] = []; - - while (fragments.length > 0) { - const fragment = fragments.shift(); - if (fragment == undefined) { - break; - } - - const match = fragment.match(/([^[]+)((\[.*\])*)/); - if (!match) { - throw new Error('Invalid JSON path.'); - } - - result.push(match[1]); - if (match[2]) { - const indices = match[2] - .slice(1, -1) - .split('][') - .map((x) => (/^\d$/.test(x) ? +x : x.replace(/"|'/g, ''))); - result.push(...indices); - } - } - - return result.filter((fragment) => fragment != null); -} - -function normalizeValue(value: string | undefined | boolean | number): JsonValue | undefined { - const valueString = `${value}`.trim(); - switch (valueString) { - case 'true': - return true; - case 'false': - return false; - case 'null': - return null; - case 'undefined': - return undefined; - } - - if (isFinite(+valueString)) { - return +valueString; - } - - try { - // We use `JSON.parse` instead of `parseJson` because the latter will parse UUIDs - // and convert them into a numberic entities. - // Example: 73b61974-182c-48e4-b4c6-30ddf08c5c98 -> 73. - // These values should never contain comments, therefore using `JSON.parse` is safe. - return JSON.parse(valueString); - } catch { - return value; - } -} - -export class ConfigCommand extends Command { - public async run(options: ConfigCommandSchema & Arguments) { - const level = options.global ? 'global' : 'local'; - - if (!options.global) { - await this.validateScope(CommandScope.InProject); - } - - let [config] = getWorkspaceRaw(level); - - if (options.global && !config) { - try { - if (migrateLegacyGlobalConfig()) { - config = getWorkspaceRaw(level)[0]; - this.logger.info(tags.oneLine` - We found a global configuration that was used in Angular CLI 1. - It has been automatically migrated.`); - } - } catch {} - } - - if (options.value == undefined) { - if (!config) { - this.logger.error('No config found.'); - - return 1; - } - - return this.get(config, options); - } else { - return this.set(options); - } - } - - private get(jsonFile: JSONFile, options: ConfigCommandSchema) { - let value; - if (options.jsonPath) { - value = jsonFile.get(parseJsonPath(options.jsonPath)); - } else { - value = jsonFile.content; - } - - if (value === undefined) { - this.logger.error('Value cannot be found.'); - - return 1; - } else if (typeof value === 'string') { - this.logger.info(value); - } else { - this.logger.info(JSON.stringify(value, null, 2)); - } - - return 0; - } - - private async set(options: ConfigCommandSchema) { - if (!options.jsonPath?.trim()) { - throw new Error('Invalid Path.'); - } - - if ( - options.global && - !options.jsonPath.startsWith('schematics.') && - !validCliPaths.has(options.jsonPath) - ) { - throw new Error('Invalid Path.'); - } - - const [config, configPath] = getWorkspaceRaw(options.global ? 'global' : 'local'); - if (!config || !configPath) { - this.logger.error('Confguration file cannot be found.'); - - return 1; - } - - const jsonPath = parseJsonPath(options.jsonPath); - const value = validCliPaths.get(options.jsonPath)?.(options.value) ?? options.value; - const modified = config.modify(jsonPath, normalizeValue(value)); - - if (!modified) { - this.logger.error('Value cannot be found.'); - - return 1; - } - - try { - await validateWorkspace(parseJson(config.content)); - } catch (error) { - this.logger.fatal(error.message); - - return 1; - } - - config.save(); - - return 0; - } -} diff --git a/packages/angular/cli/commands/config-long.md b/packages/angular/cli/commands/config-long.md deleted file mode 100644 index 7f44f63b3b32..000000000000 --- a/packages/angular/cli/commands/config-long.md +++ /dev/null @@ -1,13 +0,0 @@ -A workspace has a single CLI configuration file, `angular.json`, at the top level. -The `projects` object contains a configuration object for each project in the workspace. - -You can edit the configuration directly in a code editor, -or indirectly on the command line using this command. - -The configurable property names match command option names, -except that in the configuration file, all names must use camelCase, -while on the command line options can be given in either camelCase or dash-case. - -For further details, see [Workspace Configuration](guide/workspace-config). - -For configuration of CLI usage analytics, see [Gathering an Viewing CLI Usage Analytics](./usage-analytics-gathering). diff --git a/packages/angular/cli/commands/config.json b/packages/angular/cli/commands/config.json deleted file mode 100644 index bec13fca4c0f..000000000000 --- a/packages/angular/cli/commands/config.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/config.json", - "description": "Retrieves or sets Angular configuration values in the angular.json file for the workspace.", - "$longDescription": "", - - "$aliases": [], - "$scope": "all", - "$type": "native", - "$impl": "./config-impl#ConfigCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "jsonPath": { - "type": "string", - "description": "The configuration key to set or query, in JSON path format. For example: \"a[3].foo.bar[2]\". If no new value is provided, returns the current value of this key.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "value": { - "type": ["string", "number", "boolean"], - "description": "If provided, a new value for the given configuration key.", - "$default": { - "$source": "argv", - "index": 1 - } - }, - "global": { - "type": "boolean", - "description": "Access the global configuration in the caller's home directory.", - "default": false, - "aliases": ["g"] - } - }, - "required": [] - }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/definitions.json b/packages/angular/cli/commands/definitions.json deleted file mode 100644 index a18355349f46..000000000000 --- a/packages/angular/cli/commands/definitions.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/definitions.json", - - "definitions": { - "architect": { - "properties": { - "project": { - "type": "string", - "description": "The name of the project to build. Can be an application or a library.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "configuration": { - "description": "One or more named builder configurations as a comma-separated list as specified in the \"configurations\" section of angular.json.\nThe builder uses the named configurations to run the given target.\nFor more information, see https://angular.io/guide/workspace-config#alternate-build-configurations.\nSetting this explicitly overrides the \"--prod\" flag.", - "type": "string", - "aliases": ["c"] - }, - "prod": { - "description": "Shorthand for \"--configuration=production\".\nSet the build configuration to the production target.\nBy default, the production target is set up in the workspace configuration such that all builds make use of bundling, limited tree-shaking, and also limited dead code elimination.", - "type": "boolean", - "x-deprecated": "Use `--configuration production` instead." - } - } - }, - "base": { - "type": "object", - "properties": { - "help": { - "enum": [true, false, "json", "JSON"], - "description": "Shows a help message for this command in the console.", - "default": false - } - } - }, - "schematic": { - "type": "object", - "properties": { - "dryRun": { - "type": "boolean", - "default": false, - "aliases": ["d"], - "description": "Run through and reports activity without writing out results." - }, - "force": { - "type": "boolean", - "default": false, - "aliases": ["f"], - "description": "Force overwriting of existing files." - } - } - }, - "interactive": { - "type": "object", - "properties": { - "interactive": { - "type": "boolean", - "default": "true", - "description": "Enable interactive input prompts." - }, - "defaults": { - "type": "boolean", - "default": "false", - "description": "Disable interactive input prompts for options with a default." - } - } - } - } -} diff --git a/packages/angular/cli/commands/deploy-impl.ts b/packages/angular/cli/commands/deploy-impl.ts deleted file mode 100644 index f8e400a2550b..000000000000 --- a/packages/angular/cli/commands/deploy-impl.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { ArchitectCommand } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as DeployCommandSchema } from './deploy'; - -const BuilderMissing = ` -Cannot find "deploy" target for the specified project. - -You should add a package that implements deployment capabilities for your -favorite platform. - -For example: - ng add @angular/fire - ng add @azure/ng-deploy - -Find more packages on npm https://www.npmjs.com/search?q=ng%20deploy -`; - -export class DeployCommand extends ArchitectCommand { - public override readonly target = 'deploy'; - public override readonly missingTargetError = BuilderMissing; - - public override async initialize( - options: DeployCommandSchema & Arguments, - ): Promise { - if (!options.help) { - return super.initialize(options); - } - } -} diff --git a/packages/angular/cli/commands/deploy-long.md b/packages/angular/cli/commands/deploy-long.md deleted file mode 100644 index 9d13ad2a9890..000000000000 --- a/packages/angular/cli/commands/deploy-long.md +++ /dev/null @@ -1,22 +0,0 @@ -The command takes an optional project name, as specified in the `projects` section of the `angular.json` workspace configuration file. -When a project name is not supplied, executes the `deploy` builder for the default project. - -To use the `ng deploy` command, use `ng add` to add a package that implements deployment capabilities to your favorite platform. -Adding the package automatically updates your workspace configuration, adding a deployment -[CLI builder](guide/cli-builder). -For example: - -```json -"projects": { - "my-project": { - ... - "architect": { - ... - "deploy": { - "builder": "@angular/fire:deploy", - "options": {} - } - } - } -} -``` diff --git a/packages/angular/cli/commands/deploy.json b/packages/angular/cli/commands/deploy.json deleted file mode 100644 index cc7c860dde1c..000000000000 --- a/packages/angular/cli/commands/deploy.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/deploy.json", - "description": "Invokes the deploy builder for a specified project or for the default project in the workspace.", - "$longDescription": "./deploy-long.md", - - "$scope": "in", - "$type": "architect", - "$impl": "./deploy-impl#DeployCommand", - - "allOf": [ - { - "properties": { - "project": { - "type": "string", - "description": "The name of the project to deploy.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "configuration": { - "description": "One or more named builder configurations as a comma-separated list as specified in the \"configurations\" section of angular.json.\nThe builder uses the named configurations to run the given target.\nFor more information, see https://angular.io/guide/workspace-config#alternate-build-configurations.", - "type": "string", - "aliases": ["c"] - } - }, - "required": [] - }, - { - "$ref": "./definitions.json#/definitions/base" - } - ] -} diff --git a/packages/angular/cli/commands/doc-impl.ts b/packages/angular/cli/commands/doc-impl.ts deleted file mode 100644 index 4cd4a7a14579..000000000000 --- a/packages/angular/cli/commands/doc-impl.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import open from 'open'; -import { Command } from '../models/command'; -import { Arguments } from '../models/interface'; -import { Schema as DocCommandSchema } from './doc'; - -export class DocCommand extends Command { - public async run(options: DocCommandSchema & Arguments) { - if (!options.keyword) { - this.logger.error('You should specify a keyword, for instance, `ng doc ActivatedRoute`.'); - - return 0; - } - - let domain = 'angular.io'; - - if (options.version) { - // version can either be a string containing "next" - if (options.version == 'next') { - domain = 'next.angular.io'; - // or a number where version must be a valid Angular version (i.e. not 0, 1 or 3) - } else if (!isNaN(+options.version) && ![0, 1, 3].includes(+options.version)) { - domain = `v${options.version}.angular.io`; - } else { - this.logger.error('Version should either be a number (2, 4, 5, 6...) or "next"'); - - return 0; - } - } else { - // we try to get the current Angular version of the project - // and use it if we can find it - try { - /* eslint-disable-next-line import/no-extraneous-dependencies */ - const currentNgVersion = (await import('@angular/core')).VERSION.major; - domain = `v${currentNgVersion}.angular.io`; - } catch {} - } - - await open( - options.search - ? `https://${domain}/api?query=${options.keyword}` - : `https://${domain}/docs?search=${options.keyword}`, - ); - } -} diff --git a/packages/angular/cli/commands/doc.json b/packages/angular/cli/commands/doc.json deleted file mode 100644 index bb01549c6099..000000000000 --- a/packages/angular/cli/commands/doc.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/doc.json", - "description": "Opens the official Angular documentation (angular.io) in a browser, and searches for a given keyword.", - "$longDescription": "", - - "$aliases": ["d"], - "$type": "native", - "$impl": "./doc-impl#DocCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "keyword": { - "type": "string", - "description": "The keyword to search for, as provided in the search bar in angular.io.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "search": { - "aliases": ["s"], - "type": "boolean", - "default": false, - "description": "Search all of angular.io. Otherwise, searches only API reference documentation." - }, - "version": { - "oneOf": [ - { - "type": "number", - "minimum": 4 - }, - { - "enum": [2, "next"] - } - ], - "description": "Contains the version of Angular to use for the documentation. If not provided, the command uses your current Angular core version." - } - }, - "required": [] - }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/e2e-impl.ts b/packages/angular/cli/commands/e2e-impl.ts deleted file mode 100644 index 5a1df466d97d..000000000000 --- a/packages/angular/cli/commands/e2e-impl.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { ArchitectCommand } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as E2eCommandSchema } from './e2e'; - -export class E2eCommand extends ArchitectCommand { - public override readonly target = 'e2e'; - public override readonly multiTarget = true; - public override readonly missingTargetError = ` -Cannot find "e2e" target for the specified project. - -You should add a package that implements end-to-end testing capabilities. - -For example: - Cypress: ng add @cypress/schematic - Nightwatch: ng add @nightwatch/schematics - WebdriverIO: ng add @wdio/schematics - -More options will be added to the list as they become available. -`; - - override async initialize(options: E2eCommandSchema & Arguments) { - if (!options.help) { - return super.initialize(options); - } - } -} diff --git a/packages/angular/cli/commands/e2e-long.md b/packages/angular/cli/commands/e2e-long.md deleted file mode 100644 index 26363135a8ce..000000000000 --- a/packages/angular/cli/commands/e2e-long.md +++ /dev/null @@ -1,4 +0,0 @@ -The command takes an optional project name, as specified in the `projects` section of the `angular.json` workspace configuration file. -When a project name is not supplied, executes the `e2e` builder for the default project. - -To use the `ng e2e` command, use `ng add` to add a package that implements end-to-end testing capabilities. Adding the package automatically updates your workspace configuration, adding an `e2e` [CLI builder](guide/cli-builder). diff --git a/packages/angular/cli/commands/e2e.json b/packages/angular/cli/commands/e2e.json deleted file mode 100644 index a8c8cccc4b62..000000000000 --- a/packages/angular/cli/commands/e2e.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/e2e.json", - "description": "Builds and serves an Angular app, then runs end-to-end tests.", - "$longDescription": "./e2e-long.md", - - "$aliases": ["e"], - "$scope": "in", - "$type": "architect", - "$impl": "./e2e-impl#E2eCommand", - - "type": "object", - "allOf": [ - { "$ref": "./definitions.json#/definitions/architect" }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/easter-egg-impl.ts b/packages/angular/cli/commands/easter-egg-impl.ts deleted file mode 100644 index 3857c38444a5..000000000000 --- a/packages/angular/cli/commands/easter-egg-impl.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { Command } from '../models/command'; -import { colors } from '../utilities/color'; -import { Schema as AwesomeCommandSchema } from './easter-egg'; - -function pickOne(of: string[]): string { - return of[Math.floor(Math.random() * of.length)]; -} - -export class AwesomeCommand extends Command { - async run() { - const phrase = pickOne([ - `You're on it, there's nothing for me to do!`, - `Let's take a look... nope, it's all good!`, - `You're doing fine.`, - `You're already doing great.`, - `Nothing to do; already awesome. Exiting.`, - `Error 418: As Awesome As Can Get.`, - `I spy with my little eye a great developer!`, - `Noop... already awesome.`, - ]); - this.logger.info(colors.green(phrase)); - } -} diff --git a/packages/angular/cli/commands/easter-egg.json b/packages/angular/cli/commands/easter-egg.json deleted file mode 100644 index 79d9e1bb2edf..000000000000 --- a/packages/angular/cli/commands/easter-egg.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/easter-egg.json", - "description": "", - "$longDescription": "", - "$hidden": true, - - "$impl": "./easter-egg-impl#AwesomeCommand", - - "type": "object", - "allOf": [{ "$ref": "./definitions.json#/definitions/base" }] -} diff --git a/packages/angular/cli/commands/extract-i18n-impl.ts b/packages/angular/cli/commands/extract-i18n-impl.ts deleted file mode 100644 index 3520d57d3e2d..000000000000 --- a/packages/angular/cli/commands/extract-i18n-impl.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { ArchitectCommand } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as ExtractI18nCommandSchema } from './extract-i18n'; - -export class ExtractI18nCommand extends ArchitectCommand { - public override readonly target = 'extract-i18n'; - - public override async run(options: ExtractI18nCommandSchema & Arguments) { - const version = process.version.substr(1).split('.'); - if (Number(version[0]) === 12 && Number(version[1]) === 0) { - this.logger.error( - 'Due to a defect in Node.js 12.0, the command is not supported on this Node.js version. ' + - 'Please upgrade to Node.js 12.1 or later.', - ); - - return 1; - } - - const commandName = process.argv[2]; - if (['xi18n', 'i18n-extract'].includes(commandName)) { - this.logger.warn( - `Warning: "ng ${commandName}" has been deprecated and will be removed in a future major version. ` + - 'Please use "ng extract-i18n" instead.', - ); - } - - return this.runArchitectTarget(options); - } -} diff --git a/packages/angular/cli/commands/extract-i18n.json b/packages/angular/cli/commands/extract-i18n.json deleted file mode 100644 index 2010fa899190..000000000000 --- a/packages/angular/cli/commands/extract-i18n.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/extract-i18n.json", - "description": "Extracts i18n messages from source code.", - "$longDescription": "", - - "$aliases": ["i18n-extract", "xi18n"], - "$scope": "in", - "$type": "architect", - "$impl": "./extract-i18n-impl#ExtractI18nCommand", - - "type": "object", - "allOf": [ - { "$ref": "./definitions.json#/definitions/architect" }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/generate-impl.ts b/packages/angular/cli/commands/generate-impl.ts deleted file mode 100644 index 49d71dd3555c..000000000000 --- a/packages/angular/cli/commands/generate-impl.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { Arguments, SubCommandDescription } from '../models/interface'; -import { SchematicCommand } from '../models/schematic-command'; -import { colors } from '../utilities/color'; -import { parseJsonSchemaToSubCommandDescription } from '../utilities/json-schema'; -import { Schema as GenerateCommandSchema } from './generate'; - -export class GenerateCommand extends SchematicCommand { - // Allows us to resolve aliases before reporting analytics - longSchematicName: string | undefined; - - override async initialize(options: GenerateCommandSchema & Arguments) { - // Fill up the schematics property of the command description. - const [collectionName, schematicName] = await this.parseSchematicInfo(options); - this.collectionName = collectionName; - this.schematicName = schematicName; - - await super.initialize(options); - - const collection = this.getCollection(collectionName); - const subcommands: { [name: string]: SubCommandDescription } = {}; - - const schematicNames = schematicName ? [schematicName] : collection.listSchematicNames(); - // Sort as a courtesy for the user. - schematicNames.sort(); - - for (const name of schematicNames) { - const schematic = this.getSchematic(collection, name, true); - this.longSchematicName = schematic.description.name; - let subcommand: SubCommandDescription; - if (schematic.description.schemaJson) { - subcommand = await parseJsonSchemaToSubCommandDescription( - name, - schematic.description.path, - this._workflow.registry, - schematic.description.schemaJson, - ); - } else { - continue; - } - - if ((await this.getDefaultSchematicCollection()) == collectionName) { - subcommands[name] = subcommand; - } else { - subcommands[`${collectionName}:${name}`] = subcommand; - } - } - - this.description.options.forEach((option) => { - if (option.name == 'schematic') { - option.subcommands = subcommands; - } - }); - } - - public async run(options: GenerateCommandSchema & Arguments) { - if (!this.schematicName || !this.collectionName) { - return this.printHelp(); - } - - return this.runSchematic({ - collectionName: this.collectionName, - schematicName: this.schematicName, - schematicOptions: options['--'] || [], - debug: !!options.debug || false, - dryRun: !!options.dryRun || false, - force: !!options.force || false, - }); - } - - override async reportAnalytics( - paths: string[], - options: GenerateCommandSchema & Arguments, - ): Promise { - if (!this.collectionName || !this.schematicName) { - return; - } - const escapedSchematicName = (this.longSchematicName || this.schematicName).replace(/\//g, '_'); - - return super.reportAnalytics( - ['generate', this.collectionName.replace(/\//g, '_'), escapedSchematicName], - options, - ); - } - - private async parseSchematicInfo( - options: GenerateCommandSchema, - ): Promise<[string, string | undefined]> { - let collectionName = await this.getDefaultSchematicCollection(); - - let schematicName = options.schematic; - - if (schematicName && schematicName.includes(':')) { - [collectionName, schematicName] = schematicName.split(':', 2); - } - - return [collectionName, schematicName]; - } - - public override async printHelp() { - await super.printHelp(); - - this.logger.info(''); - // Find the generate subcommand. - const subcommand = this.description.options.filter((x) => x.subcommands)[0]; - if (Object.keys((subcommand && subcommand.subcommands) || {}).length == 1) { - this.logger.info(`\nTo see help for a schematic run:`); - this.logger.info(colors.cyan(` ng generate --help`)); - } - - return 0; - } -} diff --git a/packages/angular/cli/commands/generate.json b/packages/angular/cli/commands/generate.json deleted file mode 100644 index 53228340abd4..000000000000 --- a/packages/angular/cli/commands/generate.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/generate.json", - "description": "Generates and/or modifies files based on a schematic.", - "$longDescription": "", - - "$aliases": ["g"], - "$scope": "in", - "$type": "schematics", - "$impl": "./generate-impl#GenerateCommand", - - "allOf": [ - { - "type": "object", - "properties": { - "schematic": { - "type": "string", - "description": "The schematic or collection:schematic to generate.", - "$default": { - "$source": "argv", - "index": 0 - } - } - }, - "required": [] - }, - { "$ref": "./definitions.json#/definitions/base" }, - { "$ref": "./definitions.json#/definitions/schematic" }, - { "$ref": "./definitions.json#/definitions/interactive" } - ] -} diff --git a/packages/angular/cli/commands/help-impl.ts b/packages/angular/cli/commands/help-impl.ts deleted file mode 100644 index c7ccc282493d..000000000000 --- a/packages/angular/cli/commands/help-impl.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { Command } from '../models/command'; -import { colors } from '../utilities/color'; -import { Schema as HelpCommandSchema } from './help'; - -export class HelpCommand extends Command { - async run() { - this.logger.info(`Available Commands:`); - - for (const cmd of Object.values(await Command.commandMap())) { - if (cmd.hidden) { - continue; - } - - const aliasInfo = cmd.aliases.length > 0 ? ` (${cmd.aliases.join(', ')})` : ''; - this.logger.info(` ${colors.cyan(cmd.name)}${aliasInfo} ${cmd.description}`); - } - this.logger.info(`\nFor more detailed help run "ng [command name] --help"`); - } -} diff --git a/packages/angular/cli/commands/help-long.md b/packages/angular/cli/commands/help-long.md deleted file mode 100644 index cc4b790f906e..000000000000 --- a/packages/angular/cli/commands/help-long.md +++ /dev/null @@ -1,7 +0,0 @@ -For help with individual commands, use the `--help` or `-h` option with the command. - -For example, - -```sh -ng help serve -``` diff --git a/packages/angular/cli/commands/help.json b/packages/angular/cli/commands/help.json deleted file mode 100644 index a6513118d0e4..000000000000 --- a/packages/angular/cli/commands/help.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/help.json", - "description": "Lists available commands and their short descriptions.", - "$longDescription": "./help-long.md", - - "$scope": "all", - "$aliases": [], - "$impl": "./help-impl#HelpCommand", - - "type": "object", - "allOf": [{ "$ref": "./definitions.json#/definitions/base" }] -} diff --git a/packages/angular/cli/commands/lint-impl.ts b/packages/angular/cli/commands/lint-impl.ts deleted file mode 100644 index e9fb4dc801b3..000000000000 --- a/packages/angular/cli/commands/lint-impl.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { spawnSync } from 'child_process'; -import * as path from 'path'; -import { ArchitectCommand } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { askConfirmation } from '../utilities/prompt'; -import { Schema as LintCommandSchema } from './lint'; - -const MissingBuilder = ` -Cannot find "lint" target for the specified project. - -You should add a package that implements linting capabilities. - -For example: - ng add @angular-eslint/schematics -`; - -export class LintCommand extends ArchitectCommand { - override readonly target = 'lint'; - override readonly multiTarget = true; - - override async initialize(options: LintCommandSchema & Arguments): Promise { - if (!options.help) { - return super.initialize(options); - } - } - - override async onMissingTarget(): Promise { - this.logger.warn(MissingBuilder); - - const shouldAdd = await askConfirmation('Would you like to add ESLint now?', true, false); - if (shouldAdd) { - // Run `ng add @angular-eslint/schematics` - const binPath = path.resolve(__dirname, '../bin/ng.js'); - const { status, error } = spawnSync( - process.execPath, - [binPath, 'add', '@angular-eslint/schematics'], - { - stdio: 'inherit', - }, - ); - - if (error) { - throw error; - } - - return status ?? 0; - } - } -} diff --git a/packages/angular/cli/commands/lint-long.md b/packages/angular/cli/commands/lint-long.md deleted file mode 100644 index 1c912b2489d7..000000000000 --- a/packages/angular/cli/commands/lint-long.md +++ /dev/null @@ -1,20 +0,0 @@ -The command takes an optional project name, as specified in the `projects` section of the `angular.json` workspace configuration file. -When a project name is not supplied, executes the `lint` builder for all projects. - -To use the `ng lint` command, use `ng add` to add a package that implements linting capabilities. Adding the package automatically updates your workspace configuration, adding a lint [CLI builder](guide/cli-builder). -For example: - -```json -"projects": { - "my-project": { - ... - "architect": { - ... - "lint": { - "builder": "@angular-eslint/builder:lint", - "options": {} - } - } - } -} -``` diff --git a/packages/angular/cli/commands/lint.json b/packages/angular/cli/commands/lint.json deleted file mode 100644 index 824632e79f76..000000000000 --- a/packages/angular/cli/commands/lint.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/lint.json", - "description": "Runs linting tools on Angular app code in a given project folder.", - "$longDescription": "./lint-long.md", - - "$aliases": ["l"], - "$scope": "in", - "$type": "architect", - "$impl": "./lint-impl#LintCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "project": { - "type": "string", - "description": "The name of the project to lint.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "configuration": { - "description": "One or more named builder configurations as a comma-separated list as specified in the \"configurations\" section of angular.json.\nThe builder uses the named configurations to run the given target.\nFor more information, see https://angular.io/guide/workspace-config#alternate-build-configurations.", - "type": "string", - "aliases": ["c"] - } - }, - "required": [] - }, - { - "$ref": "./definitions.json#/definitions/base" - } - ] -} diff --git a/packages/angular/cli/commands/new-impl.ts b/packages/angular/cli/commands/new-impl.ts deleted file mode 100644 index b4869de0f043..000000000000 --- a/packages/angular/cli/commands/new-impl.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { Arguments } from '../models/interface'; -import { SchematicCommand } from '../models/schematic-command'; -import { VERSION } from '../models/version'; -import { Schema as NewCommandSchema } from './new'; - -export class NewCommand extends SchematicCommand { - public override readonly allowMissingWorkspace = true; - override schematicName = 'ng-new'; - - override async initialize(options: NewCommandSchema & Arguments) { - this.collectionName = options.collection || (await this.getDefaultSchematicCollection()); - - return super.initialize(options); - } - - public async run(options: NewCommandSchema & Arguments) { - // Register the version of the CLI in the registry. - const version = VERSION.full; - this._workflow.registry.addSmartDefaultProvider('ng-cli-version', () => version); - - return this.runSchematic({ - collectionName: this.collectionName, - schematicName: this.schematicName, - schematicOptions: options['--'] || [], - debug: !!options.debug, - dryRun: !!options.dryRun, - force: !!options.force, - }); - } -} diff --git a/packages/angular/cli/commands/new.json b/packages/angular/cli/commands/new.json deleted file mode 100644 index 90efa76056be..000000000000 --- a/packages/angular/cli/commands/new.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/new.json", - "description": "Creates a new workspace and an initial Angular application.", - "$longDescription": "./new.md", - - "$aliases": ["n"], - "$scope": "out", - "$type": "schematic", - "$impl": "./new-impl#NewCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "collection": { - "type": "string", - "aliases": ["c"], - "description": "A collection of schematics to use in generating the initial application." - }, - "verbose": { - "type": "boolean", - "default": false, - "aliases": ["v"], - "description": "Add more details to output logging." - } - }, - "required": [] - }, - { "$ref": "./definitions.json#/definitions/base" }, - { "$ref": "./definitions.json#/definitions/schematic" }, - { "$ref": "./definitions.json#/definitions/interactive" } - ] -} diff --git a/packages/angular/cli/commands/new.md b/packages/angular/cli/commands/new.md deleted file mode 100644 index 135e1b2c108a..000000000000 --- a/packages/angular/cli/commands/new.md +++ /dev/null @@ -1,16 +0,0 @@ -Creates and initializes a new Angular application that is the default project for a new workspace. - -Provides interactive prompts for optional configuration, such as adding routing support. -All prompts can safely be allowed to default. - -- The new workspace folder is given the specified project name, and contains configuration files at the top level. - -- By default, the files for a new initial application (with the same name as the workspace) are placed in the `src/` subfolder. - -- The new application's configuration appears in the `projects` section of the `angular.json` workspace configuration file, under its project name. - -- Subsequent applications that you generate in the workspace reside in the `projects/` subfolder. - -If you plan to have multiple applications in the workspace, you can create an empty workspace by setting the `--create-application` option to false. -You can then use `ng generate application` to create an initial application. -This allows a workspace name different from the initial app name, and ensures that all applications reside in the `/projects` subfolder, matching the structure of the configuration file. diff --git a/packages/angular/cli/commands/run-impl.ts b/packages/angular/cli/commands/run-impl.ts deleted file mode 100644 index d9cee91850aa..000000000000 --- a/packages/angular/cli/commands/run-impl.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { ArchitectCommand, ArchitectCommandOptions } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as RunCommandSchema } from './run'; - -export class RunCommand extends ArchitectCommand { - public override async run(options: ArchitectCommandOptions & Arguments) { - if (options.target) { - return this.runArchitectTarget(options); - } else { - throw new Error('Invalid architect target.'); - } - } -} diff --git a/packages/angular/cli/commands/run-long.md b/packages/angular/cli/commands/run-long.md deleted file mode 100644 index 65a307fcd771..000000000000 --- a/packages/angular/cli/commands/run-long.md +++ /dev/null @@ -1,16 +0,0 @@ -Architect is the tool that the CLI uses to perform complex tasks such as compilation, according to provided configurations. -The CLI commands run Architect targets such as `build`, `serve`, `test`, and `lint`. -Each named target has a default configuration, specified by an "options" object, -and an optional set of named alternate configurations in the "configurations" object. - -For example, the "serve" target for a newly generated app has a predefined -alternate configuration named "production". - -You can define new targets and their configuration options in the "architect" section -of the `angular.json` file. -If you do so, you can run them from the command line using the `ng run` command. -Execute the command using the following format. - -``` -ng run project:target[:configuration] -``` diff --git a/packages/angular/cli/commands/run.json b/packages/angular/cli/commands/run.json deleted file mode 100644 index f4e2287dbf35..000000000000 --- a/packages/angular/cli/commands/run.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/run.json", - "description": "Runs an Architect target with an optional custom builder configuration defined in your project.", - "$longDescription": "./run-long.md", - - "$aliases": [], - "$scope": "in", - "$type": "architect", - "$impl": "./run-impl#RunCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "target": { - "type": "string", - "description": "The Architect target to run.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "configuration": { - "description": "One or more named builder configurations as a comma-separated list as specified in the \"configurations\" section of angular.json.\nThe builder uses the named configurations to run the given target.\nFor more information, see https://angular.io/guide/workspace-config#alternate-build-configurations.", - "type": "string", - "aliases": ["c"] - } - }, - "required": [] - }, - { - "$ref": "./definitions.json#/definitions/base" - } - ] -} diff --git a/packages/angular/cli/commands/serve-impl.ts b/packages/angular/cli/commands/serve-impl.ts deleted file mode 100644 index 9d8dc3bec6eb..000000000000 --- a/packages/angular/cli/commands/serve-impl.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { ArchitectCommand, ArchitectCommandOptions } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as ServeCommandSchema } from './serve'; - -export class ServeCommand extends ArchitectCommand { - public override readonly target = 'serve'; - - public validate() { - return true; - } - - public override async run(options: ArchitectCommandOptions & Arguments) { - return this.runArchitectTarget(options); - } -} diff --git a/packages/angular/cli/commands/serve.json b/packages/angular/cli/commands/serve.json deleted file mode 100644 index efc7ba4089ae..000000000000 --- a/packages/angular/cli/commands/serve.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/serve.json", - "description": "Builds and serves your app, rebuilding on file changes.", - "$longDescription": "", - - "$aliases": ["s"], - "$scope": "in", - "$type": "architect", - "$impl": "./serve-impl#ServeCommand", - - "type": "object", - "allOf": [ - { "$ref": "./definitions.json#/definitions/architect" }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/test-impl.ts b/packages/angular/cli/commands/test-impl.ts deleted file mode 100644 index 511520b0f02b..000000000000 --- a/packages/angular/cli/commands/test-impl.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { ArchitectCommand, ArchitectCommandOptions } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as TestCommandSchema } from './test'; - -export class TestCommand extends ArchitectCommand { - public override readonly target = 'test'; - public override readonly multiTarget = true; - - public override async run(options: ArchitectCommandOptions & Arguments) { - return this.runArchitectTarget(options); - } -} diff --git a/packages/angular/cli/commands/test.json b/packages/angular/cli/commands/test.json deleted file mode 100644 index 5fb4ce014c48..000000000000 --- a/packages/angular/cli/commands/test.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/test.json", - "description": "Runs unit tests in a project.", - "$longDescription": "./test-long.md", - - "$aliases": ["t"], - "$scope": "in", - "$type": "architect", - "$impl": "./test-impl#TestCommand", - - "type": "object", - "allOf": [ - { "$ref": "./definitions.json#/definitions/architect" }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/update-impl.ts b/packages/angular/cli/commands/update-impl.ts deleted file mode 100644 index 23aca0a5861e..000000000000 --- a/packages/angular/cli/commands/update-impl.ts +++ /dev/null @@ -1,976 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics'; -import { NodeWorkflow } from '@angular-devkit/schematics/tools'; -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import npa from 'npm-package-arg'; -import pickManifest from 'npm-pick-manifest'; -import * as path from 'path'; -import * as semver from 'semver'; -import { PackageManager } from '../lib/config/workspace-schema'; -import { Command } from '../models/command'; -import { Arguments } from '../models/interface'; -import { SchematicEngineHost } from '../models/schematic-engine-host'; -import { VERSION } from '../models/version'; -import { colors } from '../utilities/color'; -import { installAllPackages, runTempPackageBin } from '../utilities/install-package'; -import { writeErrorToLogFile } from '../utilities/log-file'; -import { ensureCompatibleNpm, getPackageManager } from '../utilities/package-manager'; -import { - PackageIdentifier, - PackageManifest, - fetchPackageManifest, - fetchPackageMetadata, -} from '../utilities/package-metadata'; -import { - PackageTreeNode, - findPackageJson, - getProjectDependencies, - readPackageJson, -} from '../utilities/package-tree'; -import { Schema as UpdateCommandSchema } from './update'; - -const UPDATE_SCHEMATIC_COLLECTION = path.join( - __dirname, - '../src/commands/update/schematic/collection.json', -); - -/** - * Disable CLI version mismatch checks and forces usage of the invoked CLI - * instead of invoking the local installed version. - */ -const disableVersionCheckEnv = process.env['NG_DISABLE_VERSION_CHECK']; -const disableVersionCheck = - disableVersionCheckEnv !== undefined && - disableVersionCheckEnv !== '0' && - disableVersionCheckEnv.toLowerCase() !== 'false'; - -const ANGULAR_PACKAGES_REGEXP = /^@(?:angular|nguniversal)\//; - -export class UpdateCommand extends Command { - public override readonly allowMissingWorkspace = true; - private workflow!: NodeWorkflow; - private packageManager = PackageManager.Npm; - - override async initialize(options: UpdateCommandSchema & Arguments) { - this.packageManager = await getPackageManager(this.context.root); - this.workflow = new NodeWorkflow(this.context.root, { - packageManager: this.packageManager, - packageManagerForce: options.force, - // __dirname -> favor @schematics/update from this package - // Otherwise, use packages from the active workspace (migrations) - resolvePaths: [__dirname, this.context.root], - schemaValidation: true, - engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths), - }); - } - - private async executeSchematic( - collection: string, - schematic: string, - options = {}, - ): Promise<{ success: boolean; files: Set }> { - let error = false; - let logs: string[] = []; - const files = new Set(); - - const reporterSubscription = this.workflow.reporter.subscribe((event) => { - // Strip leading slash to prevent confusion. - const eventPath = event.path.startsWith('/') ? event.path.substr(1) : event.path; - - switch (event.kind) { - case 'error': - error = true; - const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist.'; - this.logger.error(`ERROR! ${eventPath} ${desc}.`); - break; - case 'update': - logs.push(`${colors.cyan('UPDATE')} ${eventPath} (${event.content.length} bytes)`); - files.add(eventPath); - break; - case 'create': - logs.push(`${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes)`); - files.add(eventPath); - break; - case 'delete': - logs.push(`${colors.yellow('DELETE')} ${eventPath}`); - files.add(eventPath); - break; - case 'rename': - const eventToPath = event.to.startsWith('/') ? event.to.substr(1) : event.to; - logs.push(`${colors.blue('RENAME')} ${eventPath} => ${eventToPath}`); - files.add(eventPath); - break; - } - }); - - const lifecycleSubscription = this.workflow.lifeCycle.subscribe((event) => { - if (event.kind == 'end' || event.kind == 'post-tasks-start') { - if (!error) { - // Output the logging queue, no error happened. - logs.forEach((log) => this.logger.info(` ${log}`)); - logs = []; - } - } - }); - - // TODO: Allow passing a schematic instance directly - try { - await this.workflow - .execute({ - collection, - schematic, - options, - logger: this.logger, - }) - .toPromise(); - - reporterSubscription.unsubscribe(); - lifecycleSubscription.unsubscribe(); - - return { success: !error, files }; - } catch (e) { - if (e instanceof UnsuccessfulWorkflowExecution) { - this.logger.error( - `${colors.symbols.cross} Migration failed. See above for further details.\n`, - ); - } else { - const logPath = writeErrorToLogFile(e); - this.logger.fatal( - `${colors.symbols.cross} Migration failed: ${e.message}\n` + - ` See "${logPath}" for further details.\n`, - ); - } - - return { success: false, files }; - } - } - - /** - * @return Whether or not the migration was performed successfully. - */ - private async executeMigration( - packageName: string, - collectionPath: string, - migrationName: string, - commit?: boolean, - ): Promise { - const collection = this.workflow.engine.createCollection(collectionPath); - const name = collection.listSchematicNames().find((name) => name === migrationName); - if (!name) { - this.logger.error(`Cannot find migration '${migrationName}' in '${packageName}'.`); - - return false; - } - - const schematic = this.workflow.engine.createSchematic(name, collection); - - this.logger.info( - colors.cyan(`** Executing '${migrationName}' of package '${packageName}' **\n`), - ); - - return this.executePackageMigrations([schematic.description], packageName, commit); - } - - /** - * @return Whether or not the migrations were performed successfully. - */ - private async executeMigrations( - packageName: string, - collectionPath: string, - from: string, - to: string, - commit?: boolean, - ): Promise { - const collection = this.workflow.engine.createCollection(collectionPath); - const migrationRange = new semver.Range( - '>' + (semver.prerelease(from) ? from.split('-')[0] + '-0' : from) + ' <=' + to.split('-')[0], - ); - const migrations = []; - - for (const name of collection.listSchematicNames()) { - const schematic = this.workflow.engine.createSchematic(name, collection); - const description = schematic.description as typeof schematic.description & { - version?: string; - }; - description.version = coerceVersionNumber(description.version) || undefined; - if (!description.version) { - continue; - } - - if (semver.satisfies(description.version, migrationRange, { includePrerelease: true })) { - migrations.push(description as typeof schematic.description & { version: string }); - } - } - - migrations.sort((a, b) => semver.compare(a.version, b.version) || a.name.localeCompare(b.name)); - - if (migrations.length === 0) { - return true; - } - - this.logger.info(colors.cyan(`** Executing migrations of package '${packageName}' **\n`)); - - return this.executePackageMigrations(migrations, packageName, commit); - } - - private async executePackageMigrations( - migrations: Iterable<{ name: string; description: string; collection: { name: string } }>, - packageName: string, - commit = false, - ): Promise { - for (const migration of migrations) { - const [title, ...description] = migration.description.split('. '); - - this.logger.info( - colors.cyan(colors.symbols.pointer) + - ' ' + - colors.bold(title.endsWith('.') ? title : title + '.'), - ); - - if (description.length) { - this.logger.info(' ' + description.join('.\n ')); - } - - const result = await this.executeSchematic(migration.collection.name, migration.name); - if (!result.success) { - return false; - } - - this.logger.info(' Migration completed.'); - - // Commit migration - if (commit) { - const commitPrefix = `${packageName} migration - ${migration.name}`; - const commitMessage = migration.description - ? `${commitPrefix}\n\n${migration.description}` - : commitPrefix; - const committed = this.commit(commitMessage); - if (!committed) { - // Failed to commit, something went wrong. Abort the update. - return false; - } - } - - this.logger.info(''); // Extra trailing newline. - } - - return true; - } - - // eslint-disable-next-line max-lines-per-function - async run(options: UpdateCommandSchema & Arguments) { - await ensureCompatibleNpm(this.context.root); - - // Check if the current installed CLI version is older than the latest compatible version. - if (!disableVersionCheck) { - const cliVersionToInstall = await this.checkCLIVersion( - options['--'], - options.verbose, - options.next, - ); - - if (cliVersionToInstall) { - this.logger.warn( - 'The installed Angular CLI version is outdated.\n' + - `Installing a temporary Angular CLI versioned ${cliVersionToInstall} to perform the update.`, - ); - - return runTempPackageBin( - `@angular/cli@${cliVersionToInstall}`, - this.packageManager, - process.argv.slice(2), - ); - } - } - - const logVerbose = (message: string) => { - if (options.verbose) { - this.logger.info(message); - } - }; - - if (options.all) { - const updateCmd = - this.packageManager === PackageManager.Yarn - ? `'yarn upgrade-interactive' or 'yarn upgrade'` - : `'${this.packageManager} update'`; - - this.logger.warn(` - '--all' functionality has been removed as updating multiple packages at once is not recommended. - To update packages which don’t provide 'ng update' capabilities in your workspace 'package.json' use ${updateCmd} instead. - Run the package manager update command after updating packages which provide 'ng update' capabilities. - `); - - return 0; - } - - const packages: PackageIdentifier[] = []; - for (const request of options['--'] || []) { - try { - const packageIdentifier = npa(request); - - // only registry identifiers are supported - if (!packageIdentifier.registry) { - this.logger.error(`Package '${request}' is not a registry package identifer.`); - - return 1; - } - - if (packages.some((v) => v.name === packageIdentifier.name)) { - this.logger.error(`Duplicate package '${packageIdentifier.name}' specified.`); - - return 1; - } - - if (options.migrateOnly && packageIdentifier.rawSpec) { - this.logger.warn('Package specifier has no effect when using "migrate-only" option.'); - } - - // If next option is used and no specifier supplied, use next tag - if (options.next && !packageIdentifier.rawSpec) { - packageIdentifier.fetchSpec = 'next'; - } - - packages.push(packageIdentifier as PackageIdentifier); - } catch (e) { - this.logger.error(e.message); - - return 1; - } - } - - if (!options.migrateOnly && (options.from || options.to)) { - this.logger.error('Can only use "from" or "to" options with "migrate-only" option.'); - - return 1; - } - - // If not asking for status then check for a clean git repository. - // This allows the user to easily reset any changes from the update. - if (packages.length && !this.checkCleanGit()) { - if (options.allowDirty) { - this.logger.warn( - 'Repository is not clean. Update changes will be mixed with pre-existing changes.', - ); - } else { - this.logger.error( - 'Repository is not clean. Please commit or stash any changes before updating.', - ); - - return 2; - } - } - - this.logger.info(`Using package manager: '${this.packageManager}'`); - this.logger.info('Collecting installed dependencies...'); - - const rootDependencies = await getProjectDependencies(this.context.root); - - this.logger.info(`Found ${rootDependencies.size} dependencies.`); - - if (packages.length === 0) { - // Show status - const { success } = await this.executeSchematic(UPDATE_SCHEMATIC_COLLECTION, 'update', { - force: options.force || false, - next: options.next || false, - verbose: options.verbose || false, - packageManager: this.packageManager, - packages: [], - }); - - return success ? 0 : 1; - } - - if (options.migrateOnly) { - if (!options.from && typeof options.migrateOnly !== 'string') { - this.logger.error( - '"from" option is required when using the "migrate-only" option without a migration name.', - ); - - return 1; - } else if (packages.length !== 1) { - this.logger.error( - 'A single package must be specified when using the "migrate-only" option.', - ); - - return 1; - } - - if (options.next) { - this.logger.warn('"next" option has no effect when using "migrate-only" option.'); - } - - const packageName = packages[0].name; - const packageDependency = rootDependencies.get(packageName); - let packagePath = packageDependency?.path; - let packageNode = packageDependency?.package; - if (packageDependency && !packageNode) { - this.logger.error('Package found in package.json but is not installed.'); - - return 1; - } else if (!packageDependency) { - // Allow running migrations on transitively installed dependencies - // There can technically be nested multiple versions - // TODO: If multiple, this should find all versions and ask which one to use - const packageJson = findPackageJson(this.context.root, packageName); - if (packageJson) { - packagePath = path.dirname(packageJson); - packageNode = await readPackageJson(packageJson); - } - } - - if (!packageNode || !packagePath) { - this.logger.error('Package is not installed.'); - - return 1; - } - - const updateMetadata = packageNode['ng-update']; - let migrations = updateMetadata?.migrations; - if (migrations === undefined) { - this.logger.error('Package does not provide migrations.'); - - return 1; - } else if (typeof migrations !== 'string') { - this.logger.error('Package contains a malformed migrations field.'); - - return 1; - } else if (path.posix.isAbsolute(migrations) || path.win32.isAbsolute(migrations)) { - this.logger.error( - 'Package contains an invalid migrations field. Absolute paths are not permitted.', - ); - - return 1; - } - - // Normalize slashes - migrations = migrations.replace(/\\/g, '/'); - - if (migrations.startsWith('../')) { - this.logger.error( - 'Package contains an invalid migrations field. Paths outside the package root are not permitted.', - ); - - return 1; - } - - // Check if it is a package-local location - const localMigrations = path.join(packagePath, migrations); - if (fs.existsSync(localMigrations)) { - migrations = localMigrations; - } else { - // Try to resolve from package location. - // This avoids issues with package hoisting. - try { - migrations = require.resolve(migrations, { paths: [packagePath] }); - } catch (e) { - if (e.code === 'MODULE_NOT_FOUND') { - this.logger.error('Migrations for package were not found.'); - } else { - this.logger.error(`Unable to resolve migrations for package. [${e.message}]`); - } - - return 1; - } - } - - let result: boolean; - if (typeof options.migrateOnly == 'string') { - result = await this.executeMigration( - packageName, - migrations, - options.migrateOnly, - options.createCommits, - ); - } else { - const from = coerceVersionNumber(options.from); - if (!from) { - this.logger.error(`"from" value [${options.from}] is not a valid version.`); - - return 1; - } - - result = await this.executeMigrations( - packageName, - migrations, - from, - options.to || packageNode.version, - options.createCommits, - ); - } - - return result ? 0 : 1; - } - - const requests: { - identifier: PackageIdentifier; - node: PackageTreeNode; - }[] = []; - - // Validate packages actually are part of the workspace - for (const pkg of packages) { - const node = rootDependencies.get(pkg.name); - if (!node?.package) { - this.logger.error(`Package '${pkg.name}' is not a dependency.`); - - return 1; - } - - // If a specific version is requested and matches the installed version, skip. - if (pkg.type === 'version' && node.package.version === pkg.fetchSpec) { - this.logger.info(`Package '${pkg.name}' is already at '${pkg.fetchSpec}'.`); - continue; - } - - requests.push({ identifier: pkg, node }); - } - - if (requests.length === 0) { - return 0; - } - - const packagesToUpdate: string[] = []; - - this.logger.info('Fetching dependency metadata from registry...'); - for (const { identifier: requestIdentifier, node } of requests) { - const packageName = requestIdentifier.name; - - let metadata; - try { - // Metadata requests are internally cached; multiple requests for same name - // does not result in additional network traffic - metadata = await fetchPackageMetadata(packageName, this.logger, { - verbose: options.verbose, - }); - } catch (e) { - this.logger.error(`Error fetching metadata for '${packageName}': ` + e.message); - - return 1; - } - - // Try to find a package version based on the user requested package specifier - // registry specifier types are either version, range, or tag - let manifest: PackageManifest | undefined; - if ( - requestIdentifier.type === 'version' || - requestIdentifier.type === 'range' || - requestIdentifier.type === 'tag' - ) { - try { - manifest = pickManifest(metadata, requestIdentifier.fetchSpec); - } catch (e) { - if (e.code === 'ETARGET') { - // If not found and next was used and user did not provide a specifier, try latest. - // Package may not have a next tag. - if ( - requestIdentifier.type === 'tag' && - requestIdentifier.fetchSpec === 'next' && - !requestIdentifier.rawSpec - ) { - try { - manifest = pickManifest(metadata, 'latest'); - } catch (e) { - if (e.code !== 'ETARGET' && e.code !== 'ENOVERSIONS') { - throw e; - } - } - } - } else if (e.code !== 'ENOVERSIONS') { - throw e; - } - } - } - - if (!manifest) { - this.logger.error( - `Package specified by '${requestIdentifier.raw}' does not exist within the registry.`, - ); - - return 1; - } - - if (manifest.version === node.package?.version) { - this.logger.info(`Package '${packageName}' is already up to date.`); - continue; - } - - if (node.package && ANGULAR_PACKAGES_REGEXP.test(node.package.name)) { - const { name, version } = node.package; - const toBeInstalledMajorVersion = +manifest.version.split('.')[0]; - const currentMajorVersion = +version.split('.')[0]; - - if (toBeInstalledMajorVersion - currentMajorVersion > 1) { - // Only allow updating a single version at a time. - if (currentMajorVersion < 6) { - // Before version 6, the major versions were not always sequential. - // Example @angular/core skipped version 3, @angular/cli skipped versions 2-5. - this.logger.error( - `Updating multiple major versions of '${name}' at once is not supported. Please migrate each major version individually.\n` + - `For more information about the update process, see https://update.angular.io/.`, - ); - } else { - const nextMajorVersionFromCurrent = currentMajorVersion + 1; - - this.logger.error( - `Updating multiple major versions of '${name}' at once is not supported. Please migrate each major version individually.\n` + - `Run 'ng update ${name}@${nextMajorVersionFromCurrent}' in your workspace directory ` + - `to update to latest '${nextMajorVersionFromCurrent}.x' version of '${name}'.\n\n` + - `For more information about the update process, see https://update.angular.io/?v=${currentMajorVersion}.0-${nextMajorVersionFromCurrent}.0`, - ); - } - - return 1; - } - } - - packagesToUpdate.push(requestIdentifier.toString()); - } - - if (packagesToUpdate.length === 0) { - return 0; - } - - const { success } = await this.executeSchematic(UPDATE_SCHEMATIC_COLLECTION, 'update', { - verbose: options.verbose || false, - force: options.force || false, - next: !!options.next, - packageManager: this.packageManager, - packages: packagesToUpdate, - }); - - if (success) { - try { - // Remove existing node modules directory to provide a stronger guarantee that packages - // will be hoisted into the correct locations. - - // The below should be removed and replaced with just `rm` when support for Node.Js 12 is removed. - const { rm, rmdir } = fs.promises as typeof fs.promises & { - rm?: ( - path: fs.PathLike, - options?: { - force?: boolean; - maxRetries?: number; - recursive?: boolean; - retryDelay?: number; - }, - ) => Promise; - }; - - if (rm) { - await rm(path.join(this.context.root, 'node_modules'), { - force: true, - recursive: true, - maxRetries: 3, - }); - } else { - await rmdir(path.join(this.context.root, 'node_modules'), { - recursive: true, - maxRetries: 3, - }); - } - } catch {} - - const result = await installAllPackages( - this.packageManager, - options.force ? ['--force'] : [], - this.context.root, - ); - if (result !== 0) { - return result; - } - } - - if (success && options.createCommits) { - const committed = this.commit( - `Angular CLI update for packages - ${packagesToUpdate.join(', ')}`, - ); - if (!committed) { - return 1; - } - } - - // This is a temporary workaround to allow data to be passed back from the update schematic - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const migrations = (global as any).externalMigrations as { - package: string; - collection: string; - from: string; - to: string; - }[]; - - if (success && migrations) { - for (const migration of migrations) { - // Resolve the package from the workspace root, as otherwise it will be resolved from the temp - // installed CLI version. - let packagePath; - logVerbose( - `Resolving migration package '${migration.package}' from '${this.context.root}'...`, - ); - try { - try { - packagePath = path.dirname( - // This may fail if the `package.json` is not exported as an entry point - require.resolve(path.join(migration.package, 'package.json'), { - paths: [this.context.root], - }), - ); - } catch (e) { - if (e.code === 'MODULE_NOT_FOUND') { - // Fallback to trying to resolve the package's main entry point - packagePath = require.resolve(migration.package, { paths: [this.context.root] }); - } else { - throw e; - } - } - } catch (e) { - if (e.code === 'MODULE_NOT_FOUND') { - logVerbose(e.toString()); - this.logger.error( - `Migrations for package (${migration.package}) were not found.` + - ' The package could not be found in the workspace.', - ); - } else { - this.logger.error( - `Unable to resolve migrations for package (${migration.package}). [${e.message}]`, - ); - } - - return 1; - } - - let migrations; - - // Check if it is a package-local location - const localMigrations = path.join(packagePath, migration.collection); - if (fs.existsSync(localMigrations)) { - migrations = localMigrations; - } else { - // Try to resolve from package location. - // This avoids issues with package hoisting. - try { - migrations = require.resolve(migration.collection, { paths: [packagePath] }); - } catch (e) { - if (e.code === 'MODULE_NOT_FOUND') { - this.logger.error(`Migrations for package (${migration.package}) were not found.`); - } else { - this.logger.error( - `Unable to resolve migrations for package (${migration.package}). [${e.message}]`, - ); - } - - return 1; - } - } - const result = await this.executeMigrations( - migration.package, - migrations, - migration.from, - migration.to, - options.createCommits, - ); - - if (!result) { - return 0; - } - } - } - - return success ? 0 : 1; - } - - /** - * @return Whether or not the commit was successful. - */ - private commit(message: string): boolean { - // Check if a commit is needed. - let commitNeeded: boolean; - try { - commitNeeded = hasChangesToCommit(); - } catch (err) { - this.logger.error(` Failed to read Git tree:\n${err.stderr}`); - - return false; - } - - if (!commitNeeded) { - this.logger.info(' No changes to commit after migration.'); - - return true; - } - - // Commit changes and abort on error. - try { - createCommit(message); - } catch (err) { - this.logger.error(`Failed to commit update (${message}):\n${err.stderr}`); - - return false; - } - - // Notify user of the commit. - const hash = findCurrentGitSha(); - const shortMessage = message.split('\n')[0]; - if (hash) { - this.logger.info(` Committed migration step (${getShortHash(hash)}): ${shortMessage}.`); - } else { - // Commit was successful, but reading the hash was not. Something weird happened, - // but nothing that would stop the update. Just log the weirdness and continue. - this.logger.info(` Committed migration step: ${shortMessage}.`); - this.logger.warn(' Failed to look up hash of most recent commit, continuing anyways.'); - } - - return true; - } - - private checkCleanGit(): boolean { - try { - const topLevel = execSync('git rev-parse --show-toplevel', { - encoding: 'utf8', - stdio: 'pipe', - }); - const result = execSync('git status --porcelain', { encoding: 'utf8', stdio: 'pipe' }); - if (result.trim().length === 0) { - return true; - } - - // Only files inside the workspace root are relevant - for (const entry of result.split('\n')) { - const relativeEntry = path.relative( - path.resolve(this.context.root), - path.resolve(topLevel.trim(), entry.slice(3).trim()), - ); - - if (!relativeEntry.startsWith('..') && !path.isAbsolute(relativeEntry)) { - return false; - } - } - } catch {} - - return true; - } - - /** - * Checks if the current installed CLI version is older or newer than a compatible version. - * @returns the version to install or null when there is no update to install. - */ - private async checkCLIVersion( - packagesToUpdate: string[] | undefined, - verbose = false, - next = false, - ): Promise { - const { version } = await fetchPackageManifest( - `@angular/cli@${this.getCLIUpdateRunnerVersion(packagesToUpdate, next)}`, - this.logger, - { - verbose, - usingYarn: this.packageManager === PackageManager.Yarn, - }, - ); - - return VERSION.full === version ? null : version; - } - - private getCLIUpdateRunnerVersion( - packagesToUpdate: string[] | undefined, - next: boolean, - ): string | number { - if (next) { - return 'next'; - } - - const updatingAngularPackage = packagesToUpdate?.find((r) => ANGULAR_PACKAGES_REGEXP.test(r)); - if (updatingAngularPackage) { - // If we are updating any Angular package we can update the CLI to the target version because - // migrations for @angular/core@13 can be executed using Angular/cli@13. - // This is same behaviour as `npx @angular/cli@13 update @angular/core@13`. - - // `@angular/cli@13` -> ['', 'angular/cli', '13'] - // `@angular/cli` -> ['', 'angular/cli'] - const tempVersion = coerceVersionNumber(updatingAngularPackage.split('@')[2]); - - return semver.parse(tempVersion)?.major ?? 'latest'; - } - - // When not updating an Angular package we cannot determine which schematic runtime the migration should to be executed in. - // Typically, we can assume that the `@angular/cli` was updated previously. - // Example: Angular official packages are typically updated prior to NGRX etc... - // Therefore, we only update to the latest patch version of the installed major version of the Angular CLI. - - // This is important because we might end up in a scenario where locally Angular v12 is installed, updating NGRX from 11 to 12. - // We end up using Angular ClI v13 to run the migrations if we run the migrations using the CLI installed major version + 1 logic. - return VERSION.major; - } -} - -/** - * @return Whether or not the working directory has Git changes to commit. - */ -function hasChangesToCommit(): boolean { - // List all modified files not covered by .gitignore. - const files = execSync('git ls-files -m -d -o --exclude-standard').toString(); - - // If any files are returned, then there must be something to commit. - return files !== ''; -} - -/** - * Precondition: Must have pending changes to commit, they do not need to be staged. - * Postcondition: The Git working tree is committed and the repo is clean. - * @param message The commit message to use. - */ -function createCommit(message: string) { - // Stage entire working tree for commit. - execSync('git add -A', { encoding: 'utf8', stdio: 'pipe' }); - - // Commit with the message passed via stdin to avoid bash escaping issues. - execSync('git commit --no-verify -F -', { encoding: 'utf8', stdio: 'pipe', input: message }); -} - -/** - * @return The Git SHA hash of the HEAD commit. Returns null if unable to retrieve the hash. - */ -function findCurrentGitSha(): string | null { - try { - const hash = execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: 'pipe' }); - - return hash.trim(); - } catch { - return null; - } -} - -function getShortHash(commitHash: string): string { - return commitHash.slice(0, 9); -} - -function coerceVersionNumber(version: string | undefined): string | null { - if (!version) { - return null; - } - - if (!version.match(/^\d{1,30}\.\d{1,30}\.\d{1,30}/)) { - const match = version.match(/^\d{1,30}(\.\d{1,30})*/); - - if (!match) { - return null; - } - - if (!match[1]) { - version = version.substr(0, match[0].length) + '.0.0' + version.substr(match[0].length); - } else if (!match[2]) { - version = version.substr(0, match[0].length) + '.0' + version.substr(match[0].length); - } else { - return null; - } - } - - return semver.valid(version); -} diff --git a/packages/angular/cli/commands/update-long.md b/packages/angular/cli/commands/update-long.md deleted file mode 100644 index 72df66ce35da..000000000000 --- a/packages/angular/cli/commands/update-long.md +++ /dev/null @@ -1,22 +0,0 @@ -Perform a basic update to the current stable release of the core framework and CLI by running the following command. - -``` -ng update @angular/cli @angular/core -``` - -To update to the next beta or pre-release version, use the `--next` option. - -To update from one major version to another, use the format - -``` -ng update @angular/cli@^ @angular/core@^ -``` - -We recommend that you always update to the latest patch version, as it contains fixes we released since the initial major release. -For example, use the following command to take the latest 10.x.x version and use that to update. - -``` -ng update @angular/cli@^10 @angular/core@^10 -``` - -For detailed information and guidance on updating your application, see the interactive [Angular Update Guide](https://update.angular.io/). diff --git a/packages/angular/cli/commands/update.json b/packages/angular/cli/commands/update.json deleted file mode 100644 index 7de5a1935146..000000000000 --- a/packages/angular/cli/commands/update.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/update.json", - "description": "Updates your application and its dependencies. See https://update.angular.io/", - "$longDescription": "./update-long.md", - - "$scope": "all", - "$aliases": [], - "$type": "schematics", - "$impl": "./update-impl#UpdateCommand", - - "type": "object", - "allOf": [ - { - "$ref": "./definitions.json#/definitions/base" - }, - { - "type": "object", - "properties": { - "packages": { - "description": "The names of package(s) to update.", - "type": "array", - "items": { - "type": "string" - }, - "$default": { - "$source": "argv" - } - }, - "force": { - "description": "Ignore peer dependency version mismatches. Passes the `--force` flag to the package manager when installing packages.", - "default": false, - "type": "boolean" - }, - "all": { - "description": "Whether to update all packages in package.json.", - "default": false, - "type": "boolean", - "x-deprecated": true - }, - "next": { - "description": "Use the prerelease version, including beta and RCs.", - "default": false, - "type": "boolean" - }, - "migrateOnly": { - "description": "Only perform a migration, do not update the installed version.", - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "description": "The name of the migration to run." - } - ] - }, - "from": { - "description": "Version from which to migrate from. Only available with a single package being updated, and only on migration only.", - "type": "string" - }, - "to": { - "description": "Version up to which to apply migrations. Only available with a single package being updated, and only on migrations only. Requires from to be specified. Default to the installed version detected.", - "type": "string" - }, - "allowDirty": { - "description": "Whether to allow updating when the repository contains modified or untracked files.", - "type": "boolean" - }, - "verbose": { - "description": "Display additional details about internal operations during execution.", - "type": "boolean", - "default": false - }, - "createCommits": { - "description": "Create source control commits for updates and migrations.", - "type": "boolean", - "default": false, - "aliases": ["C"] - } - } - } - ] -} diff --git a/packages/angular/cli/commands/version-impl.ts b/packages/angular/cli/commands/version-impl.ts deleted file mode 100644 index 7df7544e3b4a..000000000000 --- a/packages/angular/cli/commands/version-impl.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { execSync } from 'child_process'; -import nodeModule from 'module'; -import { Command } from '../models/command'; -import { colors } from '../utilities/color'; -import { getPackageManager } from '../utilities/package-manager'; -import { Schema as VersionCommandSchema } from './version'; - -/** - * Major versions of Node.js that are officially supported by Angular. - */ -const SUPPORTED_NODE_MAJORS = [12, 14, 16]; - -interface PartialPackageInfo { - name: string; - version: string; - dependencies?: Record; - devDependencies?: Record; -} - -export class VersionCommand extends Command { - public static aliases = ['v']; - - private readonly localRequire = nodeModule.createRequire(__filename); - // Trailing slash is used to allow the path to be treated as a directory - private readonly workspaceRequire = nodeModule.createRequire(this.context.root + '/'); - - async run() { - const cliPackage: PartialPackageInfo = this.localRequire('../package.json'); - let workspacePackage: PartialPackageInfo | undefined; - try { - workspacePackage = this.workspaceRequire('./package.json'); - } catch {} - - const [nodeMajor] = process.versions.node.split('.').map((part) => Number(part)); - const unsupportedNodeVersion = !SUPPORTED_NODE_MAJORS.includes(nodeMajor); - - const patterns = [ - /^@angular\/.*/, - /^@angular-devkit\/.*/, - /^@bazel\/.*/, - /^@ngtools\/.*/, - /^@nguniversal\/.*/, - /^@schematics\/.*/, - /^rxjs$/, - /^typescript$/, - /^ng-packagr$/, - /^webpack$/, - ]; - - const packageNames = [ - ...Object.keys(cliPackage.dependencies || {}), - ...Object.keys(cliPackage.devDependencies || {}), - ...Object.keys(workspacePackage?.dependencies || {}), - ...Object.keys(workspacePackage?.devDependencies || {}), - ]; - - const versions = packageNames - .filter((x) => patterns.some((p) => p.test(x))) - .reduce((acc, name) => { - if (name in acc) { - return acc; - } - - acc[name] = this.getVersion(name); - - return acc; - }, {} as { [module: string]: string }); - - const ngCliVersion = cliPackage.version; - let angularCoreVersion = ''; - const angularSameAsCore: string[] = []; - - if (workspacePackage) { - // Filter all angular versions that are the same as core. - angularCoreVersion = versions['@angular/core']; - if (angularCoreVersion) { - for (const angularPackage of Object.keys(versions)) { - if ( - versions[angularPackage] == angularCoreVersion && - angularPackage.startsWith('@angular/') - ) { - angularSameAsCore.push(angularPackage.replace(/^@angular\//, '')); - delete versions[angularPackage]; - } - } - - // Make sure we list them in alphabetical order. - angularSameAsCore.sort(); - } - } - - const namePad = ' '.repeat( - Object.keys(versions).sort((a, b) => b.length - a.length)[0].length + 3, - ); - const asciiArt = ` - _ _ ____ _ ___ - / \\ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _| - / △ \\ | '_ \\ / _\` | | | | |/ _\` | '__| | | | | | | - / ___ \\| | | | (_| | |_| | | (_| | | | |___| |___ | | - /_/ \\_\\_| |_|\\__, |\\__,_|_|\\__,_|_| \\____|_____|___| - |___/ - ` - .split('\n') - .map((x) => colors.red(x)) - .join('\n'); - - this.logger.info(asciiArt); - this.logger.info( - ` - Angular CLI: ${ngCliVersion} - Node: ${process.versions.node}${unsupportedNodeVersion ? ' (Unsupported)' : ''} - Package Manager: ${await this.getPackageManager()} - OS: ${process.platform} ${process.arch} - - Angular: ${angularCoreVersion} - ... ${angularSameAsCore - .reduce((acc, name) => { - // Perform a simple word wrap around 60. - if (acc.length == 0) { - return [name]; - } - const line = acc[acc.length - 1] + ', ' + name; - if (line.length > 60) { - acc.push(name); - } else { - acc[acc.length - 1] = line; - } - - return acc; - }, []) - .join('\n... ')} - - Package${namePad.slice(7)}Version - -------${namePad.replace(/ /g, '-')}------------------ - ${Object.keys(versions) - .map((module) => `${module}${namePad.slice(module.length)}${versions[module]}`) - .sort() - .join('\n')} - `.replace(/^ {6}/gm, ''), - ); - - if (unsupportedNodeVersion) { - this.logger.warn( - `Warning: The current version of Node (${process.versions.node}) is not supported by Angular.`, - ); - } - } - - private getVersion(moduleName: string): string { - let packageInfo: PartialPackageInfo | undefined; - let cliOnly = false; - - // Try to find the package in the workspace - try { - packageInfo = this.workspaceRequire(`${moduleName}/package.json`); - } catch {} - - // If not found, try to find within the CLI - if (!packageInfo) { - try { - packageInfo = this.localRequire(`${moduleName}/package.json`); - cliOnly = true; - } catch {} - } - - let version: string | undefined; - - // If found, attempt to get the version - if (packageInfo) { - try { - version = packageInfo.version + (cliOnly ? ' (cli-only)' : ''); - } catch {} - } - - return version || ''; - } - - private async getPackageManager(): Promise { - try { - const manager = await getPackageManager(this.context.root); - const version = execSync(`${manager} --version`, { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - env: { - ...process.env, - // NPM updater notifier will prevents the child process from closing until it timeout after 3 minutes. - NO_UPDATE_NOTIFIER: '1', - NPM_CONFIG_UPDATE_NOTIFIER: 'false', - }, - }).trim(); - - return `${manager} ${version}`; - } catch { - return ''; - } - } -} diff --git a/packages/angular/cli/commands/version.json b/packages/angular/cli/commands/version.json deleted file mode 100644 index 8f4c3ff1fdd1..000000000000 --- a/packages/angular/cli/commands/version.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema", - "$id": "ng-cli://commands/version.json", - "description": "Outputs Angular CLI version.", - "$longDescription": "", - - "$aliases": ["v"], - "$scope": "all", - "$impl": "./version-impl#VersionCommand", - - "type": "object", - "allOf": [{ "$ref": "./definitions.json#/definitions/base" }] -} diff --git a/packages/angular/cli/lib/cli/index.ts b/packages/angular/cli/lib/cli/index.ts index 820db03dfbac..361d44f0bbb5 100644 --- a/packages/angular/cli/lib/cli/index.ts +++ b/packages/angular/cli/lib/cli/index.ts @@ -3,42 +3,68 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ -import { createConsoleLogger } from '@angular-devkit/core/node'; -import { format } from 'util'; -import { runCommand } from '../../models/command-runner'; -import { colors, removeColor } from '../../utilities/color'; -import { AngularWorkspace, getWorkspaceRaw } from '../../utilities/config'; -import { writeErrorToLogFile } from '../../utilities/log-file'; -import { findWorkspaceFile } from '../../utilities/project'; +import { logging } from '@angular-devkit/core'; +import { format, stripVTControlCharacters } from 'node:util'; +import { CommandModuleError } from '../../src/command-builder/command-module'; +import { runCommand } from '../../src/command-builder/command-runner'; +import { colors, supportColor } from '../../src/utilities/color'; +import { ngDebug } from '../../src/utilities/environment-options'; +import { writeErrorToLogFile } from '../../src/utilities/log-file'; -export { VERSION, Version } from '../../models/version'; +export { VERSION } from '../../src/utilities/version'; -const debugEnv = process.env['NG_DEBUG']; -const isDebug = debugEnv !== undefined && debugEnv !== '0' && debugEnv.toLowerCase() !== 'false'; +const MIN_NODEJS_VERSION = [18, 13] as const; /* eslint-disable no-console */ -export default async function (options: { testing?: boolean; cliArgs: string[] }) { +export default async function (options: { cliArgs: string[] }) { // This node version check ensures that the requirements of the project instance of the CLI are met - const version = process.versions.node.split('.').map((part) => Number(part)); - if (version[0] < 12 || (version[0] === 12 && version[1] < 20)) { + const [major, minor] = process.versions.node.split('.').map((part) => Number(part)); + if ( + major < MIN_NODEJS_VERSION[0] || + (major === MIN_NODEJS_VERSION[0] && minor < MIN_NODEJS_VERSION[1]) + ) { process.stderr.write( `Node.js version ${process.version} detected.\n` + - 'The Angular CLI requires a minimum v12.20.\n\n' + + `The Angular CLI requires a minimum of v${MIN_NODEJS_VERSION[0]}.${MIN_NODEJS_VERSION[1]}.\n\n` + 'Please update your Node.js version or visit https://nodejs.org/ for additional instructions.\n', ); return 3; } - const logger = createConsoleLogger(isDebug, process.stdout, process.stderr, { - info: (s) => (colors.enabled ? s : removeColor(s)), - debug: (s) => (colors.enabled ? s : removeColor(s)), - warn: (s) => (colors.enabled ? colors.bold.yellow(s) : removeColor(s)), - error: (s) => (colors.enabled ? colors.bold.red(s) : removeColor(s)), - fatal: (s) => (colors.enabled ? colors.bold.red(s) : removeColor(s)), + const colorLevels: Record string> = { + info: (s) => s, + debug: (s) => s, + warn: (s) => colors.bold(colors.yellow(s)), + error: (s) => colors.bold(colors.red(s)), + fatal: (s) => colors.bold(colors.red(s)), + }; + const logger = new logging.IndentLogger('cli-main-logger'); + const logInfo = console.log; + const logError = console.error; + const useColor = supportColor(); + + const loggerFinished = logger.forEach((entry) => { + if (!ngDebug && entry.level === 'debug') { + return; + } + + const color = useColor ? colorLevels[entry.level] : stripVTControlCharacters; + const message = color(entry.message); + + switch (entry.level) { + case 'warn': + case 'fatal': + case 'error': + logError(message); + break; + default: + logInfo(message); + break; + } }); // Redirect console to logger @@ -52,39 +78,12 @@ export default async function (options: { testing?: boolean; cliArgs: string[] } logger.error(format(...args)); }; - let workspace; - const workspaceFile = findWorkspaceFile(); - if (workspaceFile === null) { - const [, localPath] = getWorkspaceRaw('local'); - if (localPath !== null) { - logger.fatal( - `An invalid configuration file was found ['${localPath}'].` + - ' Please delete the file before running the command.', - ); - - return 1; - } - } else { - try { - workspace = await AngularWorkspace.load(workspaceFile); - } catch (e) { - logger.fatal(`Unable to read workspace file '${workspaceFile}': ${e.message}`); - - return 1; - } - } - try { - const maybeExitCode = await runCommand(options.cliArgs, logger, workspace); - if (typeof maybeExitCode === 'number') { - console.assert(Number.isInteger(maybeExitCode)); - - return maybeExitCode; - } - - return 0; + return await runCommand(options.cliArgs, logger); } catch (err) { - if (err instanceof Error) { + if (err instanceof CommandModuleError) { + logger.fatal(`Error: ${err.message}`); + } else if (err instanceof Error) { try { const logPath = writeErrorToLogFile(err); logger.fatal( @@ -94,7 +93,7 @@ export default async function (options: { testing?: boolean; cliArgs: string[] } } catch (e) { logger.fatal( `An unhandled exception occurred: ${err.message}\n` + - `Fatal error writing debug log file: ${e.message}`, + `Fatal error writing debug log file: ${e}`, ); if (err.stack) { logger.fatal(err.stack); @@ -107,15 +106,12 @@ export default async function (options: { testing?: boolean; cliArgs: string[] } } else if (typeof err === 'number') { // Log nothing. } else { - logger.fatal('An unexpected error occurred: ' + JSON.stringify(err)); - } - - if (options.testing) { - // eslint-disable-next-line no-debugger - debugger; - throw err; + logger.fatal(`An unexpected error occurred: ${err}`); } return 1; + } finally { + logger.complete(); + await loggerFinished; } } diff --git a/packages/angular/cli/lib/config/workspace-schema.json b/packages/angular/cli/lib/config/workspace-schema.json index 7edd9da60eb6..650ae3ae18f2 100644 --- a/packages/angular/cli/lib/config/workspace-schema.json +++ b/packages/angular/cli/lib/config/workspace-schema.json @@ -20,14 +20,10 @@ "type": "string", "description": "Path where new projects will be created." }, - "defaultProject": { - "type": "string", - "description": "Default project name used in commands." - }, "projects": { "type": "object", "patternProperties": { - "^(?:@[a-zA-Z0-9_-]+/)?[a-zA-Z0-9_-]+$": { + "^(?:@[a-zA-Z0-9._-]+/)?[a-zA-Z0-9._-]+$": { "$ref": "#/definitions/project" } }, @@ -40,14 +36,18 @@ "cliOptions": { "type": "object", "properties": { - "defaultCollection": { - "description": "The default schematics collection to use.", - "type": "string" + "schematicCollections": { + "type": "array", + "description": "The list of schematic collections to use.", + "items": { + "type": "string", + "uniqueItems": true + } }, "packageManager": { "description": "Specify which package manager tool to use.", "type": "string", - "enum": ["npm", "cnpm", "yarn", "pnpm"] + "enum": ["npm", "cnpm", "yarn", "pnpm", "bun"] }, "warnings": { "description": "Control CLI specific console warnings", @@ -57,26 +57,12 @@ "description": "Show a warning when the global version is newer than the local one.", "type": "boolean" } - } + }, + "additionalProperties": false }, "analytics": { "type": ["boolean", "string"], - "description": "Share anonymous usage data with the Angular Team at Google." - }, - "analyticsSharing": { - "type": "object", - "properties": { - "tracking": { - "description": "Analytics sharing info tracking ID.", - "type": "string", - "pattern": "^(GA|UA)?-\\d+-\\d+$" - }, - "uuid": { - "description": "Analytics sharing info universally unique identifier.", - "type": "string", - "format": "uuid" - } - } + "description": "Share pseudonymous usage data with the Angular Team at Google." }, "cache": { "description": "Control disk cache.", @@ -95,7 +81,53 @@ "description": "Cache base path.", "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "cliGlobalOptions": { + "type": "object", + "properties": { + "schematicCollections": { + "type": "array", + "description": "The list of schematic collections to use.", + "items": { + "type": "string", + "uniqueItems": true } + }, + "packageManager": { + "description": "Specify which package manager tool to use.", + "type": "string", + "enum": ["npm", "cnpm", "yarn", "pnpm", "bun"] + }, + "warnings": { + "description": "Control CLI specific console warnings", + "type": "object", + "properties": { + "versionMismatch": { + "description": "Show a warning when the global version is newer than the local one.", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "analytics": { + "type": ["boolean", "string"], + "description": "Share pseudonymous usage data with the Angular Team at Google." + }, + "completion": { + "type": "object", + "description": "Angular CLI completion settings.", + "properties": { + "prompted": { + "type": "boolean", + "description": "Whether the user has been prompted to add completion command prompt." + } + }, + "additionalProperties": false } }, "additionalProperties": false @@ -146,9 +178,7 @@ "$ref": "../../../../schematics/angular/web-worker/schema.json" } }, - "additionalProperties": { - "type": "object" - } + "additionalProperties": true }, "fileVersion": { "type": "integer", @@ -159,9 +189,13 @@ "type": "object", "properties": { "cli": { - "defaultCollection": { - "description": "The default schematics collection to use.", - "type": "string" + "schematicCollections": { + "type": "array", + "description": "The list of schematic collections to use.", + "items": { + "type": "string", + "uniqueItems": true + } } }, "schematics": { @@ -320,14 +354,23 @@ "description": "The builder used for this package.", "not": { "enum": [ + "@angular/build:application", + "@angular/build:dev-server", + "@angular/build:extract-i18n", + "@angular-devkit/build-angular:application", "@angular-devkit/build-angular:app-shell", "@angular-devkit/build-angular:browser", + "@angular-devkit/build-angular:browser-esbuild", "@angular-devkit/build-angular:dev-server", "@angular-devkit/build-angular:extract-i18n", "@angular-devkit/build-angular:karma", + "@angular-devkit/build-angular:ng-packagr", + "@angular-devkit/build-angular:prerender", + "@angular-devkit/build-angular:jest", + "@angular-devkit/build-angular:web-test-runner", "@angular-devkit/build-angular:protractor", "@angular-devkit/build-angular:server", - "@angular-devkit/build-angular:ng-packagr" + "@angular-devkit/build-angular:ssr-dev-server" ] } }, @@ -349,6 +392,50 @@ "additionalProperties": false, "required": ["builder"] }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:application" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/application/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/application/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:application" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/application/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/application/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -393,6 +480,50 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:browser-esbuild" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/browser-esbuild/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/browser-esbuild/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:dev-server" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/dev-server/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/dev-server/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -415,6 +546,28 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:extract-i18n" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/extract-i18n/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/extract-i18n/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -459,6 +612,50 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:jest" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/jest/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/jest/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:web-test-runner" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/web-test-runner/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/web-test-runner/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -481,6 +678,50 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:prerender" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/prerender/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/prerender/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:ssr-dev-server" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/ssr-dev-server/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/ssr-dev-server/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -533,14 +774,13 @@ "type": "object", "properties": { "$schema": { - "type": "string", - "format": "uri" + "type": "string" }, "version": { "$ref": "#/definitions/fileVersion" }, "cli": { - "$ref": "#/definitions/cliOptions" + "$ref": "#/definitions/cliGlobalOptions" }, "schematics": { "$ref": "#/definitions/schematicOptions" diff --git a/packages/angular/cli/lib/init.ts b/packages/angular/cli/lib/init.ts index cf18b8bcd77b..dc3d54ab1ded 100644 --- a/packages/angular/cli/lib/init.ts +++ b/packages/angular/cli/lib/init.ts @@ -3,19 +3,29 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import 'symbol-observable'; // symbol polyfill must go first import { promises as fs } from 'fs'; +import { createRequire } from 'module'; import * as path from 'path'; -import { SemVer } from 'semver'; -import { VERSION } from '../models/version'; -import { colors } from '../utilities/color'; -import { isWarningEnabled } from '../utilities/config'; +import { SemVer, major } from 'semver'; +import { colors } from '../src/utilities/color'; +import { isWarningEnabled } from '../src/utilities/config'; +import { disableVersionCheck } from '../src/utilities/environment-options'; +import { VERSION } from '../src/utilities/version'; -(async () => { +/** + * Angular CLI versions prior to v14 may not exit correctly if not forcibly exited + * via `process.exit()`. When bootstrapping, `forceExit` will be set to `true` + * if the local CLI version is less than v14 to prevent the CLI from hanging on + * exit in those cases. + */ +let forceExit = false; + +(async (): Promise => { /** * Disable Browserslist old data warning as otherwise with every release we'd need to update this dependency * which is cumbersome considering we pin versions and the warning is not user actionable. @@ -23,26 +33,27 @@ import { isWarningEnabled } from '../utilities/config'; * See: https://github.com/browserslist/browserslist/blob/819c4337456996d19db6ba953014579329e9c6e1/node.js#L324 */ process.env.BROWSERSLIST_IGNORE_OLD_DATA = '1'; + const rawCommandName = process.argv[2]; - const disableVersionCheckEnv = process.env['NG_DISABLE_VERSION_CHECK']; /** * Disable CLI version mismatch checks and forces usage of the invoked CLI * instead of invoking the local installed version. + * + * When running `ng new` always favor the global version. As in some + * cases orphan `node_modules` would cause the non global CLI to be used. + * @see: https://github.com/angular/angular-cli/issues/14603 */ - const disableVersionCheck = - disableVersionCheckEnv !== undefined && - disableVersionCheckEnv !== '0' && - disableVersionCheckEnv.toLowerCase() !== 'false'; - - if (disableVersionCheck) { + if (disableVersionCheck || rawCommandName === 'new') { return (await import('./cli')).default; } let cli; + try { // No error implies a projectLocalCli, which will load whatever // version of ng-cli you have installed in a local package.json - const projectLocalCli = require.resolve('@angular/cli', { paths: [process.cwd()] }); + const cwdRequire = createRequire(process.cwd() + '/'); + const projectLocalCli = cwdRequire.resolve('@angular/cli'); cli = await import(projectLocalCli); const globalVersion = new SemVer(VERSION.full); @@ -62,19 +73,35 @@ import { isWarningEnabled } from '../utilities/config'; } } + // Ensure older versions of the CLI fully exit + const localMajorVersion = major(localVersion); + if (localMajorVersion > 0 && localMajorVersion < 14) { + forceExit = true; + + // Versions prior to 14 didn't implement completion command. + if (rawCommandName === 'completion') { + return null; + } + } + let isGlobalGreater = false; try { - isGlobalGreater = !!localVersion && globalVersion.compare(localVersion) > 0; + isGlobalGreater = localVersion > 0 && globalVersion.compare(localVersion) > 0; } catch (error) { // eslint-disable-next-line no-console console.error('Version mismatch check skipped. Unable to compare local version: ' + error); } - if (isGlobalGreater) { + // When using the completion command, don't show the warning as otherwise this will break completion. + if ( + isGlobalGreater && + rawCommandName !== '--get-yargs-completions' && + rawCommandName !== 'completion' + ) { // If using the update command and the global version is greater, use the newer update command // This allows improvements in update to be used in older versions that do not have bootstrapping if ( - process.argv[2] === 'update' && + rawCommandName === 'update' && cli.VERSION && cli.VERSION.major - globalVersion.major <= 1 ) { @@ -104,15 +131,16 @@ import { isWarningEnabled } from '../utilities/config'; return cli; })() - .then((cli) => { - return cli({ + .then((cli) => + cli?.({ cliArgs: process.argv.slice(2), - inputStream: process.stdin, - outputStream: process.stdout, - }); - }) - .then((exitCode: number) => { - process.exit(exitCode); + }), + ) + .then((exitCode = 0) => { + if (forceExit) { + process.exit(exitCode); + } + process.exitCode = exitCode; }) .catch((err: Error) => { // eslint-disable-next-line no-console diff --git a/packages/angular/cli/models/analytics-collector.ts b/packages/angular/cli/models/analytics-collector.ts deleted file mode 100644 index 6754d5037059..000000000000 --- a/packages/angular/cli/models/analytics-collector.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { analytics } from '@angular-devkit/core'; -import { execSync } from 'child_process'; -import debug from 'debug'; -import * as https from 'https'; -import * as os from 'os'; -import * as querystring from 'querystring'; -import { VERSION } from './version'; - -interface BaseParameters extends analytics.CustomDimensionsAndMetricsOptions { - [key: string]: string | number | boolean | undefined | (string | number | boolean | undefined)[]; -} - -interface ScreenviewParameters extends BaseParameters { - /** Screen Name */ - cd?: string; - /** Application Name */ - an?: string; - /** Application Version */ - av?: string; - /** Application ID */ - aid?: string; - /** Application Installer ID */ - aiid?: string; -} - -interface TimingParameters extends BaseParameters { - /** User timing category */ - utc?: string; - /** User timing variable name */ - utv?: string; - /** User timing time */ - utt?: string | number; - /** User timing label */ - utl?: string; -} - -interface PageviewParameters extends BaseParameters { - /** - * Document Path - * The path portion of the page URL. Should begin with '/'. - */ - dp?: string; - /** Document Host Name */ - dh?: string; - /** Document Title */ - dt?: string; - /** - * Document location URL - * Use this parameter to send the full URL (document location) of the page on which content resides. - */ - dl?: string; -} - -interface EventParameters extends BaseParameters { - /** Event Category */ - ec: string; - /** Event Action */ - ea: string; - /** Event Label */ - el?: string; - /** - * Event Value - * Specifies the event value. Values must be non-negative. - */ - ev?: string | number; - /** Page Path */ - p?: string; - /** Page */ - dp?: string; -} - -/** - * See: https://developers.google.com/analytics/devguides/collection/protocol/v1/devguide - */ -export class AnalyticsCollector implements analytics.Analytics { - private trackingEventsQueue: Record[] = []; - private readonly parameters: Record = {}; - private readonly analyticsLogDebug = debug('ng:analytics:log'); - - constructor(trackingId: string, userId: string) { - // API Version - this.parameters['v'] = '1'; - // User ID - this.parameters['cid'] = userId; - // Tracking - this.parameters['tid'] = trackingId; - - this.parameters['ds'] = 'cli'; - this.parameters['ua'] = _buildUserAgentString(); - this.parameters['ul'] = _getLanguage(); - - // @angular/cli with version. - this.parameters['an'] = '@angular/cli'; - this.parameters['av'] = VERSION.full; - - // We use the application ID for the Node version. This should be "node v12.10.0". - const nodeVersion = `node ${process.version}`; - this.parameters['aid'] = nodeVersion; - - // Custom dimentions - // We set custom metrics for values we care about. - this.parameters['cd' + analytics.NgCliAnalyticsDimensions.CpuCount] = os.cpus().length; - // Get the first CPU's speed. It's very rare to have multiple CPUs of different speed (in most - // non-ARM configurations anyway), so that's all we care about. - this.parameters['cd' + analytics.NgCliAnalyticsDimensions.CpuSpeed] = Math.floor( - os.cpus()[0].speed, - ); - this.parameters['cd' + analytics.NgCliAnalyticsDimensions.RamInGigabytes] = Math.round( - os.totalmem() / (1024 * 1024 * 1024), - ); - this.parameters['cd' + analytics.NgCliAnalyticsDimensions.NodeVersion] = nodeVersion; - } - - event(ec: string, ea: string, options: analytics.EventOptions = {}): void { - const { label: el, value: ev, metrics, dimensions } = options; - this.addToQueue('event', { ec, ea, el, ev, metrics, dimensions }); - } - - pageview(dp: string, options: analytics.PageviewOptions = {}): void { - const { hostname: dh, title: dt, metrics, dimensions } = options; - this.addToQueue('pageview', { dp, dh, dt, metrics, dimensions }); - } - - timing( - utc: string, - utv: string, - utt: string | number, - options: analytics.TimingOptions = {}, - ): void { - const { label: utl, metrics, dimensions } = options; - this.addToQueue('timing', { utc, utv, utt, utl, metrics, dimensions }); - } - - screenview(cd: string, an: string, options: analytics.ScreenviewOptions = {}): void { - const { appVersion: av, appId: aid, appInstallerId: aiid, metrics, dimensions } = options; - this.addToQueue('screenview', { cd, an, av, aid, aiid, metrics, dimensions }); - } - - async flush(): Promise { - const pending = this.trackingEventsQueue.length; - this.analyticsLogDebug(`flush queue size: ${pending}`); - - if (!pending) { - return; - } - - // The below is needed so that if flush is called multiple times, - // we don't report the same event multiple times. - const pendingTrackingEvents = this.trackingEventsQueue; - this.trackingEventsQueue = []; - - try { - await this.send(pendingTrackingEvents); - } catch (error) { - // Failure to report analytics shouldn't crash the CLI. - this.analyticsLogDebug('send error: %j', error); - } - } - - private addToQueue(eventType: 'event', parameters: EventParameters): void; - private addToQueue(eventType: 'pageview', parameters: PageviewParameters): void; - private addToQueue(eventType: 'timing', parameters: TimingParameters): void; - private addToQueue(eventType: 'screenview', parameters: ScreenviewParameters): void; - private addToQueue( - eventType: 'event' | 'pageview' | 'timing' | 'screenview', - parameters: BaseParameters, - ): void { - const { metrics, dimensions, ...restParameters } = parameters; - const data = { - ...this.parameters, - ...restParameters, - ...this.customVariables({ metrics, dimensions }), - t: eventType, - }; - - this.analyticsLogDebug('add event to queue: %j', data); - this.trackingEventsQueue.push(data); - } - - private async send(data: Record[]): Promise { - this.analyticsLogDebug('send event: %j', data); - - return new Promise((resolve, reject) => { - const request = https.request( - { - host: 'www.google-analytics.com', - method: 'POST', - path: data.length > 1 ? '/batch' : '/collect', - }, - (response) => { - if (response.statusCode !== 200) { - reject( - new Error(`Analytics reporting failed with status code: ${response.statusCode}.`), - ); - - return; - } - }, - ); - - request.on('error', reject); - - const queryParameters = data.map((p) => querystring.stringify(p)).join('\n'); - request.write(queryParameters); - request.end(resolve); - }); - } - - /** - * Creates the dimension and metrics variables to add to the queue. - * @private - */ - private customVariables( - options: analytics.CustomDimensionsAndMetricsOptions, - ): Record { - const additionals: Record = {}; - - const { dimensions, metrics } = options; - dimensions?.forEach((v, i) => (additionals[`cd${i}`] = v)); - metrics?.forEach((v, i) => (additionals[`cm${i}`] = v)); - - return additionals; - } -} - -// These are just approximations of UA strings. We just try to fool Google Analytics to give us the -// data we want. -// See https://developers.whatismybrowser.com/useragents/ -const osVersionMap: Readonly<{ [os: string]: { [release: string]: string } }> = { - darwin: { - '1.3.1': '10_0_4', - '1.4.1': '10_1_0', - '5.1': '10_1_1', - '5.2': '10_1_5', - '6.0.1': '10_2', - '6.8': '10_2_8', - '7.0': '10_3_0', - '7.9': '10_3_9', - '8.0': '10_4_0', - '8.11': '10_4_11', - '9.0': '10_5_0', - '9.8': '10_5_8', - '10.0': '10_6_0', - '10.8': '10_6_8', - // We stop here because we try to math out the version for anything greater than 10, and it - // works. Those versions are standardized using a calculation now. - }, - win32: { - '6.3.9600': 'Windows 8.1', - '6.2.9200': 'Windows 8', - '6.1.7601': 'Windows 7 SP1', - '6.1.7600': 'Windows 7', - '6.0.6002': 'Windows Vista SP2', - '6.0.6000': 'Windows Vista', - '5.1.2600': 'Windows XP', - }, -}; - -/** - * Build a fake User Agent string. This gets sent to Analytics so it shows the proper OS version. - * @private - */ -function _buildUserAgentString() { - switch (os.platform()) { - case 'darwin': { - let v = osVersionMap.darwin[os.release()]; - - if (!v) { - // Remove 4 to tie Darwin version to OSX version, add other info. - const x = parseFloat(os.release()); - if (x > 10) { - v = `10_` + (x - 4).toString().replace('.', '_'); - } - } - - const cpuModel = os.cpus()[0].model.match(/^[a-z]+/i); - const cpu = cpuModel ? cpuModel[0] : os.cpus()[0].model; - - return `(Macintosh; ${cpu} Mac OS X ${v || os.release()})`; - } - - case 'win32': - return `(Windows NT ${os.release()})`; - - case 'linux': - return `(X11; Linux i686; ${os.release()}; ${os.cpus()[0].model})`; - - default: - return os.platform() + ' ' + os.release(); - } -} - -/** - * Get a language code. - * @private - */ -function _getLanguage() { - // Note: Windows does not expose the configured language by default. - return ( - process.env.LANG || // Default Unix env variable. - process.env.LC_CTYPE || // For C libraries. Sometimes the above isn't set. - process.env.LANGSPEC || // For Windows, sometimes this will be set (not always). - _getWindowsLanguageCode() || - '??' - ); // ¯\_(ツ)_/¯ -} - -/** - * Attempt to get the Windows Language Code string. - * @private - */ -function _getWindowsLanguageCode(): string | undefined { - if (!os.platform().startsWith('win')) { - return undefined; - } - - try { - // This is true on Windows XP, 7, 8 and 10 AFAIK. Would return empty string or fail if it - // doesn't work. - return execSync('wmic.exe os get locale').toString().trim(); - } catch {} - - return undefined; -} diff --git a/packages/angular/cli/models/analytics.ts b/packages/angular/cli/models/analytics.ts deleted file mode 100644 index 1171e801dc49..000000000000 --- a/packages/angular/cli/models/analytics.ts +++ /dev/null @@ -1,369 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { json, tags } from '@angular-devkit/core'; -import debug from 'debug'; -import * as inquirer from 'inquirer'; -import { v4 as uuidV4 } from 'uuid'; -import { VERSION } from '../models/version'; -import { colors } from '../utilities/color'; -import { getWorkspace, getWorkspaceRaw } from '../utilities/config'; -import { isTTY } from '../utilities/tty'; -import { AnalyticsCollector } from './analytics-collector'; - -/* eslint-disable no-console */ -const analyticsDebug = debug('ng:analytics'); // Generate analytics, including settings and users. - -let _defaultAngularCliPropertyCache: string; -export const AnalyticsProperties = { - AngularCliProd: 'UA-8594346-29', - AngularCliStaging: 'UA-8594346-32', - get AngularCliDefault(): string { - if (_defaultAngularCliPropertyCache) { - return _defaultAngularCliPropertyCache; - } - - const v = VERSION.full; - - // The logic is if it's a full version then we should use the prod GA property. - if (/^\d+\.\d+\.\d+$/.test(v) && v !== '0.0.0') { - _defaultAngularCliPropertyCache = AnalyticsProperties.AngularCliProd; - } else { - _defaultAngularCliPropertyCache = AnalyticsProperties.AngularCliStaging; - } - - return _defaultAngularCliPropertyCache; - }, -}; - -/** - * This is the ultimate safelist for checking if a package name is safe to report to analytics. - */ -export const analyticsPackageSafelist = [ - /^@angular\//, - /^@angular-devkit\//, - /^@ngtools\//, - '@schematics/angular', -]; - -export function isPackageNameSafeForAnalytics(name: string): boolean { - return analyticsPackageSafelist.some((pattern) => { - if (typeof pattern == 'string') { - return pattern === name; - } else { - return pattern.test(name); - } - }); -} - -/** - * Set analytics settings. This does not work if the user is not inside a project. - * @param level Which config to use. "global" for user-level, and "local" for project-level. - * @param value Either a user ID, true to generate a new User ID, or false to disable analytics. - */ -export function setAnalyticsConfig(level: 'global' | 'local', value: string | boolean) { - analyticsDebug('setting %s level analytics to: %s', level, value); - const [config, configPath] = getWorkspaceRaw(level); - if (!config || !configPath) { - throw new Error(`Could not find ${level} workspace.`); - } - - const cli = config.get(['cli']); - - if (cli !== undefined && !json.isJsonObject(cli as json.JsonValue)) { - throw new Error(`Invalid config found at ${configPath}. CLI should be an object.`); - } - - if (value === true) { - value = uuidV4(); - } - - config.modify(['cli', 'analytics'], value); - config.save(); - - analyticsDebug('done'); -} - -/** - * Prompt the user for usage gathering permission. - * @param force Whether to ask regardless of whether or not the user is using an interactive shell. - * @return Whether or not the user was shown a prompt. - */ -export async function promptGlobalAnalytics(force = false) { - analyticsDebug('prompting global analytics.'); - if (force || isTTY()) { - const answers = await inquirer.prompt<{ analytics: boolean }>([ - { - type: 'confirm', - name: 'analytics', - message: tags.stripIndents` - Would you like to share anonymous usage data with the Angular Team at Google under - Google’s Privacy Policy at https://policies.google.com/privacy? For more details and - how to change this setting, see https://angular.io/analytics. - `, - default: false, - }, - ]); - - setAnalyticsConfig('global', answers.analytics); - - if (answers.analytics) { - console.log(''); - console.log(tags.stripIndent` - Thank you for sharing anonymous usage data. If you change your mind, the following - command will disable this feature entirely: - - ${colors.yellow('ng analytics off')} - `); - console.log(''); - - // Send back a ping with the user `optin`. - const ua = new AnalyticsCollector(AnalyticsProperties.AngularCliDefault, 'optin'); - ua.pageview('/telemetry/optin'); - await ua.flush(); - } else { - // Send back a ping with the user `optout`. This is the only thing we send. - const ua = new AnalyticsCollector(AnalyticsProperties.AngularCliDefault, 'optout'); - ua.pageview('/telemetry/optout'); - await ua.flush(); - } - - return true; - } else { - analyticsDebug('Either STDOUT or STDIN are not TTY and we skipped the prompt.'); - } - - return false; -} - -/** - * Prompt the user for usage gathering permission for the local project. Fails if there is no - * local workspace. - * @param force Whether to ask regardless of whether or not the user is using an interactive shell. - * @return Whether or not the user was shown a prompt. - */ -export async function promptProjectAnalytics(force = false): Promise { - analyticsDebug('prompting user'); - const [config, configPath] = getWorkspaceRaw('local'); - if (!config || !configPath) { - throw new Error(`Could not find a local workspace. Are you in a project?`); - } - - if (force || isTTY()) { - const answers = await inquirer.prompt<{ analytics: boolean }>([ - { - type: 'confirm', - name: 'analytics', - message: tags.stripIndents` - Would you like to share anonymous usage data about this project with the Angular Team at - Google under Google’s Privacy Policy at https://policies.google.com/privacy? For more - details and how to change this setting, see https://angular.io/analytics. - - `, - default: false, - }, - ]); - - setAnalyticsConfig('local', answers.analytics); - - if (answers.analytics) { - console.log(''); - console.log(tags.stripIndent` - Thank you for sharing anonymous usage data. Should you change your mind, the following - command will disable this feature entirely: - - ${colors.yellow('ng analytics project off')} - `); - console.log(''); - - // Send back a ping with the user `optin`. - const ua = new AnalyticsCollector(AnalyticsProperties.AngularCliDefault, 'optin'); - ua.pageview('/telemetry/project/optin'); - await ua.flush(); - } else { - // Send back a ping with the user `optout`. This is the only thing we send. - const ua = new AnalyticsCollector(AnalyticsProperties.AngularCliDefault, 'optout'); - ua.pageview('/telemetry/project/optout'); - await ua.flush(); - } - - return true; - } - - return false; -} - -export async function hasGlobalAnalyticsConfiguration(): Promise { - try { - const globalWorkspace = await getWorkspace('global'); - const analyticsConfig: string | undefined | null | { uid?: string } = - globalWorkspace && globalWorkspace.getCli() && globalWorkspace.getCli()['analytics']; - - if (analyticsConfig !== null && analyticsConfig !== undefined) { - return true; - } - } catch {} - - return false; -} - -/** - * Get the global analytics object for the user. This returns an instance of UniversalAnalytics, - * or undefined if analytics are disabled. - * - * If any problem happens, it is considered the user has been opting out of analytics. - */ -export async function getGlobalAnalytics(): Promise { - analyticsDebug('getGlobalAnalytics'); - const propertyId = AnalyticsProperties.AngularCliDefault; - - if ('NG_CLI_ANALYTICS' in process.env) { - if (process.env['NG_CLI_ANALYTICS'] == 'false' || process.env['NG_CLI_ANALYTICS'] == '') { - analyticsDebug('NG_CLI_ANALYTICS is false'); - - return undefined; - } - if (process.env['NG_CLI_ANALYTICS'] === 'ci') { - analyticsDebug('Running in CI mode'); - - return new AnalyticsCollector(propertyId, 'ci'); - } - } - - // If anything happens we just keep the NOOP analytics. - try { - const globalWorkspace = await getWorkspace('global'); - const analyticsConfig: string | undefined | null | { uid?: string } = - globalWorkspace && globalWorkspace.getCli() && globalWorkspace.getCli()['analytics']; - analyticsDebug('Client Analytics config found: %j', analyticsConfig); - - if (analyticsConfig === false) { - analyticsDebug('Analytics disabled. Ignoring all analytics.'); - - return undefined; - } else if (analyticsConfig === undefined || analyticsConfig === null) { - analyticsDebug('Analytics settings not found. Ignoring all analytics.'); - - // globalWorkspace can be null if there is no file. analyticsConfig would be null in this - // case. Since there is no file, the user hasn't answered and the expected return value is - // undefined. - return undefined; - } else { - let uid: string | undefined = undefined; - if (typeof analyticsConfig == 'string') { - uid = analyticsConfig; - } else if (typeof analyticsConfig == 'object' && typeof analyticsConfig['uid'] == 'string') { - uid = analyticsConfig['uid']; - } - - analyticsDebug('client id: %j', uid); - if (uid == undefined) { - return undefined; - } - - return new AnalyticsCollector(propertyId, uid); - } - } catch (err) { - analyticsDebug('Error happened during reading of analytics config: %s', err.message); - - return undefined; - } -} - -export async function hasWorkspaceAnalyticsConfiguration(): Promise { - try { - const globalWorkspace = await getWorkspace('local'); - const analyticsConfig: string | undefined | null | { uid?: string } = - globalWorkspace && globalWorkspace.getCli() && globalWorkspace.getCli()['analytics']; - - if (analyticsConfig !== undefined) { - return true; - } - } catch {} - - return false; -} - -/** - * Get the workspace analytics object for the user. This returns an instance of AnalyticsCollector, - * or undefined if analytics are disabled. - * - * If any problem happens, it is considered the user has been opting out of analytics. - */ -export async function getWorkspaceAnalytics(): Promise { - analyticsDebug('getWorkspaceAnalytics'); - try { - const globalWorkspace = await getWorkspace('local'); - const analyticsConfig: string | undefined | null | { uid?: string } = - globalWorkspace?.getCli()['analytics']; - analyticsDebug('Workspace Analytics config found: %j', analyticsConfig); - - if (analyticsConfig === false) { - analyticsDebug('Analytics disabled. Ignoring all analytics.'); - - return undefined; - } else if (analyticsConfig === undefined || analyticsConfig === null) { - analyticsDebug('Analytics settings not found. Ignoring all analytics.'); - - return undefined; - } else { - let uid: string | undefined = undefined; - if (typeof analyticsConfig == 'string') { - uid = analyticsConfig; - } else if (typeof analyticsConfig == 'object' && typeof analyticsConfig['uid'] == 'string') { - uid = analyticsConfig['uid']; - } - - analyticsDebug('client id: %j', uid); - if (uid == undefined) { - return undefined; - } - - return new AnalyticsCollector(AnalyticsProperties.AngularCliDefault, uid); - } - } catch (err) { - analyticsDebug('Error happened during reading of analytics config: %s', err.message); - - return undefined; - } -} - -/** - * Return the usage analytics sharing setting, which is either a property string (GA-XXXXXXX-XX), - * or undefined if no sharing. - */ -export async function getSharedAnalytics(): Promise { - analyticsDebug('getSharedAnalytics'); - - const envVarName = 'NG_CLI_ANALYTICS_SHARE'; - if (envVarName in process.env) { - if (process.env[envVarName] == 'false' || process.env[envVarName] == '') { - analyticsDebug('NG_CLI_ANALYTICS is false'); - - return undefined; - } - } - - // If anything happens we just keep the NOOP analytics. - try { - const globalWorkspace = await getWorkspace('global'); - const analyticsConfig = globalWorkspace?.getCli()['analyticsSharing']; - - if (!analyticsConfig || !analyticsConfig.tracking || !analyticsConfig.uuid) { - return undefined; - } else { - analyticsDebug('Analytics sharing info: %j', analyticsConfig); - - return new AnalyticsCollector(analyticsConfig.tracking, analyticsConfig.uuid); - } - } catch (err) { - analyticsDebug('Error happened during reading of analytics sharing config: %s', err.message); - - return undefined; - } -} diff --git a/packages/angular/cli/models/architect-command.ts b/packages/angular/cli/models/architect-command.ts deleted file mode 100644 index 713ce5e483b5..000000000000 --- a/packages/angular/cli/models/architect-command.ts +++ /dev/null @@ -1,460 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { Architect, Target } from '@angular-devkit/architect'; -import { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node'; -import { json, schema, tags } from '@angular-devkit/core'; -import { existsSync } from 'fs'; -import * as path from 'path'; -import { parseJsonSchemaToOptions } from '../utilities/json-schema'; -import { getPackageManager } from '../utilities/package-manager'; -import { isPackageNameSafeForAnalytics } from './analytics'; -import { BaseCommandOptions, Command } from './command'; -import { Arguments, Option } from './interface'; -import { parseArguments } from './parser'; - -export interface ArchitectCommandOptions extends BaseCommandOptions { - project?: string; - configuration?: string; - prod?: boolean; - target?: string; -} - -export abstract class ArchitectCommand< - T extends ArchitectCommandOptions = ArchitectCommandOptions, -> extends Command { - protected _architect!: Architect; - protected _architectHost!: WorkspaceNodeModulesArchitectHost; - protected _registry!: json.schema.SchemaRegistry; - protected override readonly useReportAnalytics = false; - - // If this command supports running multiple targets. - protected multiTarget = false; - - target: string | undefined; - missingTargetError: string | undefined; - - protected async onMissingTarget(projectName?: string): Promise { - if (this.missingTargetError) { - this.logger.fatal(this.missingTargetError); - - return 1; - } - - if (projectName) { - this.logger.fatal(`Project '${projectName}' does not support the '${this.target}' target.`); - } else { - this.logger.fatal(`No projects support the '${this.target}' target.`); - } - - return 1; - } - - // eslint-disable-next-line max-lines-per-function - public override async initialize(options: T & Arguments): Promise { - this._registry = new json.schema.CoreSchemaRegistry(); - this._registry.addPostTransform(json.schema.transforms.addUndefinedDefaults); - this._registry.useXDeprecatedProvider((msg) => this.logger.warn(msg)); - - if (!this.workspace) { - this.logger.fatal('A workspace is required for this command.'); - - return 1; - } - - this._architectHost = new WorkspaceNodeModulesArchitectHost( - this.workspace, - this.workspace.basePath, - ); - this._architect = new Architect(this._architectHost, this._registry); - - if (!this.target) { - if (options.help) { - // This is a special case where we just return. - return; - } - - const specifier = this._makeTargetSpecifier(options); - if (!specifier.project || !specifier.target) { - this.logger.fatal('Cannot determine project or target for command.'); - - return 1; - } - - return; - } - - let projectName = options.project; - if (projectName && !this.workspace.projects.has(projectName)) { - this.logger.fatal(`Project '${projectName}' does not exist.`); - - return 1; - } - - const commandLeftovers = options['--']; - const targetProjectNames: string[] = []; - for (const [name, project] of this.workspace.projects) { - if (project.targets.has(this.target)) { - targetProjectNames.push(name); - } - } - - if (projectName && !targetProjectNames.includes(projectName)) { - return await this.onMissingTarget(projectName); - } - - if (targetProjectNames.length === 0) { - return await this.onMissingTarget(); - } - - if (!projectName && commandLeftovers && commandLeftovers.length > 0) { - const builderNames = new Set(); - const leftoverMap = new Map(); - let potentialProjectNames = new Set(targetProjectNames); - for (const name of targetProjectNames) { - const builderName = await this._architectHost.getBuilderNameForTarget({ - project: name, - target: this.target, - }); - - if (this.multiTarget) { - builderNames.add(builderName); - } - - let builderDesc; - try { - builderDesc = await this._architectHost.resolveBuilder(builderName); - } catch (e) { - if (e.code === 'MODULE_NOT_FOUND') { - await this.warnOnMissingNodeModules(this.workspace.basePath); - this.logger.fatal(`Could not find the '${builderName}' builder's node package.`); - - return 1; - } - throw e; - } - - const optionDefs = await parseJsonSchemaToOptions( - this._registry, - builderDesc.optionSchema as json.JsonObject, - ); - const parsedOptions = parseArguments([...commandLeftovers], optionDefs); - const builderLeftovers = parsedOptions['--'] || []; - leftoverMap.set(name, { optionDefs, parsedOptions }); - - potentialProjectNames = new Set( - builderLeftovers.filter((x) => potentialProjectNames.has(x)), - ); - } - - if (potentialProjectNames.size === 1) { - projectName = [...potentialProjectNames][0]; - - // remove the project name from the leftovers - const optionInfo = leftoverMap.get(projectName); - if (optionInfo) { - const locations = []; - let i = 0; - while (i < commandLeftovers.length) { - i = commandLeftovers.indexOf(projectName, i + 1); - if (i === -1) { - break; - } - locations.push(i); - } - delete optionInfo.parsedOptions['--']; - for (const location of locations) { - const tempLeftovers = [...commandLeftovers]; - tempLeftovers.splice(location, 1); - const tempArgs = parseArguments([...tempLeftovers], optionInfo.optionDefs); - delete tempArgs['--']; - if (JSON.stringify(optionInfo.parsedOptions) === JSON.stringify(tempArgs)) { - options['--'] = tempLeftovers; - break; - } - } - } - } - - if (!projectName && this.multiTarget && builderNames.size > 1) { - this.logger.fatal(tags.oneLine` - Architect commands with command line overrides cannot target different builders. The - '${this.target}' target would run on projects ${targetProjectNames.join()} which have the - following builders: ${'\n ' + [...builderNames].join('\n ')} - `); - - return 1; - } - } - - if (!projectName && !this.multiTarget) { - const defaultProjectName = this.workspace.extensions['defaultProject'] as string; - if (targetProjectNames.length === 1) { - projectName = targetProjectNames[0]; - } else if (defaultProjectName && targetProjectNames.includes(defaultProjectName)) { - projectName = defaultProjectName; - } else if (options.help) { - // This is a special case where we just return. - return; - } else { - this.logger.fatal( - this.missingTargetError || 'Cannot determine project or target for command.', - ); - - return 1; - } - } - - options.project = projectName; - - const builderConf = await this._architectHost.getBuilderNameForTarget({ - project: projectName || (targetProjectNames.length > 0 ? targetProjectNames[0] : ''), - target: this.target, - }); - - let builderDesc; - try { - builderDesc = await this._architectHost.resolveBuilder(builderConf); - } catch (e) { - if (e.code === 'MODULE_NOT_FOUND') { - await this.warnOnMissingNodeModules(this.workspace.basePath); - this.logger.fatal(`Could not find the '${builderConf}' builder's node package.`); - - return 1; - } - throw e; - } - - this.description.options.push( - ...(await parseJsonSchemaToOptions( - this._registry, - builderDesc.optionSchema as json.JsonObject, - )), - ); - - // Update options to remove analytics from options if the builder isn't safelisted. - for (const o of this.description.options) { - if (o.userAnalytics && !isPackageNameSafeForAnalytics(builderConf)) { - o.userAnalytics = undefined; - } - } - } - - private async warnOnMissingNodeModules(basePath: string): Promise { - // Check for a `node_modules` directory (npm, yarn non-PnP, etc.) - if (existsSync(path.resolve(basePath, 'node_modules'))) { - return; - } - - // Check for yarn PnP files - if ( - existsSync(path.resolve(basePath, '.pnp.js')) || - existsSync(path.resolve(basePath, '.pnp.cjs')) || - existsSync(path.resolve(basePath, '.pnp.mjs')) - ) { - return; - } - - const packageManager = await getPackageManager(basePath); - let installSuggestion = 'Try installing with '; - switch (packageManager) { - case 'npm': - installSuggestion += `'npm install'`; - break; - case 'yarn': - installSuggestion += `'yarn'`; - break; - default: - installSuggestion += `the project's package manager`; - break; - } - - this.logger.warn(`Node packages may not be installed. ${installSuggestion}.`); - } - - async run(options: ArchitectCommandOptions & Arguments) { - return await this.runArchitectTarget(options); - } - - protected async runSingleTarget(target: Target, targetOptions: string[]) { - // We need to build the builderSpec twice because architect does not understand - // overrides separately (getting the configuration builds the whole project, including - // overrides). - const builderConf = await this._architectHost.getBuilderNameForTarget(target); - let builderDesc; - try { - builderDesc = await this._architectHost.resolveBuilder(builderConf); - } catch (e) { - if (e.code === 'MODULE_NOT_FOUND') { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await this.warnOnMissingNodeModules(this.workspace!.basePath); - this.logger.fatal(`Could not find the '${builderConf}' builder's node package.`); - - return 1; - } - throw e; - } - const targetOptionArray = await parseJsonSchemaToOptions( - this._registry, - builderDesc.optionSchema as json.JsonObject, - ); - const overrides = parseArguments(targetOptions, targetOptionArray, this.logger); - - const allowAdditionalProperties = - typeof builderDesc.optionSchema === 'object' && builderDesc.optionSchema.additionalProperties; - - if (overrides['--'] && !allowAdditionalProperties) { - (overrides['--'] || []).forEach((additional) => { - this.logger.fatal(`Unknown option: '${additional.split(/=/)[0]}'`); - }); - - return 1; - } - - await this.reportAnalytics([this.description.name], { - ...((await this._architectHost.getOptionsForTarget(target)) as unknown as T), - ...overrides, - }); - - const run = await this._architect.scheduleTarget(target, overrides as json.JsonObject, { - logger: this.logger, - analytics: isPackageNameSafeForAnalytics(builderConf) ? this.analytics : undefined, - }); - - const { error, success } = await run.output.toPromise(); - await run.stop(); - - if (error) { - this.logger.error(error); - } - - return success ? 0 : 1; - } - - protected async runArchitectTarget( - options: ArchitectCommandOptions & Arguments, - ): Promise { - const extra = options['--'] || []; - - try { - const targetSpec = this._makeTargetSpecifier(options); - if (!targetSpec.project && this.target) { - // This runs each target sequentially. - // Running them in parallel would jumble the log messages. - let result = 0; - for (const project of this.getProjectNamesByTarget(this.target)) { - result |= await this.runSingleTarget({ ...targetSpec, project } as Target, extra); - } - - return result; - } else { - return await this.runSingleTarget(targetSpec, extra); - } - } catch (e) { - if (e instanceof schema.SchemaValidationException) { - const newErrors: schema.SchemaValidatorError[] = []; - for (const schemaError of e.errors) { - if (schemaError.keyword === 'additionalProperties') { - const unknownProperty = schemaError.params?.additionalProperty; - if (unknownProperty in options) { - const dashes = unknownProperty.length === 1 ? '-' : '--'; - this.logger.fatal(`Unknown option: '${dashes}${unknownProperty}'`); - continue; - } - } - newErrors.push(schemaError); - } - - if (newErrors.length > 0) { - this.logger.error(new schema.SchemaValidationException(newErrors).message); - } - - return 1; - } else { - throw e; - } - } - } - - private getProjectNamesByTarget(targetName: string): string[] { - const allProjectsForTargetName: string[] = []; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - for (const [name, project] of this.workspace!.projects) { - if (project.targets.has(targetName)) { - allProjectsForTargetName.push(name); - } - } - - if (this.multiTarget) { - // For multi target commands, we always list all projects that have the target. - return allProjectsForTargetName; - } else { - // For single target commands, we try the default project first, - // then the full list if it has a single project, then error out. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const maybeDefaultProject = this.workspace!.extensions['defaultProject'] as string; - if (maybeDefaultProject && allProjectsForTargetName.includes(maybeDefaultProject)) { - return [maybeDefaultProject]; - } - - if (allProjectsForTargetName.length === 1) { - return allProjectsForTargetName; - } - - throw new Error(`Could not determine a single project for the '${targetName}' target.`); - } - } - - private _makeTargetSpecifier(commandOptions: ArchitectCommandOptions): Target { - let project, target, configuration; - - if (commandOptions.target) { - [project, target, configuration] = commandOptions.target.split(':'); - - if (commandOptions.configuration) { - configuration = commandOptions.configuration; - } - } else { - project = commandOptions.project; - target = this.target; - if (commandOptions.prod) { - const defaultConfig = - project && - target && - this.workspace?.projects.get(project)?.targets.get(target)?.defaultConfiguration; - - this.logger.warn( - defaultConfig === 'production' - ? 'Option "--prod" is deprecated: No need to use this option as this builder defaults to configuration "production".' - : 'Option "--prod" is deprecated: Use "--configuration production" instead.', - ); - // The --prod flag will always be the first configuration, available to be overwritten - // by following configurations. - configuration = 'production'; - } - if (commandOptions.configuration) { - configuration = `${configuration ? `${configuration},` : ''}${ - commandOptions.configuration - }`; - } - } - - if (!project) { - project = ''; - } - if (!target) { - target = ''; - } - - return { - project, - configuration: configuration || '', - target, - }; - } -} diff --git a/packages/angular/cli/models/command-runner.ts b/packages/angular/cli/models/command-runner.ts deleted file mode 100644 index 0b8b01fe4baa..000000000000 --- a/packages/angular/cli/models/command-runner.ts +++ /dev/null @@ -1,273 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { - analytics, - isJsonObject, - json, - logging, - schema, - strings, - tags, -} from '@angular-devkit/core'; -import { readFileSync } from 'fs'; -import { join, resolve } from 'path'; -import { AngularWorkspace } from '../utilities/config'; -import { readAndParseJson } from '../utilities/json-file'; -import { parseJsonSchemaToCommandDescription } from '../utilities/json-schema'; -import { - getGlobalAnalytics, - getSharedAnalytics, - getWorkspaceAnalytics, - hasWorkspaceAnalyticsConfiguration, - promptProjectAnalytics, -} from './analytics'; -import { Command } from './command'; -import { CommandDescription } from './interface'; -import * as parser from './parser'; - -// NOTE: Update commands.json if changing this. It's still deep imported in one CI validation -const standardCommands = { - 'add': '../commands/add.json', - 'analytics': '../commands/analytics.json', - 'build': '../commands/build.json', - 'deploy': '../commands/deploy.json', - 'config': '../commands/config.json', - 'doc': '../commands/doc.json', - 'e2e': '../commands/e2e.json', - 'extract-i18n': '../commands/extract-i18n.json', - 'make-this-awesome': '../commands/easter-egg.json', - 'generate': '../commands/generate.json', - 'help': '../commands/help.json', - 'lint': '../commands/lint.json', - 'new': '../commands/new.json', - 'run': '../commands/run.json', - 'serve': '../commands/serve.json', - 'test': '../commands/test.json', - 'update': '../commands/update.json', - 'version': '../commands/version.json', -}; - -export interface CommandMapOptions { - [key: string]: string; -} - -/** - * Create the analytics instance. - * @private - */ -async function _createAnalytics( - workspace: boolean, - skipPrompt = false, -): Promise { - let config = await getGlobalAnalytics(); - // If in workspace and global analytics is enabled, defer to workspace level - if (workspace && config) { - const skipAnalytics = - skipPrompt || - (process.env['NG_CLI_ANALYTICS'] && - (process.env['NG_CLI_ANALYTICS'].toLowerCase() === 'false' || - process.env['NG_CLI_ANALYTICS'] === '0')); - // TODO: This should honor the `no-interactive` option. - // It is currently not an `ng` option but rather only an option for specific commands. - // The concept of `ng`-wide options are needed to cleanly handle this. - if (!skipAnalytics && !(await hasWorkspaceAnalyticsConfiguration())) { - await promptProjectAnalytics(); - } - config = await getWorkspaceAnalytics(); - } - - const maybeSharedAnalytics = await getSharedAnalytics(); - - if (config && maybeSharedAnalytics) { - return new analytics.MultiAnalytics([config, maybeSharedAnalytics]); - } else if (config) { - return config; - } else if (maybeSharedAnalytics) { - return maybeSharedAnalytics; - } else { - return new analytics.NoopAnalytics(); - } -} - -async function loadCommandDescription( - name: string, - path: string, - registry: json.schema.CoreSchemaRegistry, -): Promise { - const schemaPath = resolve(__dirname, path); - const schema = readAndParseJson(schemaPath); - if (!isJsonObject(schema)) { - throw new Error('Invalid command JSON loaded from ' + JSON.stringify(schemaPath)); - } - - return parseJsonSchemaToCommandDescription(name, schemaPath, registry, schema); -} - -/** - * Run a command. - * @param args Raw unparsed arguments. - * @param logger The logger to use. - * @param workspace Workspace information. - * @param commands The map of supported commands. - * @param options Additional options. - */ -export async function runCommand( - args: string[], - logger: logging.Logger, - workspace: AngularWorkspace | undefined, - commands: CommandMapOptions = standardCommands, - options: { analytics?: analytics.Analytics; currentDirectory: string } = { - currentDirectory: process.cwd(), - }, -): Promise { - // This registry is exclusively used for flattening schemas, and not for validating. - const registry = new schema.CoreSchemaRegistry([]); - registry.registerUriHandler((uri: string) => { - if (uri.startsWith('ng-cli://')) { - const content = readFileSync(join(__dirname, '..', uri.substr('ng-cli://'.length)), 'utf-8'); - - return Promise.resolve(JSON.parse(content)); - } else { - return null; - } - }); - - let commandName: string | undefined = undefined; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (!arg.startsWith('-')) { - commandName = arg; - args.splice(i, 1); - break; - } - } - - let description: CommandDescription | null = null; - - // if no commands were found, use `help`. - if (!commandName) { - if (args.length === 1 && args[0] === '--version') { - commandName = 'version'; - } else { - commandName = 'help'; - } - - if (!(commandName in commands)) { - logger.error(tags.stripIndent` - The "${commandName}" command seems to be disabled. - This is an issue with the CLI itself. If you see this comment, please report it and - provide your repository. - `); - - return 1; - } - } - - if (commandName in commands) { - description = await loadCommandDescription(commandName, commands[commandName], registry); - } else { - const commandNames = Object.keys(commands); - - // Optimize loading for common aliases - if (commandName.length === 1) { - commandNames.sort((a, b) => { - const aMatch = a[0] === commandName; - const bMatch = b[0] === commandName; - if (aMatch && !bMatch) { - return -1; - } else if (!aMatch && bMatch) { - return 1; - } else { - return 0; - } - }); - } - - for (const name of commandNames) { - const aliasDesc = await loadCommandDescription(name, commands[name], registry); - const aliases = aliasDesc.aliases; - - if (aliases && aliases.some((alias) => alias === commandName)) { - commandName = name; - description = aliasDesc; - break; - } - } - } - - if (!description) { - const commandsDistance = {} as { [name: string]: number }; - const name = commandName; - const allCommands = Object.keys(commands).sort((a, b) => { - if (!(a in commandsDistance)) { - commandsDistance[a] = strings.levenshtein(a, name); - } - if (!(b in commandsDistance)) { - commandsDistance[b] = strings.levenshtein(b, name); - } - - return commandsDistance[a] - commandsDistance[b]; - }); - - logger.error(tags.stripIndent` - The specified command ("${commandName}") is invalid. For a list of available options, - run "ng help". - - Did you mean "${allCommands[0]}"? - `); - - return 1; - } - - try { - const parsedOptions = parser.parseArguments(args, description.options, logger); - Command.setCommandMap(async () => { - const map: Record = {}; - for (const [name, path] of Object.entries(commands)) { - map[name] = await loadCommandDescription(name, path, registry); - } - - return map; - }); - - const analytics = - options.analytics || (await _createAnalytics(!!workspace, description.name === 'update')); - const context = { - workspace, - analytics, - currentDirectory: options.currentDirectory, - root: workspace?.basePath ?? options.currentDirectory, - }; - const command = new description.impl(context, description, logger); - - // Flush on an interval (if the event loop is waiting). - let analyticsFlushPromise = Promise.resolve(); - const analyticsFlushInterval = setInterval(() => { - analyticsFlushPromise = analyticsFlushPromise.then(() => analytics.flush()); - }, 1000); - - const result = await command.validateAndRun(parsedOptions); - - // Flush one last time. - clearInterval(analyticsFlushInterval); - await analyticsFlushPromise.then(() => analytics.flush()); - - return result; - } catch (e) { - if (e instanceof parser.ParseArgumentException) { - logger.fatal('Cannot parse arguments. See below for the reasons.'); - logger.fatal(' ' + e.comments.join('\n ')); - - return 1; - } else { - throw e; - } - } -} diff --git a/packages/angular/cli/models/command.ts b/packages/angular/cli/models/command.ts deleted file mode 100644 index d40b21620d98..000000000000 --- a/packages/angular/cli/models/command.ts +++ /dev/null @@ -1,188 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { analytics, logging, strings, tags } from '@angular-devkit/core'; -import { colors } from '../utilities/color'; -import { AngularWorkspace } from '../utilities/config'; -import { - Arguments, - CommandContext, - CommandDescription, - CommandDescriptionMap, - CommandScope, - Option, -} from './interface'; - -export interface BaseCommandOptions { - help?: boolean | string; -} - -export abstract class Command { - protected allowMissingWorkspace = false; - protected useReportAnalytics = true; - readonly workspace?: AngularWorkspace; - readonly analytics: analytics.Analytics; - - protected static commandMap: () => Promise; - static setCommandMap(map: () => Promise) { - this.commandMap = map; - } - - constructor( - protected readonly context: CommandContext, - public readonly description: CommandDescription, - protected readonly logger: logging.Logger, - ) { - this.workspace = context.workspace; - this.analytics = context.analytics || new analytics.NoopAnalytics(); - } - - async initialize(options: T & Arguments): Promise {} - - async printHelp(): Promise { - await this.printHelpUsage(); - await this.printHelpOptions(); - - return 0; - } - - async printJsonHelp(): Promise { - const replacer = (key: string, value: string) => - key === 'name' ? strings.dasherize(value) : value; - this.logger.info(JSON.stringify(this.description, replacer, 2)); - - return 0; - } - - protected async printHelpUsage() { - this.logger.info(this.description.description); - - const name = this.description.name; - const args = this.description.options.filter((x) => x.positional !== undefined); - const opts = this.description.options.filter((x) => x.positional === undefined); - - const argDisplay = - args && args.length > 0 ? ' ' + args.map((a) => `<${a.name}>`).join(' ') : ''; - const optionsDisplay = opts && opts.length > 0 ? ` [options]` : ``; - - this.logger.info(`usage: ng ${name}${argDisplay}${optionsDisplay}`); - this.logger.info(''); - } - - protected async printHelpOptions(options: Option[] = this.description.options) { - const args = options.filter((opt) => opt.positional !== undefined); - const opts = options.filter((opt) => opt.positional === undefined); - - const formatDescription = (description: string) => - ` ${description.replace(/\n/g, '\n ')}`; - - if (args.length > 0) { - this.logger.info(`arguments:`); - args.forEach((o) => { - this.logger.info(` ${colors.cyan(o.name)}`); - if (o.description) { - this.logger.info(formatDescription(o.description)); - } - }); - } - if (options.length > 0) { - if (args.length > 0) { - this.logger.info(''); - } - this.logger.info(`options:`); - opts - .filter((o) => !o.hidden) - .sort((a, b) => a.name.localeCompare(b.name)) - .forEach((o) => { - const aliases = - o.aliases && o.aliases.length > 0 - ? '(' + o.aliases.map((a) => `-${a}`).join(' ') + ')' - : ''; - this.logger.info(` ${colors.cyan('--' + strings.dasherize(o.name))} ${aliases}`); - if (o.description) { - this.logger.info(formatDescription(o.description)); - } - }); - } - } - - async validateScope(scope?: CommandScope): Promise { - switch (scope === undefined ? this.description.scope : scope) { - case CommandScope.OutProject: - if (this.workspace) { - this.logger.fatal(tags.oneLine` - The ${this.description.name} command requires to be run outside of a project, but a - project definition was found at "${this.workspace.filePath}". - `); - // eslint-disable-next-line no-throw-literal - throw 1; - } - break; - case CommandScope.InProject: - if (!this.workspace) { - this.logger.fatal(tags.oneLine` - The ${this.description.name} command requires to be run in an Angular project, but a - project definition could not be found. - `); - // eslint-disable-next-line no-throw-literal - throw 1; - } - break; - case CommandScope.Everywhere: - // Can't miss this. - break; - } - } - - async reportAnalytics( - paths: string[], - options: Arguments, - dimensions: (boolean | number | string)[] = [], - metrics: (boolean | number | string)[] = [], - ): Promise { - for (const option of this.description.options) { - const ua = option.userAnalytics; - const v = options[option.name]; - - if (v !== undefined && !Array.isArray(v) && ua) { - dimensions[ua] = v; - } - } - - this.analytics.pageview('/command/' + paths.join('/'), { dimensions, metrics }); - } - - abstract run(options: T & Arguments): Promise; - - async validateAndRun(options: T & Arguments): Promise { - if (!(options.help === true || options.help === 'json' || options.help === 'JSON')) { - await this.validateScope(); - } - let result = await this.initialize(options); - if (typeof result === 'number' && result !== 0) { - return result; - } - - if (options.help === true) { - return this.printHelp(); - } else if (options.help === 'json' || options.help === 'JSON') { - return this.printJsonHelp(); - } else { - const startTime = +new Date(); - if (this.useReportAnalytics) { - await this.reportAnalytics([this.description.name], options); - } - result = await this.run(options); - const endTime = +new Date(); - - this.analytics.timing(this.description.name, 'duration', endTime - startTime); - - return result; - } - } -} diff --git a/packages/angular/cli/models/interface.ts b/packages/angular/cli/models/interface.ts deleted file mode 100644 index 9c908d913247..000000000000 --- a/packages/angular/cli/models/interface.ts +++ /dev/null @@ -1,239 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { analytics, json, logging } from '@angular-devkit/core'; -import { AngularWorkspace } from '../utilities/config'; - -/** - * Value type of arguments. - */ -export type Value = number | string | boolean | (number | string | boolean)[]; - -/** - * An object representing parsed arguments from the command line. - */ -export interface Arguments { - [argName: string]: Value | undefined; - - /** - * Extra arguments that were not parsed. Will be omitted if all arguments were parsed. - */ - '--'?: string[]; -} - -/** - * The base interface for Command, understood by the command runner. - */ -export interface CommandInterface { - printHelp(options: T): Promise; - printJsonHelp(options: T): Promise; - validateAndRun(options: T): Promise; -} - -/** - * Command constructor. - */ -export interface CommandConstructor { - new ( - context: CommandContext, - description: CommandDescription, - logger: logging.Logger, - ): CommandInterface; -} - -/** - * A command runner context. - */ -export interface CommandContext { - currentDirectory: string; - root: string; - - workspace?: AngularWorkspace; - - // This property is optional for backward compatibility. - analytics?: analytics.Analytics; -} - -/** - * Value types of an Option. - */ -export enum OptionType { - Any = 'any', - Array = 'array', - Boolean = 'boolean', - Number = 'number', - String = 'string', -} - -/** - * An option description. This is exposed when using `ng --help=json`. - */ -export interface Option { - /** - * The name of the option. - */ - name: string; - - /** - * A short description of the option. - */ - description: string; - - /** - * The type of option value. If multiple types exist, this type will be the first one, and the - * types array will contain all types accepted. - */ - type: OptionType; - - /** - * {@see type} - */ - types?: OptionType[]; - - /** - * If this field is set, only values contained in this field are valid. This array can be mixed - * types (strings, numbers, boolean). For example, if this field is "enum: ['hello', true]", - * then "type" will be either string or boolean, types will be at least both, and the values - * accepted will only be either 'hello' or true (not false or any other string). - * This mean that prefixing with `no-` will not work on this field. - */ - enum?: Value[]; - - /** - * If this option maps to a subcommand in the parent command, will contain all the subcommands - * supported. There is a maximum of 1 subcommand Option per command, and the type of this - * option will always be "string" (no other types). The value of this option will map into - * this map and return the extra information. - */ - subcommands?: { - [name: string]: SubCommandDescription; - }; - - /** - * Aliases supported by this option. - */ - aliases: string[]; - - /** - * Whether this option is required or not. - */ - required?: boolean; - - /** - * Format field of this option. - */ - format?: string; - - /** - * Whether this option should be hidden from the help output. It will still show up in JSON help. - */ - hidden?: boolean; - - /** - * Default value of this option. - */ - default?: string | number | boolean; - - /** - * If this option can be used as an argument, the position of the argument. Otherwise omitted. - */ - positional?: number; - - /** - * Smart default object. - */ - $default?: OptionSmartDefault; - - /** - * Whether or not to report this option to the Angular Team, and which custom field to use. - * If this is falsey, do not report this option. - */ - userAnalytics?: number; - - /** - * Deprecation. If this flag is not false a warning will be shown on the console. Either `true` - * or a string to show the user as a notice. - */ - deprecated?: boolean | string; -} - -/** - * Scope of the command. - */ -export enum CommandScope { - InProject = 'in', - OutProject = 'out', - Everywhere = 'all', - - Default = InProject, -} - -/** - * A description of a command and its options. - */ -export interface SubCommandDescription { - /** - * The name of the subcommand. - */ - name: string; - - /** - * Short description (1-2 lines) of this sub command. - */ - description: string; - - /** - * A long description of the sub command, in Markdown format. - */ - longDescription?: string; - - /** - * Additional notes about usage of this sub command, in Markdown format. - */ - usageNotes?: string; - - /** - * List of all supported options. - */ - options: Option[]; - - /** - * Aliases supported for this sub command. - */ - aliases: string[]; -} - -/** - * A description of a command, its metadata. - */ -export interface CommandDescription extends SubCommandDescription { - /** - * Scope of the command, whether it can be executed in a project, outside of a project or - * anywhere. - */ - scope: CommandScope; - - /** - * Whether this command should be hidden from a list of all commands. - */ - hidden: boolean; - - /** - * The constructor of the command, which should be extending the abstract Command<> class. - */ - impl: CommandConstructor; -} - -export interface OptionSmartDefault { - $source: string; - [key: string]: json.JsonValue; -} - -export interface CommandDescriptionMap { - [key: string]: CommandDescription; -} diff --git a/packages/angular/cli/models/parser.ts b/packages/angular/cli/models/parser.ts deleted file mode 100644 index b1e98d0b3f2a..000000000000 --- a/packages/angular/cli/models/parser.ts +++ /dev/null @@ -1,405 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { BaseException, logging, strings } from '@angular-devkit/core'; -import { Arguments, Option, OptionType, Value } from './interface'; - -export class ParseArgumentException extends BaseException { - constructor( - public readonly comments: string[], - public readonly parsed: Arguments, - public readonly ignored: string[], - ) { - super(`One or more errors occurred while parsing arguments:\n ${comments.join('\n ')}`); - } -} - -function _coerceType(str: string | undefined, type: OptionType, v?: Value): Value | undefined { - switch (type) { - case OptionType.Any: - if (Array.isArray(v)) { - return v.concat(str || ''); - } - - return _coerceType(str, OptionType.Boolean, v) !== undefined - ? _coerceType(str, OptionType.Boolean, v) - : _coerceType(str, OptionType.Number, v) !== undefined - ? _coerceType(str, OptionType.Number, v) - : _coerceType(str, OptionType.String, v); - - case OptionType.String: - return str || ''; - - case OptionType.Boolean: - switch (str) { - case 'false': - return false; - - case undefined: - case '': - case 'true': - return true; - - default: - return undefined; - } - - case OptionType.Number: - if (str === undefined) { - return 0; - } else if (str === '') { - return undefined; - } else if (Number.isFinite(+str)) { - return +str; - } else { - return undefined; - } - - case OptionType.Array: - return Array.isArray(v) - ? v.concat(str || '') - : v === undefined - ? [str || ''] - : [v + '', str || '']; - - default: - return undefined; - } -} - -function _coerce(str: string | undefined, o: Option | null, v?: Value): Value | undefined { - if (!o) { - return _coerceType(str, OptionType.Any, v); - } else { - const types = o.types || [o.type]; - - // Try all the types one by one and pick the first one that returns a value contained in the - // enum. If there's no enum, just return the first one that matches. - for (const type of types) { - const maybeResult = _coerceType(str, type, v); - if (maybeResult !== undefined && (!o.enum || o.enum.includes(maybeResult))) { - return maybeResult; - } - } - - return undefined; - } -} - -function _getOptionFromName(name: string, options: Option[]): Option | undefined { - const camelName = /(-|_)/.test(name) ? strings.camelize(name) : name; - - for (const option of options) { - if (option.name === name || option.name === camelName) { - return option; - } - - if (option.aliases.some((x) => x === name || x === camelName)) { - return option; - } - } - - return undefined; -} - -function _removeLeadingDashes(key: string): string { - const from = key.startsWith('--') ? 2 : key.startsWith('-') ? 1 : 0; - - return key.substr(from); -} - -function _assignOption( - arg: string, - nextArg: string | undefined, - { - options, - parsedOptions, - leftovers, - ignored, - errors, - warnings, - }: { - options: Option[]; - parsedOptions: Arguments; - positionals: string[]; - leftovers: string[]; - ignored: string[]; - errors: string[]; - warnings: string[]; - }, -) { - const from = arg.startsWith('--') ? 2 : 1; - let consumedNextArg = false; - let key = arg.substr(from); - let option: Option | null = null; - let value: string | undefined = ''; - const i = arg.indexOf('='); - - // If flag is --no-abc AND there's no equal sign. - if (i == -1) { - if (key.startsWith('no')) { - // Only use this key if the option matching the rest is a boolean. - const from = key.startsWith('no-') ? 3 : 2; - const maybeOption = _getOptionFromName(strings.camelize(key.substr(from)), options); - if (maybeOption && maybeOption.type == 'boolean') { - value = 'false'; - option = maybeOption; - } - } - - if (option === null) { - // Set it to true if it's a boolean and the next argument doesn't match true/false. - const maybeOption = _getOptionFromName(key, options); - if (maybeOption) { - value = nextArg; - let shouldShift = true; - - if (value && value.startsWith('-') && _coerce(undefined, maybeOption) !== undefined) { - // Verify if not having a value results in a correct parse, if so don't shift. - shouldShift = false; - } - - // Only absorb it if it leads to a better value. - if (shouldShift && _coerce(value, maybeOption) !== undefined) { - consumedNextArg = true; - } else { - value = ''; - } - option = maybeOption; - } - } - } else { - key = arg.substring(0, i); - option = _getOptionFromName(_removeLeadingDashes(key), options) || null; - if (option) { - value = arg.substring(i + 1); - } - } - - if (option === null) { - if (nextArg && !nextArg.startsWith('-')) { - leftovers.push(arg, nextArg); - consumedNextArg = true; - } else { - leftovers.push(arg); - } - } else { - const v = _coerce(value, option, parsedOptions[option.name]); - if (v !== undefined) { - if (parsedOptions[option.name] !== v) { - if (parsedOptions[option.name] !== undefined && option.type !== OptionType.Array) { - warnings.push( - `Option ${JSON.stringify(option.name)} was already specified with value ` + - `${JSON.stringify(parsedOptions[option.name])}. The new value ${JSON.stringify(v)} ` + - `will override it.`, - ); - } - - parsedOptions[option.name] = v; - } - } else { - let error = `Argument ${key} could not be parsed using value ${JSON.stringify(value)}.`; - if (option.enum) { - error += ` Valid values are: ${option.enum.map((x) => JSON.stringify(x)).join(', ')}.`; - } else { - error += `Valid type(s) is: ${(option.types || [option.type]).join(', ')}`; - } - - errors.push(error); - ignored.push(arg); - } - - if (/^[a-z]+[A-Z]/.test(key)) { - warnings.push( - 'Support for camel case arguments has been deprecated and will be removed in a future major version.\n' + - `Use '--${strings.dasherize(key)}' instead of '--${key}'.`, - ); - } - } - - return consumedNextArg; -} - -/** - * Parse the arguments in a consistent way, but without having any option definition. This tries - * to assess what the user wants in a free form. For example, using `--name=false` will set the - * name properties to a boolean type. - * This should only be used when there's no schema available or if a schema is "true" (anything is - * valid). - * - * @param args Argument list to parse. - * @returns An object that contains a property per flags from the args. - */ -export function parseFreeFormArguments(args: string[]): Arguments { - const parsedOptions: Arguments = {}; - const leftovers = []; - - for (let arg = args.shift(); arg !== undefined; arg = args.shift()) { - if (arg == '--') { - leftovers.push(...args); - break; - } - - if (arg.startsWith('--')) { - const eqSign = arg.indexOf('='); - let name: string; - let value: string | undefined; - if (eqSign !== -1) { - name = arg.substring(2, eqSign); - value = arg.substring(eqSign + 1); - } else { - name = arg.substr(2); - value = args.shift(); - } - - const v = _coerce(value, null, parsedOptions[name]); - if (v !== undefined) { - parsedOptions[name] = v; - } - } else if (arg.startsWith('-')) { - arg.split('').forEach((x) => (parsedOptions[x] = true)); - } else { - leftovers.push(arg); - } - } - - if (leftovers.length) { - parsedOptions['--'] = leftovers; - } - - return parsedOptions; -} - -/** - * Parse the arguments in a consistent way, from a list of standardized options. - * The result object will have a key per option name, with the `_` key reserved for positional - * arguments, and `--` will contain everything that did not match. Any key that don't have an - * option will be pushed back in `--` and removed from the object. If you need to validate that - * there's no additionalProperties, you need to check the `--` key. - * - * @param args The argument array to parse. - * @param options List of supported options. {@see Option}. - * @param logger Logger to use to warn users. - * @returns An object that contains a property per option. - */ -export function parseArguments( - args: string[], - options: Option[] | null, - logger?: logging.Logger, -): Arguments { - if (options === null) { - options = []; - } - - const leftovers: string[] = []; - const positionals: string[] = []; - const parsedOptions: Arguments = {}; - - const ignored: string[] = []; - const errors: string[] = []; - const warnings: string[] = []; - - const state = { options, parsedOptions, positionals, leftovers, ignored, errors, warnings }; - - for (let argIndex = 0; argIndex < args.length; argIndex++) { - const arg = args[argIndex]; - let consumedNextArg = false; - - if (arg == '--') { - // If we find a --, we're done. - leftovers.push(...args.slice(argIndex + 1)); - break; - } - - if (arg.startsWith('--')) { - consumedNextArg = _assignOption(arg, args[argIndex + 1], state); - } else if (arg.startsWith('-')) { - // Argument is of form -abcdef. Starts at 1 because we skip the `-`. - for (let i = 1; i < arg.length; i++) { - const flag = arg[i]; - // If the next character is an '=', treat it as a long flag. - if (arg[i + 1] == '=') { - const f = '-' + flag + arg.slice(i + 1); - consumedNextArg = _assignOption(f, args[argIndex + 1], state); - break; - } - // Treat the last flag as `--a` (as if full flag but just one letter). We do this in - // the loop because it saves us a check to see if the arg is just `-`. - if (i == arg.length - 1) { - const arg = '-' + flag; - consumedNextArg = _assignOption(arg, args[argIndex + 1], state); - } else { - const maybeOption = _getOptionFromName(flag, options); - if (maybeOption) { - const v = _coerce(undefined, maybeOption, parsedOptions[maybeOption.name]); - if (v !== undefined) { - parsedOptions[maybeOption.name] = v; - } - } - } - } - } else { - positionals.push(arg); - } - - if (consumedNextArg) { - argIndex++; - } - } - - // Deal with positionals. - // TODO(hansl): this is by far the most complex piece of code in this file. Try to refactor it - // simpler. - if (positionals.length > 0) { - let pos = 0; - for (let i = 0; i < positionals.length; ) { - let found = false; - let incrementPos = false; - let incrementI = true; - - // We do this with a found flag because more than 1 option could have the same positional. - for (const option of options) { - // If any option has this positional and no value, AND fit the type, we need to remove it. - if (option.positional === pos) { - const coercedValue = _coerce(positionals[i], option, parsedOptions[option.name]); - if (parsedOptions[option.name] === undefined && coercedValue !== undefined) { - parsedOptions[option.name] = coercedValue; - found = true; - } else { - incrementI = false; - } - incrementPos = true; - } - } - - if (found) { - positionals.splice(i--, 1); - } - if (incrementPos) { - pos++; - } - if (incrementI) { - i++; - } - } - } - - if (positionals.length > 0 || leftovers.length > 0) { - parsedOptions['--'] = [...positionals, ...leftovers]; - } - - if (warnings.length > 0 && logger) { - warnings.forEach((message) => logger.warn(message)); - } - - if (errors.length > 0) { - throw new ParseArgumentException(errors, parsedOptions, ignored); - } - - return parsedOptions; -} diff --git a/packages/angular/cli/models/parser_spec.ts b/packages/angular/cli/models/parser_spec.ts deleted file mode 100644 index 1f543d8d560e..000000000000 --- a/packages/angular/cli/models/parser_spec.ts +++ /dev/null @@ -1,226 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { logging } from '@angular-devkit/core'; -import { Arguments, Option, OptionType } from './interface'; -import { ParseArgumentException, parseArguments } from './parser'; - -describe('parseArguments', () => { - const options: Option[] = [ - { name: 'bool', aliases: ['b'], type: OptionType.Boolean, description: '' }, - { name: 'num', aliases: ['n'], type: OptionType.Number, description: '' }, - { name: 'str', aliases: ['s'], type: OptionType.String, description: '' }, - { name: 'strUpper', aliases: ['S'], type: OptionType.String, description: '' }, - { name: 'helloWorld', aliases: [], type: OptionType.String, description: '' }, - { name: 'helloBool', aliases: [], type: OptionType.Boolean, description: '' }, - { name: 'arr', aliases: ['a'], type: OptionType.Array, description: '' }, - { name: 'p1', positional: 0, aliases: [], type: OptionType.String, description: '' }, - { name: 'p2', positional: 1, aliases: [], type: OptionType.String, description: '' }, - { name: 'p3', positional: 2, aliases: [], type: OptionType.Number, description: '' }, - { - name: 't1', - aliases: [], - type: OptionType.Boolean, - types: [OptionType.Boolean, OptionType.String], - description: '', - }, - { - name: 't2', - aliases: [], - type: OptionType.Boolean, - types: [OptionType.Boolean, OptionType.Number], - description: '', - }, - { - name: 't3', - aliases: [], - type: OptionType.Number, - types: [OptionType.Number, OptionType.Any], - description: '', - }, - { name: 'e1', aliases: [], type: OptionType.String, enum: ['hello', 'world'], description: '' }, - { name: 'e2', aliases: [], type: OptionType.String, enum: ['hello', ''], description: '' }, - { - name: 'e3', - aliases: [], - type: OptionType.Boolean, - types: [OptionType.Boolean, OptionType.String], - enum: ['json', true, false], - description: '', - }, - ]; - - const tests: { [test: string]: Partial | ['!!!', Partial, string[]] } = { - '-S=b': { strUpper: 'b' }, - '--bool': { bool: true }, - '--bool=1': ['!!!', {}, ['--bool=1']], - '--bool ': { bool: true, p1: '' }, - '-- --bool=1': { '--': ['--bool=1'] }, - '--bool=yellow': ['!!!', {}, ['--bool=yellow']], - '--bool=true': { bool: true }, - '--bool=false': { bool: false }, - '--no-bool': { bool: false }, - '--no-bool=true': { '--': ['--no-bool=true'] }, - '--b=true': { bool: true }, - '--b=false': { bool: false }, - '--b true': { bool: true }, - '--b false': { bool: false }, - '--bool --num': { bool: true, num: 0 }, - '--bool --num=true': ['!!!', { bool: true }, ['--num=true']], - '-- --bool --num=true': { '--': ['--bool', '--num=true'] }, - '--bool=true --num': { bool: true, num: 0 }, - '--bool true --num': { bool: true, num: 0 }, - '--bool=false --num': { bool: false, num: 0 }, - '--bool false --num': { bool: false, num: 0 }, - '--str false --num': { str: 'false', num: 0 }, - '--str=false --num': { str: 'false', num: 0 }, - '--str=false --num1': { str: 'false', '--': ['--num1'] }, - '--str=false val1 --num1': { str: 'false', p1: 'val1', '--': ['--num1'] }, - '--str=false val1 val2': { str: 'false', p1: 'val1', p2: 'val2' }, - '--str=false val1 val2 --num1': { str: 'false', p1: 'val1', p2: 'val2', '--': ['--num1'] }, - '--str=false val1 --num1 val2': { str: 'false', p1: 'val1', '--': ['--num1', 'val2'] }, - '--bool --bool=false': { bool: false }, - '--bool --bool=false --bool': { bool: true }, - '--num=1 --num=2 --num=3': { num: 3 }, - '--str=1 --str=2 --str=3': { str: '3' }, - 'val1 --num=1 val2': { num: 1, p1: 'val1', p2: 'val2' }, - '--p1=val1 --num=1 val2': { num: 1, p1: 'val1', p2: 'val2' }, - '--p1=val1 --num=1 --p2=val2 val3': { num: 1, p1: 'val1', p2: 'val2', '--': ['val3'] }, - '--bool val1 --etc --num val2 --v': [ - '!!!', - { bool: true, p1: 'val1', p2: 'val2', '--': ['--etc', '--v'] }, - ['--num'], - ], - '--bool val1 --etc --num=1 val2 --v': { - bool: true, - num: 1, - p1: 'val1', - p2: 'val2', - '--': ['--etc', '--v'], - }, - '--arr=a d': { arr: ['a'], p1: 'd' }, - '--arr=a --arr=b --arr c d': { arr: ['a', 'b', 'c'], p1: 'd' }, - '--arr=1 --arr --arr c d': { arr: ['1', '', 'c'], p1: 'd' }, - '--arr=1 --arr --arr c d e': { arr: ['1', '', 'c'], p1: 'd', p2: 'e' }, - '--str=1': { str: '1' }, - '--str=': { str: '' }, - '--str ': { str: '' }, - '--str ': { str: '', p1: '' }, - '--str ': { str: '', p1: '', p2: '', '--': [''] }, - '--hello-world=1': { helloWorld: '1' }, - '--hello-bool': { helloBool: true }, - '--helloBool': { helloBool: true }, - '--no-helloBool': { helloBool: false }, - '--noHelloBool': { helloBool: false }, - '--noBool': { bool: false }, - '-b': { bool: true }, - '-b=true': { bool: true }, - '-sb': { bool: true, str: '' }, - '-s=b': { str: 'b' }, - '-bs': { bool: true, str: '' }, - '--t1=true': { t1: true }, - '--t1': { t1: true }, - '--t1 --num': { t1: true, num: 0 }, - '--no-t1': { t1: false }, - '--t1=yellow': { t1: 'yellow' }, - '--no-t1=true': { '--': ['--no-t1=true'] }, - '--t1=123': { t1: '123' }, - '--t2=true': { t2: true }, - '--t2': { t2: true }, - '--no-t2': { t2: false }, - '--t2=yellow': ['!!!', {}, ['--t2=yellow']], - '--no-t2=true': { '--': ['--no-t2=true'] }, - '--t2=123': { t2: 123 }, - '--t3=a': { t3: 'a' }, - '--t3': { t3: 0 }, - '--t3 true': { t3: true }, - '--e1 hello': { e1: 'hello' }, - '--e1=hello': { e1: 'hello' }, - '--e1 yellow': ['!!!', { p1: 'yellow' }, ['--e1']], - '--e1=yellow': ['!!!', {}, ['--e1=yellow']], - '--e1': ['!!!', {}, ['--e1']], - '--e1 true': ['!!!', { p1: 'true' }, ['--e1']], - '--e1=true': ['!!!', {}, ['--e1=true']], - '--e2 hello': { e2: 'hello' }, - '--e2=hello': { e2: 'hello' }, - '--e2 yellow': { p1: 'yellow', e2: '' }, - '--e2=yellow': ['!!!', {}, ['--e2=yellow']], - '--e2': { e2: '' }, - '--e2 true': { p1: 'true', e2: '' }, - '--e2=true': ['!!!', {}, ['--e2=true']], - '--e3 json': { e3: 'json' }, - '--e3=json': { e3: 'json' }, - '--e3 yellow': { p1: 'yellow', e3: true }, - '--e3=yellow': ['!!!', {}, ['--e3=yellow']], - '--e3': { e3: true }, - '--e3 true': { e3: true }, - '--e3=true': { e3: true }, - 'a b c 1': { p1: 'a', p2: 'b', '--': ['c', '1'] }, - - '-p=1 -c=prod': { '--': ['-p=1', '-c=prod'] }, - '--p --c': { '--': ['--p', '--c'] }, - '--p=123': { '--': ['--p=123'] }, - '--p -c': { '--': ['--p', '-c'] }, - '-p --c': { '--': ['-p', '--c'] }, - '-p --c 123': { '--': ['-p', '--c', '123'] }, - '--c 123 -p': { '--': ['--c', '123', '-p'] }, - }; - - Object.entries(tests).forEach(([str, expected]) => { - it(`works for ${str}`, () => { - try { - const originalArgs = str.split(' '); - const args = originalArgs.slice(); - - const actual = parseArguments(args, options); - - expect(Array.isArray(expected)).toBe(false); - expect(actual).toEqual(expected as Arguments); - expect(args).toEqual(originalArgs); - } catch (e) { - if (!(e instanceof ParseArgumentException)) { - throw e; - } - - // The expected values are an array. - expect(Array.isArray(expected)).toBe(true); - expect(e.parsed).toEqual(expected[1] as Arguments); - expect(e.ignored).toEqual(expected[2] as string[]); - } - }); - }); - - it('handles a flag being added multiple times', () => { - const options = [{ name: 'bool', aliases: [], type: OptionType.Boolean, description: '' }]; - - const logger = new logging.Logger(''); - const messages: string[] = []; - - logger.subscribe((entry) => messages.push(entry.message)); - - let result = parseArguments(['--bool'], options, logger); - expect(result).toEqual({ bool: true }); - expect(messages).toEqual([]); - - result = parseArguments(['--bool', '--bool'], options, logger); - expect(result).toEqual({ bool: true }); - expect(messages).toEqual([]); - - result = parseArguments(['--bool', '--bool=false'], options, logger); - expect(result).toEqual({ bool: false }); - expect(messages.length).toEqual(1); - expect(messages[0]).toMatch(/\bbool\b.*\btrue\b.*\bfalse\b/); - messages.shift(); - - result = parseArguments(['--bool', '--bool=false', '--bool=false'], options, logger); - expect(result).toEqual({ bool: false }); - expect(messages.length).toEqual(1); - expect(messages[0]).toMatch(/\bbool\b.*\btrue\b.*\bfalse\b/); - messages.shift(); - }); -}); diff --git a/packages/angular/cli/models/schematic-command.ts b/packages/angular/cli/models/schematic-command.ts deleted file mode 100644 index 884ba71f7d9d..000000000000 --- a/packages/angular/cli/models/schematic-command.ts +++ /dev/null @@ -1,599 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { logging, normalize, schema, strings, tags, workspaces } from '@angular-devkit/core'; -import { - DryRunEvent, - UnsuccessfulWorkflowExecution, - formats, - workflow, -} from '@angular-devkit/schematics'; -import { - FileSystemCollection, - FileSystemEngine, - FileSystemSchematic, - NodeWorkflow, -} from '@angular-devkit/schematics/tools'; -import * as inquirer from 'inquirer'; -import * as systemPath from 'path'; -import { colors } from '../utilities/color'; -import { getProjectByCwd, getSchematicDefaults, getWorkspace } from '../utilities/config'; -import { parseJsonSchemaToOptions } from '../utilities/json-schema'; -import { ensureCompatibleNpm, getPackageManager } from '../utilities/package-manager'; -import { isTTY } from '../utilities/tty'; -import { isPackageNameSafeForAnalytics } from './analytics'; -import { BaseCommandOptions, Command } from './command'; -import { Arguments, CommandContext, CommandDescription, Option } from './interface'; -import { parseArguments, parseFreeFormArguments } from './parser'; -import { SchematicEngineHost } from './schematic-engine-host'; - -export interface BaseSchematicSchema { - debug?: boolean; - dryRun?: boolean; - force?: boolean; - interactive?: boolean; - defaults?: boolean; - packageRegistry?: string; -} - -export interface RunSchematicOptions extends BaseSchematicSchema { - collectionName: string; - schematicName: string; - additionalOptions?: { [key: string]: {} }; - schematicOptions?: string[]; - showNothingDone?: boolean; -} - -export class UnknownCollectionError extends Error { - constructor(collectionName: string) { - super(`Invalid collection (${collectionName}).`); - } -} - -export abstract class SchematicCommand< - T extends BaseSchematicSchema & BaseCommandOptions, -> extends Command { - protected readonly allowPrivateSchematics: boolean = false; - protected override readonly useReportAnalytics = false; - protected _workflow!: NodeWorkflow; - - protected defaultCollectionName = '@schematics/angular'; - protected collectionName = this.defaultCollectionName; - protected schematicName?: string; - - constructor(context: CommandContext, description: CommandDescription, logger: logging.Logger) { - super(context, description, logger); - } - - public override async initialize(options: T & Arguments) { - await this.createWorkflow(options); - - if (this.schematicName) { - // Set the options. - const collection = this.getCollection(this.collectionName); - const schematic = this.getSchematic(collection, this.schematicName, true); - const options = await parseJsonSchemaToOptions( - this._workflow.registry, - schematic.description.schemaJson || {}, - ); - - this.description.description = schematic.description.description; - this.description.options.push(...options.filter((x) => !x.hidden)); - - // Remove any user analytics from schematics that are NOT part of our safelist. - for (const o of this.description.options) { - if (o.userAnalytics && !isPackageNameSafeForAnalytics(this.collectionName)) { - o.userAnalytics = undefined; - } - } - } - } - - public override async printHelp() { - await super.printHelp(); - this.logger.info(''); - - const subCommandOption = this.description.options.filter((x) => x.subcommands)[0]; - - if (!subCommandOption || !subCommandOption.subcommands) { - return 0; - } - - const schematicNames = Object.keys(subCommandOption.subcommands); - - if (schematicNames.length > 1) { - this.logger.info('Available Schematics:'); - - const namesPerCollection: { [c: string]: string[] } = {}; - schematicNames.forEach((name) => { - let [collectionName, schematicName] = name.split(/:/, 2); - if (!schematicName) { - schematicName = collectionName; - collectionName = this.collectionName; - } - - if (!namesPerCollection[collectionName]) { - namesPerCollection[collectionName] = []; - } - - namesPerCollection[collectionName].push(schematicName); - }); - - const defaultCollection = await this.getDefaultSchematicCollection(); - Object.keys(namesPerCollection).forEach((collectionName) => { - const isDefault = defaultCollection == collectionName; - this.logger.info(` Collection "${collectionName}"${isDefault ? ' (default)' : ''}:`); - - namesPerCollection[collectionName].forEach((schematicName) => { - this.logger.info(` ${schematicName}`); - }); - }); - } - - return 0; - } - - override async printHelpUsage() { - const subCommandOption = this.description.options.filter((x) => x.subcommands)[0]; - - if (!subCommandOption || !subCommandOption.subcommands) { - return; - } - - const schematicNames = Object.keys(subCommandOption.subcommands); - if (schematicNames.length == 1) { - this.logger.info(this.description.description); - - const opts = this.description.options.filter((x) => x.positional === undefined); - const [collectionName, schematicName] = schematicNames[0].split(/:/)[0]; - - // Display if this is not the default collectionName, - // otherwise just show the schematicName. - const displayName = - collectionName == (await this.getDefaultSchematicCollection()) - ? schematicName - : schematicNames[0]; - - const schematicOptions = subCommandOption.subcommands[schematicNames[0]].options; - const schematicArgs = schematicOptions.filter((x) => x.positional !== undefined); - const argDisplay = - schematicArgs.length > 0 - ? ' ' + schematicArgs.map((a) => `<${strings.dasherize(a.name)}>`).join(' ') - : ''; - - this.logger.info(tags.oneLine` - usage: ng ${this.description.name} ${displayName}${argDisplay} - ${opts.length > 0 ? `[options]` : ``} - `); - this.logger.info(''); - } else { - await super.printHelpUsage(); - } - } - - protected getEngine(): FileSystemEngine { - return this._workflow.engine; - } - - protected getCollection(collectionName: string): FileSystemCollection { - const engine = this.getEngine(); - const collection = engine.createCollection(collectionName); - - if (collection === null) { - throw new UnknownCollectionError(collectionName); - } - - return collection; - } - - protected getSchematic( - collection: FileSystemCollection, - schematicName: string, - allowPrivate?: boolean, - ): FileSystemSchematic { - return collection.createSchematic(schematicName, allowPrivate); - } - - protected setPathOptions(options: Option[], workingDir: string) { - if (workingDir === '') { - return {}; - } - - return options - .filter((o) => o.format === 'path') - .map((o) => o.name) - .reduce((acc, curr) => { - acc[curr] = workingDir; - - return acc; - }, {} as { [name: string]: string }); - } - - /* - * Runtime hook to allow specifying customized workflow - */ - protected async createWorkflow(options: BaseSchematicSchema): Promise { - if (this._workflow) { - return this._workflow; - } - - const { force, dryRun } = options; - const root = this.context.root; - const workflow = new NodeWorkflow(root, { - force, - dryRun, - packageManager: await getPackageManager(root), - packageRegistry: options.packageRegistry, - // A schema registry is required to allow customizing addUndefinedDefaults - registry: new schema.CoreSchemaRegistry(formats.standardFormats), - resolvePaths: this.workspace - ? // Workspace - this.collectionName === this.defaultCollectionName - ? // Favor __dirname for @schematics/angular to use the build-in version - [__dirname, process.cwd(), root] - : [process.cwd(), root, __dirname] - : // Global - [__dirname, process.cwd()], - schemaValidation: true, - optionTransforms: [ - // Add configuration file defaults - async (schematic, current) => { - const projectName = - typeof (current as Record).project === 'string' - ? ((current as Record).project as string) - : getProjectName(); - - return { - ...(await getSchematicDefaults(schematic.collection.name, schematic.name, projectName)), - ...current, - }; - }, - ], - engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths), - }); - - const getProjectName = () => { - if (this.workspace) { - const projectNames = getProjectsByPath( - this.workspace, - process.cwd(), - this.workspace.basePath, - ); - - if (projectNames.length === 1) { - return projectNames[0]; - } else { - if (projectNames.length > 1) { - this.logger.warn(tags.oneLine` - Two or more projects are using identical roots. - Unable to determine project using current working directory. - Using default workspace project instead. - `); - } - - const defaultProjectName = this.workspace.extensions['defaultProject']; - if (typeof defaultProjectName === 'string' && defaultProjectName) { - return defaultProjectName; - } - } - } - - return undefined; - }; - - workflow.registry.addPostTransform(schema.transforms.addUndefinedDefaults); - workflow.registry.addSmartDefaultProvider('projectName', getProjectName); - workflow.registry.useXDeprecatedProvider((msg) => this.logger.warn(msg)); - - let shouldReportAnalytics = true; - workflow.engineHost.registerOptionsTransform(async (_, options) => { - if (shouldReportAnalytics) { - shouldReportAnalytics = false; - await this.reportAnalytics([this.description.name], options as Arguments); - } - - return options; - }); - - if (options.interactive !== false && isTTY()) { - workflow.registry.usePromptProvider((definitions: Array) => { - const questions: inquirer.QuestionCollection = definitions - .filter((definition) => !options.defaults || definition.default === undefined) - .map((definition) => { - const question: inquirer.Question = { - name: definition.id, - message: definition.message, - default: definition.default, - }; - - const validator = definition.validator; - if (validator) { - question.validate = (input) => validator(input); - - // Filter allows transformation of the value prior to validation - question.filter = async (input) => { - for (const type of definition.propertyTypes) { - let value; - switch (type) { - case 'string': - value = String(input); - break; - case 'integer': - case 'number': - value = Number(input); - break; - default: - value = input; - break; - } - // Can be a string if validation fails - const isValid = (await validator(value)) === true; - if (isValid) { - return value; - } - } - - return input; - }; - } - - switch (definition.type) { - case 'confirmation': - question.type = 'confirm'; - break; - case 'list': - question.type = definition.multiselect ? 'checkbox' : 'list'; - (question as inquirer.CheckboxQuestion).choices = definition.items?.map((item) => { - return typeof item == 'string' - ? item - : { - name: item.label, - value: item.value, - }; - }); - break; - default: - question.type = definition.type; - break; - } - - return question; - }); - - return inquirer.prompt(questions); - }); - } - - return (this._workflow = workflow); - } - - protected async getDefaultSchematicCollection(): Promise { - let workspace = await getWorkspace('local'); - - if (workspace) { - const project = getProjectByCwd(workspace); - if (project && workspace.getProjectCli(project)) { - const value = workspace.getProjectCli(project)['defaultCollection']; - if (typeof value == 'string') { - return value; - } - } - if (workspace.getCli()) { - const value = workspace.getCli()['defaultCollection']; - if (typeof value == 'string') { - return value; - } - } - } - - workspace = await getWorkspace('global'); - if (workspace && workspace.getCli()) { - const value = workspace.getCli()['defaultCollection']; - if (typeof value == 'string') { - return value; - } - } - - return this.defaultCollectionName; - } - - protected async runSchematic(options: RunSchematicOptions) { - const { schematicOptions, debug, dryRun } = options; - let { collectionName, schematicName } = options; - - let nothingDone = true; - let loggingQueue: string[] = []; - let error = false; - - const workflow = this._workflow; - - const workingDir = normalize(systemPath.relative(this.context.root, process.cwd())); - - // Get the option object from the schematic schema. - const schematic = this.getSchematic( - this.getCollection(collectionName), - schematicName, - this.allowPrivateSchematics, - ); - // Update the schematic and collection name in case they're not the same as the ones we - // received in our options, e.g. after alias resolution or extension. - collectionName = schematic.collection.description.name; - schematicName = schematic.description.name; - - // Set the options of format "path". - let o: Option[] | null = null; - let args: Arguments; - - if (!schematic.description.schemaJson) { - args = await this.parseFreeFormArguments(schematicOptions || []); - } else { - o = await parseJsonSchemaToOptions(workflow.registry, schematic.description.schemaJson); - args = await this.parseArguments(schematicOptions || [], o); - } - - const allowAdditionalProperties = - typeof schematic.description.schemaJson === 'object' && - schematic.description.schemaJson.additionalProperties; - - if (args['--'] && !allowAdditionalProperties) { - args['--'].forEach((additional) => { - this.logger.fatal(`Unknown option: '${additional.split(/=/)[0]}'`); - }); - - return 1; - } - - const pathOptions = o ? this.setPathOptions(o, workingDir) : {}; - const input = { - ...pathOptions, - ...args, - ...options.additionalOptions, - }; - - workflow.reporter.subscribe((event: DryRunEvent) => { - nothingDone = false; - - // Strip leading slash to prevent confusion. - const eventPath = event.path.startsWith('/') ? event.path.substr(1) : event.path; - - switch (event.kind) { - case 'error': - error = true; - const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist.'; - this.logger.warn(`ERROR! ${eventPath} ${desc}.`); - break; - case 'update': - loggingQueue.push(tags.oneLine` - ${colors.cyan('UPDATE')} ${eventPath} (${event.content.length} bytes) - `); - break; - case 'create': - loggingQueue.push(tags.oneLine` - ${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes) - `); - break; - case 'delete': - loggingQueue.push(`${colors.yellow('DELETE')} ${eventPath}`); - break; - case 'rename': - const eventToPath = event.to.startsWith('/') ? event.to.substr(1) : event.to; - loggingQueue.push(`${colors.blue('RENAME')} ${eventPath} => ${eventToPath}`); - break; - } - }); - - workflow.lifeCycle.subscribe((event) => { - if (event.kind == 'end' || event.kind == 'post-tasks-start') { - if (!error) { - // Output the logging queue, no error happened. - loggingQueue.forEach((log) => this.logger.info(log)); - } - - loggingQueue = []; - error = false; - } - }); - - // Temporary compatibility check for NPM 7 - if (collectionName === '@schematics/angular' && schematicName === 'ng-new') { - if ( - !input.skipInstall && - (input.packageManager === undefined || input.packageManager === 'npm') - ) { - await ensureCompatibleNpm(this.context.root); - } - } - - return new Promise((resolve) => { - workflow - .execute({ - collection: collectionName, - schematic: schematicName, - options: input, - debug: debug, - logger: this.logger, - allowPrivate: this.allowPrivateSchematics, - }) - .subscribe({ - error: (err: Error) => { - // In case the workflow was not successful, show an appropriate error message. - if (err instanceof UnsuccessfulWorkflowExecution) { - // "See above" because we already printed the error. - this.logger.fatal('The Schematic workflow failed. See above.'); - } else if (debug) { - this.logger.fatal(`An error occurred:\n${err.message}\n${err.stack}`); - } else { - this.logger.fatal(err.message); - } - - resolve(1); - }, - complete: () => { - const showNothingDone = !(options.showNothingDone === false); - if (nothingDone && showNothingDone) { - this.logger.info('Nothing to be done.'); - } - if (dryRun) { - this.logger.warn(`\nNOTE: The "dryRun" flag means no changes were made.`); - } - resolve(); - }, - }); - }); - } - - protected async parseFreeFormArguments(schematicOptions: string[]) { - return parseFreeFormArguments(schematicOptions); - } - - protected async parseArguments( - schematicOptions: string[], - options: Option[] | null, - ): Promise { - return parseArguments(schematicOptions, options, this.logger); - } -} - -function getProjectsByPath( - workspace: workspaces.WorkspaceDefinition, - path: string, - root: string, -): string[] { - if (workspace.projects.size === 1) { - return Array.from(workspace.projects.keys()); - } - - const isInside = (base: string, potential: string): boolean => { - const absoluteBase = systemPath.resolve(root, base); - const absolutePotential = systemPath.resolve(root, potential); - const relativePotential = systemPath.relative(absoluteBase, absolutePotential); - if (!relativePotential.startsWith('..') && !systemPath.isAbsolute(relativePotential)) { - return true; - } - - return false; - }; - - const projects = Array.from(workspace.projects.entries()) - .map(([name, project]) => [systemPath.resolve(root, project.root), name] as [string, string]) - .filter((tuple) => isInside(tuple[0], path)) - // Sort tuples by depth, with the deeper ones first. Since the first member is a path and - // we filtered all invalid paths, the longest will be the deepest (and in case of equality - // the sort is stable and the first declared project will win). - .sort((a, b) => b[0].length - a[0].length); - - if (projects.length === 1) { - return [projects[0][1]]; - } else if (projects.length > 1) { - const firstPath = projects[0][0]; - - return projects.filter((v) => v[0] === firstPath).map((v) => v[1]); - } - - return []; -} diff --git a/packages/angular/cli/models/schematic-engine-host.ts b/packages/angular/cli/models/schematic-engine-host.ts deleted file mode 100644 index 57d678321d76..000000000000 --- a/packages/angular/cli/models/schematic-engine-host.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { RuleFactory, SchematicsException, Tree } from '@angular-devkit/schematics'; -import { NodeModulesEngineHost } from '@angular-devkit/schematics/tools'; -import { readFileSync } from 'fs'; -import { parse as parseJson } from 'jsonc-parser'; -import nodeModule from 'module'; -import { dirname, resolve } from 'path'; -import { Script } from 'vm'; - -/** - * Environment variable to control schematic package redirection - * Default: Angular schematics only - */ -const schematicRedirectVariable = process.env['NG_SCHEMATIC_REDIRECT']?.toLowerCase(); - -function shouldWrapSchematic(schematicFile: string): boolean { - // Check environment variable if present - if (schematicRedirectVariable !== undefined) { - switch (schematicRedirectVariable) { - case '0': - case 'false': - case 'off': - case 'none': - return false; - case 'all': - return true; - } - } - - const normalizedSchematicFile = schematicFile.replace(/\\/g, '/'); - // Never wrap the internal update schematic when executed directly - // It communicates with the update command via `global` - // But we still want to redirect schematics located in `@angular/cli/node_modules`. - if ( - normalizedSchematicFile.includes('node_modules/@angular/cli/') && - !normalizedSchematicFile.includes('node_modules/@angular/cli/node_modules/') - ) { - return false; - } - - // Default is only first-party Angular schematic packages - // Angular schematics are safe to use in the wrapped VM context - return /\/node_modules\/@(?:angular|schematics|nguniversal)\//.test(normalizedSchematicFile); -} - -export class SchematicEngineHost extends NodeModulesEngineHost { - protected override _resolveReferenceString(refString: string, parentPath: string) { - const [path, name] = refString.split('#', 2); - // Mimic behavior of ExportStringRef class used in default behavior - const fullPath = path[0] === '.' ? resolve(parentPath ?? process.cwd(), path) : path; - - const schematicFile = require.resolve(fullPath, { paths: [parentPath] }); - - if (shouldWrapSchematic(schematicFile)) { - const schematicPath = dirname(schematicFile); - - const moduleCache = new Map(); - const factoryInitializer = wrap( - schematicFile, - schematicPath, - moduleCache, - name || 'default', - ) as () => RuleFactory<{}>; - - const factory = factoryInitializer(); - if (!factory || typeof factory !== 'function') { - return null; - } - - return { ref: factory, path: schematicPath }; - } - - // All other schematics use default behavior - return super._resolveReferenceString(refString, parentPath); - } -} - -/** - * Minimal shim modules for legacy deep imports of `@schematics/angular` - */ -const legacyModules: Record = { - '@schematics/angular/utility/config': { - getWorkspace(host: Tree) { - const path = '/.angular.json'; - const data = host.read(path); - if (!data) { - throw new SchematicsException(`Could not find (${path})`); - } - - return parseJson(data.toString(), [], { allowTrailingComma: true }); - }, - }, - '@schematics/angular/utility/project': { - buildDefaultPath(project: { sourceRoot?: string; root: string; projectType: string }): string { - const root = project.sourceRoot ? `/${project.sourceRoot}/` : `/${project.root}/src/`; - - return `${root}${project.projectType === 'application' ? 'app' : 'lib'}`; - }, - }, -}; - -/** - * Wrap a JavaScript file in a VM context to allow specific Angular dependencies to be redirected. - * This VM setup is ONLY intended to redirect dependencies. - * - * @param schematicFile A JavaScript schematic file path that should be wrapped. - * @param schematicDirectory A directory that will be used as the location of the JavaScript file. - * @param moduleCache A map to use for caching repeat module usage and proper `instanceof` support. - * @param exportName An optional name of a specific export to return. Otherwise, return all exports. - */ -function wrap( - schematicFile: string, - schematicDirectory: string, - moduleCache: Map, - exportName?: string, -): () => unknown { - const hostRequire = nodeModule.createRequire(__filename); - const schematicRequire = nodeModule.createRequire(schematicFile); - - const customRequire = function (id: string) { - if (legacyModules[id]) { - // Provide compatibility modules for older versions of @angular/cdk - return legacyModules[id]; - } else if (id.startsWith('@angular-devkit/') || id.startsWith('@schematics/')) { - // Files should not redirect `@angular/core` and instead use the direct - // dependency if available. This allows old major version migrations to continue to function - // even though the latest major version may have breaking changes in `@angular/core`. - if (id.startsWith('@angular-devkit/core')) { - try { - return schematicRequire(id); - } catch (e) { - if (e.code !== 'MODULE_NOT_FOUND') { - throw e; - } - } - } - - // Resolve from inside the `@angular/cli` project - return hostRequire(id); - } else if (id.startsWith('.') || id.startsWith('@angular/cdk')) { - // Wrap relative files inside the schematic collection - // Also wrap `@angular/cdk`, it contains helper utilities that import core schematic packages - - // Resolve from the original file - const modulePath = schematicRequire.resolve(id); - - // Use cached module if available - const cachedModule = moduleCache.get(modulePath); - if (cachedModule) { - return cachedModule; - } - - // Do not wrap vendored third-party packages or JSON files - if ( - !/[/\\]node_modules[/\\]@schematics[/\\]angular[/\\]third_party[/\\]/.test(modulePath) && - !modulePath.endsWith('.json') - ) { - // Wrap module and save in cache - const wrappedModule = wrap(modulePath, dirname(modulePath), moduleCache)(); - moduleCache.set(modulePath, wrappedModule); - - return wrappedModule; - } - } - - // All others are required directly from the original file - return schematicRequire(id); - }; - - // Setup a wrapper function to capture the module's exports - const schematicCode = readFileSync(schematicFile, 'utf8'); - // `module` is required due to @angular/localize ng-add being in UMD format - const headerCode = '(function() {\nvar exports = {};\nvar module = { exports };\n'; - const footerCode = exportName ? `\nreturn exports['${exportName}'];});` : '\nreturn exports;});'; - - const script = new Script(headerCode + schematicCode + footerCode, { - filename: schematicFile, - lineOffset: 3, - }); - - const context = { - __dirname: schematicDirectory, - __filename: schematicFile, - Buffer, - console, - process, - get global() { - return this; - }, - require: customRequire, - }; - - const exportsFactory = script.runInNewContext(context); - - return exportsFactory; -} diff --git a/packages/angular/cli/models/version.ts b/packages/angular/cli/models/version.ts deleted file mode 100644 index f24082ff1229..000000000000 --- a/packages/angular/cli/models/version.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { readFileSync } from 'fs'; -import { resolve } from 'path'; - -// Same structure as used in framework packages -export class Version { - public readonly major: string; - public readonly minor: string; - public readonly patch: string; - - constructor(public readonly full: string) { - this.major = full.split('.')[0]; - this.minor = full.split('.')[1]; - this.patch = full.split('.').slice(2).join('.'); - } -} - -// TODO: Convert this to use build-time version stamping after flipping the build script to use bazel -// export const VERSION = new Version('0.0.0-PLACEHOLDER'); -export const VERSION = new Version( - ( - JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf-8')) as { version: string } - ).version, -); diff --git a/packages/angular/cli/package.json b/packages/angular/cli/package.json index aae0af126f2e..ef742616d1ec 100644 --- a/packages/angular/cli/package.json +++ b/packages/angular/cli/package.json @@ -11,9 +11,6 @@ "angular-cli", "Angular CLI" ], - "scripts": { - "postinstall": "node ./bin/postinstall/script.js" - }, "repository": { "type": "git", "url": "https://github.com/angular/angular-cli.git" @@ -25,36 +22,33 @@ }, "homepage": "https://github.com/angular/angular-cli", "dependencies": { - "@angular-devkit/architect": "0.0.0-PLACEHOLDER", + "@angular-devkit/architect": "0.0.0-EXPERIMENTAL-PLACEHOLDER", "@angular-devkit/core": "0.0.0-PLACEHOLDER", "@angular-devkit/schematics": "0.0.0-PLACEHOLDER", + "@inquirer/prompts": "5.0.7", + "@listr2/prompt-adapter-inquirer": "2.0.13", "@schematics/angular": "0.0.0-PLACEHOLDER", "@yarnpkg/lockfile": "1.1.0", - "ansi-colors": "4.1.1", - "debug": "4.3.3", - "ini": "2.0.0", - "inquirer": "8.2.0", - "jsonc-parser": "3.0.0", - "npm-package-arg": "8.1.5", - "npm-pick-manifest": "6.1.1", - "open": "8.4.0", - "ora": "5.4.1", - "pacote": "12.0.2", - "resolve": "1.21.0", - "semver": "7.3.5", + "ini": "4.1.3", + "jsonc-parser": "3.3.1", + "listr2": "8.2.3", + "npm-package-arg": "11.0.2", + "npm-pick-manifest": "9.0.1", + "pacote": "18.0.6", + "resolve": "1.22.8", + "semver": "7.6.2", "symbol-observable": "4.0.0", - "uuid": "8.3.2" - }, - "devDependencies": { - "rxjs": "6.6.7" + "yargs": "17.7.2" }, "ng-update": { "migrations": "@schematics/angular/migrations/migration-collection.json", "packageGroup": { "@angular/cli": "0.0.0-PLACEHOLDER", - "@angular-devkit/architect": "0.0.0-PLACEHOLDER", + "@angular/build": "0.0.0-PLACEHOLDER", + "@angular/ssr": "0.0.0-PLACEHOLDER", + "@angular-devkit/architect": "0.0.0-EXPERIMENTAL-PLACEHOLDER", "@angular-devkit/build-angular": "0.0.0-PLACEHOLDER", - "@angular-devkit/build-webpack": "0.0.0-PLACEHOLDER", + "@angular-devkit/build-webpack": "0.0.0-EXPERIMENTAL-PLACEHOLDER", "@angular-devkit/core": "0.0.0-PLACEHOLDER", "@angular-devkit/schematics": "0.0.0-PLACEHOLDER" } diff --git a/packages/angular/cli/src/analytics/analytics-collector.ts b/packages/angular/cli/src/analytics/analytics-collector.ts new file mode 100644 index 000000000000..379006bef2b9 --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics-collector.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { randomUUID } from 'crypto'; +import * as https from 'https'; +import * as os from 'os'; +import * as querystring from 'querystring'; +import * as semver from 'semver'; +import type { CommandContext } from '../command-builder/command-module'; +import { ngDebug } from '../utilities/environment-options'; +import { assertIsError } from '../utilities/error'; +import { VERSION } from '../utilities/version'; +import { + EventCustomDimension, + EventCustomMetric, + PrimitiveTypes, + RequestParameter, + UserCustomDimension, +} from './analytics-parameters'; + +const TRACKING_ID_PROD = 'G-VETNJBW8L4'; +const TRACKING_ID_STAGING = 'G-TBMPRL1BTM'; + +export class AnalyticsCollector { + private trackingEventsQueue: Record[] | undefined; + private readonly requestParameterStringified: string; + private readonly userParameters: Record; + + constructor( + private context: CommandContext, + userId: string, + ) { + const requestParameters: Partial> = { + [RequestParameter.ProtocolVersion]: 2, + [RequestParameter.ClientId]: userId, + [RequestParameter.UserId]: userId, + [RequestParameter.TrackingId]: + /^\d+\.\d+\.\d+$/.test(VERSION.full) && VERSION.full !== '0.0.0' + ? TRACKING_ID_PROD + : TRACKING_ID_STAGING, + + // Built-in user properties + [RequestParameter.SessionId]: randomUUID(), + [RequestParameter.UserAgentArchitecture]: os.arch(), + [RequestParameter.UserAgentPlatform]: os.platform(), + [RequestParameter.UserAgentPlatformVersion]: os.release(), + [RequestParameter.UserAgentMobile]: 0, + [RequestParameter.SessionEngaged]: 1, + // The below is needed for tech details to be collected. + [RequestParameter.UserAgentFullVersionList]: + 'Google%20Chrome;111.0.5563.64|Not(A%3ABrand;8.0.0.0|Chromium;111.0.5563.64', + }; + + if (ngDebug) { + requestParameters[RequestParameter.DebugView] = 1; + } + + this.requestParameterStringified = querystring.stringify(requestParameters); + + const parsedVersion = semver.parse(process.version); + const packageManagerVersion = context.packageManager.version; + + this.userParameters = { + // While architecture is being collect by GA as UserAgentArchitecture. + // It doesn't look like there is a way to query this. Therefore we collect this as a custom user dimension too. + [UserCustomDimension.OsArchitecture]: os.arch(), + // While User ID is being collected by GA, this is not visible in reports/for filtering. + [UserCustomDimension.UserId]: userId, + [UserCustomDimension.NodeVersion]: parsedVersion + ? `${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}` + : 'other', + [UserCustomDimension.NodeMajorVersion]: parsedVersion?.major, + [UserCustomDimension.PackageManager]: context.packageManager.name, + [UserCustomDimension.PackageManagerVersion]: packageManagerVersion, + [UserCustomDimension.PackageManagerMajorVersion]: packageManagerVersion + ? +packageManagerVersion.split('.', 1)[0] + : undefined, + [UserCustomDimension.AngularCLIVersion]: VERSION.full, + [UserCustomDimension.AngularCLIMajorVersion]: VERSION.major, + }; + } + + reportWorkspaceInfoEvent( + parameters: Partial>, + ): void { + this.event('workspace_info', parameters); + } + + reportRebuildRunEvent( + parameters: Partial< + Record + >, + ): void { + this.event('run_rebuild', parameters); + } + + reportBuildRunEvent( + parameters: Partial< + Record + >, + ): void { + this.event('run_build', parameters); + } + + reportArchitectRunEvent(parameters: Partial>): void { + this.event('run_architect', parameters); + } + + reportSchematicRunEvent(parameters: Partial>): void { + this.event('run_schematic', parameters); + } + + reportCommandRunEvent(command: string): void { + this.event('run_command', { [EventCustomDimension.Command]: command }); + } + + private event(eventName: string, parameters?: Record): void { + this.trackingEventsQueue ??= []; + this.trackingEventsQueue.push({ + ...this.userParameters, + ...parameters, + 'en': eventName, + }); + } + + /** + * Flush on an interval (if the event loop is waiting). + * + * @returns a method that when called will terminate the periodic + * flush and call flush one last time. + */ + periodFlush(): () => Promise { + let analyticsFlushPromise = Promise.resolve(); + const analyticsFlushInterval = setInterval(() => { + if (this.trackingEventsQueue?.length) { + analyticsFlushPromise = analyticsFlushPromise.then(() => this.flush()); + } + }, 4000); + + return () => { + clearInterval(analyticsFlushInterval); + + // Flush one last time. + return analyticsFlushPromise.then(() => this.flush()); + }; + } + + async flush(): Promise { + const pendingTrackingEvents = this.trackingEventsQueue; + this.context.logger.debug(`Analytics flush size. ${pendingTrackingEvents?.length}.`); + + if (!pendingTrackingEvents?.length) { + return; + } + + // The below is needed so that if flush is called multiple times, + // we don't report the same event multiple times. + this.trackingEventsQueue = undefined; + + try { + await this.send(pendingTrackingEvents); + } catch (error) { + // Failure to report analytics shouldn't crash the CLI. + assertIsError(error); + this.context.logger.debug(`Send analytics error. ${error.message}.`); + } + } + + private async send(data: Record[]): Promise { + return new Promise((resolve, reject) => { + const request = https.request( + { + host: 'www.google-analytics.com', + method: 'POST', + path: '/g/collect?' + this.requestParameterStringified, + headers: { + // The below is needed for tech details to be collected even though we provide our own information from the OS Node.js module + 'user-agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36', + }, + }, + (response) => { + // The below is needed as otherwise the response will never close which will cause the CLI not to terminate. + response.on('data', () => {}); + + if (response.statusCode !== 200 && response.statusCode !== 204) { + reject( + new Error(`Analytics reporting failed with status code: ${response.statusCode}.`), + ); + } else { + resolve(); + } + }, + ); + + request.on('error', reject); + const queryParameters = data.map((p) => querystring.stringify(p)).join('\n'); + request.write(queryParameters); + request.end(); + }); + } +} diff --git a/packages/angular/cli/src/analytics/analytics-parameters.mts b/packages/angular/cli/src/analytics/analytics-parameters.mts new file mode 100644 index 000000000000..0fd81c4531f0 --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics-parameters.mts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** This is a copy of analytics-parameters.ts and is needed for `yarn admin validate-user-analytics` due to ts-node. */ + +/** + * GA built-in request parameters + * @see https://www.thyngster.com/ga4-measurement-protocol-cheatsheet + * @see http://go/depot/google3/analytics/container_tag/templates/common/gold/mpv2_schema.js + */ +export enum RequestParameter { + ClientId = 'cid', + DebugView = '_dbg', + GtmVersion = 'gtm', + Language = 'ul', + NewToSite = '_nsi', + NonInteraction = 'ni', + PageLocation = 'dl', + PageTitle = 'dt', + ProtocolVersion = 'v', + SessionEngaged = 'seg', + SessionId = 'sid', + SessionNumber = 'sct', + SessionStart = '_ss', + TrackingId = 'tid', + TrafficType = 'tt', + UserAgentArchitecture = 'uaa', + UserAgentBitness = 'uab', + UserAgentFullVersionList = 'uafvl', + UserAgentMobile = 'uamb', + UserAgentModel = 'uam', + UserAgentPlatform = 'uap', + UserAgentPlatformVersion = 'uapv', + UserId = 'uid', +} + +/** + * User scoped custom dimensions. + * @notes + * - User custom dimensions limit is 25. + * - `up.*` string type. + * - `upn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum UserCustomDimension { + UserId = 'up.ng_user_id', + OsArchitecture = 'up.ng_os_architecture', + NodeVersion = 'up.ng_node_version', + NodeMajorVersion = 'upn.ng_node_major_version', + AngularCLIVersion = 'up.ng_cli_version', + AngularCLIMajorVersion = 'upn.ng_cli_major_version', + PackageManager = 'up.ng_package_manager', + PackageManagerVersion = 'up.ng_pkg_manager_version', + PackageManagerMajorVersion = 'upn.ng_pkg_manager_major_v', +} + +/** + * Event scoped custom dimensions. + * @notes + * - Event custom dimensions limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomDimension { + Command = 'ep.ng_command', + SchematicCollectionName = 'ep.ng_schematic_collection_name', + SchematicName = 'ep.ng_schematic_name', + Standalone = 'ep.ng_standalone', + SSR = 'ep.ng_ssr', + Style = 'ep.ng_style', + Routing = 'ep.ng_routing', + InlineTemplate = 'ep.ng_inline_template', + InlineStyle = 'ep.ng_inline_style', + BuilderTarget = 'ep.ng_builder_target', + Aot = 'ep.ng_aot', + Optimization = 'ep.ng_optimization', +} + +/** + * Event scoped custom mertics. + * @notes + * - Event scoped custom mertics limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomMetric { + AllChunksCount = 'epn.ng_all_chunks_count', + LazyChunksCount = 'epn.ng_lazy_chunks_count', + InitialChunksCount = 'epn.ng_initial_chunks_count', + ChangedChunksCount = 'epn.ng_changed_chunks_count', + DurationInMs = 'epn.ng_duration_ms', + CssSizeInBytes = 'epn.ng_css_size_bytes', + JsSizeInBytes = 'epn.ng_js_size_bytes', + NgComponentCount = 'epn.ng_component_count', + AllProjectsCount = 'epn.all_projects_count', + LibraryProjectsCount = 'epn.libs_projects_count', + ApplicationProjectsCount = 'epn.apps_projects_count', +} diff --git a/packages/angular/cli/src/analytics/analytics-parameters.ts b/packages/angular/cli/src/analytics/analytics-parameters.ts new file mode 100644 index 000000000000..7249131f348e --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics-parameters.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** Any changes in this file needs to be done in the mts version. */ + +export type PrimitiveTypes = string | number | boolean; + +/** + * GA built-in request parameters + * @see https://www.thyngster.com/ga4-measurement-protocol-cheatsheet + * @see http://go/depot/google3/analytics/container_tag/templates/common/gold/mpv2_schema.js + */ +export enum RequestParameter { + ClientId = 'cid', + DebugView = '_dbg', + GtmVersion = 'gtm', + Language = 'ul', + NewToSite = '_nsi', + NonInteraction = 'ni', + PageLocation = 'dl', + PageTitle = 'dt', + ProtocolVersion = 'v', + SessionEngaged = 'seg', + SessionId = 'sid', + SessionNumber = 'sct', + SessionStart = '_ss', + TrackingId = 'tid', + TrafficType = 'tt', + UserAgentArchitecture = 'uaa', + UserAgentBitness = 'uab', + UserAgentFullVersionList = 'uafvl', + UserAgentMobile = 'uamb', + UserAgentModel = 'uam', + UserAgentPlatform = 'uap', + UserAgentPlatformVersion = 'uapv', + UserId = 'uid', +} + +/** + * User scoped custom dimensions. + * @notes + * - User custom dimensions limit is 25. + * - `up.*` string type. + * - `upn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum UserCustomDimension { + UserId = 'up.ng_user_id', + OsArchitecture = 'up.ng_os_architecture', + NodeVersion = 'up.ng_node_version', + NodeMajorVersion = 'upn.ng_node_major_version', + AngularCLIVersion = 'up.ng_cli_version', + AngularCLIMajorVersion = 'upn.ng_cli_major_version', + PackageManager = 'up.ng_package_manager', + PackageManagerVersion = 'up.ng_pkg_manager_version', + PackageManagerMajorVersion = 'upn.ng_pkg_manager_major_v', +} + +/** + * Event scoped custom dimensions. + * @notes + * - Event custom dimensions limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomDimension { + Command = 'ep.ng_command', + SchematicCollectionName = 'ep.ng_schematic_collection_name', + SchematicName = 'ep.ng_schematic_name', + Standalone = 'ep.ng_standalone', + SSR = 'ep.ng_ssr', + Style = 'ep.ng_style', + Routing = 'ep.ng_routing', + InlineTemplate = 'ep.ng_inline_template', + InlineStyle = 'ep.ng_inline_style', + BuilderTarget = 'ep.ng_builder_target', + Aot = 'ep.ng_aot', + Optimization = 'ep.ng_optimization', +} + +/** + * Event scoped custom mertics. + * @notes + * - Event scoped custom mertics limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomMetric { + AllChunksCount = 'epn.ng_all_chunks_count', + LazyChunksCount = 'epn.ng_lazy_chunks_count', + InitialChunksCount = 'epn.ng_initial_chunks_count', + ChangedChunksCount = 'epn.ng_changed_chunks_count', + DurationInMs = 'epn.ng_duration_ms', + CssSizeInBytes = 'epn.ng_css_size_bytes', + JsSizeInBytes = 'epn.ng_js_size_bytes', + NgComponentCount = 'epn.ng_component_count', + AllProjectsCount = 'epn.all_projects_count', + LibraryProjectsCount = 'epn.libs_projects_count', + ApplicationProjectsCount = 'epn.apps_projects_count', +} diff --git a/packages/angular/cli/src/analytics/analytics.ts b/packages/angular/cli/src/analytics/analytics.ts new file mode 100644 index 000000000000..f107f6f5ca22 --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { json, tags } from '@angular-devkit/core'; +import { randomUUID } from 'crypto'; +import type { CommandContext } from '../command-builder/command-module'; +import { colors } from '../utilities/color'; +import { getWorkspace } from '../utilities/config'; +import { analyticsDisabled } from '../utilities/environment-options'; +import { askConfirmation } from '../utilities/prompt'; +import { isTTY } from '../utilities/tty'; + +/* eslint-disable no-console */ + +/** + * This is the ultimate safelist for checking if a package name is safe to report to analytics. + */ +export const analyticsPackageSafelist = [ + /^@angular\//, + /^@angular-devkit\//, + /^@nguniversal\//, + '@schematics/angular', +]; + +export function isPackageNameSafeForAnalytics(name: string): boolean { + return analyticsPackageSafelist.some((pattern) => { + if (typeof pattern == 'string') { + return pattern === name; + } else { + return pattern.test(name); + } + }); +} + +/** + * Set analytics settings. This does not work if the user is not inside a project. + * @param global Which config to use. "global" for user-level, and "local" for project-level. + * @param value Either a user ID, true to generate a new User ID, or false to disable analytics. + */ +export async function setAnalyticsConfig(global: boolean, value: string | boolean): Promise { + const level = global ? 'global' : 'local'; + const workspace = await getWorkspace(level); + if (!workspace) { + throw new Error(`Could not find ${level} workspace.`); + } + + const cli = (workspace.extensions['cli'] ??= {}); + if (!workspace || !json.isJsonObject(cli)) { + throw new Error(`Invalid config found at ${workspace.filePath}. CLI should be an object.`); + } + + cli.analytics = value === true ? randomUUID() : value; + await workspace.save(); +} + +/** + * Prompt the user for usage gathering permission. + * @param force Whether to ask regardless of whether or not the user is using an interactive shell. + * @return Whether or not the user was shown a prompt. + */ +export async function promptAnalytics( + context: CommandContext, + global: boolean, + force = false, +): Promise { + const level = global ? 'global' : 'local'; + const workspace = await getWorkspace(level); + if (!workspace) { + throw new Error(`Could not find a ${level} workspace. Are you in a project?`); + } + + if (force || isTTY()) { + const answer = await askConfirmation( + ` +Would you like to share pseudonymous usage data about this project with the Angular Team +at Google under Google's Privacy Policy at https://policies.google.com/privacy. For more +details and how to change this setting, see https://angular.dev/cli/analytics. + + `, + false, + ); + + await setAnalyticsConfig(global, answer); + + if (answer) { + console.log(''); + console.log( + tags.stripIndent` + Thank you for sharing pseudonymous usage data. Should you change your mind, the following + command will disable this feature entirely: + + ${colors.yellow(`ng analytics disable${global ? ' --global' : ''}`)} + `, + ); + console.log(''); + } + + process.stderr.write(await getAnalyticsInfoString(context)); + + return true; + } + + return false; +} + +/** + * Get the analytics user id. + * + * @returns + * - `string` user id. + * - `false` when disabled. + * - `undefined` when not configured. + */ +async function getAnalyticsUserIdForLevel( + level: 'local' | 'global', +): Promise { + if (analyticsDisabled) { + return false; + } + + const workspace = await getWorkspace(level); + const analyticsConfig: string | undefined | null | { uid?: string } | boolean = + workspace?.getCli()?.['analytics']; + + if (analyticsConfig === false) { + return false; + } else if (analyticsConfig === undefined || analyticsConfig === null) { + return undefined; + } else { + if (typeof analyticsConfig == 'string') { + return analyticsConfig; + } else if (typeof analyticsConfig == 'object' && typeof analyticsConfig['uid'] == 'string') { + return analyticsConfig['uid']; + } + + return undefined; + } +} + +export async function getAnalyticsUserId( + context: CommandContext, + skipPrompt = false, +): Promise { + const { workspace } = context; + // Global config takes precedence over local config only for the disabled check. + // IE: + // global: disabled & local: enabled = disabled + // global: id: 123 & local: id: 456 = 456 + + // check global + const globalConfig = await getAnalyticsUserIdForLevel('global'); + if (globalConfig === false) { + return undefined; + } + + // Not disabled globally, check locally or not set globally and command is run outside of workspace example: `ng new` + if (workspace || globalConfig === undefined) { + const level = workspace ? 'local' : 'global'; + let localOrGlobalConfig = await getAnalyticsUserIdForLevel(level); + if (localOrGlobalConfig === undefined) { + if (!skipPrompt) { + // config is unset, prompt user. + // TODO: This should honor the `no-interactive` option. + // It is currently not an `ng` option but rather only an option for specific commands. + // The concept of `ng`-wide options are needed to cleanly handle this. + await promptAnalytics(context, !workspace /** global */); + localOrGlobalConfig = await getAnalyticsUserIdForLevel(level); + } + } + + if (localOrGlobalConfig === false) { + return undefined; + } else if (typeof localOrGlobalConfig === 'string') { + return localOrGlobalConfig; + } + } + + return globalConfig; +} + +function analyticsConfigValueToHumanFormat(value: unknown): 'enabled' | 'disabled' | 'not set' { + if (value === false) { + return 'disabled'; + } else if (typeof value === 'string' || value === true) { + return 'enabled'; + } else { + return 'not set'; + } +} + +export async function getAnalyticsInfoString(context: CommandContext): Promise { + const analyticsInstance = await getAnalyticsUserId(context, true /** skipPrompt */); + + const { globalConfiguration, workspace: localWorkspace } = context; + const globalSetting = globalConfiguration?.getCli()?.['analytics']; + const localSetting = localWorkspace?.getCli()?.['analytics']; + + return ( + tags.stripIndents` + Global setting: ${analyticsConfigValueToHumanFormat(globalSetting)} + Local setting: ${ + localWorkspace + ? analyticsConfigValueToHumanFormat(localSetting) + : 'No local workspace configuration file.' + } + Effective status: ${analyticsInstance ? 'enabled' : 'disabled'} + ` + '\n' + ); +} diff --git a/packages/angular/cli/src/command-builder/architect-base-command-module.ts b/packages/angular/cli/src/command-builder/architect-base-command-module.ts new file mode 100644 index 000000000000..5835a14101bd --- /dev/null +++ b/packages/angular/cli/src/command-builder/architect-base-command-module.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect, Target } from '@angular-devkit/architect'; +import { + NodeModulesBuilderInfo, + WorkspaceNodeModulesArchitectHost, +} from '@angular-devkit/architect/node'; +import { json } from '@angular-devkit/core'; +import { existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { isPackageNameSafeForAnalytics } from '../analytics/analytics'; +import { EventCustomDimension, EventCustomMetric } from '../analytics/analytics-parameters'; +import { assertIsError } from '../utilities/error'; +import { askConfirmation, askQuestion } from '../utilities/prompt'; +import { isTTY } from '../utilities/tty'; +import { + CommandModule, + CommandModuleError, + CommandModuleImplementation, + CommandScope, + OtherOptions, +} from './command-module'; +import { Option, parseJsonSchemaToOptions } from './utilities/json-schema'; + +export interface MissingTargetChoice { + name: string; + value: string; +} + +export abstract class ArchitectBaseCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + override scope = CommandScope.In; + protected readonly missingTargetChoices: MissingTargetChoice[] | undefined; + + protected async runSingleTarget(target: Target, options: OtherOptions): Promise { + const architectHost = this.getArchitectHost(); + + let builderName: string; + try { + builderName = await architectHost.getBuilderNameForTarget(target); + } catch (e) { + assertIsError(e); + + return this.onMissingTarget(e.message); + } + + const { logger } = this.context; + const run = await this.getArchitect().scheduleTarget(target, options as json.JsonObject, { + logger, + }); + + const analytics = isPackageNameSafeForAnalytics(builderName) + ? await this.getAnalytics() + : undefined; + + let outputSubscription; + if (analytics) { + analytics.reportArchitectRunEvent({ + [EventCustomDimension.BuilderTarget]: builderName, + }); + + let firstRun = true; + outputSubscription = run.output.subscribe(({ stats }) => { + const parameters = this.builderStatsToAnalyticsParameters(stats, builderName); + if (!parameters) { + return; + } + + if (firstRun) { + firstRun = false; + analytics.reportBuildRunEvent(parameters); + } else { + analytics.reportRebuildRunEvent(parameters); + } + }); + } + + try { + const { error, success } = await run.lastOutput; + if (error) { + logger.error(error); + } + + return success ? 0 : 1; + } finally { + await run.stop(); + outputSubscription?.unsubscribe(); + } + } + + private builderStatsToAnalyticsParameters( + stats: json.JsonValue, + builderName: string, + ): Partial< + | Record + | undefined + > { + if (!stats || typeof stats !== 'object' || !('durationInMs' in stats)) { + return undefined; + } + + const { + optimization, + allChunksCount, + aot, + lazyChunksCount, + initialChunksCount, + durationInMs, + changedChunksCount, + cssSizeInBytes, + jsSizeInBytes, + ngComponentCount, + } = stats; + + return { + [EventCustomDimension.BuilderTarget]: builderName, + [EventCustomDimension.Aot]: aot, + [EventCustomDimension.Optimization]: optimization, + [EventCustomMetric.AllChunksCount]: allChunksCount, + [EventCustomMetric.LazyChunksCount]: lazyChunksCount, + [EventCustomMetric.InitialChunksCount]: initialChunksCount, + [EventCustomMetric.ChangedChunksCount]: changedChunksCount, + [EventCustomMetric.DurationInMs]: durationInMs, + [EventCustomMetric.JsSizeInBytes]: jsSizeInBytes, + [EventCustomMetric.CssSizeInBytes]: cssSizeInBytes, + [EventCustomMetric.NgComponentCount]: ngComponentCount, + }; + } + + private _architectHost: WorkspaceNodeModulesArchitectHost | undefined; + protected getArchitectHost(): WorkspaceNodeModulesArchitectHost { + if (this._architectHost) { + return this._architectHost; + } + + const workspace = this.getWorkspaceOrThrow(); + + return (this._architectHost = new WorkspaceNodeModulesArchitectHost( + workspace, + workspace.basePath, + )); + } + + private _architect: Architect | undefined; + protected getArchitect(): Architect { + if (this._architect) { + return this._architect; + } + + const registry = new json.schema.CoreSchemaRegistry(); + registry.addPostTransform(json.schema.transforms.addUndefinedDefaults); + registry.useXDeprecatedProvider((msg) => this.context.logger.warn(msg)); + + const architectHost = this.getArchitectHost(); + + return (this._architect = new Architect(architectHost, registry)); + } + + protected async getArchitectTargetOptions(target: Target): Promise { + const architectHost = this.getArchitectHost(); + let builderConf: string; + + try { + builderConf = await architectHost.getBuilderNameForTarget(target); + } catch { + return []; + } + + let builderDesc: NodeModulesBuilderInfo; + try { + builderDesc = await architectHost.resolveBuilder(builderConf); + } catch (e) { + assertIsError(e); + if (e.code === 'MODULE_NOT_FOUND') { + this.warnOnMissingNodeModules(); + throw new CommandModuleError(`Could not find the '${builderConf}' builder's node package.`); + } + + throw e; + } + + return parseJsonSchemaToOptions( + new json.schema.CoreSchemaRegistry(), + builderDesc.optionSchema as json.JsonObject, + true, + ); + } + + private warnOnMissingNodeModules(): void { + const basePath = this.context.workspace?.basePath; + if (!basePath) { + return; + } + + // Check if yarn PnP is used. https://yarnpkg.com/advanced/pnpapi#processversionspnp + if (process.versions.pnp) { + return; + } + + // Check for a `node_modules` directory (npm, yarn non-PnP, etc.) + if (existsSync(resolve(basePath, 'node_modules'))) { + return; + } + + this.context.logger.warn( + `Node packages may not be installed. Try installing with '${this.context.packageManager.name} install'.`, + ); + } + + protected getArchitectTarget(): string { + return this.commandName; + } + + protected async onMissingTarget(defaultMessage: string): Promise<1> { + const { logger } = this.context; + const choices = this.missingTargetChoices; + + if (!choices?.length) { + logger.error(defaultMessage); + + return 1; + } + + const missingTargetMessage = + `Cannot find "${this.getArchitectTarget()}" target for the specified project.\n` + + `You can add a package that implements these capabilities.\n\n` + + `For example:\n` + + choices.map(({ name, value }) => ` ${name}: ng add ${value}`).join('\n') + + '\n'; + + if (isTTY()) { + // Use prompts to ask the user if they'd like to install a package. + logger.warn(missingTargetMessage); + + const packageToInstall = await this.getMissingTargetPackageToInstall(choices); + if (packageToInstall) { + // Example run: `ng add @angular-eslint/schematics`. + const AddCommandModule = (await import('../commands/add/cli')).default; + await new AddCommandModule(this.context).run({ + interactive: true, + force: false, + dryRun: false, + defaults: false, + collection: packageToInstall, + }); + } + } else { + // Non TTY display error message. + logger.error(missingTargetMessage); + } + + return 1; + } + + private async getMissingTargetPackageToInstall( + choices: MissingTargetChoice[], + ): Promise { + if (choices.length === 1) { + // Single choice + const { name, value } = choices[0]; + if (await askConfirmation(`Would you like to add ${name} now?`, true, false)) { + return value; + } + + return null; + } + + // Multiple choice + return askQuestion( + `Would you like to add a package with "${this.getArchitectTarget()}" capabilities now?`, + [ + { + name: 'No', + value: null, + }, + ...choices, + ], + 0, + null, + ); + } +} diff --git a/packages/angular/cli/src/command-builder/architect-command-module.ts b/packages/angular/cli/src/command-builder/architect-command-module.ts new file mode 100644 index 000000000000..4855b629b360 --- /dev/null +++ b/packages/angular/cli/src/command-builder/architect-command-module.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Target } from '@angular-devkit/architect'; +import { workspaces } from '@angular-devkit/core'; +import { Argv } from 'yargs'; +import { getProjectByCwd } from '../utilities/config'; +import { memoize } from '../utilities/memoize'; +import { ArchitectBaseCommandModule } from './architect-base-command-module'; +import { + CommandModuleError, + CommandModuleImplementation, + Options, + OtherOptions, +} from './command-module'; + +export interface ArchitectCommandArgs { + configuration?: string; + project?: string; +} + +export abstract class ArchitectCommandModule + extends ArchitectBaseCommandModule + implements CommandModuleImplementation +{ + abstract readonly multiTarget: boolean; + + findDefaultBuilderName?( + project: workspaces.ProjectDefinition, + target: Target, + ): Promise; + + async builder(argv: Argv): Promise> { + const target = this.getArchitectTarget(); + + // Add default builder if target is not in project and a command default is provided + if (this.findDefaultBuilderName && this.context.workspace) { + for (const [project, projectDefinition] of this.context.workspace.projects) { + if (projectDefinition.targets.has(target)) { + continue; + } + + const defaultBuilder = await this.findDefaultBuilderName(projectDefinition, { + project, + target, + }); + if (defaultBuilder) { + projectDefinition.targets.set(target, { + builder: defaultBuilder, + }); + } + } + } + + const project = this.getArchitectProject(); + const { jsonHelp, getYargsCompletions, help } = this.context.args.options; + + const localYargs: Argv = argv + .positional('project', { + describe: 'The name of the project to build. Can be an application or a library.', + type: 'string', + // Hide choices from JSON help so that we don't display them in AIO. + choices: jsonHelp ? undefined : this.getProjectChoices(), + }) + .option('configuration', { + describe: + `One or more named builder configurations as a comma-separated ` + + `list as specified in the "configurations" section in angular.json.\n` + + `The builder uses the named configurations to run the given target.\n` + + `For more information, see https://angular.dev/reference/configs/workspace-config#alternate-build-configurations.`, + alias: 'c', + type: 'string', + // Show only in when using --help and auto completion because otherwise comma seperated configuration values will be invalid. + // Also, hide choices from JSON help so that we don't display them in AIO. + choices: + (getYargsCompletions || help) && !jsonHelp && project + ? this.getConfigurationChoices(project) + : undefined, + }) + .strict(); + + if (!project) { + return localYargs; + } + + const schemaOptions = await this.getArchitectTargetOptions({ + project, + target, + }); + + return this.addSchemaOptionsToCommand(localYargs, schemaOptions); + } + + async run(options: Options & OtherOptions): Promise { + const target = this.getArchitectTarget(); + + const { configuration = '', project, ...architectOptions } = options; + + if (!project) { + // This runs each target sequentially. + // Running them in parallel would jumble the log messages. + let result = 0; + const projectNames = this.getProjectNamesByTarget(target); + if (!projectNames) { + return this.onMissingTarget('Cannot determine project or target for command.'); + } + + for (const project of projectNames) { + result |= await this.runSingleTarget({ configuration, target, project }, architectOptions); + } + + return result; + } else { + return await this.runSingleTarget({ configuration, target, project }, architectOptions); + } + } + + private getArchitectProject(): string | undefined { + const { options, positional } = this.context.args; + const [, projectName] = positional; + + if (projectName) { + return projectName; + } + + // Yargs allows positional args to be used as flags. + if (typeof options['project'] === 'string') { + return options['project']; + } + + const target = this.getArchitectTarget(); + const projectFromTarget = this.getProjectNamesByTarget(target); + + return projectFromTarget?.length ? projectFromTarget[0] : undefined; + } + + @memoize + private getProjectNamesByTarget(target: string): string[] | undefined { + const workspace = this.getWorkspaceOrThrow(); + const allProjectsForTargetName: string[] = []; + + for (const [name, project] of workspace.projects) { + if (project.targets.has(target)) { + allProjectsForTargetName.push(name); + } + } + + if (allProjectsForTargetName.length === 0) { + return undefined; + } + + if (this.multiTarget) { + // For multi target commands, we always list all projects that have the target. + return allProjectsForTargetName; + } else { + if (allProjectsForTargetName.length === 1) { + return allProjectsForTargetName; + } + + const maybeProject = getProjectByCwd(workspace); + if (maybeProject) { + return allProjectsForTargetName.includes(maybeProject) ? [maybeProject] : undefined; + } + + const { getYargsCompletions, help } = this.context.args.options; + if (!getYargsCompletions && !help) { + // Only issue the below error when not in help / completion mode. + throw new CommandModuleError( + 'Cannot determine project for command.\n' + + 'This is a multi-project workspace and more than one project supports this command. ' + + `Run "ng ${this.command}" to execute the command for a specific project or change the current ` + + 'working directory to a project directory.\n\n' + + `Available projects are:\n${allProjectsForTargetName + .sort() + .map((p) => `- ${p}`) + .join('\n')}`, + ); + } + } + + return undefined; + } + + /** @returns a sorted list of project names to be used for auto completion. */ + private getProjectChoices(): string[] | undefined { + const { workspace } = this.context; + + return workspace ? [...workspace.projects.keys()].sort() : undefined; + } + + /** @returns a sorted list of configuration names to be used for auto completion. */ + private getConfigurationChoices(project: string): string[] | undefined { + const projectDefinition = this.context.workspace?.projects.get(project); + if (!projectDefinition) { + return undefined; + } + + const target = this.getArchitectTarget(); + const configurations = projectDefinition.targets.get(target)?.configurations; + + return configurations ? Object.keys(configurations).sort() : undefined; + } +} diff --git a/packages/angular/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts new file mode 100644 index 000000000000..e608c4b1d089 --- /dev/null +++ b/packages/angular/cli/src/command-builder/command-module.ts @@ -0,0 +1,346 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { logging, schema, strings } from '@angular-devkit/core'; +import { readFileSync } from 'fs'; +import * as path from 'path'; +import yargs, { + Arguments, + ArgumentsCamelCase, + Argv, + CamelCaseKey, + PositionalOptions, + CommandModule as YargsCommandModule, + Options as YargsOptions, +} from 'yargs'; +import { Parser as yargsParser } from 'yargs/helpers'; +import { getAnalyticsUserId } from '../analytics/analytics'; +import { AnalyticsCollector } from '../analytics/analytics-collector'; +import { EventCustomDimension, EventCustomMetric } from '../analytics/analytics-parameters'; +import { considerSettingUpAutocompletion } from '../utilities/completion'; +import { AngularWorkspace } from '../utilities/config'; +import { memoize } from '../utilities/memoize'; +import { PackageManagerUtils } from '../utilities/package-manager'; +import { Option } from './utilities/json-schema'; + +export type Options = { [key in keyof T as CamelCaseKey]: T[key] }; + +export enum CommandScope { + /** Command can only run inside an Angular workspace. */ + In, + /** Command can only run outside an Angular workspace. */ + Out, + /** Command can run inside and outside an Angular workspace. */ + Both, +} + +export interface CommandContext { + currentDirectory: string; + root: string; + workspace?: AngularWorkspace; + globalConfiguration: AngularWorkspace; + logger: logging.Logger; + packageManager: PackageManagerUtils; + /** Arguments parsed in free-from without parser configuration. */ + args: { + positional: string[]; + options: { + help: boolean; + jsonHelp: boolean; + getYargsCompletions: boolean; + } & Record; + }; +} + +export type OtherOptions = Record; + +export interface CommandModuleImplementation + extends Omit, 'builder' | 'handler'> { + /** Scope in which the command can be executed in. */ + scope: CommandScope; + /** Path used to load the long description for the command in JSON help text. */ + longDescriptionPath?: string; + /** Object declaring the options the command accepts, or a function accepting and returning a yargs instance. */ + builder(argv: Argv): Promise> | Argv; + /** A function which will be passed the parsed argv. */ + run(options: Options & OtherOptions): Promise | number | void; +} + +export interface FullDescribe { + describe?: string; + longDescription?: string; + longDescriptionRelativePath?: string; +} + +export abstract class CommandModule implements CommandModuleImplementation { + abstract readonly command: string; + abstract readonly describe: string | false; + abstract readonly longDescriptionPath?: string; + protected readonly shouldReportAnalytics: boolean = true; + readonly scope: CommandScope = CommandScope.Both; + + private readonly optionsWithAnalytics = new Map(); + + constructor(protected readonly context: CommandContext) {} + + /** + * Description object which contains the long command descroption. + * This is used to generate JSON help wich is used in AIO. + * + * `false` will result in a hidden command. + */ + public get fullDescribe(): FullDescribe | false { + return this.describe === false + ? false + : { + describe: this.describe, + ...(this.longDescriptionPath + ? { + longDescriptionRelativePath: path + .relative(path.join(__dirname, '../../../../'), this.longDescriptionPath) + .replace(/\\/g, path.posix.sep), + longDescription: readFileSync(this.longDescriptionPath, 'utf8').replace( + /\r\n/g, + '\n', + ), + } + : {}), + }; + } + + protected get commandName(): string { + return this.command.split(' ', 1)[0]; + } + + abstract builder(argv: Argv): Promise> | Argv; + abstract run(options: Options & OtherOptions): Promise | number | void; + + async handler(args: ArgumentsCamelCase & OtherOptions): Promise { + const { _, $0, ...options } = args; + + // Camelize options as yargs will return the object in kebab-case when camel casing is disabled. + const camelCasedOptions: Record = {}; + for (const [key, value] of Object.entries(options)) { + camelCasedOptions[yargsParser.camelCase(key)] = value; + } + + // Set up autocompletion if appropriate. + const autocompletionExitCode = await considerSettingUpAutocompletion( + this.commandName, + this.context.logger, + ); + if (autocompletionExitCode !== undefined) { + process.exitCode = autocompletionExitCode; + + return; + } + + // Gather and report analytics. + const analytics = await this.getAnalytics(); + const stopPeriodicFlushes = analytics && analytics.periodFlush(); + + let exitCode: number | void | undefined; + try { + if (analytics) { + this.reportCommandRunAnalytics(analytics); + this.reportWorkspaceInfoAnalytics(analytics); + } + + exitCode = await this.run(camelCasedOptions as Options & OtherOptions); + } catch (e) { + if (e instanceof schema.SchemaValidationException) { + this.context.logger.fatal(`Error: ${e.message}`); + exitCode = 1; + } else { + throw e; + } + } finally { + await stopPeriodicFlushes?.(); + + if (typeof exitCode === 'number' && exitCode > 0) { + process.exitCode = exitCode; + } + } + } + + @memoize + protected async getAnalytics(): Promise { + if (!this.shouldReportAnalytics) { + return undefined; + } + + const userId = await getAnalyticsUserId( + this.context, + // Don't prompt on `ng update`, 'ng version' or `ng analytics`. + ['version', 'update', 'analytics'].includes(this.commandName), + ); + + return userId ? new AnalyticsCollector(this.context, userId) : undefined; + } + + /** + * Adds schema options to a command also this keeps track of options that are required for analytics. + * **Note:** This method should be called from the command bundler method. + */ + protected addSchemaOptionsToCommand(localYargs: Argv, options: Option[]): Argv { + const booleanOptionsWithNoPrefix = new Set(); + + for (const option of options) { + const { + default: defaultVal, + positional, + deprecated, + description, + alias, + userAnalytics, + type, + hidden, + name, + choices, + } = option; + + const sharedOptions: YargsOptions & PositionalOptions = { + alias, + hidden, + description, + deprecated, + choices, + // This should only be done when `--help` is used otherwise default will override options set in angular.json. + ...(this.context.args.options.help ? { default: defaultVal } : {}), + }; + + let dashedName = strings.dasherize(name); + + // Handle options which have been defined in the schema with `no` prefix. + if (type === 'boolean' && dashedName.startsWith('no-')) { + dashedName = dashedName.slice(3); + booleanOptionsWithNoPrefix.add(dashedName); + } + + if (positional === undefined) { + localYargs = localYargs.option(dashedName, { + type, + ...sharedOptions, + }); + } else { + localYargs = localYargs.positional(dashedName, { + type: type === 'array' || type === 'count' ? 'string' : type, + ...sharedOptions, + }); + } + + // Record option of analytics. + if (userAnalytics !== undefined) { + this.optionsWithAnalytics.set(name, userAnalytics); + } + } + + // Handle options which have been defined in the schema with `no` prefix. + if (booleanOptionsWithNoPrefix.size) { + localYargs.middleware((options: Arguments) => { + for (const key of booleanOptionsWithNoPrefix) { + if (key in options) { + options[`no-${key}`] = !options[key]; + delete options[key]; + } + } + }, false); + } + + return localYargs; + } + + protected getWorkspaceOrThrow(): AngularWorkspace { + const { workspace } = this.context; + if (!workspace) { + throw new CommandModuleError('A workspace is required for this command.'); + } + + return workspace; + } + + /** + * Flush on an interval (if the event loop is waiting). + * + * @returns a method that when called will terminate the periodic + * flush and call flush one last time. + */ + protected getAnalyticsParameters( + options: (Options & OtherOptions) | OtherOptions, + ): Partial> { + const parameters: Partial< + Record + > = {}; + + const validEventCustomDimensionAndMetrics = new Set([ + ...Object.values(EventCustomDimension), + ...Object.values(EventCustomMetric), + ]); + + for (const [name, ua] of this.optionsWithAnalytics) { + const value = options[name]; + if ( + (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') && + validEventCustomDimensionAndMetrics.has(ua as EventCustomDimension | EventCustomMetric) + ) { + parameters[ua as EventCustomDimension | EventCustomMetric] = value; + } + } + + return parameters; + } + + private reportCommandRunAnalytics(analytics: AnalyticsCollector): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const internalMethods = (yargs as any).getInternalMethods(); + // $0 generate component [name] -> generate_component + // $0 add -> add + const fullCommand = (internalMethods.getUsageInstance().getUsage()[0][0] as string) + .split(' ') + .filter((x) => { + const code = x.charCodeAt(0); + + return code >= 97 && code <= 122; + }) + .join('_'); + + analytics.reportCommandRunEvent(fullCommand); + } + + private reportWorkspaceInfoAnalytics(analytics: AnalyticsCollector): void { + const { workspace } = this.context; + if (!workspace) { + return; + } + + let applicationProjectsCount = 0; + let librariesProjectsCount = 0; + for (const project of workspace.projects.values()) { + switch (project.extensions['projectType']) { + case 'application': + applicationProjectsCount++; + break; + case 'library': + librariesProjectsCount++; + break; + } + } + + analytics.reportWorkspaceInfoEvent({ + [EventCustomMetric.AllProjectsCount]: librariesProjectsCount + applicationProjectsCount, + [EventCustomMetric.ApplicationProjectsCount]: applicationProjectsCount, + [EventCustomMetric.LibraryProjectsCount]: librariesProjectsCount, + }); + } +} + +/** + * Creates an known command module error. + * This is used so during executation we can filter between known validation error and real non handled errors. + */ +export class CommandModuleError extends Error {} diff --git a/packages/angular/cli/src/command-builder/command-runner.ts b/packages/angular/cli/src/command-builder/command-runner.ts new file mode 100644 index 000000000000..0c2242414ce1 --- /dev/null +++ b/packages/angular/cli/src/command-builder/command-runner.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import yargs from 'yargs'; +import { Parser } from 'yargs/helpers'; +import { + CommandConfig, + CommandNames, + RootCommands, + RootCommandsAliases, +} from '../commands/command-config'; +import { colors } from '../utilities/color'; +import { AngularWorkspace, getWorkspace } from '../utilities/config'; +import { assertIsError } from '../utilities/error'; +import { PackageManagerUtils } from '../utilities/package-manager'; +import { VERSION } from '../utilities/version'; +import { CommandContext, CommandModuleError } from './command-module'; +import { + CommandModuleConstructor, + addCommandModuleToYargs, + demandCommandFailureMessage, +} from './utilities/command'; +import { jsonHelpUsage } from './utilities/json-help'; +import { normalizeOptionsMiddleware } from './utilities/normalize-options-middleware'; + +const yargsParser = Parser as unknown as typeof Parser.default; + +export async function runCommand(args: string[], logger: logging.Logger): Promise { + const { + $0, + _, + help = false, + jsonHelp = false, + getYargsCompletions = false, + ...rest + } = yargsParser(args, { + boolean: ['help', 'json-help', 'get-yargs-completions'], + alias: { 'collection': 'c' }, + }); + + // When `getYargsCompletions` is true the scriptName 'ng' at index 0 is not removed. + const positional = getYargsCompletions ? _.slice(1) : _; + + let workspace: AngularWorkspace | undefined; + let globalConfiguration: AngularWorkspace; + try { + [workspace, globalConfiguration] = await Promise.all([ + getWorkspace('local'), + getWorkspace('global'), + ]); + } catch (e) { + assertIsError(e); + logger.fatal(e.message); + + return 1; + } + + const root = workspace?.basePath ?? process.cwd(); + const context: CommandContext = { + globalConfiguration, + workspace, + logger, + currentDirectory: process.cwd(), + root, + packageManager: new PackageManagerUtils({ globalConfiguration, workspace, root }), + args: { + positional: positional.map((v) => v.toString()), + options: { + help, + jsonHelp, + getYargsCompletions, + ...rest, + }, + }, + }; + + let localYargs = yargs(args); + for (const CommandModule of await getCommandsToRegister(positional[0])) { + localYargs = addCommandModuleToYargs(localYargs, CommandModule, context); + } + + if (jsonHelp) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const usageInstance = (localYargs as any).getInternalMethods().getUsageInstance(); + usageInstance.help = () => jsonHelpUsage(); + } + + // Add default command to support version option when no subcommand is specified + localYargs.command('*', false, (builder) => + builder.version('version', 'Show Angular CLI version.', VERSION.full), + ); + + await localYargs + .scriptName('ng') + // https://github.com/yargs/yargs/blob/main/docs/advanced.md#customizing-yargs-parser + .parserConfiguration({ + 'populate--': true, + 'unknown-options-as-args': false, + 'dot-notation': false, + 'boolean-negation': true, + 'strip-aliased': true, + 'strip-dashed': true, + 'camel-case-expansion': false, + }) + .option('json-help', { + describe: 'Show help in JSON format.', + implies: ['help'], + hidden: true, + type: 'boolean', + }) + .help('help', 'Shows a help message for this command in the console.') + // A complete list of strings can be found: https://github.com/yargs/yargs/blob/main/locales/en.json + .updateStrings({ + 'Commands:': colors.cyan('Commands:'), + 'Options:': colors.cyan('Options:'), + 'Positionals:': colors.cyan('Arguments:'), + 'deprecated': colors.yellow('deprecated'), + 'deprecated: %s': colors.yellow('deprecated:') + ' %s', + 'Did you mean %s?': 'Unknown command. Did you mean %s?', + }) + .epilogue('For more information, see https://angular.dev/cli/.\n') + .demandCommand(1, demandCommandFailureMessage) + .recommendCommands() + .middleware(normalizeOptionsMiddleware) + .version(false) + .showHelpOnFail(false) + .strict() + .fail((msg, err) => { + throw msg + ? // Validation failed example: `Unknown argument:` + new CommandModuleError(msg) + : // Unknown exception, re-throw. + err; + }) + .wrap(yargs.terminalWidth()) + .parseAsync(); + + return process.exitCode ?? 0; +} + +/** + * Get the commands that need to be registered. + * @returns One or more command factories that needs to be registered. + */ +async function getCommandsToRegister( + commandName: string | number, +): Promise { + const commands: CommandConfig[] = []; + if (commandName in RootCommands) { + commands.push(RootCommands[commandName as CommandNames]); + } else if (commandName in RootCommandsAliases) { + commands.push(RootCommandsAliases[commandName]); + } else { + // Unknown command, register every possible command. + Object.values(RootCommands).forEach((c) => commands.push(c)); + } + + return Promise.all(commands.map((command) => command.factory().then((m) => m.default))); +} diff --git a/packages/angular/cli/src/command-builder/schematics-command-module.ts b/packages/angular/cli/src/command-builder/schematics-command-module.ts new file mode 100644 index 000000000000..6f92b461415f --- /dev/null +++ b/packages/angular/cli/src/command-builder/schematics-command-module.ts @@ -0,0 +1,405 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JsonValue, normalize as devkitNormalize, schema } from '@angular-devkit/core'; +import { Collection, UnsuccessfulWorkflowExecution, formats } from '@angular-devkit/schematics'; +import { + FileSystemCollectionDescription, + FileSystemSchematicDescription, + NodeWorkflow, +} from '@angular-devkit/schematics/tools'; +import { relative, resolve } from 'path'; +import { Argv } from 'yargs'; +import { isPackageNameSafeForAnalytics } from '../analytics/analytics'; +import { EventCustomDimension } from '../analytics/analytics-parameters'; +import { getProjectByCwd, getSchematicDefaults } from '../utilities/config'; +import { assertIsError } from '../utilities/error'; +import { memoize } from '../utilities/memoize'; +import { isTTY } from '../utilities/tty'; +import { + CommandModule, + CommandModuleImplementation, + CommandScope, + Options, + OtherOptions, +} from './command-module'; +import { Option, parseJsonSchemaToOptions } from './utilities/json-schema'; +import { SchematicEngineHost } from './utilities/schematic-engine-host'; +import { subscribeToWorkflow } from './utilities/schematic-workflow'; + +export const DEFAULT_SCHEMATICS_COLLECTION = '@schematics/angular'; + +export interface SchematicsCommandArgs { + interactive: boolean; + force: boolean; + 'dry-run': boolean; + defaults: boolean; +} + +export interface SchematicsExecutionOptions extends Options { + packageRegistry?: string; +} + +export abstract class SchematicsCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + override scope = CommandScope.In; + protected readonly allowPrivateSchematics: boolean = false; + + async builder(argv: Argv): Promise> { + return argv + .option('interactive', { + describe: 'Enable interactive input prompts.', + type: 'boolean', + default: true, + }) + .option('dry-run', { + describe: 'Run through and reports activity without writing out results.', + type: 'boolean', + alias: ['d'], + default: false, + }) + .option('defaults', { + describe: 'Disable interactive input prompts for options with a default.', + type: 'boolean', + default: false, + }) + .option('force', { + describe: 'Force overwriting of existing files.', + type: 'boolean', + default: false, + }) + .strict(); + } + + /** Get schematic schema options.*/ + protected async getSchematicOptions( + collection: Collection, + schematicName: string, + workflow: NodeWorkflow, + ): Promise { + const schematic = collection.createSchematic(schematicName, true); + const { schemaJson } = schematic.description; + + if (!schemaJson) { + return []; + } + + return parseJsonSchemaToOptions(workflow.registry, schemaJson); + } + + @memoize + protected getOrCreateWorkflowForBuilder(collectionName: string): NodeWorkflow { + return new NodeWorkflow(this.context.root, { + resolvePaths: this.getResolvePaths(collectionName), + engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths), + }); + } + + @memoize + protected async getOrCreateWorkflowForExecution( + collectionName: string, + options: SchematicsExecutionOptions, + ): Promise { + const { logger, root, packageManager } = this.context; + const { force, dryRun, packageRegistry } = options; + + const workflow = new NodeWorkflow(root, { + force, + dryRun, + packageManager: packageManager.name, + // A schema registry is required to allow customizing addUndefinedDefaults + registry: new schema.CoreSchemaRegistry(formats.standardFormats), + packageRegistry, + resolvePaths: this.getResolvePaths(collectionName), + schemaValidation: true, + optionTransforms: [ + // Add configuration file defaults + async (schematic, current) => { + const projectName = + typeof current?.project === 'string' ? current.project : this.getProjectName(); + + return { + ...(await getSchematicDefaults(schematic.collection.name, schematic.name, projectName)), + ...current, + }; + }, + ], + engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths), + }); + + workflow.registry.addPostTransform(schema.transforms.addUndefinedDefaults); + workflow.registry.useXDeprecatedProvider((msg) => logger.warn(msg)); + workflow.registry.addSmartDefaultProvider('projectName', () => this.getProjectName()); + + const workingDir = devkitNormalize(relative(this.context.root, process.cwd())); + workflow.registry.addSmartDefaultProvider('workingDirectory', () => + workingDir === '' ? undefined : workingDir, + ); + + let shouldReportAnalytics = true; + workflow.engineHost.registerOptionsTransform(async (schematic, options) => { + // Report analytics + if (shouldReportAnalytics) { + shouldReportAnalytics = false; + + const { + collection: { name: collectionName }, + name: schematicName, + } = schematic; + + const analytics = isPackageNameSafeForAnalytics(collectionName) + ? await this.getAnalytics() + : undefined; + + analytics?.reportSchematicRunEvent({ + [EventCustomDimension.SchematicCollectionName]: collectionName, + [EventCustomDimension.SchematicName]: schematicName, + ...this.getAnalyticsParameters(options as unknown as {}), + }); + } + + return options; + }); + + if (options.interactive !== false && isTTY()) { + workflow.registry.usePromptProvider(async (definitions: Array) => { + let prompts: typeof import('@inquirer/prompts') | undefined; + const answers: Record = {}; + + for (const definition of definitions) { + if (options.defaults && definition.default !== undefined) { + continue; + } + + // Only load prompt package if needed + prompts ??= await import('@inquirer/prompts'); + + switch (definition.type) { + case 'confirmation': + answers[definition.id] = await prompts.confirm({ + message: definition.message, + default: definition.default as boolean | undefined, + }); + break; + case 'list': + if (!definition.items?.length) { + continue; + } + + const choices = definition.items?.map((item) => { + return typeof item == 'string' + ? { + name: item, + value: item, + } + : { + name: item.label, + value: item.value, + }; + }); + + answers[definition.id] = await ( + definition.multiselect ? prompts.checkbox : prompts.select + )({ + message: definition.message, + default: definition.default, + choices, + }); + break; + case 'input': + let finalValue: JsonValue | undefined; + answers[definition.id] = await prompts.input({ + message: definition.message, + default: definition.default as string | undefined, + async validate(value) { + if (definition.validator === undefined) { + return true; + } + + let lastValidation: ReturnType = false; + for (const type of definition.propertyTypes) { + let potential; + switch (type) { + case 'string': + potential = String(value); + break; + case 'integer': + case 'number': + potential = Number(value); + break; + default: + potential = value; + break; + } + lastValidation = await definition.validator(potential); + + // Can be a string if validation fails + if (lastValidation === true) { + finalValue = potential; + + return true; + } + } + + return lastValidation; + }, + }); + + // Use validated value if present. + // This ensures the correct type is inserted into the final schema options. + if (finalValue !== undefined) { + answers[definition.id] = finalValue; + } + break; + } + } + + return answers; + }); + } + + return workflow; + } + + @memoize + protected async getSchematicCollections(): Promise> { + // Resolve relative collections from the location of `angular.json` + const resolveRelativeCollection = (collectionName: string) => + collectionName.charAt(0) === '.' + ? resolve(this.context.root, collectionName) + : collectionName; + + const getSchematicCollections = ( + configSection: Record | undefined, + ): Set | undefined => { + if (!configSection) { + return undefined; + } + + const { schematicCollections } = configSection; + if (Array.isArray(schematicCollections)) { + return new Set(schematicCollections.map((c) => resolveRelativeCollection(c))); + } + + return undefined; + }; + + const { workspace, globalConfiguration } = this.context; + if (workspace) { + const project = getProjectByCwd(workspace); + if (project) { + const value = getSchematicCollections(workspace.getProjectCli(project)); + if (value) { + return value; + } + } + } + + const value = + getSchematicCollections(workspace?.getCli()) ?? + getSchematicCollections(globalConfiguration.getCli()); + if (value) { + return value; + } + + return new Set([DEFAULT_SCHEMATICS_COLLECTION]); + } + + protected parseSchematicInfo( + schematic: string | undefined, + ): [collectionName: string | undefined, schematicName: string | undefined] { + if (schematic?.includes(':')) { + const [collectionName, schematicName] = schematic.split(':', 2); + + return [collectionName, schematicName]; + } + + return [undefined, schematic]; + } + + protected async runSchematic(options: { + executionOptions: SchematicsExecutionOptions; + schematicOptions: OtherOptions; + collectionName: string; + schematicName: string; + }): Promise { + const { logger } = this.context; + const { schematicOptions, executionOptions, collectionName, schematicName } = options; + const workflow = await this.getOrCreateWorkflowForExecution(collectionName, executionOptions); + + if (!schematicName) { + throw new Error('schematicName cannot be undefined.'); + } + + const { unsubscribe, files } = subscribeToWorkflow(workflow, logger); + + try { + await workflow + .execute({ + collection: collectionName, + schematic: schematicName, + options: schematicOptions, + logger, + allowPrivate: this.allowPrivateSchematics, + }) + .toPromise(); + + if (!files.size) { + logger.info('Nothing to be done.'); + } + + if (executionOptions.dryRun) { + logger.warn(`\nNOTE: The "--dry-run" option means no changes were made.`); + } + } catch (err) { + // In case the workflow was not successful, show an appropriate error message. + if (err instanceof UnsuccessfulWorkflowExecution) { + // "See above" because we already printed the error. + logger.fatal('The Schematic workflow failed. See above.'); + } else { + assertIsError(err); + logger.fatal(err.message); + } + + return 1; + } finally { + unsubscribe(); + } + + return 0; + } + + private getProjectName(): string | undefined { + const { workspace, logger } = this.context; + if (!workspace) { + return undefined; + } + + const projectName = getProjectByCwd(workspace); + if (projectName) { + return projectName; + } + + return undefined; + } + + private getResolvePaths(collectionName: string): string[] { + const { workspace, root } = this.context; + + return workspace + ? // Workspace + collectionName === DEFAULT_SCHEMATICS_COLLECTION + ? // Favor __dirname for @schematics/angular to use the build-in version + [__dirname, process.cwd(), root] + : [process.cwd(), root, __dirname] + : // Global + [__dirname, process.cwd()]; + } +} diff --git a/packages/angular/cli/src/command-builder/utilities/command.ts b/packages/angular/cli/src/command-builder/utilities/command.ts new file mode 100644 index 000000000000..04a88c1f7113 --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/command.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { + CommandContext, + CommandModule, + CommandModuleError, + CommandModuleImplementation, + CommandScope, +} from '../command-module'; + +export const demandCommandFailureMessage = `You need to specify a command before moving on. Use '--help' to view the available commands.`; +export type CommandModuleConstructor = Partial & { + new (context: CommandContext): Partial & CommandModule; +}; + +export function addCommandModuleToYargs( + localYargs: Argv, + commandModule: U, + context: CommandContext, +): Argv { + const cmd = new commandModule(context); + const { + args: { + options: { jsonHelp }, + }, + workspace, + } = context; + + const describe = jsonHelp ? cmd.fullDescribe : cmd.describe; + + return localYargs.command({ + command: cmd.command, + aliases: cmd.aliases, + describe: + // We cannot add custom fields in help, such as long command description which is used in AIO. + // Therefore, we get around this by adding a complex object as a string which we later parse when generating the help files. + typeof describe === 'object' ? JSON.stringify(describe) : describe, + deprecated: cmd.deprecated, + builder: (argv) => { + // Skip scope validation when running with '--json-help' since it's easier to generate the output for all commands this way. + const isInvalidScope = + !jsonHelp && + ((cmd.scope === CommandScope.In && !workspace) || + (cmd.scope === CommandScope.Out && workspace)); + + if (isInvalidScope) { + throw new CommandModuleError( + `This command is not available when running the Angular CLI ${ + workspace ? 'inside' : 'outside' + } a workspace.`, + ); + } + + return cmd.builder(argv) as Argv; + }, + handler: (args) => cmd.handler(args), + }); +} diff --git a/packages/angular/cli/src/command-builder/utilities/json-help.ts b/packages/angular/cli/src/command-builder/utilities/json-help.ts new file mode 100644 index 000000000000..6e673804ed84 --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/json-help.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import yargs from 'yargs'; +import { FullDescribe } from '../command-module'; + +interface JsonHelpOption { + name: string; + type?: string; + deprecated: boolean | string; + aliases?: string[]; + default?: string; + required?: boolean; + positional?: number; + enum?: string[]; + description?: string; +} + +interface JsonHelpDescription { + shortDescription?: string; + longDescription?: string; + longDescriptionRelativePath?: string; +} + +interface JsonHelpSubcommand extends JsonHelpDescription { + name: string; + aliases: string[]; + deprecated: string | boolean; +} + +export interface JsonHelp extends JsonHelpDescription { + name: string; + command: string; + options: JsonHelpOption[]; + subcommands?: JsonHelpSubcommand[]; +} + +const yargsDefaultCommandRegExp = /^\$0|\*/; + +export function jsonHelpUsage(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const localYargs = yargs as any; + const { + deprecatedOptions, + alias: aliases, + array, + string, + boolean, + number, + choices, + demandedOptions, + default: defaultVal, + hiddenOptions = [], + } = localYargs.getOptions(); + + const internalMethods = localYargs.getInternalMethods(); + const usageInstance = internalMethods.getUsageInstance(); + const context = internalMethods.getContext(); + const descriptions = usageInstance.getDescriptions(); + const groups = localYargs.getGroups(); + const positional = groups[usageInstance.getPositionalGroupName()] as string[] | undefined; + + const hidden = new Set(hiddenOptions); + const normalizeOptions: JsonHelpOption[] = []; + const allAliases = new Set([...Object.values(aliases).flat()]); + + for (const [names, type] of [ + [array, 'array'], + [string, 'string'], + [boolean, 'boolean'], + [number, 'number'], + ]) { + for (const name of names) { + if (allAliases.has(name) || hidden.has(name)) { + // Ignore hidden, aliases and already visited option. + continue; + } + + const positionalIndex = positional?.indexOf(name) ?? -1; + const alias = aliases[name]; + + normalizeOptions.push({ + name, + type, + deprecated: deprecatedOptions[name], + aliases: alias?.length > 0 ? alias : undefined, + default: defaultVal[name], + required: demandedOptions[name], + enum: choices[name], + description: descriptions[name]?.replace('__yargsString__:', ''), + positional: positionalIndex >= 0 ? positionalIndex : undefined, + }); + } + } + + // https://github.com/yargs/yargs/blob/00e4ebbe3acd438e73fdb101e75b4f879eb6d345/lib/usage.ts#L124 + const subcommands = ( + usageInstance.getCommands() as [ + name: string, + description: string, + isDefault: boolean, + aliases: string[], + deprecated: string | boolean, + ][] + ) + .map(([name, rawDescription, isDefault, aliases, deprecated]) => ({ + name: name.split(' ', 1)[0].replace(yargsDefaultCommandRegExp, ''), + command: name.replace(yargsDefaultCommandRegExp, ''), + default: isDefault || undefined, + ...parseDescription(rawDescription), + aliases, + deprecated, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const [command, rawDescription] = usageInstance.getUsage()[0] ?? []; + const defaultSubCommand = subcommands.find((x) => x.default)?.command ?? ''; + const otherSubcommands = subcommands.filter((s) => !s.default); + + const output: JsonHelp = { + name: [...context.commands].pop(), + command: `${command?.replace(yargsDefaultCommandRegExp, localYargs['$0'])}${defaultSubCommand}`, + ...parseDescription(rawDescription), + options: normalizeOptions.sort((a, b) => a.name.localeCompare(b.name)), + subcommands: otherSubcommands.length ? otherSubcommands : undefined, + }; + + return JSON.stringify(output, undefined, 2); +} + +function parseDescription(rawDescription: string): JsonHelpDescription { + try { + const { + longDescription, + describe: shortDescription, + longDescriptionRelativePath, + } = JSON.parse(rawDescription) as FullDescribe; + + return { + shortDescription, + longDescriptionRelativePath, + longDescription, + }; + } catch { + return { + shortDescription: rawDescription, + }; + } +} diff --git a/packages/angular/cli/src/command-builder/utilities/json-schema.ts b/packages/angular/cli/src/command-builder/utilities/json-schema.ts new file mode 100644 index 000000000000..2b17c1eb0226 --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/json-schema.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { json } from '@angular-devkit/core'; +import yargs from 'yargs'; + +/** + * An option description. + */ +export interface Option extends yargs.Options { + /** + * The name of the option. + */ + name: string; + + /** + * Whether this option is required or not. + */ + required?: boolean; + + /** + * Format field of this option. + */ + format?: string; + + /** + * Whether this option should be hidden from the help output. It will still show up in JSON help. + */ + hidden?: boolean; + + /** + * If this option can be used as an argument, the position of the argument. Otherwise omitted. + */ + positional?: number; + + /** + * Whether or not to report this option to the Angular Team, and which custom field to use. + * If this is falsey, do not report this option. + */ + userAnalytics?: string; +} + +export async function parseJsonSchemaToOptions( + registry: json.schema.SchemaRegistry, + schema: json.JsonObject, + interactive = true, +): Promise { + const options: Option[] = []; + + function visitor( + current: json.JsonObject | json.JsonArray, + pointer: json.schema.JsonPointer, + parentSchema?: json.JsonObject | json.JsonArray, + ) { + if (!parentSchema) { + // Ignore root. + return; + } else if (pointer.split(/\/(?:properties|items|definitions)\//g).length > 2) { + // Ignore subitems (objects or arrays). + return; + } else if (json.isJsonArray(current)) { + return; + } + + if (pointer.indexOf('/not/') != -1) { + // We don't support anyOf/not. + throw new Error('The "not" keyword is not supported in JSON Schema.'); + } + + const ptr = json.schema.parseJsonPointer(pointer); + const name = ptr[ptr.length - 1]; + + if (ptr[ptr.length - 2] != 'properties') { + // Skip any non-property items. + return; + } + + const typeSet = json.schema.getTypesOfSchema(current); + + if (typeSet.size == 0) { + throw new Error('Cannot find type of schema.'); + } + + // We only support number, string or boolean (or array of those), so remove everything else. + const types = [...typeSet].filter((x) => { + switch (x) { + case 'boolean': + case 'number': + case 'string': + return true; + + case 'array': + // Only include arrays if they're boolean, string or number. + if ( + json.isJsonObject(current.items) && + typeof current.items.type == 'string' && + ['boolean', 'number', 'string'].includes(current.items.type) + ) { + return true; + } + + return false; + + default: + return false; + } + }) as ('string' | 'number' | 'boolean' | 'array')[]; + + if (types.length == 0) { + // This means it's not usable on the command line. e.g. an Object. + return; + } + + // Only keep enum values we support (booleans, numbers and strings). + const enumValues = ((json.isJsonArray(current.enum) && current.enum) || []).filter((x) => { + switch (typeof x) { + case 'boolean': + case 'number': + case 'string': + return true; + + default: + return false; + } + }) as (string | true | number)[]; + + let defaultValue: string | number | boolean | undefined = undefined; + if (current.default !== undefined) { + switch (types[0]) { + case 'string': + if (typeof current.default == 'string') { + defaultValue = current.default; + } + break; + case 'number': + if (typeof current.default == 'number') { + defaultValue = current.default; + } + break; + case 'boolean': + if (typeof current.default == 'boolean') { + defaultValue = current.default; + } + break; + } + } + + const type = types[0]; + const $default = current.$default; + const $defaultIndex = + json.isJsonObject($default) && $default['$source'] == 'argv' ? $default['index'] : undefined; + const positional: number | undefined = + typeof $defaultIndex == 'number' ? $defaultIndex : undefined; + + let required = json.isJsonArray(schema.required) ? schema.required.includes(name) : false; + if (required && interactive && current['x-prompt']) { + required = false; + } + + const alias = json.isJsonArray(current.aliases) + ? [...current.aliases].map((x) => '' + x) + : current.alias + ? ['' + current.alias] + : []; + const format = typeof current.format == 'string' ? current.format : undefined; + const visible = current.visible === undefined || current.visible === true; + const hidden = !!current.hidden || !visible; + + const xUserAnalytics = current['x-user-analytics']; + const userAnalytics = typeof xUserAnalytics === 'string' ? xUserAnalytics : undefined; + + // Deprecated is set only if it's true or a string. + const xDeprecated = current['x-deprecated']; + const deprecated = + xDeprecated === true || typeof xDeprecated === 'string' ? xDeprecated : undefined; + + const option: Option = { + name, + description: '' + (current.description === undefined ? '' : current.description), + type, + default: defaultValue, + choices: enumValues.length ? enumValues : undefined, + required, + alias, + format, + hidden, + userAnalytics, + deprecated, + positional, + }; + + options.push(option); + } + + const flattenedSchema = await registry.ɵflatten(schema); + json.schema.visitJsonSchema(flattenedSchema, visitor); + + // Sort by positional and name. + return options.sort((a, b) => { + if (a.positional) { + return b.positional ? a.positional - b.positional : a.name.localeCompare(b.name); + } else if (b.positional) { + return -1; + } + + return a.name.localeCompare(b.name); + }); +} diff --git a/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts b/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts new file mode 100644 index 000000000000..709f9e5a7c67 --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import * as yargs from 'yargs'; + +/** + * A Yargs middleware that normalizes non Array options when the argument has been provided multiple times. + * + * By default, when an option is non array and it is provided multiple times in the command line, yargs + * will not override it's value but instead it will be changed to an array unless `duplicate-arguments-array` is disabled. + * But this option also have an effect on real array options which isn't desired. + * + * See: https://github.com/yargs/yargs-parser/pull/163#issuecomment-516566614 + */ +export function normalizeOptionsMiddleware(args: yargs.Arguments): void { + // `getOptions` is not included in the types even though it's public API. + // https://github.com/yargs/yargs/issues/2098 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { array } = (yargs as any).getOptions(); + const arrayOptions = new Set(array); + + for (const [key, value] of Object.entries(args)) { + if (key !== '_' && Array.isArray(value) && !arrayOptions.has(key)) { + const newValue = value.pop(); + // eslint-disable-next-line no-console + console.warn( + `Option '${key}' has been specified multiple times. The value '${newValue}' will be used.`, + ); + args[key] = newValue; + } + } +} diff --git a/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts b/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts new file mode 100644 index 000000000000..d3bcf86176f2 --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts @@ -0,0 +1,243 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { RuleFactory, SchematicsException, Tree } from '@angular-devkit/schematics'; +import { FileSystemCollectionDesc, NodeModulesEngineHost } from '@angular-devkit/schematics/tools'; +import { readFileSync } from 'fs'; +import { parse as parseJson } from 'jsonc-parser'; +import { createRequire } from 'module'; +import { dirname, resolve } from 'path'; +import { TextEncoder } from 'util'; +import { Script } from 'vm'; +import { assertIsError } from '../../utilities/error'; + +/** + * Environment variable to control schematic package redirection + */ +const schematicRedirectVariable = process.env['NG_SCHEMATIC_REDIRECT']?.toLowerCase(); + +function shouldWrapSchematic(schematicFile: string, schematicEncapsulation: boolean): boolean { + // Check environment variable if present + switch (schematicRedirectVariable) { + case '0': + case 'false': + case 'off': + case 'none': + return false; + case 'all': + return true; + } + + const normalizedSchematicFile = schematicFile.replace(/\\/g, '/'); + // Never wrap the internal update schematic when executed directly + // It communicates with the update command via `global` + // But we still want to redirect schematics located in `@angular/cli/node_modules`. + if ( + normalizedSchematicFile.includes('node_modules/@angular/cli/') && + !normalizedSchematicFile.includes('node_modules/@angular/cli/node_modules/') + ) { + return false; + } + + // @angular/pwa uses dynamic imports which causes `[1] 2468039 segmentation fault` when wrapped. + // We should remove this when make `importModuleDynamically` work. + // See: https://nodejs.org/docs/latest-v14.x/api/vm.html + if (normalizedSchematicFile.includes('@angular/pwa')) { + return false; + } + + // Check for first-party Angular schematic packages + // Angular schematics are safe to use in the wrapped VM context + if (/\/node_modules\/@(?:angular|schematics|nguniversal)\//.test(normalizedSchematicFile)) { + return true; + } + + // Otherwise use the value of the schematic collection's encapsulation option (current default of false) + return schematicEncapsulation; +} + +export class SchematicEngineHost extends NodeModulesEngineHost { + protected override _resolveReferenceString( + refString: string, + parentPath: string, + collectionDescription?: FileSystemCollectionDesc, + ) { + const [path, name] = refString.split('#', 2); + // Mimic behavior of ExportStringRef class used in default behavior + const fullPath = path[0] === '.' ? resolve(parentPath ?? process.cwd(), path) : path; + + const referenceRequire = createRequire(__filename); + const schematicFile = referenceRequire.resolve(fullPath, { paths: [parentPath] }); + + if (shouldWrapSchematic(schematicFile, !!collectionDescription?.encapsulation)) { + const schematicPath = dirname(schematicFile); + + const moduleCache = new Map(); + const factoryInitializer = wrap( + schematicFile, + schematicPath, + moduleCache, + name || 'default', + ) as () => RuleFactory<{}>; + + const factory = factoryInitializer(); + if (!factory || typeof factory !== 'function') { + return null; + } + + return { ref: factory, path: schematicPath }; + } + + // All other schematics use default behavior + return super._resolveReferenceString(refString, parentPath, collectionDescription); + } +} + +/** + * Minimal shim modules for legacy deep imports of `@schematics/angular` + */ +const legacyModules: Record = { + '@schematics/angular/utility/config': { + getWorkspace(host: Tree) { + const path = '/.angular.json'; + const data = host.read(path); + if (!data) { + throw new SchematicsException(`Could not find (${path})`); + } + + return parseJson(data.toString(), [], { allowTrailingComma: true }); + }, + }, + '@schematics/angular/utility/project': { + buildDefaultPath(project: { sourceRoot?: string; root: string; projectType: string }): string { + const root = project.sourceRoot ? `/${project.sourceRoot}/` : `/${project.root}/src/`; + + return `${root}${project.projectType === 'application' ? 'app' : 'lib'}`; + }, + }, +}; + +/** + * Wrap a JavaScript file in a VM context to allow specific Angular dependencies to be redirected. + * This VM setup is ONLY intended to redirect dependencies. + * + * @param schematicFile A JavaScript schematic file path that should be wrapped. + * @param schematicDirectory A directory that will be used as the location of the JavaScript file. + * @param moduleCache A map to use for caching repeat module usage and proper `instanceof` support. + * @param exportName An optional name of a specific export to return. Otherwise, return all exports. + */ +function wrap( + schematicFile: string, + schematicDirectory: string, + moduleCache: Map, + exportName?: string, +): () => unknown { + const hostRequire = createRequire(__filename); + const schematicRequire = createRequire(schematicFile); + + const customRequire = function (id: string) { + if (legacyModules[id]) { + // Provide compatibility modules for older versions of @angular/cdk + return legacyModules[id]; + } else if (id.startsWith('schematics:')) { + // Schematics built-in modules use the `schematics` scheme (similar to the Node.js `node` scheme) + const builtinId = id.slice(11); + const builtinModule = loadBuiltinModule(builtinId); + if (!builtinModule) { + throw new Error( + `Unknown schematics built-in module '${id}' requested from schematic '${schematicFile}'`, + ); + } + + return builtinModule; + } else if (id.startsWith('@angular-devkit/') || id.startsWith('@schematics/')) { + // Files should not redirect `@angular/core` and instead use the direct + // dependency if available. This allows old major version migrations to continue to function + // even though the latest major version may have breaking changes in `@angular/core`. + if (id.startsWith('@angular-devkit/core')) { + try { + return schematicRequire(id); + } catch (e) { + assertIsError(e); + if (e.code !== 'MODULE_NOT_FOUND') { + throw e; + } + } + } + + // Resolve from inside the `@angular/cli` project + return hostRequire(id); + } else if (id.startsWith('.') || id.startsWith('@angular/cdk')) { + // Wrap relative files inside the schematic collection + // Also wrap `@angular/cdk`, it contains helper utilities that import core schematic packages + + // Resolve from the original file + const modulePath = schematicRequire.resolve(id); + + // Use cached module if available + const cachedModule = moduleCache.get(modulePath); + if (cachedModule) { + return cachedModule; + } + + // Do not wrap vendored third-party packages or JSON files + if ( + !/[/\\]node_modules[/\\]@schematics[/\\]angular[/\\]third_party[/\\]/.test(modulePath) && + !modulePath.endsWith('.json') + ) { + // Wrap module and save in cache + const wrappedModule = wrap(modulePath, dirname(modulePath), moduleCache)(); + moduleCache.set(modulePath, wrappedModule); + + return wrappedModule; + } + } + + // All others are required directly from the original file + return schematicRequire(id); + }; + + // Setup a wrapper function to capture the module's exports + const schematicCode = readFileSync(schematicFile, 'utf8'); + // `module` is required due to @angular/localize ng-add being in UMD format + const headerCode = '(function() {\nvar exports = {};\nvar module = { exports };\n'; + const footerCode = exportName + ? `\nreturn module.exports['${exportName}'];});` + : '\nreturn module.exports;});'; + + const script = new Script(headerCode + schematicCode + footerCode, { + filename: schematicFile, + lineOffset: 3, + }); + + const context = { + __dirname: schematicDirectory, + __filename: schematicFile, + Buffer, + // TextEncoder is used by the compiler to generate i18n message IDs. See: + // https://github.com/angular/angular/blob/main/packages/compiler/src/i18n/digest.ts#L17 + // It is referenced globally, because it may be run either on the browser or the server. + // Usually Node exposes it globally, but in order for it to work, our custom context + // has to expose it too. Issue context: https://github.com/angular/angular/issues/48940. + TextEncoder, + console, + process, + get global() { + return this; + }, + require: customRequire, + }; + + const exportsFactory = script.runInNewContext(context); + + return exportsFactory; +} + +function loadBuiltinModule(id: string): unknown { + return undefined; +} diff --git a/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts b/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts new file mode 100644 index 000000000000..7df95063e55e --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { NodeWorkflow } from '@angular-devkit/schematics/tools'; +import { colors } from '../../utilities/color'; + +export function subscribeToWorkflow( + workflow: NodeWorkflow, + logger: logging.LoggerApi, +): { + files: Set; + error: boolean; + unsubscribe: () => void; +} { + const files = new Set(); + let error = false; + let logs: string[] = []; + + const reporterSubscription = workflow.reporter.subscribe((event) => { + // Strip leading slash to prevent confusion. + const eventPath = event.path.charAt(0) === '/' ? event.path.substring(1) : event.path; + + switch (event.kind) { + case 'error': + error = true; + const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist'; + logger.error(`ERROR! ${eventPath} ${desc}.`); + break; + case 'update': + logs.push(`${colors.cyan('UPDATE')} ${eventPath} (${event.content.length} bytes)`); + files.add(eventPath); + break; + case 'create': + logs.push(`${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes)`); + files.add(eventPath); + break; + case 'delete': + logs.push(`${colors.yellow('DELETE')} ${eventPath}`); + files.add(eventPath); + break; + case 'rename': + const eventToPath = event.to.charAt(0) === '/' ? event.to.substring(1) : event.to; + logs.push(`${colors.blue('RENAME')} ${eventPath} => ${eventToPath}`); + files.add(eventPath); + break; + } + }); + + const lifecycleSubscription = workflow.lifeCycle.subscribe((event) => { + if (event.kind == 'end' || event.kind == 'post-tasks-start') { + if (!error) { + // Output the logging queue, no error happened. + logs.forEach((log) => logger.info(log)); + } + + logs = []; + error = false; + } + }); + + return { + files, + error, + unsubscribe: () => { + reporterSubscription.unsubscribe(); + lifecycleSubscription.unsubscribe(); + }, + }; +} diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts new file mode 100644 index 000000000000..ccc830eaa1f0 --- /dev/null +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -0,0 +1,544 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { NodePackageDoesNotSupportSchematics } from '@angular-devkit/schematics/tools'; +import { Listr, color, figures } from 'listr2'; +import { createRequire } from 'module'; +import assert from 'node:assert'; +import npa from 'npm-package-arg'; +import { dirname, join } from 'path'; +import { Range, compare, intersects, prerelease, satisfies, valid } from 'semver'; +import { Argv } from 'yargs'; +import { PackageManager } from '../../../lib/config/workspace-schema'; +import { + CommandModuleImplementation, + Options, + OtherOptions, +} from '../../command-builder/command-module'; +import { + SchematicsCommandArgs, + SchematicsCommandModule, +} from '../../command-builder/schematics-command-module'; +import { assertIsError } from '../../utilities/error'; +import { + NgAddSaveDependency, + PackageManifest, + fetchPackageManifest, + fetchPackageMetadata, +} from '../../utilities/package-metadata'; +import { isTTY } from '../../utilities/tty'; +import { VERSION } from '../../utilities/version'; + +class CommandError extends Error {} + +interface AddCommandArgs extends SchematicsCommandArgs { + collection: string; + verbose?: boolean; + registry?: string; + 'skip-confirmation'?: boolean; +} + +interface AddCommandTaskContext { + packageIdentifier: npa.Result; + usingYarn?: boolean; + savePackage?: NgAddSaveDependency; + collectionName?: string; + executeSchematic: AddCommandModule['executeSchematic']; + hasMismatchedPeer: AddCommandModule['hasMismatchedPeer']; +} + +/** + * The set of packages that should have certain versions excluded from consideration + * when attempting to find a compatible version for a package. + * The key is a package name and the value is a SemVer range of versions to exclude. + */ +const packageVersionExclusions: Record = { + // @angular/localize@9.x and earlier versions as well as @angular/localize@10.0 prereleases do not have peer dependencies setup. + '@angular/localize': '<10.0.0', + // @angular/material@7.x versions have unbounded peer dependency ranges (>=7.0.0). + '@angular/material': '7.x', +}; + +export default class AddCommandModule + extends SchematicsCommandModule + implements CommandModuleImplementation +{ + command = 'add '; + describe = 'Adds support for an external library to your project.'; + longDescriptionPath = join(__dirname, 'long-description.md'); + protected override allowPrivateSchematics = true; + private readonly schematicName = 'ng-add'; + private rootRequire = createRequire(this.context.root + '/'); + + override async builder(argv: Argv): Promise> { + const localYargs = (await super.builder(argv)) + .positional('collection', { + description: 'The package to be added.', + type: 'string', + demandOption: true, + }) + .option('registry', { description: 'The NPM registry to use.', type: 'string' }) + .option('verbose', { + description: 'Display additional details about internal operations during execution.', + type: 'boolean', + default: false, + }) + .option('skip-confirmation', { + description: + 'Skip asking a confirmation prompt before installing and executing the package. ' + + 'Ensure package name is correct prior to using this option.', + type: 'boolean', + default: false, + }) + // Prior to downloading we don't know the full schema and therefore we cannot be strict on the options. + // Possibly in the future update the logic to use the following syntax: + // `ng add @angular/localize -- --package-options`. + .strict(false); + + const collectionName = await this.getCollectionName(); + const workflow = this.getOrCreateWorkflowForBuilder(collectionName); + + try { + const collection = workflow.engine.createCollection(collectionName); + const options = await this.getSchematicOptions(collection, this.schematicName, workflow); + + return this.addSchemaOptionsToCommand(localYargs, options); + } catch (error) { + // During `ng add` prior to the downloading of the package + // we are not able to resolve and create a collection. + // Or when the collection value is a path to a tarball. + } + + return localYargs; + } + + // eslint-disable-next-line max-lines-per-function + async run(options: Options & OtherOptions): Promise { + const { logger, packageManager } = this.context; + const { verbose, registry, collection, skipConfirmation } = options; + + let packageIdentifier; + try { + packageIdentifier = npa(collection); + } catch (e) { + assertIsError(e); + logger.error(e.message); + + return 1; + } + + if ( + packageIdentifier.name && + packageIdentifier.registry && + this.isPackageInstalled(packageIdentifier.name) + ) { + const validVersion = await this.isProjectVersionValid(packageIdentifier); + if (validVersion) { + // Already installed so just run schematic + logger.info('Skipping installation: Package already installed'); + + return this.executeSchematic({ ...options, collection: packageIdentifier.name }); + } + } + + const taskContext: AddCommandTaskContext = { + packageIdentifier, + executeSchematic: this.executeSchematic.bind(this), + hasMismatchedPeer: this.hasMismatchedPeer.bind(this), + }; + + const tasks = new Listr([ + { + title: 'Determining Package Manager', + task(context, task) { + context.usingYarn = packageManager.name === PackageManager.Yarn; + task.output = `Using package manager: ${color.dim(packageManager.name)}`; + }, + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Searching for compatible package version', + enabled: packageIdentifier.type === 'range' && packageIdentifier.rawSpec === '*', + async task(context, task) { + assert( + context.packageIdentifier.name, + 'Registry package identifiers should always have a name.', + ); + + // only package name provided; search for viable version + // plus special cases for packages that did not have peer deps setup + let packageMetadata; + try { + packageMetadata = await fetchPackageMetadata(context.packageIdentifier.name, logger, { + registry, + usingYarn: context.usingYarn, + verbose, + }); + } catch (e) { + assertIsError(e); + throw new CommandError( + `Unable to load package information from registry: ${e.message}`, + ); + } + + // Start with the version tagged as `latest` if it exists + const latestManifest = packageMetadata.tags['latest']; + if (latestManifest) { + context.packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version); + } + + // Adjust the version based on name and peer dependencies + if ( + latestManifest?.peerDependencies && + Object.keys(latestManifest.peerDependencies).length === 0 + ) { + task.output = `Found compatible package version: ${color.blue(latestManifest.version)}.`; + } else if (!latestManifest || (await context.hasMismatchedPeer(latestManifest))) { + // 'latest' is invalid so search for most recent matching package + + // Allow prelease versions if the CLI itself is a prerelease + const allowPrereleases = prerelease(VERSION.full); + + const versionExclusions = packageVersionExclusions[packageMetadata.name]; + const versionManifests = Object.values(packageMetadata.versions).filter( + (value: PackageManifest) => { + // Prerelease versions are not stable and should not be considered by default + if (!allowPrereleases && prerelease(value.version)) { + return false; + } + // Deprecated versions should not be used or considered + if (value.deprecated) { + return false; + } + // Excluded package versions should not be considered + if ( + versionExclusions && + satisfies(value.version, versionExclusions, { includePrerelease: true }) + ) { + return false; + } + + return true; + }, + ); + + // Sort in reverse SemVer order so that the newest compatible version is chosen + versionManifests.sort((a, b) => compare(b.version, a.version, true)); + + let found = false; + for (const versionManifest of versionManifests) { + const mismatch = await context.hasMismatchedPeer(versionManifest); + if (mismatch) { + continue; + } + + context.packageIdentifier = npa.resolve( + versionManifest.name, + versionManifest.version, + ); + found = true; + } + + if (!found) { + task.output = "Unable to find compatible package. Using 'latest' tag."; + } else { + task.output = `Found compatible package version: ${color.blue(context.packageIdentifier.toString())}.`; + } + } else { + task.output = `Found compatible package version: ${color.blue(context.packageIdentifier.toString())}.`; + } + }, + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Loading package information from registry', + async task(context, task) { + let manifest; + try { + manifest = await fetchPackageManifest(context.packageIdentifier.toString(), logger, { + registry, + verbose, + usingYarn: context.usingYarn, + }); + } catch (e) { + assertIsError(e); + throw new CommandError( + `Unable to fetch package information for '${context.packageIdentifier}': ${e.message}`, + ); + } + + context.savePackage = manifest['ng-add']?.save; + context.collectionName = manifest.name; + + if (await context.hasMismatchedPeer(manifest)) { + task.output = color.yellow( + figures.warning + + ' Package has unmet peer dependencies. Adding the package may not succeed.', + ); + } + }, + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Confirming installation', + enabled: !skipConfirmation, + async task(context, task) { + if (!isTTY()) { + task.output = + `'--skip-confirmation' can be used to bypass installation confirmation. ` + + `Ensure package name is correct prior to '--skip-confirmation' option usage.`; + throw new CommandError('No terminal detected'); + } + + const { ListrInquirerPromptAdapter } = await import('@listr2/prompt-adapter-inquirer'); + const { confirm } = await import('@inquirer/prompts'); + const shouldProceed = await task.prompt(ListrInquirerPromptAdapter).run(confirm, { + message: + `The package ${color.blue(context.packageIdentifier.toString())} will be installed and executed.\n` + + 'Would you like to proceed?', + default: true, + theme: { prefix: '' }, + }); + + if (!shouldProceed) { + throw new CommandError('Command aborted'); + } + }, + rendererOptions: { persistentOutput: true }, + }, + { + async task(context, task) { + // Only show if installation will actually occur + task.title = 'Installing package'; + + if (context.savePackage === false) { + task.title += ' in temporary location'; + + // Temporary packages are located in a different directory + // Hence we need to resolve them using the temp path + const { success, tempNodeModules } = await packageManager.installTemp( + context.packageIdentifier.toString(), + registry ? [`--registry="${registry}"`] : undefined, + ); + const tempRequire = createRequire(tempNodeModules + '/'); + assert(context.collectionName, 'Collection name should always be available'); + const resolvedCollectionPath = tempRequire.resolve( + join(context.collectionName, 'package.json'), + ); + + if (!success) { + throw new CommandError('Unable to install package'); + } + + context.collectionName = dirname(resolvedCollectionPath); + } else { + const success = await packageManager.install( + context.packageIdentifier.toString(), + context.savePackage, + registry ? [`--registry="${registry}"`] : undefined, + undefined, + ); + + if (!success) { + throw new CommandError('Unable to install package'); + } + } + }, + rendererOptions: { bottomBar: Infinity }, + }, + // TODO: Rework schematic execution as a task and insert here + ]); + + try { + const result = await tasks.run(taskContext); + assert(result.collectionName, 'Collection name should always be available'); + + return this.executeSchematic({ ...options, collection: result.collectionName }); + } catch (e) { + if (e instanceof CommandError) { + return 1; + } + + throw e; + } + } + + private async isProjectVersionValid(packageIdentifier: npa.Result): Promise { + if (!packageIdentifier.name) { + return false; + } + + const installedVersion = await this.findProjectVersion(packageIdentifier.name); + if (!installedVersion) { + return false; + } + + if (packageIdentifier.rawSpec === '*') { + return true; + } + + if ( + packageIdentifier.type === 'range' && + packageIdentifier.fetchSpec && + packageIdentifier.fetchSpec !== '*' + ) { + return satisfies(installedVersion, packageIdentifier.fetchSpec); + } + + if (packageIdentifier.type === 'version') { + const v1 = valid(packageIdentifier.fetchSpec); + const v2 = valid(installedVersion); + + return v1 !== null && v1 === v2; + } + + return false; + } + + private async getCollectionName(): Promise { + let [, collectionName] = this.context.args.positional; + + // The CLI argument may specify also a version, like `ng add @my/lib@13.0.0`, + // but here we need only the name of the package, like `@my/lib` + try { + const packageIdentifier = npa(collectionName); + collectionName = packageIdentifier.name ?? collectionName; + } catch (e) { + assertIsError(e); + this.context.logger.error(e.message); + } + + return collectionName; + } + + private isPackageInstalled(name: string): boolean { + try { + this.rootRequire.resolve(join(name, 'package.json')); + + return true; + } catch (e) { + assertIsError(e); + if (e.code !== 'MODULE_NOT_FOUND') { + throw e; + } + } + + return false; + } + + private async executeSchematic( + options: Options & OtherOptions, + ): Promise { + try { + const { + verbose, + skipConfirmation, + interactive, + force, + dryRun, + registry, + defaults, + collection: collectionName, + ...schematicOptions + } = options; + + return await this.runSchematic({ + schematicOptions, + schematicName: this.schematicName, + collectionName, + executionOptions: { + interactive, + force, + dryRun, + defaults, + packageRegistry: registry, + }, + }); + } catch (e) { + if (e instanceof NodePackageDoesNotSupportSchematics) { + this.context.logger.error( + 'The package that you are trying to add does not support schematics.' + + 'You can try using a different version of the package or contact the package author to add ng-add support.', + ); + + return 1; + } + + throw e; + } + } + + private async findProjectVersion(name: string): Promise { + const { logger, root } = this.context; + let installedPackage; + try { + installedPackage = this.rootRequire.resolve(join(name, 'package.json')); + } catch {} + + if (installedPackage) { + try { + const installed = await fetchPackageManifest(dirname(installedPackage), logger); + + return installed.version; + } catch {} + } + + let projectManifest; + try { + projectManifest = await fetchPackageManifest(root, logger); + } catch {} + + if (projectManifest) { + const version = + projectManifest.dependencies?.[name] || projectManifest.devDependencies?.[name]; + if (version) { + return version; + } + } + + return null; + } + + private async hasMismatchedPeer(manifest: PackageManifest): Promise { + for (const peer in manifest.peerDependencies) { + let peerIdentifier; + try { + peerIdentifier = npa.resolve(peer, manifest.peerDependencies[peer]); + } catch { + this.context.logger.warn(`Invalid peer dependency ${peer} found in package.`); + continue; + } + + if (peerIdentifier.type === 'version' || peerIdentifier.type === 'range') { + try { + const version = await this.findProjectVersion(peer); + if (!version) { + continue; + } + + const options = { includePrerelease: true }; + + if ( + !intersects(version, peerIdentifier.rawSpec, options) && + !satisfies(version, peerIdentifier.rawSpec, options) + ) { + return true; + } + } catch { + // Not found or invalid so ignore + continue; + } + } else { + // type === 'tag' | 'file' | 'directory' | 'remote' | 'git' + // Cannot accurately compare these as the tag/location may have changed since install + } + } + + return false; + } +} diff --git a/packages/angular/cli/src/commands/add/long-description.md b/packages/angular/cli/src/commands/add/long-description.md new file mode 100644 index 000000000000..347b3a5971aa --- /dev/null +++ b/packages/angular/cli/src/commands/add/long-description.md @@ -0,0 +1,7 @@ +Adds the npm package for a published library to your workspace, and configures +the project in the current working directory to use that library, as specified by the library's schematic. +For example, adding `@angular/pwa` configures your project for PWA support: + +```bash +ng add @angular/pwa +``` diff --git a/packages/angular/cli/src/commands/analytics/cli.ts b/packages/angular/cli/src/commands/analytics/cli.ts new file mode 100644 index 000000000000..56841a95bd6b --- /dev/null +++ b/packages/angular/cli/src/commands/analytics/cli.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join } from 'node:path'; +import { Argv } from 'yargs'; +import { + CommandModule, + CommandModuleImplementation, + Options, +} from '../../command-builder/command-module'; +import { + addCommandModuleToYargs, + demandCommandFailureMessage, +} from '../../command-builder/utilities/command'; +import { AnalyticsInfoCommandModule } from './info/cli'; +import { + AnalyticsDisableModule, + AnalyticsEnableModule, + AnalyticsPromptModule, +} from './settings/cli'; + +export default class AnalyticsCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'analytics'; + describe = 'Configures the gathering of Angular CLI usage metrics.'; + longDescriptionPath = join(__dirname, 'long-description.md'); + + builder(localYargs: Argv): Argv { + const subcommands = [ + AnalyticsInfoCommandModule, + AnalyticsDisableModule, + AnalyticsEnableModule, + AnalyticsPromptModule, + ].sort(); // sort by class name. + + for (const module of subcommands) { + localYargs = addCommandModuleToYargs(localYargs, module, this.context); + } + + return localYargs.demandCommand(1, demandCommandFailureMessage).strict(); + } + + run(_options: Options<{}>): void {} +} diff --git a/packages/angular/cli/src/commands/analytics/info/cli.ts b/packages/angular/cli/src/commands/analytics/info/cli.ts new file mode 100644 index 000000000000..e4434d35baee --- /dev/null +++ b/packages/angular/cli/src/commands/analytics/info/cli.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { getAnalyticsInfoString } from '../../../analytics/analytics'; +import { + CommandModule, + CommandModuleImplementation, + Options, +} from '../../../command-builder/command-module'; + +export class AnalyticsInfoCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'info'; + describe = 'Prints analytics gathering and reporting configuration in the console.'; + longDescriptionPath?: string; + + builder(localYargs: Argv): Argv { + return localYargs.strict(); + } + + async run(_options: Options<{}>): Promise { + this.context.logger.info(await getAnalyticsInfoString(this.context)); + } +} diff --git a/packages/angular/cli/src/commands/analytics/long-description.md b/packages/angular/cli/src/commands/analytics/long-description.md new file mode 100644 index 000000000000..69ee9ad7ee00 --- /dev/null +++ b/packages/angular/cli/src/commands/analytics/long-description.md @@ -0,0 +1,20 @@ +You can help the Angular Team to prioritize features and improvements by permitting the Angular team to send command-line command usage statistics to Google. +The Angular Team does not collect usage statistics unless you explicitly opt in. When installing the Angular CLI you are prompted to allow global collection of usage statistics. +If you say no or skip the prompt, no data is collected. + +### What is collected? + +Usage analytics include the commands and selected flags for each execution. +Usage analytics may include the following information: + +- Your operating system \(macOS, Linux distribution, Windows\) and its version. +- Package manager name and version \(local version only\). +- Node.js version \(local version only\). +- Angular CLI version \(local version only\). +- Command name that was run. +- Workspace information, the number of application and library projects. +- For schematics commands \(add, generate and new\), the schematic collection and name and a list of selected flags. +- For build commands \(build, serve\), the builder name, the number and size of bundles \(initial and lazy\), compilation units, the time it took to build and rebuild, and basic Angular-specific API usage. + +Only Angular owned and developed schematics and builders are reported. +Third-party schematics and builders do not send data to the Angular Team. diff --git a/packages/angular/cli/src/commands/analytics/settings/cli.ts b/packages/angular/cli/src/commands/analytics/settings/cli.ts new file mode 100644 index 000000000000..16f07b353d1a --- /dev/null +++ b/packages/angular/cli/src/commands/analytics/settings/cli.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { + getAnalyticsInfoString, + promptAnalytics, + setAnalyticsConfig, +} from '../../../analytics/analytics'; +import { + CommandModule, + CommandModuleImplementation, + Options, +} from '../../../command-builder/command-module'; + +interface AnalyticsCommandArgs { + global: boolean; +} + +abstract class AnalyticsSettingModule + extends CommandModule + implements CommandModuleImplementation +{ + longDescriptionPath?: string; + + builder(localYargs: Argv): Argv { + return localYargs + .option('global', { + description: `Configure analytics gathering and reporting globally in the caller's home directory.`, + alias: ['g'], + type: 'boolean', + default: false, + }) + .strict(); + } + + abstract override run({ global }: Options): Promise; +} + +export class AnalyticsDisableModule + extends AnalyticsSettingModule + implements CommandModuleImplementation +{ + command = 'disable'; + aliases = 'off'; + describe = 'Disables analytics gathering and reporting for the user.'; + + async run({ global }: Options): Promise { + await setAnalyticsConfig(global, false); + process.stderr.write(await getAnalyticsInfoString(this.context)); + } +} + +export class AnalyticsEnableModule + extends AnalyticsSettingModule + implements CommandModuleImplementation +{ + command = 'enable'; + aliases = 'on'; + describe = 'Enables analytics gathering and reporting for the user.'; + async run({ global }: Options): Promise { + await setAnalyticsConfig(global, true); + process.stderr.write(await getAnalyticsInfoString(this.context)); + } +} + +export class AnalyticsPromptModule + extends AnalyticsSettingModule + implements CommandModuleImplementation +{ + command = 'prompt'; + describe = 'Prompts the user to set the analytics gathering status interactively.'; + + async run({ global }: Options): Promise { + await promptAnalytics(this.context, global, true); + } +} diff --git a/packages/angular/cli/src/commands/build/cli.ts b/packages/angular/cli/src/commands/build/cli.ts new file mode 100644 index 000000000000..b98fc46c48e7 --- /dev/null +++ b/packages/angular/cli/src/commands/build/cli.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join } from 'path'; +import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; +import { CommandModuleImplementation } from '../../command-builder/command-module'; +import { RootCommands } from '../command-config'; + +export default class BuildCommandModule + extends ArchitectCommandModule + implements CommandModuleImplementation +{ + multiTarget = false; + command = 'build [project]'; + aliases = RootCommands['build'].aliases; + describe = + 'Compiles an Angular application or library into an output directory named dist/ at the given output path.'; + longDescriptionPath = join(__dirname, 'long-description.md'); +} diff --git a/packages/angular/cli/src/commands/build/long-description.md b/packages/angular/cli/src/commands/build/long-description.md new file mode 100644 index 000000000000..b2c14d8f23fe --- /dev/null +++ b/packages/angular/cli/src/commands/build/long-description.md @@ -0,0 +1,18 @@ +The command can be used to build a project of type "application" or "library". +When used to build a library, a different builder is invoked, and only the `ts-config`, `configuration`, `poll` and `watch` options are applied. +All other options apply only to building applications. + +The application builder uses the [esbuild](https://esbuild.github.io/) build tool, with default configuration options specified in the workspace configuration file (`angular.json`) or with a named alternative configuration. +A "development" configuration is created by default when you use the CLI to create the project, and you can use that configuration by specifying the `--configuration development`. + +The configuration options generally correspond to the command options. +You can override individual configuration defaults by specifying the corresponding options on the command line. +The command can accept option names given in dash-case. +Note that in the configuration file, you must specify names in camelCase. + +Some additional options can only be set through the configuration file, +either by direct editing or with the `ng config` command. +These include `assets`, `styles`, and `scripts` objects that provide runtime-global resources to include in the project. +Resources in CSS, such as images and fonts, are automatically written and fingerprinted at the root of the output folder. + +For further details, see [Workspace Configuration](reference/configs/workspace-config). diff --git a/packages/angular/cli/src/commands/cache/clean/cli.ts b/packages/angular/cli/src/commands/cache/clean/cli.ts new file mode 100644 index 000000000000..4ede8b0a60a2 --- /dev/null +++ b/packages/angular/cli/src/commands/cache/clean/cli.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { promises as fs } from 'fs'; +import { Argv } from 'yargs'; +import { + CommandModule, + CommandModuleImplementation, + CommandScope, +} from '../../../command-builder/command-module'; +import { getCacheConfig } from '../utilities'; + +export class CacheCleanModule extends CommandModule implements CommandModuleImplementation { + command = 'clean'; + describe = 'Deletes persistent disk cache from disk.'; + longDescriptionPath: string | undefined; + override scope = CommandScope.In; + + builder(localYargs: Argv): Argv { + return localYargs.strict(); + } + + run(): Promise { + const { path } = getCacheConfig(this.context.workspace); + + return fs.rm(path, { + force: true, + recursive: true, + maxRetries: 3, + }); + } +} diff --git a/packages/angular/cli/src/commands/cache/cli.ts b/packages/angular/cli/src/commands/cache/cli.ts new file mode 100644 index 000000000000..046673995846 --- /dev/null +++ b/packages/angular/cli/src/commands/cache/cli.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join } from 'path'; +import { Argv } from 'yargs'; +import { + CommandModule, + CommandModuleImplementation, + CommandScope, + Options, +} from '../../command-builder/command-module'; +import { + addCommandModuleToYargs, + demandCommandFailureMessage, +} from '../../command-builder/utilities/command'; +import { CacheCleanModule } from './clean/cli'; +import { CacheInfoCommandModule } from './info/cli'; +import { CacheDisableModule, CacheEnableModule } from './settings/cli'; + +export default class CacheCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'cache'; + describe = 'Configure persistent disk cache and retrieve cache statistics.'; + longDescriptionPath = join(__dirname, 'long-description.md'); + override scope = CommandScope.In; + + builder(localYargs: Argv): Argv { + const subcommands = [ + CacheEnableModule, + CacheDisableModule, + CacheCleanModule, + CacheInfoCommandModule, + ].sort(); + + for (const module of subcommands) { + localYargs = addCommandModuleToYargs(localYargs, module, this.context); + } + + return localYargs.demandCommand(1, demandCommandFailureMessage).strict(); + } + + run(_options: Options<{}>): void {} +} diff --git a/packages/angular/cli/src/commands/cache/info/cli.ts b/packages/angular/cli/src/commands/cache/info/cli.ts new file mode 100644 index 000000000000..ec1802c65695 --- /dev/null +++ b/packages/angular/cli/src/commands/cache/info/cli.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { tags } from '@angular-devkit/core'; +import { promises as fs } from 'fs'; +import { join } from 'path'; +import { Argv } from 'yargs'; +import { + CommandModule, + CommandModuleImplementation, + CommandScope, +} from '../../../command-builder/command-module'; +import { isCI } from '../../../utilities/environment-options'; +import { getCacheConfig } from '../utilities'; + +export class CacheInfoCommandModule extends CommandModule implements CommandModuleImplementation { + command = 'info'; + describe = 'Prints persistent disk cache configuration and statistics in the console.'; + longDescriptionPath?: string | undefined; + override scope = CommandScope.In; + + builder(localYargs: Argv): Argv { + return localYargs.strict(); + } + + async run(): Promise { + const { path, environment, enabled } = getCacheConfig(this.context.workspace); + + this.context.logger.info(tags.stripIndents` + Enabled: ${enabled ? 'yes' : 'no'} + Environment: ${environment} + Path: ${path} + Size on disk: ${await this.getSizeOfDirectory(path)} + Effective status on current machine: ${this.effectiveEnabledStatus() ? 'enabled' : 'disabled'} + `); + } + + private async getSizeOfDirectory(path: string): Promise { + const directoriesStack = [path]; + let size = 0; + + while (directoriesStack.length) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const dirPath = directoriesStack.pop()!; + let entries: string[] = []; + + try { + entries = await fs.readdir(dirPath); + } catch {} + + for (const entry of entries) { + const entryPath = join(dirPath, entry); + const stats = await fs.stat(entryPath); + + if (stats.isDirectory()) { + directoriesStack.push(entryPath); + } + + size += stats.size; + } + } + + return this.formatSize(size); + } + + private formatSize(size: number): string { + if (size <= 0) { + return '0 bytes'; + } + + const abbreviations = ['bytes', 'kB', 'MB', 'GB']; + const index = Math.floor(Math.log(size) / Math.log(1024)); + const roundedSize = size / Math.pow(1024, index); + // bytes don't have a fraction + const fractionDigits = index === 0 ? 0 : 2; + + return `${roundedSize.toFixed(fractionDigits)} ${abbreviations[index]}`; + } + + private effectiveEnabledStatus(): boolean { + const { enabled, environment } = getCacheConfig(this.context.workspace); + + if (enabled) { + switch (environment) { + case 'ci': + return isCI; + case 'local': + return !isCI; + } + } + + return enabled; + } +} diff --git a/packages/angular/cli/src/commands/cache/long-description.md b/packages/angular/cli/src/commands/cache/long-description.md new file mode 100644 index 000000000000..3ebfec598c4e --- /dev/null +++ b/packages/angular/cli/src/commands/cache/long-description.md @@ -0,0 +1,53 @@ +Angular CLI saves a number of cachable operations on disk by default. + +When you re-run the same build, the build system restores the state of the previous build and re-uses previously performed operations, which decreases the time taken to build and test your applications and libraries. + +To amend the default cache settings, add the `cli.cache` object to your [Workspace Configuration](reference/configs/workspace-config). +The object goes under `cli.cache` at the top level of the file, outside the `projects` sections. + +```jsonc +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "cache": { + // ... + }, + }, + "projects": {}, +} +``` + +For more information, see [cache options](reference/configs/workspace-config#cache-options). + +### Cache environments + +By default, disk cache is only enabled for local environments. The value of environment can be one of the following: + +- `all` - allows disk cache on all machines. +- `local` - allows disk cache only on development machines. +- `ci` - allows disk cache only on continuous integration (CI) systems. + +To change the environment setting to `all`, run the following command: + +```bash +ng config cli.cache.environment all +``` + +For more information, see `environment` in [cache options](reference/configs/workspace-config#cache-options). + +
+ +The Angular CLI checks for the presence and value of the `CI` environment variable to determine in which environment it is running. + +
+ +### Cache path + +By default, `.angular/cache` is used as a base directory to store cache results. + +To change this path to `.cache/ng`, run the following command: + +```bash +ng config cli.cache.path ".cache/ng" +``` diff --git a/packages/angular/cli/src/commands/cache/settings/cli.ts b/packages/angular/cli/src/commands/cache/settings/cli.ts new file mode 100644 index 000000000000..9a4f654f7ac7 --- /dev/null +++ b/packages/angular/cli/src/commands/cache/settings/cli.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { + CommandModule, + CommandModuleImplementation, + CommandScope, +} from '../../../command-builder/command-module'; +import { updateCacheConfig } from '../utilities'; + +export class CacheDisableModule extends CommandModule implements CommandModuleImplementation { + command = 'disable'; + aliases = 'off'; + describe = 'Disables persistent disk cache for all projects in the workspace.'; + longDescriptionPath: string | undefined; + override scope = CommandScope.In; + + builder(localYargs: Argv): Argv { + return localYargs; + } + + run(): Promise { + return updateCacheConfig(this.getWorkspaceOrThrow(), 'enabled', false); + } +} + +export class CacheEnableModule extends CommandModule implements CommandModuleImplementation { + command = 'enable'; + aliases = 'on'; + describe = 'Enables disk cache for all projects in the workspace.'; + longDescriptionPath: string | undefined; + override scope = CommandScope.In; + + builder(localYargs: Argv): Argv { + return localYargs; + } + + run(): Promise { + return updateCacheConfig(this.getWorkspaceOrThrow(), 'enabled', true); + } +} diff --git a/packages/angular/cli/src/commands/cache/utilities.ts b/packages/angular/cli/src/commands/cache/utilities.ts new file mode 100644 index 000000000000..3f82b2d3a91e --- /dev/null +++ b/packages/angular/cli/src/commands/cache/utilities.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { isJsonObject } from '@angular-devkit/core'; +import { resolve } from 'path'; +import { Cache, Environment } from '../../../lib/config/workspace-schema'; +import { AngularWorkspace } from '../../utilities/config'; + +export function updateCacheConfig( + workspace: AngularWorkspace, + key: K, + value: Cache[K], +): Promise { + const cli = (workspace.extensions['cli'] ??= {}) as Record>; + const cache = (cli['cache'] ??= {}); + cache[key] = value; + + return workspace.save(); +} + +export function getCacheConfig(workspace: AngularWorkspace | undefined): Required { + if (!workspace) { + throw new Error(`Cannot retrieve cache configuration as workspace is not defined.`); + } + + const defaultSettings: Required = { + path: resolve(workspace.basePath, '.angular/cache'), + environment: Environment.Local, + enabled: true, + }; + + const cliSetting = workspace.extensions['cli']; + if (!cliSetting || !isJsonObject(cliSetting)) { + return defaultSettings; + } + + const cacheSettings = cliSetting['cache']; + if (!isJsonObject(cacheSettings)) { + return defaultSettings; + } + + const { + path = defaultSettings.path, + environment = defaultSettings.environment, + enabled = defaultSettings.enabled, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } = cacheSettings as Record; + + return { + path: resolve(workspace.basePath, path), + environment, + enabled, + }; +} diff --git a/packages/angular/cli/src/commands/command-config.ts b/packages/angular/cli/src/commands/command-config.ts new file mode 100644 index 000000000000..cd048cbb2240 --- /dev/null +++ b/packages/angular/cli/src/commands/command-config.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { CommandModuleConstructor } from '../command-builder/utilities/command'; + +export type CommandNames = + | 'add' + | 'analytics' + | 'build' + | 'cache' + | 'completion' + | 'config' + | 'deploy' + | 'e2e' + | 'extract-i18n' + | 'generate' + | 'lint' + | 'make-this-awesome' + | 'new' + | 'run' + | 'serve' + | 'test' + | 'update' + | 'version'; + +export interface CommandConfig { + aliases?: string[]; + factory: () => Promise<{ default: CommandModuleConstructor }>; +} + +export const RootCommands: Record< + /* Command */ CommandNames & string, + /* Command Config */ CommandConfig +> = { + 'add': { + factory: () => import('./add/cli'), + }, + 'analytics': { + factory: () => import('./analytics/cli'), + }, + 'build': { + factory: () => import('./build/cli'), + aliases: ['b'], + }, + 'cache': { + factory: () => import('./cache/cli'), + }, + 'completion': { + factory: () => import('./completion/cli'), + }, + 'config': { + factory: () => import('./config/cli'), + }, + 'deploy': { + factory: () => import('./deploy/cli'), + }, + + 'e2e': { + factory: () => import('./e2e/cli'), + aliases: ['e'], + }, + 'extract-i18n': { + factory: () => import('./extract-i18n/cli'), + }, + 'generate': { + factory: () => import('./generate/cli'), + aliases: ['g'], + }, + 'lint': { + factory: () => import('./lint/cli'), + }, + 'make-this-awesome': { + factory: () => import('./make-this-awesome/cli'), + }, + 'new': { + factory: () => import('./new/cli'), + aliases: ['n'], + }, + 'run': { + factory: () => import('./run/cli'), + }, + 'serve': { + factory: () => import('./serve/cli'), + aliases: ['dev', 's'], + }, + 'test': { + factory: () => import('./test/cli'), + aliases: ['t'], + }, + 'update': { + factory: () => import('./update/cli'), + }, + 'version': { + factory: () => import('./version/cli'), + aliases: ['v'], + }, +}; + +export const RootCommandsAliases = Object.values(RootCommands).reduce( + (prev, current) => { + current.aliases?.forEach((alias) => { + prev[alias] = current; + }); + + return prev; + }, + {} as Record, +); diff --git a/packages/angular/cli/src/commands/completion/cli.ts b/packages/angular/cli/src/commands/completion/cli.ts new file mode 100644 index 000000000000..4cf0ef89bff8 --- /dev/null +++ b/packages/angular/cli/src/commands/completion/cli.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join } from 'path'; +import yargs, { Argv } from 'yargs'; +import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; +import { addCommandModuleToYargs } from '../../command-builder/utilities/command'; +import { colors } from '../../utilities/color'; +import { hasGlobalCliInstall, initializeAutocomplete } from '../../utilities/completion'; +import { assertIsError } from '../../utilities/error'; + +export default class CompletionCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'completion'; + describe = 'Set up Angular CLI autocompletion for your terminal.'; + longDescriptionPath = join(__dirname, 'long-description.md'); + + builder(localYargs: Argv): Argv { + return addCommandModuleToYargs(localYargs, CompletionScriptCommandModule, this.context); + } + + async run(): Promise { + let rcFile: string; + try { + rcFile = await initializeAutocomplete(); + } catch (err) { + assertIsError(err); + this.context.logger.error(err.message); + + return 1; + } + + this.context.logger.info( + ` +Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your terminal or run the following to autocomplete \`ng\` commands: + + ${colors.yellow('source <(ng completion script)')} + `.trim(), + ); + + if ((await hasGlobalCliInstall()) === false) { + this.context.logger.warn( + 'Setup completed successfully, but there does not seem to be a global install of the' + + ' Angular CLI. For autocompletion to work, the CLI will need to be on your `$PATH`, which' + + ' is typically done with the `-g` flag in `npm install -g @angular/cli`.' + + '\n\n' + + 'For more information, see https://angular.dev/cli/completion#global-install', + ); + } + + return 0; + } +} + +class CompletionScriptCommandModule extends CommandModule implements CommandModuleImplementation { + command = 'script'; + describe = 'Generate a bash and zsh real-time type-ahead autocompletion script.'; + longDescriptionPath = undefined; + + builder(localYargs: Argv): Argv { + return localYargs; + } + + run(): void { + yargs.showCompletionScript(); + } +} diff --git a/packages/angular/cli/src/commands/completion/long-description.md b/packages/angular/cli/src/commands/completion/long-description.md new file mode 100644 index 000000000000..b75803ac9cb0 --- /dev/null +++ b/packages/angular/cli/src/commands/completion/long-description.md @@ -0,0 +1,67 @@ +Setting up autocompletion configures your terminal, so pressing the `` key while in the middle +of typing will display various commands and options available to you. This makes it very easy to +discover and use CLI commands without lots of memorization. + +![A demo of Angular CLI autocompletion in a terminal. The user types several partial `ng` commands, +using autocompletion to finish several arguments and list contextual options. +](assets/images/guide/cli/completion.gif) + +## Automated setup + +The CLI should prompt and ask to set up autocompletion for you the first time you use it (v14+). +Simply answer "Yes" and the CLI will take care of the rest. + +``` +$ ng serve +? Would you like to enable autocompletion? This will set up your terminal so pressing TAB while typing Angular CLI commands will show possible options and autocomplete arguments. (Enabling autocompletion will modify configuration files in your home directory.) Yes +Appended `source <(ng completion script)` to `/home/my-username/.bashrc`. Restart your terminal or run: + +source <(ng completion script) + +to autocomplete `ng` commands. + +# Serve output... +``` + +If you already refused the prompt, it won't ask again. But you can run `ng completion` to +do the same thing automatically. + +This modifies your terminal environment to load Angular CLI autocompletion, but can't update your +current terminal session. Either restart it or run `source <(ng completion script)` directly to +enable autocompletion in your current session. + +Test it out by typing `ng ser` and it should autocomplete to `ng serve`. Ambiguous arguments +will show all possible options and their documentation, such as `ng generate `. + +## Manual setup + +Some users may have highly customized terminal setups, possibly with configuration files checked +into source control with an opinionated structure. `ng completion` only ever appends Angular's setup +to an existing configuration file for your current shell, or creates one if none exists. If you want +more control over exactly where this configuration lives, you can manually set it up by having your +shell run at startup: + +```bash +source <(ng completion script) +``` + +This is equivalent to what `ng completion` will automatically set up, and gives power users more +flexibility in their environments when desired. + +## Platform support + +Angular CLI supports autocompletion for the Bash and Zsh shells on MacOS and Linux operating +systems. On Windows, Git Bash and [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/) +using Bash or Zsh are supported. + +## Global install + +Autocompletion works by configuring your terminal to invoke the Angular CLI on startup to load the +setup script. This means the terminal must be able to find and execute the Angular CLI, typically +through a global install that places the binary on the user's `$PATH`. If you get +`command not found: ng`, make sure the CLI is installed globally which you can do with the `-g` +flag: + +```bash +npm install -g @angular/cli +``` diff --git a/packages/angular/cli/src/commands/config/cli.ts b/packages/angular/cli/src/commands/config/cli.ts new file mode 100644 index 000000000000..caa3c2504030 --- /dev/null +++ b/packages/angular/cli/src/commands/config/cli.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JsonValue } from '@angular-devkit/core'; +import { randomUUID } from 'crypto'; +import { join } from 'path'; +import { Argv } from 'yargs'; +import { + CommandModule, + CommandModuleError, + CommandModuleImplementation, + Options, +} from '../../command-builder/command-module'; +import { getWorkspaceRaw, validateWorkspace } from '../../utilities/config'; +import { JSONFile, parseJson } from '../../utilities/json-file'; + +interface ConfigCommandArgs { + 'json-path'?: string; + value?: string; + global?: boolean; +} + +export default class ConfigCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'config [json-path] [value]'; + describe = + 'Retrieves or sets Angular configuration values in the angular.json file for the workspace.'; + longDescriptionPath = join(__dirname, 'long-description.md'); + + builder(localYargs: Argv): Argv { + return localYargs + .positional('json-path', { + description: + `The configuration key to set or query, in JSON path format. ` + + `For example: "a[3].foo.bar[2]". If no new value is provided, returns the current value of this key.`, + type: 'string', + }) + .positional('value', { + description: 'If provided, a new value for the given configuration key.', + type: 'string', + }) + .option('global', { + description: `Access the global configuration in the caller's home directory.`, + alias: ['g'], + type: 'boolean', + default: false, + }) + .strict(); + } + + async run(options: Options): Promise { + const level = options.global ? 'global' : 'local'; + const [config] = await getWorkspaceRaw(level); + + if (options.value == undefined) { + if (!config) { + this.context.logger.error('No config found.'); + + return 1; + } + + return this.get(config, options); + } else { + return this.set(options); + } + } + + private get(jsonFile: JSONFile, options: Options): number { + const { logger } = this.context; + + const value = options.jsonPath + ? jsonFile.get(parseJsonPath(options.jsonPath)) + : jsonFile.content; + + if (value === undefined) { + logger.error('Value cannot be found.'); + + return 1; + } else if (typeof value === 'string') { + logger.info(value); + } else { + logger.info(JSON.stringify(value, null, 2)); + } + + return 0; + } + + private async set(options: Options): Promise { + if (!options.jsonPath?.trim()) { + throw new CommandModuleError('Invalid Path.'); + } + + const [config, configPath] = await getWorkspaceRaw(options.global ? 'global' : 'local'); + const { logger } = this.context; + + if (!config || !configPath) { + throw new CommandModuleError('Confguration file cannot be found.'); + } + + const normalizeUUIDValue = (v: string | undefined) => (v === '' ? randomUUID() : `${v}`); + + const value = + options.jsonPath === 'cli.analyticsSharing.uuid' + ? normalizeUUIDValue(options.value) + : options.value; + + const modified = config.modify(parseJsonPath(options.jsonPath), normalizeValue(value)); + + if (!modified) { + logger.error('Value cannot be found.'); + + return 1; + } + + await validateWorkspace(parseJson(config.content), options.global ?? false); + + config.save(); + + return 0; + } +} + +/** + * Splits a JSON path string into fragments. Fragments can be used to get the value referenced + * by the path. For example, a path of "a[3].foo.bar[2]" would give you a fragment array of + * ["a", 3, "foo", "bar", 2]. + * @param path The JSON string to parse. + * @returns {(string|number)[]} The fragments for the string. + * @private + */ +function parseJsonPath(path: string): (string | number)[] { + const fragments = (path || '').split(/\./g); + const result: (string | number)[] = []; + + while (fragments.length > 0) { + const fragment = fragments.shift(); + if (fragment == undefined) { + break; + } + + const match = fragment.match(/([^[]+)((\[.*\])*)/); + if (!match) { + throw new CommandModuleError('Invalid JSON path.'); + } + + result.push(match[1]); + if (match[2]) { + const indices = match[2] + .slice(1, -1) + .split('][') + .map((x) => (/^\d$/.test(x) ? +x : x.replace(/"|'/g, ''))); + result.push(...indices); + } + } + + return result.filter((fragment) => fragment != null); +} + +function normalizeValue(value: string | undefined | boolean | number): JsonValue | undefined { + const valueString = `${value}`.trim(); + switch (valueString) { + case 'true': + return true; + case 'false': + return false; + case 'null': + return null; + case 'undefined': + return undefined; + } + + if (isFinite(+valueString)) { + return +valueString; + } + + try { + // We use `JSON.parse` instead of `parseJson` because the latter will parse UUIDs + // and convert them into a numberic entities. + // Example: 73b61974-182c-48e4-b4c6-30ddf08c5c98 -> 73. + // These values should never contain comments, therefore using `JSON.parse` is safe. + return JSON.parse(valueString) as JsonValue; + } catch { + return value; + } +} diff --git a/packages/angular/cli/src/commands/config/long-description.md b/packages/angular/cli/src/commands/config/long-description.md new file mode 100644 index 000000000000..db32cb294152 --- /dev/null +++ b/packages/angular/cli/src/commands/config/long-description.md @@ -0,0 +1,13 @@ +A workspace has a single CLI configuration file, `angular.json`, at the top level. +The `projects` object contains a configuration object for each project in the workspace. + +You can edit the configuration directly in a code editor, +or indirectly on the command line using this command. + +The configurable property names match command option names, +except that in the configuration file, all names must use camelCase, +while on the command line options can be given dash-case. + +For further details, see [Workspace Configuration](reference/configs/workspace-config). + +For configuration of CLI usage analytics, see [ng analytics](cli/analytics). diff --git a/packages/angular/cli/src/commands/deploy/cli.ts b/packages/angular/cli/src/commands/deploy/cli.ts new file mode 100644 index 000000000000..947dc90af2d4 --- /dev/null +++ b/packages/angular/cli/src/commands/deploy/cli.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join } from 'node:path'; +import { MissingTargetChoice } from '../../command-builder/architect-base-command-module'; +import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; +import { CommandModuleImplementation } from '../../command-builder/command-module'; + +export default class DeployCommandModule + extends ArchitectCommandModule + implements CommandModuleImplementation +{ + // The below choices should be kept in sync with the list in https://angular.dev/tools/cli/deployment + override missingTargetChoices: MissingTargetChoice[] = [ + { + name: 'Amazon S3', + value: '@jefiozie/ngx-aws-deploy', + }, + { + name: 'Firebase', + value: '@angular/fire', + }, + { + name: 'Netlify', + value: '@netlify-builder/deploy', + }, + { + name: 'GitHub Pages', + value: 'angular-cli-ghpages', + }, + ]; + + multiTarget = false; + command = 'deploy [project]'; + longDescriptionPath = join(__dirname, 'long-description.md'); + describe = + 'Invokes the deploy builder for a specified project or for the default project in the workspace.'; +} diff --git a/packages/angular/cli/src/commands/deploy/long-description.md b/packages/angular/cli/src/commands/deploy/long-description.md new file mode 100644 index 000000000000..0436390680a4 --- /dev/null +++ b/packages/angular/cli/src/commands/deploy/long-description.md @@ -0,0 +1,22 @@ +The command takes an optional project name, as specified in the `projects` section of the `angular.json` workspace configuration file. +When a project name is not supplied, executes the `deploy` builder for the default project. + +To use the `ng deploy` command, use `ng add` to add a package that implements deployment capabilities to your favorite platform. +Adding the package automatically updates your workspace configuration, adding a deployment +[CLI builder](tools/cli/cli-builder). +For example: + +```json +"projects": { + "my-project": { + ... + "architect": { + ... + "deploy": { + "builder": "@angular/fire:deploy", + "options": {} + } + } + } +} +``` diff --git a/packages/angular/cli/src/commands/e2e/cli.ts b/packages/angular/cli/src/commands/e2e/cli.ts new file mode 100644 index 000000000000..7054d358d7fb --- /dev/null +++ b/packages/angular/cli/src/commands/e2e/cli.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { MissingTargetChoice } from '../../command-builder/architect-base-command-module'; +import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; +import { CommandModuleImplementation } from '../../command-builder/command-module'; +import { RootCommands } from '../command-config'; + +export default class E2eCommandModule + extends ArchitectCommandModule + implements CommandModuleImplementation +{ + override missingTargetChoices: MissingTargetChoice[] = [ + { + name: 'Cypress', + value: '@cypress/schematic', + }, + { + name: 'Nightwatch', + value: '@nightwatch/schematics', + }, + { + name: 'WebdriverIO', + value: '@wdio/schematics', + }, + { + name: 'Puppeteer', + value: '@puppeteer/ng-schematics', + }, + ]; + + multiTarget = true; + command = 'e2e [project]'; + aliases = RootCommands['e2e'].aliases; + describe = 'Builds and serves an Angular application, then runs end-to-end tests.'; + longDescriptionPath?: string; +} diff --git a/packages/angular/cli/src/commands/extract-i18n/cli.ts b/packages/angular/cli/src/commands/extract-i18n/cli.ts new file mode 100644 index 000000000000..4f3dea2d8e7e --- /dev/null +++ b/packages/angular/cli/src/commands/extract-i18n/cli.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { workspaces } from '@angular-devkit/core'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; +import { CommandModuleImplementation } from '../../command-builder/command-module'; + +export default class ExtractI18nCommandModule + extends ArchitectCommandModule + implements CommandModuleImplementation +{ + multiTarget = false; + command = 'extract-i18n [project]'; + describe = 'Extracts i18n messages from source code.'; + longDescriptionPath?: string | undefined; + + override async findDefaultBuilderName( + project: workspaces.ProjectDefinition, + ): Promise { + // Only application type projects have a default i18n extraction target + if (project.extensions['projectType'] !== 'application') { + return; + } + + const buildTarget = project.targets.get('build'); + if (!buildTarget) { + // No default if there is no build target + return; + } + + // Provide a default based on the defined builder for the 'build' target + switch (buildTarget.builder) { + case '@angular-devkit/build-angular:application': + case '@angular-devkit/build-angular:browser-esbuild': + case '@angular-devkit/build-angular:browser': + return '@angular-devkit/build-angular:extract-i18n'; + case '@angular/build:application': + return '@angular/build:extract-i18n'; + } + + // For other builders, check for `@angular-devkit/build-angular` and use if found. + // This package is safer to use since it supports both application builder types. + try { + const projectRequire = createRequire(join(this.context.root, project.root) + '/'); + projectRequire.resolve('@angular-devkit/build-angular'); + + return '@angular-devkit/build-angular:extract-i18n'; + } catch {} + } +} diff --git a/packages/angular/cli/src/commands/generate/cli.ts b/packages/angular/cli/src/commands/generate/cli.ts new file mode 100644 index 000000000000..4be29c3eaea0 --- /dev/null +++ b/packages/angular/cli/src/commands/generate/cli.ts @@ -0,0 +1,283 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { strings } from '@angular-devkit/core'; +import { Collection } from '@angular-devkit/schematics'; +import { + FileSystemCollectionDescription, + FileSystemSchematicDescription, +} from '@angular-devkit/schematics/tools'; +import { ArgumentsCamelCase, Argv } from 'yargs'; +import { + CommandModuleError, + CommandModuleImplementation, + Options, + OtherOptions, +} from '../../command-builder/command-module'; +import { + SchematicsCommandArgs, + SchematicsCommandModule, +} from '../../command-builder/schematics-command-module'; +import { demandCommandFailureMessage } from '../../command-builder/utilities/command'; +import { Option } from '../../command-builder/utilities/json-schema'; +import { RootCommands } from '../command-config'; + +interface GenerateCommandArgs extends SchematicsCommandArgs { + schematic?: string; +} + +export default class GenerateCommandModule + extends SchematicsCommandModule + implements CommandModuleImplementation +{ + command = 'generate'; + aliases = RootCommands['generate'].aliases; + describe = 'Generates and/or modifies files based on a schematic.'; + longDescriptionPath?: string | undefined; + + override async builder(argv: Argv): Promise> { + let localYargs = (await super.builder(argv)).command({ + command: '$0 ', + describe: 'Run the provided schematic.', + builder: (localYargs) => + localYargs + .positional('schematic', { + describe: 'The [collection:schematic] to run.', + type: 'string', + demandOption: true, + }) + .strict(), + handler: (options) => this.handler(options as ArgumentsCamelCase), + }); + + for (const [schematicName, collectionName] of await this.getSchematicsToRegister()) { + const workflow = this.getOrCreateWorkflowForBuilder(collectionName); + const collection = workflow.engine.createCollection(collectionName); + + const { + description: { + schemaJson, + aliases: schematicAliases, + hidden: schematicHidden, + description: schematicDescription, + }, + } = collection.createSchematic(schematicName, true); + + if (!schemaJson) { + continue; + } + + const { + 'x-deprecated': xDeprecated, + description = schematicDescription, + hidden = schematicHidden, + } = schemaJson; + const options = await this.getSchematicOptions(collection, schematicName, workflow); + + localYargs = localYargs.command({ + command: await this.generateCommandString(collectionName, schematicName, options), + // When 'describe' is set to false, it results in a hidden command. + describe: hidden === true ? false : typeof description === 'string' ? description : '', + deprecated: xDeprecated === true || typeof xDeprecated === 'string' ? xDeprecated : false, + aliases: Array.isArray(schematicAliases) + ? await this.generateCommandAliasesStrings(collectionName, schematicAliases) + : undefined, + builder: (localYargs) => this.addSchemaOptionsToCommand(localYargs, options).strict(), + handler: (options) => + this.handler({ + ...options, + schematic: `${collectionName}:${schematicName}`, + } as ArgumentsCamelCase< + SchematicsCommandArgs & { + schematic: string; + } + >), + }); + } + + return localYargs.demandCommand(1, demandCommandFailureMessage); + } + + async run(options: Options & OtherOptions): Promise { + const { dryRun, schematic, defaults, force, interactive, ...schematicOptions } = options; + + const [collectionName, schematicName] = this.parseSchematicInfo(schematic); + + if (!collectionName || !schematicName) { + throw new CommandModuleError('A collection and schematic is required during execution.'); + } + + return this.runSchematic({ + collectionName, + schematicName, + schematicOptions, + executionOptions: { + dryRun, + defaults, + force, + interactive, + }, + }); + } + + private async getCollectionNames(): Promise { + const [collectionName] = this.parseSchematicInfo( + // positional = [generate, component] or [generate] + this.context.args.positional[1], + ); + + return collectionName ? [collectionName] : [...(await this.getSchematicCollections())]; + } + + private async shouldAddCollectionNameAsPartOfCommand(): Promise { + const [collectionNameFromArgs] = this.parseSchematicInfo( + // positional = [generate, component] or [generate] + this.context.args.positional[1], + ); + + const schematicCollectionsFromConfig = await this.getSchematicCollections(); + const collectionNames = await this.getCollectionNames(); + + // Only add the collection name as part of the command when it's not a known + // schematics collection or when it has been provided via the CLI. + // Ex:`ng generate @schematics/angular:c` + return ( + !!collectionNameFromArgs || + !collectionNames.some((c) => schematicCollectionsFromConfig.has(c)) + ); + } + + /** + * Generate an aliases string array to be passed to the command builder. + * + * @example `[component]` or `[@schematics/angular:component]`. + */ + private async generateCommandAliasesStrings( + collectionName: string, + schematicAliases: string[], + ): Promise { + // Only add the collection name as part of the command when it's not a known + // schematics collection or when it has been provided via the CLI. + // Ex:`ng generate @schematics/angular:c` + return (await this.shouldAddCollectionNameAsPartOfCommand()) + ? schematicAliases.map((alias) => `${collectionName}:${alias}`) + : schematicAliases; + } + + /** + * Generate a command string to be passed to the command builder. + * + * @example `component [name]` or `@schematics/angular:component [name]`. + */ + private async generateCommandString( + collectionName: string, + schematicName: string, + options: Option[], + ): Promise { + const dasherizedSchematicName = strings.dasherize(schematicName); + + // Only add the collection name as part of the command when it's not a known + // schematics collection or when it has been provided via the CLI. + // Ex:`ng generate @schematics/angular:component` + const commandName = (await this.shouldAddCollectionNameAsPartOfCommand()) + ? collectionName + ':' + dasherizedSchematicName + : dasherizedSchematicName; + + const positionalArgs = options + .filter((o) => o.positional !== undefined) + .map((o) => { + const label = `${strings.dasherize(o.name)}${o.type === 'array' ? ' ..' : ''}`; + + return o.required ? `<${label}>` : `[${label}]`; + }) + .join(' '); + + return `${commandName}${positionalArgs ? ' ' + positionalArgs : ''}`; + } + + /** + * Get schematics that can to be registered as subcommands. + */ + private async *getSchematics(): AsyncGenerator<{ + schematicName: string; + schematicAliases?: Set; + collectionName: string; + }> { + const seenNames = new Set(); + for (const collectionName of await this.getCollectionNames()) { + const workflow = this.getOrCreateWorkflowForBuilder(collectionName); + const collection = workflow.engine.createCollection(collectionName); + + for (const schematicName of collection.listSchematicNames(true /** includeHidden */)) { + // If a schematic with this same name is already registered skip. + if (!seenNames.has(schematicName)) { + seenNames.add(schematicName); + + yield { + schematicName, + collectionName, + schematicAliases: this.listSchematicAliases(collection, schematicName), + }; + } + } + } + } + + private listSchematicAliases( + collection: Collection, + schematicName: string, + ): Set | undefined { + const description = collection.description.schematics[schematicName]; + if (description) { + return description.aliases && new Set(description.aliases); + } + + // Extended collections + if (collection.baseDescriptions) { + for (const base of collection.baseDescriptions) { + const description = base.schematics[schematicName]; + if (description) { + return description.aliases && new Set(description.aliases); + } + } + } + + return undefined; + } + + /** + * Get schematics that should to be registered as subcommands. + * + * @returns a sorted list of schematic that needs to be registered as subcommands. + */ + private async getSchematicsToRegister(): Promise< + [schematicName: string, collectionName: string][] + > { + const schematicsToRegister: [schematicName: string, collectionName: string][] = []; + const [, schematicNameFromArgs] = this.parseSchematicInfo( + // positional = [generate, component] or [generate] + this.context.args.positional[1], + ); + + for await (const { schematicName, collectionName, schematicAliases } of this.getSchematics()) { + if ( + schematicNameFromArgs && + (schematicName === schematicNameFromArgs || schematicAliases?.has(schematicNameFromArgs)) + ) { + return [[schematicName, collectionName]]; + } + + schematicsToRegister.push([schematicName, collectionName]); + } + + // Didn't find the schematic or no schematic name was provided Ex: `ng generate --help`. + return schematicsToRegister.sort(([nameA], [nameB]) => + nameA.localeCompare(nameB, undefined, { sensitivity: 'accent' }), + ); + } +} diff --git a/packages/angular/cli/src/commands/lint/cli.ts b/packages/angular/cli/src/commands/lint/cli.ts new file mode 100644 index 000000000000..cb7897284951 --- /dev/null +++ b/packages/angular/cli/src/commands/lint/cli.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join } from 'path'; +import { MissingTargetChoice } from '../../command-builder/architect-base-command-module'; +import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; +import { CommandModuleImplementation } from '../../command-builder/command-module'; + +export default class LintCommandModule + extends ArchitectCommandModule + implements CommandModuleImplementation +{ + override missingTargetChoices: MissingTargetChoice[] = [ + { + name: 'ESLint', + value: '@angular-eslint/schematics', + }, + ]; + + multiTarget = true; + command = 'lint [project]'; + longDescriptionPath = join(__dirname, 'long-description.md'); + describe = 'Runs linting tools on Angular application code in a given project folder.'; +} diff --git a/packages/angular/cli/src/commands/lint/long-description.md b/packages/angular/cli/src/commands/lint/long-description.md new file mode 100644 index 000000000000..5e5fa3da951c --- /dev/null +++ b/packages/angular/cli/src/commands/lint/long-description.md @@ -0,0 +1,20 @@ +The command takes an optional project name, as specified in the `projects` section of the `angular.json` workspace configuration file. +When a project name is not supplied, executes the `lint` builder for all projects. + +To use the `ng lint` command, use `ng add` to add a package that implements linting capabilities. Adding the package automatically updates your workspace configuration, adding a lint [CLI builder](tools/cli/cli-builder). +For example: + +```json +"projects": { + "my-project": { + ... + "architect": { + ... + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": {} + } + } + } +} +``` diff --git a/packages/angular/cli/src/commands/make-this-awesome/cli.ts b/packages/angular/cli/src/commands/make-this-awesome/cli.ts new file mode 100644 index 000000000000..6a17c5614b94 --- /dev/null +++ b/packages/angular/cli/src/commands/make-this-awesome/cli.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; +import { colors } from '../../utilities/color'; + +export default class AwesomeCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'make-this-awesome'; + describe = false as const; + deprecated = false; + longDescriptionPath?: string | undefined; + + builder(localYargs: Argv): Argv { + return localYargs; + } + + run(): void { + const pickOne = (of: string[]) => of[Math.floor(Math.random() * of.length)]; + + const phrase = pickOne([ + `You're on it, there's nothing for me to do!`, + `Let's take a look... nope, it's all good!`, + `You're doing fine.`, + `You're already doing great.`, + `Nothing to do; already awesome. Exiting.`, + `Error 418: As Awesome As Can Get.`, + `I spy with my little eye a great developer!`, + `Noop... already awesome.`, + ]); + + this.context.logger.info(colors.green(phrase)); + } +} diff --git a/packages/angular/cli/src/commands/new/cli.ts b/packages/angular/cli/src/commands/new/cli.ts new file mode 100644 index 000000000000..9163708726b6 --- /dev/null +++ b/packages/angular/cli/src/commands/new/cli.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join } from 'node:path'; +import { Argv } from 'yargs'; +import { + CommandModuleImplementation, + CommandScope, + Options, + OtherOptions, +} from '../../command-builder/command-module'; +import { + DEFAULT_SCHEMATICS_COLLECTION, + SchematicsCommandArgs, + SchematicsCommandModule, +} from '../../command-builder/schematics-command-module'; +import { VERSION } from '../../utilities/version'; +import { RootCommands } from '../command-config'; + +interface NewCommandArgs extends SchematicsCommandArgs { + collection?: string; +} + +export default class NewCommandModule + extends SchematicsCommandModule + implements CommandModuleImplementation +{ + private readonly schematicName = 'ng-new'; + override scope = CommandScope.Out; + protected override allowPrivateSchematics = true; + + command = 'new [name]'; + aliases = RootCommands['new'].aliases; + describe = 'Creates a new Angular workspace.'; + longDescriptionPath = join(__dirname, 'long-description.md'); + + override async builder(argv: Argv): Promise> { + const localYargs = (await super.builder(argv)).option('collection', { + alias: 'c', + describe: 'A collection of schematics to use in generating the initial application.', + type: 'string', + }); + + const { + options: { collection: collectionNameFromArgs }, + } = this.context.args; + + const collectionName = + typeof collectionNameFromArgs === 'string' + ? collectionNameFromArgs + : await this.getCollectionFromConfig(); + + const workflow = this.getOrCreateWorkflowForBuilder(collectionName); + const collection = workflow.engine.createCollection(collectionName); + const options = await this.getSchematicOptions(collection, this.schematicName, workflow); + + return this.addSchemaOptionsToCommand(localYargs, options); + } + + async run(options: Options & OtherOptions): Promise { + // Register the version of the CLI in the registry. + const collectionName = options.collection ?? (await this.getCollectionFromConfig()); + const { dryRun, force, interactive, defaults, collection, ...schematicOptions } = options; + const workflow = await this.getOrCreateWorkflowForExecution(collectionName, { + dryRun, + force, + interactive, + defaults, + }); + workflow.registry.addSmartDefaultProvider('ng-cli-version', () => VERSION.full); + + return this.runSchematic({ + collectionName, + schematicName: this.schematicName, + schematicOptions, + executionOptions: { + dryRun, + force, + interactive, + defaults, + }, + }); + } + + /** Find a collection from config that has an `ng-new` schematic. */ + private async getCollectionFromConfig(): Promise { + for (const collectionName of await this.getSchematicCollections()) { + const workflow = this.getOrCreateWorkflowForBuilder(collectionName); + const collection = workflow.engine.createCollection(collectionName); + const schematicsInCollection = collection.description.schematics; + + if (Object.keys(schematicsInCollection).includes(this.schematicName)) { + return collectionName; + } + } + + return DEFAULT_SCHEMATICS_COLLECTION; + } +} diff --git a/packages/angular/cli/src/commands/new/long-description.md b/packages/angular/cli/src/commands/new/long-description.md new file mode 100644 index 000000000000..1166f974887a --- /dev/null +++ b/packages/angular/cli/src/commands/new/long-description.md @@ -0,0 +1,15 @@ +Creates and initializes a new Angular application that is the default project for a new workspace. + +Provides interactive prompts for optional configuration, such as adding routing support. +All prompts can safely be allowed to default. + +- The new workspace folder is given the specified project name, and contains configuration files at the top level. + +- By default, the files for a new initial application (with the same name as the workspace) are placed in the `src/` subfolder. +- The new application's configuration appears in the `projects` section of the `angular.json` workspace configuration file, under its project name. + +- Subsequent applications that you generate in the workspace reside in the `projects/` subfolder. + +If you plan to have multiple applications in the workspace, you can create an empty workspace by using the `--no-create-application` option. +You can then use `ng generate application` to create an initial application. +This allows a workspace name different from the initial app name, and ensures that all applications reside in the `/projects` subfolder, matching the structure of the configuration file. diff --git a/packages/angular/cli/src/commands/run/cli.ts b/packages/angular/cli/src/commands/run/cli.ts new file mode 100644 index 000000000000..bd65dac53fc3 --- /dev/null +++ b/packages/angular/cli/src/commands/run/cli.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Target } from '@angular-devkit/architect'; +import { join } from 'path'; +import { Argv } from 'yargs'; +import { ArchitectBaseCommandModule } from '../../command-builder/architect-base-command-module'; +import { + CommandModuleError, + CommandModuleImplementation, + CommandScope, + Options, + OtherOptions, +} from '../../command-builder/command-module'; + +export interface RunCommandArgs { + target: string; +} + +export default class RunCommandModule + extends ArchitectBaseCommandModule + implements CommandModuleImplementation +{ + override scope = CommandScope.In; + + command = 'run '; + describe = + 'Runs an Architect target with an optional custom builder configuration defined in your project.'; + longDescriptionPath = join(__dirname, 'long-description.md'); + + async builder(argv: Argv): Promise> { + const { jsonHelp, getYargsCompletions, help } = this.context.args.options; + + const localYargs: Argv = argv + .positional('target', { + describe: + 'The Architect target to run provided in the following format `project:target[:configuration]`.', + type: 'string', + demandOption: true, + // Show only in when using --help and auto completion because otherwise comma seperated configuration values will be invalid. + // Also, hide choices from JSON help so that we don't display them in AIO. + choices: (getYargsCompletions || help) && !jsonHelp ? this.getTargetChoices() : undefined, + }) + .middleware((args) => { + // TODO: remove in version 15. + const { configuration, target } = args; + if (typeof configuration === 'string' && target) { + const targetWithConfig = target.split(':', 2); + targetWithConfig.push(configuration); + + throw new CommandModuleError( + 'Unknown argument: configuration.\n' + + `Provide the configuration as part of the target 'ng run ${targetWithConfig.join( + ':', + )}'.`, + ); + } + }, true) + .strict(); + + const target = this.makeTargetSpecifier(); + if (!target) { + return localYargs; + } + + const schemaOptions = await this.getArchitectTargetOptions(target); + + return this.addSchemaOptionsToCommand(localYargs, schemaOptions); + } + + async run(options: Options & OtherOptions): Promise { + const target = this.makeTargetSpecifier(options); + const { target: _target, ...extraOptions } = options; + + if (!target) { + throw new CommandModuleError('Cannot determine project or target.'); + } + + return this.runSingleTarget(target, extraOptions); + } + + protected makeTargetSpecifier(options?: Options): Target | undefined { + const architectTarget = options?.target ?? this.context.args.positional[1]; + if (!architectTarget) { + return undefined; + } + + const [project = '', target = '', configuration] = architectTarget.split(':'); + + return { + project, + target, + configuration, + }; + } + + /** @returns a sorted list of target specifiers to be used for auto completion. */ + private getTargetChoices(): string[] | undefined { + if (!this.context.workspace) { + return; + } + + const targets = []; + for (const [projectName, project] of this.context.workspace.projects) { + for (const [targetName, target] of project.targets) { + const currentTarget = `${projectName}:${targetName}`; + targets.push(currentTarget); + + if (!target.configurations) { + continue; + } + + for (const configName of Object.keys(target.configurations)) { + targets.push(`${currentTarget}:${configName}`); + } + } + } + + return targets.sort(); + } +} diff --git a/packages/angular/cli/src/commands/run/long-description.md b/packages/angular/cli/src/commands/run/long-description.md new file mode 100644 index 000000000000..e74f8756679d --- /dev/null +++ b/packages/angular/cli/src/commands/run/long-description.md @@ -0,0 +1,10 @@ +Architect is the tool that the CLI uses to perform complex tasks such as compilation, according to provided configurations. +The CLI commands run Architect targets such as `build`, `serve`, `test`, and `lint`. +Each named target has a default configuration, specified by an `options` object, +and an optional set of named alternate configurations in the `configurations` object. + +For example, the `serve` target for a newly generated app has a predefined +alternate configuration named `production`. + +You can define new targets and their configuration options in the `architect` section +of the `angular.json` file which you can run them from the command line using the `ng run` command. diff --git a/packages/angular/cli/src/commands/serve/cli.ts b/packages/angular/cli/src/commands/serve/cli.ts new file mode 100644 index 000000000000..3b38fa122acd --- /dev/null +++ b/packages/angular/cli/src/commands/serve/cli.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; +import { CommandModuleImplementation } from '../../command-builder/command-module'; +import { RootCommands } from '../command-config'; + +export default class ServeCommandModule + extends ArchitectCommandModule + implements CommandModuleImplementation +{ + multiTarget = false; + command = 'serve [project]'; + aliases = RootCommands['serve'].aliases; + describe = 'Builds and serves your application, rebuilding on file changes.'; + longDescriptionPath?: string | undefined; +} diff --git a/packages/angular/cli/src/commands/test/cli.ts b/packages/angular/cli/src/commands/test/cli.ts new file mode 100644 index 000000000000..fde58fda5d6e --- /dev/null +++ b/packages/angular/cli/src/commands/test/cli.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join } from 'path'; +import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; +import { CommandModuleImplementation } from '../../command-builder/command-module'; +import { RootCommands } from '../command-config'; + +export default class TestCommandModule + extends ArchitectCommandModule + implements CommandModuleImplementation +{ + multiTarget = true; + command = 'test [project]'; + aliases = RootCommands['test'].aliases; + describe = 'Runs unit tests in a project.'; + longDescriptionPath = join(__dirname, 'long-description.md'); +} diff --git a/packages/angular/cli/commands/test-long.md b/packages/angular/cli/src/commands/test/long-description.md similarity index 100% rename from packages/angular/cli/commands/test-long.md rename to packages/angular/cli/src/commands/test/long-description.md diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts new file mode 100644 index 000000000000..b9e991e3ea4a --- /dev/null +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -0,0 +1,1227 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { SchematicDescription, UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics'; +import { + FileSystemCollectionDescription, + FileSystemSchematicDescription, + NodeWorkflow, +} from '@angular-devkit/schematics/tools'; +import { Listr } from 'listr2'; +import { SpawnSyncReturns, execSync, spawnSync } from 'node:child_process'; +import { existsSync, promises as fs } from 'node:fs'; +import { createRequire } from 'node:module'; +import npa from 'npm-package-arg'; +import pickManifest from 'npm-pick-manifest'; +import * as path from 'path'; +import { join, resolve } from 'path'; +import * as semver from 'semver'; +import { Argv } from 'yargs'; +import { PackageManager } from '../../../lib/config/workspace-schema'; +import { + CommandModule, + CommandModuleError, + CommandScope, + Options, +} from '../../command-builder/command-module'; +import { SchematicEngineHost } from '../../command-builder/utilities/schematic-engine-host'; +import { subscribeToWorkflow } from '../../command-builder/utilities/schematic-workflow'; +import { colors, figures } from '../../utilities/color'; +import { disableVersionCheck } from '../../utilities/environment-options'; +import { assertIsError } from '../../utilities/error'; +import { writeErrorToLogFile } from '../../utilities/log-file'; +import { + PackageIdentifier, + PackageManifest, + fetchPackageManifest, + fetchPackageMetadata, +} from '../../utilities/package-metadata'; +import { + PackageTreeNode, + findPackageJson, + getProjectDependencies, + readPackageJson, +} from '../../utilities/package-tree'; +import { askChoices } from '../../utilities/prompt'; +import { isTTY } from '../../utilities/tty'; +import { VERSION } from '../../utilities/version'; + +interface UpdateCommandArgs { + packages?: string[]; + force: boolean; + next: boolean; + 'migrate-only'?: boolean; + name?: string; + from?: string; + to?: string; + 'allow-dirty': boolean; + verbose: boolean; + 'create-commits': boolean; +} + +interface MigrationSchematicDescription + extends SchematicDescription { + version?: string; + optional?: boolean; + documentation?: string; +} + +interface MigrationSchematicDescriptionWithVersion extends MigrationSchematicDescription { + version: string; +} + +class CommandError extends Error {} + +const ANGULAR_PACKAGES_REGEXP = /^@(?:angular|nguniversal)\//; +const UPDATE_SCHEMATIC_COLLECTION = path.join(__dirname, 'schematic/collection.json'); + +export default class UpdateCommandModule extends CommandModule { + override scope = CommandScope.In; + protected override shouldReportAnalytics = false; + private readonly resolvePaths = [__dirname, this.context.root]; + + command = 'update [packages..]'; + describe = 'Updates your workspace and its dependencies. See https://update.angular.dev/.'; + longDescriptionPath = join(__dirname, 'long-description.md'); + + builder(localYargs: Argv): Argv { + return localYargs + .positional('packages', { + description: 'The names of package(s) to update.', + type: 'string', + array: true, + }) + .option('force', { + description: 'Ignore peer dependency version mismatches.', + type: 'boolean', + default: false, + }) + .option('next', { + description: 'Use the prerelease version, including beta and RCs.', + type: 'boolean', + default: false, + }) + .option('migrate-only', { + description: 'Only perform a migration, do not update the installed version.', + type: 'boolean', + }) + .option('name', { + description: + 'The name of the migration to run. Only available when a single package is updated.', + type: 'string', + conflicts: ['to', 'from'], + }) + .option('from', { + description: + 'Version from which to migrate from. ' + + `Only available when a single package is updated, and only with 'migrate-only'.`, + type: 'string', + implies: ['migrate-only'], + conflicts: ['name'], + }) + .option('to', { + describe: + 'Version up to which to apply migrations. Only available when a single package is updated, ' + + `and only with 'migrate-only' option. Requires 'from' to be specified. Default to the installed version detected.`, + type: 'string', + implies: ['from', 'migrate-only'], + conflicts: ['name'], + }) + .option('allow-dirty', { + describe: + 'Whether to allow updating when the repository contains modified or untracked files.', + type: 'boolean', + default: false, + }) + .option('verbose', { + describe: 'Display additional details about internal operations during execution.', + type: 'boolean', + default: false, + }) + .option('create-commits', { + describe: 'Create source control commits for updates and migrations.', + type: 'boolean', + alias: ['C'], + default: false, + }) + .middleware((argv) => { + if (argv.name) { + argv['migrate-only'] = true; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return argv as any; + }) + .check(({ packages, 'allow-dirty': allowDirty, 'migrate-only': migrateOnly }) => { + const { logger } = this.context; + + // This allows the user to easily reset any changes from the update. + if (packages?.length && !this.checkCleanGit()) { + if (allowDirty) { + logger.warn( + 'Repository is not clean. Update changes will be mixed with pre-existing changes.', + ); + } else { + throw new CommandModuleError( + 'Repository is not clean. Please commit or stash any changes before updating.', + ); + } + } + + if (migrateOnly) { + if (packages?.length !== 1) { + throw new CommandModuleError( + `A single package must be specified when using the 'migrate-only' option.`, + ); + } + } + + return true; + }) + .strict(); + } + + async run(options: Options): Promise { + const { logger, packageManager } = this.context; + + // Check if the current installed CLI version is older than the latest compatible version. + // Skip when running `ng update` without a package name as this will not trigger an actual update. + if (!disableVersionCheck && options.packages?.length) { + const cliVersionToInstall = await this.checkCLIVersion( + options.packages, + options.verbose, + options.next, + ); + + if (cliVersionToInstall) { + logger.warn( + 'The installed Angular CLI version is outdated.\n' + + `Installing a temporary Angular CLI versioned ${cliVersionToInstall} to perform the update.`, + ); + + return this.runTempBinary(`@angular/cli@${cliVersionToInstall}`, process.argv.slice(2)); + } + } + + const packages: PackageIdentifier[] = []; + for (const request of options.packages ?? []) { + try { + const packageIdentifier = npa(request); + + // only registry identifiers are supported + if (!packageIdentifier.registry) { + logger.error(`Package '${request}' is not a registry package identifer.`); + + return 1; + } + + if (packages.some((v) => v.name === packageIdentifier.name)) { + logger.error(`Duplicate package '${packageIdentifier.name}' specified.`); + + return 1; + } + + if (options.migrateOnly && packageIdentifier.rawSpec !== '*') { + logger.warn('Package specifier has no effect when using "migrate-only" option.'); + } + + // If next option is used and no specifier supplied, use next tag + if (options.next && packageIdentifier.rawSpec === '*') { + packageIdentifier.fetchSpec = 'next'; + } + + packages.push(packageIdentifier as PackageIdentifier); + } catch (e) { + assertIsError(e); + logger.error(e.message); + + return 1; + } + } + + logger.info(`Using package manager: ${colors.gray(packageManager.name)}`); + logger.info('Collecting installed dependencies...'); + + const rootDependencies = await getProjectDependencies(this.context.root); + logger.info(`Found ${rootDependencies.size} dependencies.`); + + const workflow = new NodeWorkflow(this.context.root, { + packageManager: packageManager.name, + packageManagerForce: this.packageManagerForce(options.verbose), + // __dirname -> favor @schematics/update from this package + // Otherwise, use packages from the active workspace (migrations) + resolvePaths: this.resolvePaths, + schemaValidation: true, + engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths), + }); + + if (packages.length === 0) { + // Show status + const { success } = await this.executeSchematic( + workflow, + UPDATE_SCHEMATIC_COLLECTION, + 'update', + { + force: options.force, + next: options.next, + verbose: options.verbose, + packageManager: packageManager.name, + packages: [], + }, + ); + + return success ? 0 : 1; + } + + return options.migrateOnly + ? this.migrateOnly(workflow, (options.packages ?? [])[0], rootDependencies, options) + : this.updatePackagesAndMigrate(workflow, rootDependencies, options, packages); + } + + private async executeSchematic( + workflow: NodeWorkflow, + collection: string, + schematic: string, + options: Record = {}, + ): Promise<{ success: boolean; files: Set }> { + const { logger } = this.context; + const workflowSubscription = subscribeToWorkflow(workflow, logger); + + // TODO: Allow passing a schematic instance directly + try { + await workflow + .execute({ + collection, + schematic, + options, + logger, + }) + .toPromise(); + + return { success: !workflowSubscription.error, files: workflowSubscription.files }; + } catch (e) { + if (e instanceof UnsuccessfulWorkflowExecution) { + logger.error(`${figures.cross} Migration failed. See above for further details.\n`); + } else { + assertIsError(e); + const logPath = writeErrorToLogFile(e); + logger.fatal( + `${figures.cross} Migration failed: ${e.message}\n` + + ` See "${logPath}" for further details.\n`, + ); + } + + return { success: false, files: workflowSubscription.files }; + } finally { + workflowSubscription.unsubscribe(); + } + } + + /** + * @return Whether or not the migration was performed successfully. + */ + private async executeMigration( + workflow: NodeWorkflow, + packageName: string, + collectionPath: string, + migrationName: string, + commit?: boolean, + ): Promise { + const { logger } = this.context; + const collection = workflow.engine.createCollection(collectionPath); + const name = collection.listSchematicNames().find((name) => name === migrationName); + if (!name) { + logger.error(`Cannot find migration '${migrationName}' in '${packageName}'.`); + + return 1; + } + + logger.info(colors.cyan(`** Executing '${migrationName}' of package '${packageName}' **\n`)); + const schematic = workflow.engine.createSchematic(name, collection); + + return this.executePackageMigrations(workflow, [schematic.description], packageName, commit); + } + + /** + * @return Whether or not the migrations were performed successfully. + */ + private async executeMigrations( + workflow: NodeWorkflow, + packageName: string, + collectionPath: string, + from: string, + to: string, + commit?: boolean, + ): Promise { + const collection = workflow.engine.createCollection(collectionPath); + const migrationRange = new semver.Range( + '>' + (semver.prerelease(from) ? from.split('-')[0] + '-0' : from) + ' <=' + to.split('-')[0], + ); + + const requiredMigrations: MigrationSchematicDescriptionWithVersion[] = []; + const optionalMigrations: MigrationSchematicDescriptionWithVersion[] = []; + + for (const name of collection.listSchematicNames()) { + const schematic = workflow.engine.createSchematic(name, collection); + const description = schematic.description as MigrationSchematicDescription; + + description.version = coerceVersionNumber(description.version); + if (!description.version) { + continue; + } + + if (semver.satisfies(description.version, migrationRange, { includePrerelease: true })) { + (description.optional ? optionalMigrations : requiredMigrations).push( + description as MigrationSchematicDescriptionWithVersion, + ); + } + } + + if (requiredMigrations.length === 0 && optionalMigrations.length === 0) { + return 0; + } + + // Required migrations + if (requiredMigrations.length) { + this.context.logger.info( + colors.cyan(`** Executing migrations of package '${packageName}' **\n`), + ); + + requiredMigrations.sort( + (a, b) => semver.compare(a.version, b.version) || a.name.localeCompare(b.name), + ); + + const result = await this.executePackageMigrations( + workflow, + requiredMigrations, + packageName, + commit, + ); + + if (result === 1) { + return 1; + } + } + + // Optional migrations + if (optionalMigrations.length) { + this.context.logger.info( + colors.magenta(`** Optional migrations of package '${packageName}' **\n`), + ); + + optionalMigrations.sort( + (a, b) => semver.compare(a.version, b.version) || a.name.localeCompare(b.name), + ); + + const migrationsToRun = await this.getOptionalMigrationsToRun( + optionalMigrations, + packageName, + ); + + if (migrationsToRun?.length) { + return this.executePackageMigrations(workflow, migrationsToRun, packageName, commit); + } + } + + return 0; + } + + private async executePackageMigrations( + workflow: NodeWorkflow, + migrations: MigrationSchematicDescription[], + packageName: string, + commit = false, + ): Promise<1 | 0> { + const { logger } = this.context; + for (const migration of migrations) { + const { title, description } = getMigrationTitleAndDescription(migration); + + logger.info(colors.cyan(figures.pointer) + ' ' + colors.bold(title)); + + if (description) { + logger.info(' ' + description); + } + + const { success, files } = await this.executeSchematic( + workflow, + migration.collection.name, + migration.name, + ); + if (!success) { + return 1; + } + + let modifiedFilesText: string; + switch (files.size) { + case 0: + modifiedFilesText = 'No changes made'; + break; + case 1: + modifiedFilesText = '1 file modified'; + break; + default: + modifiedFilesText = `${files.size} files modified`; + break; + } + + logger.info(` Migration completed (${modifiedFilesText}).`); + + // Commit migration + if (commit) { + const commitPrefix = `${packageName} migration - ${migration.name}`; + const commitMessage = migration.description + ? `${commitPrefix}\n\n${migration.description}` + : commitPrefix; + const committed = this.commit(commitMessage); + if (!committed) { + // Failed to commit, something went wrong. Abort the update. + return 1; + } + } + + logger.info(''); // Extra trailing newline. + } + + return 0; + } + + private async migrateOnly( + workflow: NodeWorkflow, + packageName: string, + rootDependencies: Map, + options: Options, + ): Promise { + const { logger } = this.context; + const packageDependency = rootDependencies.get(packageName); + let packagePath = packageDependency?.path; + let packageNode = packageDependency?.package; + if (packageDependency && !packageNode) { + logger.error('Package found in package.json but is not installed.'); + + return 1; + } else if (!packageDependency) { + // Allow running migrations on transitively installed dependencies + // There can technically be nested multiple versions + // TODO: If multiple, this should find all versions and ask which one to use + const packageJson = findPackageJson(this.context.root, packageName); + if (packageJson) { + packagePath = path.dirname(packageJson); + packageNode = await readPackageJson(packageJson); + } + } + + if (!packageNode || !packagePath) { + logger.error('Package is not installed.'); + + return 1; + } + + const updateMetadata = packageNode['ng-update']; + let migrations = updateMetadata?.migrations; + if (migrations === undefined) { + logger.error('Package does not provide migrations.'); + + return 1; + } else if (typeof migrations !== 'string') { + logger.error('Package contains a malformed migrations field.'); + + return 1; + } else if (path.posix.isAbsolute(migrations) || path.win32.isAbsolute(migrations)) { + logger.error( + 'Package contains an invalid migrations field. Absolute paths are not permitted.', + ); + + return 1; + } + + // Normalize slashes + migrations = migrations.replace(/\\/g, '/'); + + if (migrations.startsWith('../')) { + logger.error( + 'Package contains an invalid migrations field. Paths outside the package root are not permitted.', + ); + + return 1; + } + + // Check if it is a package-local location + const localMigrations = path.join(packagePath, migrations); + if (existsSync(localMigrations)) { + migrations = localMigrations; + } else { + // Try to resolve from package location. + // This avoids issues with package hoisting. + try { + const packageRequire = createRequire(packagePath + '/'); + migrations = packageRequire.resolve(migrations, { paths: this.resolvePaths }); + } catch (e) { + assertIsError(e); + if (e.code === 'MODULE_NOT_FOUND') { + logger.error('Migrations for package were not found.'); + } else { + logger.error(`Unable to resolve migrations for package. [${e.message}]`); + } + + return 1; + } + } + + if (options.name) { + return this.executeMigration( + workflow, + packageName, + migrations, + options.name, + options.createCommits, + ); + } + + const from = coerceVersionNumber(options.from); + if (!from) { + logger.error(`"from" value [${options.from}] is not a valid version.`); + + return 1; + } + + return this.executeMigrations( + workflow, + packageName, + migrations, + from, + options.to || packageNode.version, + options.createCommits, + ); + } + + // eslint-disable-next-line max-lines-per-function + private async updatePackagesAndMigrate( + workflow: NodeWorkflow, + rootDependencies: Map, + options: Options, + packages: PackageIdentifier[], + ): Promise { + const { logger } = this.context; + + const logVerbose = (message: string) => { + if (options.verbose) { + logger.info(message); + } + }; + + const requests: { + identifier: PackageIdentifier; + node: PackageTreeNode; + }[] = []; + + // Validate packages actually are part of the workspace + for (const pkg of packages) { + const node = rootDependencies.get(pkg.name); + if (!node?.package) { + logger.error(`Package '${pkg.name}' is not a dependency.`); + + return 1; + } + + // If a specific version is requested and matches the installed version, skip. + if (pkg.type === 'version' && node.package.version === pkg.fetchSpec) { + logger.info(`Package '${pkg.name}' is already at '${pkg.fetchSpec}'.`); + continue; + } + + requests.push({ identifier: pkg, node }); + } + + if (requests.length === 0) { + return 0; + } + + logger.info('Fetching dependency metadata from registry...'); + + const packagesToUpdate: string[] = []; + for (const { identifier: requestIdentifier, node } of requests) { + const packageName = requestIdentifier.name; + + let metadata; + try { + // Metadata requests are internally cached; multiple requests for same name + // does not result in additional network traffic + metadata = await fetchPackageMetadata(packageName, logger, { + verbose: options.verbose, + }); + } catch (e) { + assertIsError(e); + logger.error(`Error fetching metadata for '${packageName}': ` + e.message); + + return 1; + } + + // Try to find a package version based on the user requested package specifier + // registry specifier types are either version, range, or tag + let manifest: PackageManifest | undefined; + if ( + requestIdentifier.type === 'version' || + requestIdentifier.type === 'range' || + requestIdentifier.type === 'tag' + ) { + try { + manifest = pickManifest(metadata, requestIdentifier.fetchSpec); + } catch (e) { + assertIsError(e); + if (e.code === 'ETARGET') { + // If not found and next was used and user did not provide a specifier, try latest. + // Package may not have a next tag. + if ( + requestIdentifier.type === 'tag' && + requestIdentifier.fetchSpec === 'next' && + !requestIdentifier.rawSpec + ) { + try { + manifest = pickManifest(metadata, 'latest'); + } catch (e) { + assertIsError(e); + if (e.code !== 'ETARGET' && e.code !== 'ENOVERSIONS') { + throw e; + } + } + } + } else if (e.code !== 'ENOVERSIONS') { + throw e; + } + } + } + + if (!manifest) { + logger.error( + `Package specified by '${requestIdentifier.raw}' does not exist within the registry.`, + ); + + return 1; + } + + if (manifest.version === node.package?.version) { + logger.info(`Package '${packageName}' is already up to date.`); + continue; + } + + if (node.package && ANGULAR_PACKAGES_REGEXP.test(node.package.name)) { + const { name, version } = node.package; + const toBeInstalledMajorVersion = +manifest.version.split('.')[0]; + const currentMajorVersion = +version.split('.')[0]; + + if (toBeInstalledMajorVersion - currentMajorVersion > 1) { + // Only allow updating a single version at a time. + if (currentMajorVersion < 6) { + // Before version 6, the major versions were not always sequential. + // Example @angular/core skipped version 3, @angular/cli skipped versions 2-5. + logger.error( + `Updating multiple major versions of '${name}' at once is not supported. Please migrate each major version individually.\n` + + `For more information about the update process, see https://update.angular.dev/.`, + ); + } else { + const nextMajorVersionFromCurrent = currentMajorVersion + 1; + + logger.error( + `Updating multiple major versions of '${name}' at once is not supported. Please migrate each major version individually.\n` + + `Run 'ng update ${name}@${nextMajorVersionFromCurrent}' in your workspace directory ` + + `to update to latest '${nextMajorVersionFromCurrent}.x' version of '${name}'.\n\n` + + `For more information about the update process, see https://update.angular.dev/?v=${currentMajorVersion}.0-${nextMajorVersionFromCurrent}.0`, + ); + } + + return 1; + } + } + + packagesToUpdate.push(requestIdentifier.toString()); + } + + if (packagesToUpdate.length === 0) { + return 0; + } + + const { success } = await this.executeSchematic( + workflow, + UPDATE_SCHEMATIC_COLLECTION, + 'update', + { + verbose: options.verbose, + force: options.force, + next: options.next, + packageManager: this.context.packageManager.name, + packages: packagesToUpdate, + }, + ); + + if (success) { + const { root: commandRoot, packageManager } = this.context; + const installArgs = this.packageManagerForce(options.verbose) ? ['--force'] : []; + const tasks = new Listr([ + { + title: 'Cleaning node modules directory', + async task(_, task) { + try { + await fs.rm(path.join(commandRoot, 'node_modules'), { + force: true, + recursive: true, + maxRetries: 3, + }); + } catch (e) { + assertIsError(e); + if (e.code === 'ENOENT') { + task.skip('Cleaning not required. Node modules directory not found.'); + } + } + }, + }, + { + title: 'Installing packages', + async task() { + const installationSuccess = await packageManager.installAll(installArgs, commandRoot); + + if (!installationSuccess) { + throw new CommandError('Unable to install packages'); + } + }, + }, + ]); + + try { + await tasks.run(); + } catch (e) { + if (e instanceof CommandError) { + return 1; + } + + throw e; + } + } + + if (success && options.createCommits) { + if (!this.commit(`Angular CLI update for packages - ${packagesToUpdate.join(', ')}`)) { + return 1; + } + } + + // This is a temporary workaround to allow data to be passed back from the update schematic + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const migrations = (global as any).externalMigrations as { + package: string; + collection: string; + from: string; + to: string; + }[]; + + if (success && migrations) { + const rootRequire = createRequire(this.context.root + '/'); + for (const migration of migrations) { + // Resolve the package from the workspace root, as otherwise it will be resolved from the temp + // installed CLI version. + let packagePath; + logVerbose( + `Resolving migration package '${migration.package}' from '${this.context.root}'...`, + ); + try { + try { + packagePath = path.dirname( + // This may fail if the `package.json` is not exported as an entry point + rootRequire.resolve(path.join(migration.package, 'package.json')), + ); + } catch (e) { + assertIsError(e); + if (e.code === 'MODULE_NOT_FOUND') { + // Fallback to trying to resolve the package's main entry point + packagePath = rootRequire.resolve(migration.package); + } else { + throw e; + } + } + } catch (e) { + assertIsError(e); + if (e.code === 'MODULE_NOT_FOUND') { + logVerbose(e.toString()); + logger.error( + `Migrations for package (${migration.package}) were not found.` + + ' The package could not be found in the workspace.', + ); + } else { + logger.error( + `Unable to resolve migrations for package (${migration.package}). [${e.message}]`, + ); + } + + return 1; + } + + let migrations; + + // Check if it is a package-local location + const localMigrations = path.join(packagePath, migration.collection); + if (existsSync(localMigrations)) { + migrations = localMigrations; + } else { + // Try to resolve from package location. + // This avoids issues with package hoisting. + try { + const packageRequire = createRequire(packagePath + '/'); + migrations = packageRequire.resolve(migration.collection); + } catch (e) { + assertIsError(e); + if (e.code === 'MODULE_NOT_FOUND') { + logger.error(`Migrations for package (${migration.package}) were not found.`); + } else { + logger.error( + `Unable to resolve migrations for package (${migration.package}). [${e.message}]`, + ); + } + + return 1; + } + } + const result = await this.executeMigrations( + workflow, + migration.package, + migrations, + migration.from, + migration.to, + options.createCommits, + ); + + // A non-zero value is a failure for the package's migrations + if (result !== 0) { + return result; + } + } + } + + return success ? 0 : 1; + } + /** + * @return Whether or not the commit was successful. + */ + private commit(message: string): boolean { + const { logger } = this.context; + + // Check if a commit is needed. + let commitNeeded: boolean; + try { + commitNeeded = hasChangesToCommit(); + } catch (err) { + logger.error(` Failed to read Git tree:\n${(err as SpawnSyncReturns).stderr}`); + + return false; + } + + if (!commitNeeded) { + logger.info(' No changes to commit after migration.'); + + return true; + } + + // Commit changes and abort on error. + try { + createCommit(message); + } catch (err) { + logger.error( + `Failed to commit update (${message}):\n${(err as SpawnSyncReturns).stderr}`, + ); + + return false; + } + + // Notify user of the commit. + const hash = findCurrentGitSha(); + const shortMessage = message.split('\n')[0]; + if (hash) { + logger.info(` Committed migration step (${getShortHash(hash)}): ${shortMessage}.`); + } else { + // Commit was successful, but reading the hash was not. Something weird happened, + // but nothing that would stop the update. Just log the weirdness and continue. + logger.info(` Committed migration step: ${shortMessage}.`); + logger.warn(' Failed to look up hash of most recent commit, continuing anyways.'); + } + + return true; + } + + private checkCleanGit(): boolean { + try { + const topLevel = execSync('git rev-parse --show-toplevel', { + encoding: 'utf8', + stdio: 'pipe', + }); + const result = execSync('git status --porcelain', { encoding: 'utf8', stdio: 'pipe' }); + if (result.trim().length === 0) { + return true; + } + + // Only files inside the workspace root are relevant + for (const entry of result.split('\n')) { + const relativeEntry = path.relative( + path.resolve(this.context.root), + path.resolve(topLevel.trim(), entry.slice(3).trim()), + ); + + if (!relativeEntry.startsWith('..') && !path.isAbsolute(relativeEntry)) { + return false; + } + } + } catch {} + + return true; + } + + /** + * Checks if the current installed CLI version is older or newer than a compatible version. + * @returns the version to install or null when there is no update to install. + */ + private async checkCLIVersion( + packagesToUpdate: string[], + verbose = false, + next = false, + ): Promise { + const { version } = await fetchPackageManifest( + `@angular/cli@${this.getCLIUpdateRunnerVersion(packagesToUpdate, next)}`, + this.context.logger, + { + verbose, + usingYarn: this.context.packageManager.name === PackageManager.Yarn, + }, + ); + + return VERSION.full === version ? null : version; + } + + private getCLIUpdateRunnerVersion( + packagesToUpdate: string[] | undefined, + next: boolean, + ): string | number { + if (next) { + return 'next'; + } + + const updatingAngularPackage = packagesToUpdate?.find((r) => ANGULAR_PACKAGES_REGEXP.test(r)); + if (updatingAngularPackage) { + // If we are updating any Angular package we can update the CLI to the target version because + // migrations for @angular/core@13 can be executed using Angular/cli@13. + // This is same behaviour as `npx @angular/cli@13 update @angular/core@13`. + + // `@angular/cli@13` -> ['', 'angular/cli', '13'] + // `@angular/cli` -> ['', 'angular/cli'] + const tempVersion = coerceVersionNumber(updatingAngularPackage.split('@')[2]); + + return semver.parse(tempVersion)?.major ?? 'latest'; + } + + // When not updating an Angular package we cannot determine which schematic runtime the migration should to be executed in. + // Typically, we can assume that the `@angular/cli` was updated previously. + // Example: Angular official packages are typically updated prior to NGRX etc... + // Therefore, we only update to the latest patch version of the installed major version of the Angular CLI. + + // This is important because we might end up in a scenario where locally Angular v12 is installed, updating NGRX from 11 to 12. + // We end up using Angular ClI v13 to run the migrations if we run the migrations using the CLI installed major version + 1 logic. + return VERSION.major; + } + + private async runTempBinary(packageName: string, args: string[] = []): Promise { + const { success, tempNodeModules } = await this.context.packageManager.installTemp(packageName); + if (!success) { + return 1; + } + + // Remove version/tag etc... from package name + // Ex: @angular/cli@latest -> @angular/cli + const packageNameNoVersion = packageName.substring(0, packageName.lastIndexOf('@')); + const pkgLocation = join(tempNodeModules, packageNameNoVersion); + const packageJsonPath = join(pkgLocation, 'package.json'); + + // Get a binary location for this package + let binPath: string | undefined; + if (existsSync(packageJsonPath)) { + const content = await fs.readFile(packageJsonPath, 'utf-8'); + if (content) { + const { bin = {} } = JSON.parse(content) as { bin: Record }; + const binKeys = Object.keys(bin); + + if (binKeys.length) { + binPath = resolve(pkgLocation, bin[binKeys[0]]); + } + } + } + + if (!binPath) { + throw new Error(`Cannot locate bin for temporary package: ${packageNameNoVersion}.`); + } + + const { status, error } = spawnSync(process.execPath, [binPath, ...args], { + stdio: 'inherit', + env: { + ...process.env, + NG_DISABLE_VERSION_CHECK: 'true', + NG_CLI_ANALYTICS: 'false', + }, + }); + + if (status === null && error) { + throw error; + } + + return status ?? 0; + } + + private packageManagerForce(verbose: boolean): boolean { + // npm 7+ can fail due to it incorrectly resolving peer dependencies that have valid SemVer + // ranges during an update. Update will set correct versions of dependencies within the + // package.json file. The force option is set to workaround these errors. + // Example error: + // npm ERR! Conflicting peer dependency: @angular/compiler-cli@14.0.0-rc.0 + // npm ERR! node_modules/@angular/compiler-cli + // npm ERR! peer @angular/compiler-cli@"^14.0.0 || ^14.0.0-rc" from @angular-devkit/build-angular@14.0.0-rc.0 + // npm ERR! node_modules/@angular-devkit/build-angular + // npm ERR! dev @angular-devkit/build-angular@"~14.0.0-rc.0" from the root project + if ( + this.context.packageManager.name === PackageManager.Npm && + this.context.packageManager.version && + semver.gte(this.context.packageManager.version, '7.0.0') + ) { + if (verbose) { + this.context.logger.info( + 'NPM 7+ detected -- enabling force option for package installation', + ); + } + + return true; + } + + return false; + } + + private async getOptionalMigrationsToRun( + optionalMigrations: MigrationSchematicDescription[], + packageName: string, + ): Promise { + const { logger } = this.context; + const numberOfMigrations = optionalMigrations.length; + logger.info( + `This package has ${numberOfMigrations} optional migration${ + numberOfMigrations > 1 ? 's' : '' + } that can be executed.`, + ); + + if (!isTTY()) { + for (const migration of optionalMigrations) { + const { title } = getMigrationTitleAndDescription(migration); + logger.info(colors.cyan(figures.pointer) + ' ' + colors.bold(title)); + logger.info(colors.gray(` ng update ${packageName} --name ${migration.name}`)); + logger.info(''); // Extra trailing newline. + } + + return undefined; + } + + logger.info( + 'Optional migrations may be skipped and executed after the update process, if preferred.', + ); + logger.info(''); // Extra trailing newline. + + const answer = await askChoices( + `Select the migrations that you'd like to run`, + optionalMigrations.map((migration) => { + const { title, documentation } = getMigrationTitleAndDescription(migration); + + return { + name: `[${colors.white(migration.name)}] ${title}${documentation ? ` (${documentation})` : ''}`, + value: migration.name, + }; + }), + null, + ); + + logger.info(''); // Extra trailing newline. + + return optionalMigrations.filter(({ name }) => answer?.includes(name)); + } +} + +/** + * @return Whether or not the working directory has Git changes to commit. + */ +function hasChangesToCommit(): boolean { + // List all modified files not covered by .gitignore. + // If any files are returned, then there must be something to commit. + + return execSync('git ls-files -m -d -o --exclude-standard').toString() !== ''; +} + +/** + * Precondition: Must have pending changes to commit, they do not need to be staged. + * Postcondition: The Git working tree is committed and the repo is clean. + * @param message The commit message to use. + */ +function createCommit(message: string) { + // Stage entire working tree for commit. + execSync('git add -A', { encoding: 'utf8', stdio: 'pipe' }); + + // Commit with the message passed via stdin to avoid bash escaping issues. + execSync('git commit --no-verify -F -', { encoding: 'utf8', stdio: 'pipe', input: message }); +} + +/** + * @return The Git SHA hash of the HEAD commit. Returns null if unable to retrieve the hash. + */ +function findCurrentGitSha(): string | null { + try { + return execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: 'pipe' }).trim(); + } catch { + return null; + } +} + +function getShortHash(commitHash: string): string { + return commitHash.slice(0, 9); +} + +function coerceVersionNumber(version: string | undefined): string | undefined { + if (!version) { + return undefined; + } + + if (!/^\d{1,30}\.\d{1,30}\.\d{1,30}/.test(version)) { + const match = version.match(/^\d{1,30}(\.\d{1,30})*/); + + if (!match) { + return undefined; + } + + if (!match[1]) { + version = version.substring(0, match[0].length) + '.0.0' + version.substring(match[0].length); + } else if (!match[2]) { + version = version.substring(0, match[0].length) + '.0' + version.substring(match[0].length); + } else { + return undefined; + } + } + + return semver.valid(version) ?? undefined; +} + +function getMigrationTitleAndDescription(migration: MigrationSchematicDescription): { + title: string; + description: string; + documentation?: string; +} { + const [title, ...description] = migration.description.split('. '); + + return { + title: title.endsWith('.') ? title : title + '.', + description: description.join('.\n '), + documentation: migration.documentation + ? new URL(migration.documentation, 'https://angular.dev').href + : undefined, + }; +} diff --git a/packages/angular/cli/src/commands/update/long-description.md b/packages/angular/cli/src/commands/update/long-description.md new file mode 100644 index 000000000000..612971de0c4d --- /dev/null +++ b/packages/angular/cli/src/commands/update/long-description.md @@ -0,0 +1,22 @@ +Perform a basic update to the current stable release of the core framework and CLI by running the following command. + +``` +ng update @angular/cli @angular/core +``` + +To update to the next beta or pre-release version, use the `--next` option. + +To update from one major version to another, use the format + +``` +ng update @angular/cli@^ @angular/core@^ +``` + +We recommend that you always update to the latest patch version, as it contains fixes we released since the initial major release. +For example, use the following command to take the latest 10.x.x version and use that to update. + +``` +ng update @angular/cli@^10 @angular/core@^10 +``` + +For detailed information and guidance on updating your application, see the interactive [Angular Update Guide](https://update.angular.dev/). diff --git a/packages/angular/cli/src/commands/update/schematic/index.ts b/packages/angular/cli/src/commands/update/schematic/index.ts index a0b92febe48b..9b56ec01d363 100644 --- a/packages/angular/cli/src/commands/update/schematic/index.ts +++ b/packages/angular/cli/src/commands/update/schematic/index.ts @@ -3,20 +3,25 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ -import { logging, tags } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; import { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; import * as npa from 'npm-package-arg'; +import type { Manifest } from 'pacote'; import * as semver from 'semver'; -import { Dependency, JsonSchemaForNpmPackageJsonFiles } from '../../../../utilities/package-json'; import { + NgPackageManifestProperties, NpmRepositoryPackageJson, getNpmPackageJson, -} from '../../../../utilities/package-metadata'; +} from '../../../utilities/package-metadata'; import { Schema as UpdateSchema } from './schema'; +interface JsonSchemaForNpmPackageJsonFiles extends Manifest, NgPackageManifestProperties { + peerDependenciesMeta?: Record; +} + type VersionRange = string & { __VERSION_RANGE: void }; type PeerVersionTransform = string | ((range: string) => string); @@ -177,6 +182,7 @@ function _validateReversePeerDependencies( '@schematics/update', '@angular-devkit/build-ng-packagr', 'tsickle', + '@nguniversal/builders', ]; if (ignoredPackages.includes(installed)) { continue; @@ -242,9 +248,11 @@ function _validateUpdatePackages( }); if (!force && peerErrors) { - throw new SchematicsException(tags.stripIndents`Incompatible peer dependencies found. - Peer dependency warnings when installing dependencies means that those dependencies might not work correctly together. - You can use the '--force' option to ignore incompatible peer dependencies and instead address these warnings later.`); + throw new SchematicsException( + 'Incompatible peer dependencies found.\n' + + 'Peer dependency warnings when installing dependencies means that those dependencies might not work correctly together.\n' + + `You can use the '--force' option to ignore incompatible peer dependencies and instead address these warnings later.`, + ); } } @@ -255,19 +263,14 @@ function _performUpdate( logger: logging.LoggerApi, migrateOnly: boolean, ): void { - const packageJsonContent = tree.read('/package.json'); + const packageJsonContent = tree.read('/package.json')?.toString(); if (!packageJsonContent) { throw new SchematicsException('Could not find a package.json. Are you in a Node project?'); } - let packageJson: JsonSchemaForNpmPackageJsonFiles; - try { - packageJson = JSON.parse(packageJsonContent.toString()) as JsonSchemaForNpmPackageJsonFiles; - } catch (e) { - throw new SchematicsException('package.json could not be parsed: ' + e.message); - } + const packageJson = tree.readJson('/package.json') as JsonSchemaForNpmPackageJsonFiles; - const updateDependency = (deps: Dependency, name: string, newVersion: string) => { + const updateDependency = (deps: Record, name: string, newVersion: string) => { const oldVersion = deps[name]; // We only respect caret and tilde ranges on update. const execResult = /^[\^~]/.exec(oldVersion); @@ -307,11 +310,12 @@ function _performUpdate( logger.warn(`Package ${name} was not found in dependencies.`); } }); - - const newContent = JSON.stringify(packageJson, null, 2); - if (packageJsonContent.toString() != newContent || migrateOnly) { + const eofMatches = packageJsonContent.match(/\r?\n$/); + const eof = eofMatches?.[0] ?? ''; + const newContent = JSON.stringify(packageJson, null, 2) + eof; + if (packageJsonContent != newContent || migrateOnly) { if (!migrateOnly) { - tree.overwrite('/package.json', JSON.stringify(packageJson, null, 2)); + tree.overwrite('/package.json', newContent); } const externalMigrations: {}[] = []; @@ -370,6 +374,7 @@ function _getUpdateMetadata( } else if ( typeof packageGroup == 'object' && packageGroup && + !Array.isArray(packageGroup) && Object.values(packageGroup).every((x) => typeof x == 'string') ) { result.packageGroup = packageGroup; @@ -466,15 +471,19 @@ function _usageMessage( ) .map(({ name, info, version, tag, target }) => { // Look for packageGroup. - const packageGroup = target['ng-update']['packageGroup']; + const packageGroup = target['ng-update']?.['packageGroup']; if (packageGroup) { - const packageGroupName = target['ng-update']['packageGroupName'] || packageGroup[0]; + const packageGroupNames = Array.isArray(packageGroup) + ? packageGroup + : Object.keys(packageGroup); + + const packageGroupName = target['ng-update']?.['packageGroupName'] || packageGroupNames[0]; if (packageGroupName) { if (packageGroups.has(name)) { return null; } - packageGroup.forEach((x: string) => packageGroups.set(x, packageGroupName)); + packageGroupNames.forEach((x: string) => packageGroups.set(x, packageGroupName)); packageGroups.set(packageGroupName, packageGroupName); name = packageGroupName; } @@ -543,15 +552,34 @@ function _buildPackageInfo( // Find out the currently installed version. Either from the package.json or the node_modules/ // TODO: figure out a way to read package-lock.json and/or yarn.lock. + const pkgJsonPath = `/node_modules/${name}/package.json`; + const pkgJsonExists = tree.exists(pkgJsonPath); + let installedVersion: string | undefined | null; - const packageContent = tree.read(`/node_modules/${name}/package.json`); - if (packageContent) { - const content = JSON.parse(packageContent.toString()) as JsonSchemaForNpmPackageJsonFiles; - installedVersion = content.version; + if (pkgJsonExists) { + const { version } = tree.readJson(pkgJsonPath) as JsonSchemaForNpmPackageJsonFiles; + installedVersion = version; } + + const packageVersionsNonDeprecated: string[] = []; + const packageVersionsDeprecated: string[] = []; + + for (const [version, { deprecated }] of Object.entries(npmPackageJson.versions)) { + if (deprecated) { + packageVersionsDeprecated.push(version); + } else { + packageVersionsNonDeprecated.push(version); + } + } + + const findSatisfyingVersion = (targetVersion: VersionRange): VersionRange | undefined => + ((semver.maxSatisfying(packageVersionsNonDeprecated, targetVersion) ?? + semver.maxSatisfying(packageVersionsDeprecated, targetVersion)) as VersionRange | null) ?? + undefined; + if (!installedVersion) { // Find the version from NPM that fits the range to max. - installedVersion = semver.maxSatisfying(Object.keys(npmPackageJson.versions), packageJsonRange); + installedVersion = findSatisfyingVersion(packageJsonRange); } if (!installedVersion) { @@ -560,7 +588,7 @@ function _buildPackageInfo( ); } - const installedPackageJson = npmPackageJson.versions[installedVersion] || packageContent; + const installedPackageJson = npmPackageJson.versions[installedVersion] || pkgJsonExists; if (!installedPackageJson) { throw new SchematicsException( `An unexpected error happened; package ${name} has no version ${installedVersion}.`, @@ -574,10 +602,7 @@ function _buildPackageInfo( } else if (targetVersion == 'next') { targetVersion = npmPackageJson['dist-tags']['latest'] as VersionRange; } else { - targetVersion = semver.maxSatisfying( - Object.keys(npmPackageJson.versions), - targetVersion, - ) as VersionRange; + targetVersion = findSatisfyingVersion(targetVersion); } } @@ -666,35 +691,40 @@ function _addPackageGroup( return; } - let packageGroup = ngUpdateMetadata['packageGroup']; + const packageGroup = ngUpdateMetadata['packageGroup']; if (!packageGroup) { return; } + let packageGroupNormalized: Record = {}; if (Array.isArray(packageGroup) && !packageGroup.some((x) => typeof x != 'string')) { - packageGroup = packageGroup.reduce((acc, curr) => { - acc[curr] = maybePackage; - - return acc; - }, {} as { [name: string]: string }); - } + packageGroupNormalized = packageGroup.reduce( + (acc, curr) => { + acc[curr] = maybePackage; - // Only need to check if it's an object because we set it right the time before. - if ( - typeof packageGroup != 'object' || - packageGroup === null || - Object.values(packageGroup).some((v) => typeof v != 'string') + return acc; + }, + {} as { [name: string]: string }, + ); + } else if ( + typeof packageGroup == 'object' && + packageGroup && + !Array.isArray(packageGroup) && + Object.values(packageGroup).every((x) => typeof x == 'string') ) { - logger.warn(`packageGroup metadata of package ${npmPackageJson.name} is malformed.`); + packageGroupNormalized = packageGroup; + } else { + logger.warn(`packageGroup metadata of package ${npmPackageJson.name} is malformed. Ignoring.`); return; } - Object.keys(packageGroup) - .filter((name) => !packages.has(name)) // Don't override names from the command line. - .filter((name) => allDependencies.has(name)) // Remove packages that aren't installed. - .forEach((name) => { - packages.set(name, packageGroup[name]); - }); + for (const [name, value] of Object.entries(packageGroupNormalized)) { + // Don't override names from the command line. + // Remove packages that aren't installed. + if (!packages.has(name) && allDependencies.has(name)) { + packages.set(name, value as VersionRange); + } + } } /** @@ -751,22 +781,14 @@ function _addPeerDependencies( } function _getAllDependencies(tree: Tree): Array { - const packageJsonContent = tree.read('/package.json'); - if (!packageJsonContent) { - throw new SchematicsException('Could not find a package.json. Are you in a Node project?'); - } - - let packageJson: JsonSchemaForNpmPackageJsonFiles; - try { - packageJson = JSON.parse(packageJsonContent.toString()) as JsonSchemaForNpmPackageJsonFiles; - } catch (e) { - throw new SchematicsException('package.json could not be parsed: ' + e.message); - } + const { dependencies, devDependencies, peerDependencies } = tree.readJson( + '/package.json', + ) as JsonSchemaForNpmPackageJsonFiles; return [ - ...(Object.entries(packageJson.peerDependencies || {}) as Array<[string, VersionRange]>), - ...(Object.entries(packageJson.devDependencies || {}) as Array<[string, VersionRange]>), - ...(Object.entries(packageJson.dependencies || {}) as Array<[string, VersionRange]>), + ...(Object.entries(peerDependencies || {}) as Array<[string, VersionRange]>), + ...(Object.entries(devDependencies || {}) as Array<[string, VersionRange]>), + ...(Object.entries(dependencies || {}) as Array<[string, VersionRange]>), ]; } @@ -852,9 +874,7 @@ export default function (options: UpdateSchema): Rule { const npmPackageJsonMap = allPackageMetadata.reduce((acc, npmPackageJson) => { // If the package was not found on the registry. It could be private, so we will just // ignore. If the package was part of the list, we will error out, but will simply ignore - // if it's either not requested (so just part of package.json. silently) or if it's a - // `--all` situation. There is an edge case here where a public package peer depends on a - // private one, but it's rare enough. + // if it's either not requested (so just part of package.json. silently). if (!npmPackageJson.name) { if (npmPackageJson.requestedName && packages.has(npmPackageJson.requestedName)) { throw new SchematicsException( diff --git a/packages/angular/cli/src/commands/update/schematic/index_spec.ts b/packages/angular/cli/src/commands/update/schematic/index_spec.ts index c133647bb2a3..3954e3c78254 100644 --- a/packages/angular/cli/src/commands/update/schematic/index_spec.ts +++ b/packages/angular/cli/src/commands/update/schematic/index_spec.ts @@ -3,13 +3,12 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { normalize, virtualFs } from '@angular-devkit/core'; import { HostTree } from '@angular-devkit/schematics'; import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; -import { map } from 'rxjs/operators'; import * as semver from 'semver'; import { angularMajorCompatGuarantee } from './index'; @@ -51,8 +50,8 @@ describe('@schematics/update', () => { appTree = new UnitTestTree(new HostTree(host)); }); - it('ignores dependencies not hosted on the NPM registry', (done) => { - const tree = new UnitTestTree( + it('ignores dependencies not hosted on the NPM registry', async () => { + let newTree = new UnitTestTree( new HostTree( new virtualFs.test.TestHost({ '/package.json': `{ @@ -65,22 +64,15 @@ describe('@schematics/update', () => { ), ); - schematicRunner - .runSchematicAsync('update', undefined, tree) - .pipe( - map((t) => { - const packageJson = JSON.parse(t.readContent('/package.json')); - expect(packageJson['dependencies']['@angular-devkit-tests/update-base']).toBe( - 'file:update-base-1.0.0.tgz', - ); - }), - ) - .toPromise() - .then(done, done.fail); + newTree = await schematicRunner.runSchematic('update', undefined, newTree); + const packageJson = JSON.parse(newTree.readContent('/package.json')); + expect(packageJson['dependencies']['@angular-devkit-tests/update-base']).toBe( + 'file:update-base-1.0.0.tgz', + ); }, 45000); it('should not error with yarn 2.0 protocols', async () => { - const tree = new UnitTestTree( + let newTree = new UnitTestTree( new HostTree( new virtualFs.test.TestHost({ '/package.json': `{ @@ -94,20 +86,18 @@ describe('@schematics/update', () => { ), ); - const newTree = await schematicRunner - .runSchematicAsync( - 'update', - { - packages: ['@angular-devkit-tests/update-base'], - }, - tree, - ) - .toPromise(); + newTree = await schematicRunner.runSchematic( + 'update', + { + packages: ['@angular-devkit-tests/update-base'], + }, + newTree, + ); const { dependencies } = JSON.parse(newTree.readContent('/package.json')); expect(dependencies['@angular-devkit-tests/update-base']).toBe('1.1.0'); }); - it('updates Angular as compatible with Angular N-1', (done) => { + it('updates Angular as compatible with Angular N-1', async () => { // Add the basic migration package. const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); const packageJson = JSON.parse(content); @@ -121,25 +111,18 @@ describe('@schematics/update', () => { virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), ); - schematicRunner - .runSchematicAsync( - 'update', - { - packages: ['@angular/core@^6.0.0'], - }, - appTree, - ) - .pipe( - map((tree) => { - const packageJson = JSON.parse(tree.readContent('/package.json')); - expect(packageJson['dependencies']['@angular/core'][0]).toBe('6'); - }), - ) - .toPromise() - .then(done, done.fail); + const newTree = await schematicRunner.runSchematic( + 'update', + { + packages: ['@angular/core@^6.0.0'], + }, + appTree, + ); + const newPpackageJson = JSON.parse(newTree.readContent('/package.json')); + expect(newPpackageJson['dependencies']['@angular/core'][0]).toBe('6'); }, 45000); - it('updates Angular as compatible with Angular N-1 (2)', (done) => { + it('updates Angular as compatible with Angular N-1 (2)', async () => { // Add the basic migration package. const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); const packageJson = JSON.parse(content); @@ -159,25 +142,19 @@ describe('@schematics/update', () => { virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), ); - schematicRunner - .runSchematicAsync( - 'update', - { - packages: ['@angular/core@^6.0.0'], - }, - appTree, - ) - .pipe( - map((tree) => { - const packageJson = JSON.parse(tree.readContent('/package.json')); - expect(packageJson['dependencies']['@angular/core'][0]).toBe('6'); - expect(packageJson['dependencies']['rxjs'][0]).toBe('6'); - expect(packageJson['dependencies']['typescript'][0]).toBe('2'); - expect(packageJson['dependencies']['typescript'][2]).not.toBe('4'); - }), - ) - .toPromise() - .then(done, done.fail); + const newTree = await schematicRunner.runSchematic( + 'update', + { + packages: ['@angular/core@^6.0.0'], + }, + appTree, + ); + + const newPackageJson = JSON.parse(newTree.readContent('/package.json')); + expect(newPackageJson['dependencies']['@angular/core'][0]).toBe('6'); + expect(newPackageJson['dependencies']['rxjs'][0]).toBe('6'); + expect(newPackageJson['dependencies']['typescript'][0]).toBe('2'); + expect(newPackageJson['dependencies']['typescript'][2]).not.toBe('4'); }, 45000); it('uses packageGroup for versioning', async () => { @@ -192,26 +169,19 @@ describe('@schematics/update', () => { virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), ); - await schematicRunner - .runSchematicAsync( - 'update', - { - packages: ['@angular-devkit-tests/update-package-group-1'], - }, - appTree, - ) - .pipe( - map((tree) => { - const packageJson = JSON.parse(tree.readContent('/package.json')); - const deps = packageJson['dependencies']; - expect(deps['@angular-devkit-tests/update-package-group-1']).toBe('1.2.0'); - expect(deps['@angular-devkit-tests/update-package-group-2']).toBe('2.0.0'); - }), - ) - .toPromise(); + const newTree = await schematicRunner.runSchematic( + 'update', + { + packages: ['@angular-devkit-tests/update-package-group-1'], + }, + appTree, + ); + const { dependencies: deps } = JSON.parse(newTree.readContent('/package.json')); + expect(deps['@angular-devkit-tests/update-package-group-1']).toBe('1.2.0'); + expect(deps['@angular-devkit-tests/update-package-group-2']).toBe('2.0.0'); }, 45000); - it('can migrate only', (done) => { + it('can migrate only', async () => { // Add the basic migration package. const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); const packageJson = JSON.parse(content); @@ -221,29 +191,21 @@ describe('@schematics/update', () => { virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), ); - schematicRunner - .runSchematicAsync( - 'update', - { - packages: ['@angular-devkit-tests/update-migrations'], - migrateOnly: true, - }, - appTree, - ) - .pipe( - map((tree) => { - const packageJson = JSON.parse(tree.readContent('/package.json')); - expect(packageJson['dependencies']['@angular-devkit-tests/update-base']).toBe('1.0.0'); - expect(packageJson['dependencies']['@angular-devkit-tests/update-migrations']).toBe( - '1.0.0', - ); - }), - ) - .toPromise() - .then(done, done.fail); + const newTree = await schematicRunner.runSchematic( + 'update', + { + packages: ['@angular-devkit-tests/update-migrations'], + migrateOnly: true, + }, + appTree, + ); + + const newPackageJson = JSON.parse(newTree.readContent('/package.json')); + expect(newPackageJson['dependencies']['@angular-devkit-tests/update-base']).toBe('1.0.0'); + expect(newPackageJson['dependencies']['@angular-devkit-tests/update-migrations']).toBe('1.0.0'); }, 45000); - it('can migrate from only', (done) => { + it('can migrate from only', async () => { // Add the basic migration package. const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); const packageJson = JSON.parse(content); @@ -253,29 +215,20 @@ describe('@schematics/update', () => { virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), ); - schematicRunner - .runSchematicAsync( - 'update', - { - packages: ['@angular-devkit-tests/update-migrations'], - migrateOnly: true, - from: '0.1.2', - }, - appTree, - ) - .pipe( - map((tree) => { - const packageJson = JSON.parse(tree.readContent('/package.json')); - expect(packageJson['dependencies']['@angular-devkit-tests/update-migrations']).toBe( - '1.6.0', - ); - }), - ) - .toPromise() - .then(done, done.fail); + const newTree = await schematicRunner.runSchematic( + 'update', + { + packages: ['@angular-devkit-tests/update-migrations'], + migrateOnly: true, + from: '0.1.2', + }, + appTree, + ); + const { dependencies } = JSON.parse(newTree.readContent('/package.json')); + expect(dependencies['@angular-devkit-tests/update-migrations']).toBe('1.6.0'); }, 45000); - it('can install and migrate with --from (short version number)', (done) => { + it('can install and migrate with --from (short version number)', async () => { // Add the basic migration package. const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); const packageJson = JSON.parse(content); @@ -285,29 +238,20 @@ describe('@schematics/update', () => { virtualFs.stringToFileBuffer(JSON.stringify(packageJson)), ); - schematicRunner - .runSchematicAsync( - 'update', - { - packages: ['@angular-devkit-tests/update-migrations'], - migrateOnly: true, - from: '0', - }, - appTree, - ) - .pipe( - map((tree) => { - const packageJson = JSON.parse(tree.readContent('/package.json')); - expect(packageJson['dependencies']['@angular-devkit-tests/update-migrations']).toBe( - '1.6.0', - ); - }), - ) - .toPromise() - .then(done, done.fail); + const newTree = await schematicRunner.runSchematic( + 'update', + { + packages: ['@angular-devkit-tests/update-migrations'], + migrateOnly: true, + from: '0', + }, + appTree, + ); + const { dependencies } = JSON.parse(newTree.readContent('/package.json')); + expect(dependencies['@angular-devkit-tests/update-migrations']).toBe('1.6.0'); }, 45000); - it('validates peer dependencies', (done) => { + it('validates peer dependencies', async () => { const content = virtualFs.fileBufferToString(host.sync.read(normalize('/package.json'))); const packageJson = JSON.parse(content); const dependencies = packageJson['dependencies']; @@ -326,27 +270,69 @@ describe('@schematics/update', () => { const hasPeerdepMsg = (dep: string) => messages.some((str) => str.includes(`missing peer dependency of "${dep}"`)); - schematicRunner - .runSchematicAsync( + await schematicRunner.runSchematic( + 'update', + { + packages: ['@angular-devkit/build-angular'], + next: true, + }, + appTree, + ); + expect(hasPeerdepMsg('@angular/compiler-cli')).toBeTruthy(); + expect(hasPeerdepMsg('typescript')).toBeTruthy(); + expect(hasPeerdepMsg('@angular/localize')).toBeFalsy(); + }, 45000); + + it('does not remove newline at the end of package.json', async () => { + const newlineStyles = ['\n', '\r\n']; + for (const newline of newlineStyles) { + const packageJsonContent = `{ + "name": "blah", + "dependencies": { + "@angular-devkit-tests/update-base": "1.0.0" + } + }${newline}`; + const inputTree = new UnitTestTree( + new HostTree( + new virtualFs.test.TestHost({ + '/package.json': packageJsonContent, + }), + ), + ); + + const resultTree = await schematicRunner.runSchematic( 'update', - { - packages: ['@angular-devkit/build-angular'], - next: true, - }, - appTree, - ) - .pipe( - map(() => { - expect(hasPeerdepMsg('@angular/compiler-cli')).toBeTruthy( - `Should show @angular/compiler-cli message.`, - ); - expect(hasPeerdepMsg('typescript')).toBeTruthy(`Should show typescript message.`); - expect(hasPeerdepMsg('@angular/localize')).toBeFalsy( - `Should not show @angular/localize message.`, - ); + { packages: ['@angular-devkit-tests/update-base'] }, + inputTree, + ); + + const resultTreeContent = resultTree.readContent('/package.json'); + expect(resultTreeContent.endsWith(newline)).toBeTrue(); + } + }); + + it('does not add a newline at the end of package.json', async () => { + const packageJsonContent = `{ + "name": "blah", + "dependencies": { + "@angular-devkit-tests/update-base": "1.0.0" + } + }`; + const inputTree = new UnitTestTree( + new HostTree( + new virtualFs.test.TestHost({ + '/package.json': packageJsonContent, }), - ) - .toPromise() - .then(done, done.fail); - }, 45000); + ), + ); + + const resultTree = await schematicRunner.runSchematic( + 'update', + { packages: ['@angular-devkit-tests/update-base'] }, + inputTree, + ); + + const resultTreeContent = resultTree.readContent('/package.json'); + expect(resultTreeContent.endsWith('}')).toBeTrue(); + }); }); diff --git a/packages/angular/cli/src/commands/update/schematic/schema.json b/packages/angular/cli/src/commands/update/schematic/schema.json index 9811d1a3fe9a..649d2f5db01f 100644 --- a/packages/angular/cli/src/commands/update/schematic/schema.json +++ b/packages/angular/cli/src/commands/update/schematic/schema.json @@ -57,7 +57,7 @@ "description": "The preferred package manager configuration files to use for registry settings.", "type": "string", "default": "npm", - "enum": ["npm", "yarn", "cnpm", "pnpm"] + "enum": ["npm", "yarn", "cnpm", "pnpm", "bun"] } }, "required": [] diff --git a/packages/angular/cli/src/commands/version/cli.ts b/packages/angular/cli/src/commands/version/cli.ts new file mode 100644 index 000000000000..3e2c27d31a1c --- /dev/null +++ b/packages/angular/cli/src/commands/version/cli.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import nodeModule from 'node:module'; +import { resolve } from 'node:path'; +import { Argv } from 'yargs'; +import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; +import { colors } from '../../utilities/color'; +import { RootCommands } from '../command-config'; + +interface PartialPackageInfo { + name: string; + version: string; + dependencies?: Record; + devDependencies?: Record; +} + +/** + * Major versions of Node.js that are officially supported by Angular. + */ +const SUPPORTED_NODE_MAJORS = [18, 20, 22]; + +const PACKAGE_PATTERNS = [ + /^@angular\/.*/, + /^@angular-devkit\/.*/, + /^@ngtools\/.*/, + /^@schematics\/.*/, + /^rxjs$/, + /^typescript$/, + /^ng-packagr$/, + /^webpack$/, + /^zone\.js$/, +]; + +export default class VersionCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'version'; + aliases = RootCommands['version'].aliases; + describe = 'Outputs Angular CLI version.'; + longDescriptionPath?: string | undefined; + + builder(localYargs: Argv): Argv { + return localYargs; + } + + async run(): Promise { + const { packageManager, logger, root } = this.context; + const localRequire = nodeModule.createRequire(resolve(__filename, '../../../')); + // Trailing slash is used to allow the path to be treated as a directory + const workspaceRequire = nodeModule.createRequire(root + '/'); + + const cliPackage: PartialPackageInfo = localRequire('./package.json'); + let workspacePackage: PartialPackageInfo | undefined; + try { + workspacePackage = workspaceRequire('./package.json'); + } catch {} + + const [nodeMajor] = process.versions.node.split('.').map((part) => Number(part)); + const unsupportedNodeVersion = !SUPPORTED_NODE_MAJORS.includes(nodeMajor); + + const packageNames = new Set( + Object.keys({ + ...cliPackage.dependencies, + ...cliPackage.devDependencies, + ...workspacePackage?.dependencies, + ...workspacePackage?.devDependencies, + }), + ); + + const versions: Record = {}; + for (const name of packageNames) { + if (PACKAGE_PATTERNS.some((p) => p.test(name))) { + versions[name] = this.getVersion(name, workspaceRequire, localRequire); + } + } + + const ngCliVersion = cliPackage.version; + let angularCoreVersion = ''; + const angularSameAsCore: string[] = []; + + if (workspacePackage) { + // Filter all angular versions that are the same as core. + angularCoreVersion = versions['@angular/core']; + if (angularCoreVersion) { + for (const [name, version] of Object.entries(versions)) { + if (version === angularCoreVersion && name.startsWith('@angular/')) { + angularSameAsCore.push(name.replace(/^@angular\//, '')); + delete versions[name]; + } + } + + // Make sure we list them in alphabetical order. + angularSameAsCore.sort(); + } + } + + const namePad = ' '.repeat( + Object.keys(versions).sort((a, b) => b.length - a.length)[0].length + 3, + ); + const asciiArt = ` + _ _ ____ _ ___ + / \\ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _| + / â–³ \\ | '_ \\ / _\` | | | | |/ _\` | '__| | | | | | | + / ___ \\| | | | (_| | |_| | | (_| | | | |___| |___ | | + /_/ \\_\\_| |_|\\__, |\\__,_|_|\\__,_|_| \\____|_____|___| + |___/ + ` + .split('\n') + .map((x) => colors.red(x)) + .join('\n'); + + logger.info(asciiArt); + logger.info( + ` + Angular CLI: ${ngCliVersion} + Node: ${process.versions.node}${unsupportedNodeVersion ? ' (Unsupported)' : ''} + Package Manager: ${packageManager.name} ${packageManager.version ?? ''} + OS: ${process.platform} ${process.arch} + + Angular: ${angularCoreVersion} + ... ${angularSameAsCore + .reduce((acc, name) => { + // Perform a simple word wrap around 60. + if (acc.length == 0) { + return [name]; + } + const line = acc[acc.length - 1] + ', ' + name; + if (line.length > 60) { + acc.push(name); + } else { + acc[acc.length - 1] = line; + } + + return acc; + }, []) + .join('\n... ')} + + Package${namePad.slice(7)}Version + -------${namePad.replace(/ /g, '-')}------------------ + ${Object.keys(versions) + .map((module) => `${module}${namePad.slice(module.length)}${versions[module]}`) + .sort() + .join('\n')} + `.replace(/^ {6}/gm, ''), + ); + + if (unsupportedNodeVersion) { + logger.warn( + `Warning: The current version of Node (${process.versions.node}) is not supported by Angular.`, + ); + } + } + + private getVersion( + moduleName: string, + workspaceRequire: NodeRequire, + localRequire: NodeRequire, + ): string { + let packageInfo: PartialPackageInfo | undefined; + let cliOnly = false; + + // Try to find the package in the workspace + try { + packageInfo = workspaceRequire(`${moduleName}/package.json`); + } catch {} + + // If not found, try to find within the CLI + if (!packageInfo) { + try { + packageInfo = localRequire(`${moduleName}/package.json`); + cliOnly = true; + } catch {} + } + + // If found, attempt to get the version + if (packageInfo) { + try { + return packageInfo.version + (cliOnly ? ' (cli-only)' : ''); + } catch {} + } + + return ''; + } +} diff --git a/packages/angular/cli/src/typings-bazel.d.ts b/packages/angular/cli/src/typings-bazel.d.ts new file mode 100644 index 000000000000..80cc2d571570 --- /dev/null +++ b/packages/angular/cli/src/typings-bazel.d.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* eslint-disable import/no-extraneous-dependencies */ +// Workaround for https://github.com/bazelbuild/rules_nodejs/issues/1033 +// Alternative approach instead of https://github.com/angular/angular/pull/33226 +declare module '@yarnpkg/lockfile' { + export * from '@types/yarnpkg__lockfile'; +} diff --git a/packages/angular/cli/src/typings.ts b/packages/angular/cli/src/typings.ts index 169bbb457e68..0ccb3728b882 100644 --- a/packages/angular/cli/src/typings.ts +++ b/packages/angular/cli/src/typings.ts @@ -3,35 +3,13 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ -declare module '@yarnpkg/lockfile' { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - export function parse(data: string): Record; -} - -declare module 'ini' { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - export function parse(data: string): Record; -} - declare module 'npm-pick-manifest' { function pickManifest( - metadata: import('../utilities/package-metadata').PackageMetadata, + metadata: import('./utilities/package-metadata').PackageMetadata, selector: string, - ): import('../utilities/package-metadata').PackageManifest; + ): import('./utilities/package-metadata').PackageManifest; export = pickManifest; } - -declare module 'pacote' { - export function manifest( - specifier: string, - options: Record, - ): Promise<{ name: string; version: string }>; - - export function packument( - specifier: string, - options: Record, - ): Promise; -} diff --git a/packages/angular/cli/src/utilities/color.ts b/packages/angular/cli/src/utilities/color.ts new file mode 100644 index 000000000000..3915d99ce248 --- /dev/null +++ b/packages/angular/cli/src/utilities/color.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { WriteStream } from 'node:tty'; + +export { color as colors, figures } from 'listr2'; + +export function supportColor(stream: NodeJS.WritableStream = process.stdout): boolean { + if (stream instanceof WriteStream) { + return stream.hasColors(); + } + + try { + // The hasColors function does not rely on any instance state and should ideally be static + return WriteStream.prototype.hasColors(); + } catch { + return process.env['FORCE_COLOR'] !== undefined && process.env['FORCE_COLOR'] !== '0'; + } +} diff --git a/packages/angular/cli/src/utilities/completion.ts b/packages/angular/cli/src/utilities/completion.ts new file mode 100644 index 000000000000..07483065caed --- /dev/null +++ b/packages/angular/cli/src/utilities/completion.ts @@ -0,0 +1,306 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { json, logging } from '@angular-devkit/core'; +import { execFile } from 'child_process'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { env } from 'process'; +import { colors } from '../utilities/color'; +import { getWorkspace } from '../utilities/config'; +import { forceAutocomplete } from '../utilities/environment-options'; +import { isTTY } from '../utilities/tty'; +import { assertIsError } from './error'; +import { askConfirmation } from './prompt'; + +/** Interface for the autocompletion configuration stored in the global workspace. */ +interface CompletionConfig { + /** + * Whether or not the user has been prompted to set up autocompletion. If `true`, should *not* + * prompt them again. + */ + prompted?: boolean; +} + +/** + * Checks if it is appropriate to prompt the user to setup autocompletion. If not, does nothing. If + * so prompts and sets up autocompletion for the user. Returns an exit code if the program should + * terminate, otherwise returns `undefined`. + * @returns an exit code if the program should terminate, undefined otherwise. + */ +export async function considerSettingUpAutocompletion( + command: string, + logger: logging.Logger, +): Promise { + // Check if we should prompt the user to setup autocompletion. + const completionConfig = await getCompletionConfig(); + if (!(await shouldPromptForAutocompletionSetup(command, completionConfig))) { + return undefined; // Already set up or prompted previously, nothing to do. + } + + // Prompt the user and record their response. + const shouldSetupAutocompletion = await promptForAutocompletion(); + if (!shouldSetupAutocompletion) { + // User rejected the prompt and doesn't want autocompletion. + logger.info( + ` +Ok, you won't be prompted again. Should you change your mind, the following command will set up autocompletion for you: + + ${colors.yellow(`ng completion`)} + `.trim(), + ); + + // Save configuration to remember that the user was prompted and avoid prompting again. + await setCompletionConfig({ ...completionConfig, prompted: true }); + + return undefined; + } + + // User accepted the prompt, set up autocompletion. + let rcFile: string; + try { + rcFile = await initializeAutocomplete(); + } catch (err) { + assertIsError(err); + // Failed to set up autocompeletion, log the error and abort. + logger.error(err.message); + + return 1; + } + + // Notify the user autocompletion was set up successfully. + logger.info( + ` +Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your terminal or run the following to autocomplete \`ng\` commands: + + ${colors.yellow(`source <(ng completion script)`)} + `.trim(), + ); + + if (!(await hasGlobalCliInstall())) { + logger.warn( + 'Setup completed successfully, but there does not seem to be a global install of the' + + ' Angular CLI. For autocompletion to work, the CLI will need to be on your `$PATH`, which' + + ' is typically done with the `-g` flag in `npm install -g @angular/cli`.' + + '\n\n' + + 'For more information, see https://angular.dev/cli/completion#global-install', + ); + } + + // Save configuration to remember that the user was prompted. + await setCompletionConfig({ ...completionConfig, prompted: true }); + + return undefined; +} + +async function getCompletionConfig(): Promise { + const wksp = await getWorkspace('global'); + + return wksp?.getCli()?.['completion']; +} + +async function setCompletionConfig(config: CompletionConfig): Promise { + const wksp = await getWorkspace('global'); + if (!wksp) { + throw new Error(`Could not find global workspace`); + } + + wksp.extensions['cli'] ??= {}; + const cli = wksp.extensions['cli']; + if (!json.isJsonObject(cli)) { + throw new Error( + `Invalid config found at ${wksp.filePath}. \`extensions.cli\` should be an object.`, + ); + } + cli.completion = config as json.JsonObject; + await wksp.save(); +} + +async function shouldPromptForAutocompletionSetup( + command: string, + config?: CompletionConfig, +): Promise { + // Force whether or not to prompt for autocomplete to give an easy path for e2e testing to skip. + if (forceAutocomplete !== undefined) { + return forceAutocomplete; + } + + // Don't prompt on `ng update`, 'ng version' or `ng completion`. + if (['version', 'update', 'completion'].includes(command)) { + return false; + } + + // Non-interactive and continuous integration systems don't care about autocompletion. + if (!isTTY()) { + return false; + } + + // Skip prompt if the user has already been prompted. + if (config?.prompted) { + return false; + } + + // `$HOME` variable is necessary to find RC files to modify. + const home = env['HOME']; + if (!home) { + return false; + } + + // Get possible RC files for the current shell. + const shell = env['SHELL']; + if (!shell) { + return false; + } + const rcFiles = getShellRunCommandCandidates(shell, home); + if (!rcFiles) { + return false; // Unknown shell. + } + + // Don't prompt if the user is missing a global CLI install. Autocompletion won't work after setup + // anyway and could be annoying for users running one-off commands via `npx` or using `npm start`. + if ((await hasGlobalCliInstall()) === false) { + return false; + } + + // Check each RC file if they already use `ng completion script` in any capacity and don't prompt. + for (const rcFile of rcFiles) { + const contents = await fs.readFile(rcFile, 'utf-8').catch(() => undefined); + if (contents?.includes('ng completion script')) { + return false; + } + } + + return true; +} + +async function promptForAutocompletion(): Promise { + const autocomplete = await askConfirmation( + ` +Would you like to enable autocompletion? This will set up your terminal so pressing TAB while typing +Angular CLI commands will show possible options and autocomplete arguments. (Enabling autocompletion +will modify configuration files in your home directory.) + ` + .split('\n') + .join(' ') + .trim(), + true, + ); + + return autocomplete; +} + +/** + * Sets up autocompletion for the user's terminal. This attempts to find the configuration file for + * the current shell (`.bashrc`, `.zshrc`, etc.) and append a command which enables autocompletion + * for the Angular CLI. Supports only Bash and Zsh. Returns whether or not it was successful. + * @return The full path of the configuration file modified. + */ +export async function initializeAutocomplete(): Promise { + // Get the currently active `$SHELL` and `$HOME` environment variables. + const shell = env['SHELL']; + if (!shell) { + throw new Error( + '`$SHELL` environment variable not set. Angular CLI autocompletion only supports Bash or' + + " Zsh. If you're on Windows, Cmd and Powershell don't support command autocompletion," + + ' but Git Bash or Windows Subsystem for Linux should work, so please try again in one of' + + ' those environments.', + ); + } + const home = env['HOME']; + if (!home) { + throw new Error( + '`$HOME` environment variable not set. Setting up autocompletion modifies configuration files' + + ' in the home directory and must be set.', + ); + } + + // Get all the files we can add `ng completion` to which apply to the user's `$SHELL`. + const runCommandCandidates = getShellRunCommandCandidates(shell, home); + if (!runCommandCandidates) { + throw new Error( + `Unknown \`$SHELL\` environment variable value (${shell}). Angular CLI autocompletion only supports Bash or Zsh.`, + ); + } + + // Get the first file that already exists or fallback to a new file of the first candidate. + const candidates = await Promise.allSettled( + runCommandCandidates.map((rcFile) => fs.access(rcFile).then(() => rcFile)), + ); + const rcFile = + candidates.find( + (result): result is PromiseFulfilledResult => result.status === 'fulfilled', + )?.value ?? runCommandCandidates[0]; + + // Append Angular autocompletion setup to RC file. + try { + await fs.appendFile( + rcFile, + '\n\n# Load Angular CLI autocompletion.\nsource <(ng completion script)\n', + ); + } catch (err) { + assertIsError(err); + throw new Error(`Failed to append autocompletion setup to \`${rcFile}\`:\n${err.message}`); + } + + return rcFile; +} + +/** Returns an ordered list of possible candidates of RC files used by the given shell. */ +function getShellRunCommandCandidates(shell: string, home: string): string[] | undefined { + if (shell.toLowerCase().includes('bash')) { + return ['.bashrc', '.bash_profile', '.profile'].map((file) => path.join(home, file)); + } else if (shell.toLowerCase().includes('zsh')) { + return ['.zshrc', '.zsh_profile', '.profile'].map((file) => path.join(home, file)); + } else { + return undefined; + } +} + +/** + * Returns whether the user has a global CLI install. + * Execution from `npx` is *not* considered a global CLI install. + * + * This does *not* mean the current execution is from a global CLI install, only that a global + * install exists on the system. + */ +export function hasGlobalCliInstall(): Promise { + // List all binaries with the `ng` name on the user's `$PATH`. + return new Promise((resolve) => { + execFile('which', ['-a', 'ng'], (error, stdout) => { + if (error) { + // No instances of `ng` on the user's `$PATH` + + // `which` returns exit code 2 if an invalid option is specified and `-a` doesn't appear to be + // supported on all systems. Other exit codes mean unknown errors occurred. Can't tell whether + // CLI is globally installed, so treat this as inconclusive. + + // `which` was killed by a signal and did not exit gracefully. Maybe it hung or something else + // went very wrong, so treat this as inconclusive. + resolve(false); + + return; + } + + // Successfully listed all `ng` binaries on the `$PATH`. Look for at least one line which is a + // global install. We can't easily identify global installs, but local installs are typically + // placed in `node_modules/.bin` by NPM / Yarn. `npx` also currently caches files at + // `~/.npm/_npx/*/node_modules/.bin/`, so the same logic applies. + const lines = stdout.split('\n').filter((line) => line !== ''); + const hasGlobalInstall = lines.some((line) => { + // A binary is a local install if it is a direct child of a `node_modules/.bin/` directory. + const parent = path.parse(path.parse(line).dir); + const grandparent = path.parse(parent.dir); + const localInstall = grandparent.base === 'node_modules' && parent.base === '.bin'; + + return !localInstall; + }); + + return resolve(hasGlobalInstall); + }); + }); +} diff --git a/packages/angular/cli/src/utilities/config.ts b/packages/angular/cli/src/utilities/config.ts new file mode 100644 index 000000000000..af370a164a35 --- /dev/null +++ b/packages/angular/cli/src/utilities/config.ts @@ -0,0 +1,420 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { json, workspaces } from '@angular-devkit/core'; +import { existsSync, promises as fs } from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { PackageManager } from '../../lib/config/workspace-schema'; +import { findUp } from './find-up'; +import { JSONFile, readAndParseJson } from './json-file'; + +function isJsonObject(value: json.JsonValue | undefined): value is json.JsonObject { + return value !== undefined && json.isJsonObject(value); +} + +function createWorkspaceHost(): workspaces.WorkspaceHost { + return { + readFile(path) { + return fs.readFile(path, 'utf-8'); + }, + async writeFile(path, data) { + await fs.writeFile(path, data); + }, + async isDirectory(path) { + try { + const stats = await fs.stat(path); + + return stats.isDirectory(); + } catch { + return false; + } + }, + async isFile(path) { + try { + const stats = await fs.stat(path); + + return stats.isFile(); + } catch { + return false; + } + }, + }; +} + +export const workspaceSchemaPath = path.join(__dirname, '../../lib/config/schema.json'); + +const configNames = ['angular.json', '.angular.json']; +const globalFileName = '.angular-config.json'; +const defaultGlobalFilePath = path.join(os.homedir(), globalFileName); + +function xdgConfigHome(home: string, configFile?: string): string { + // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html + const xdgConfigHome = process.env['XDG_CONFIG_HOME'] || path.join(home, '.config'); + const xdgAngularHome = path.join(xdgConfigHome, 'angular'); + + return configFile ? path.join(xdgAngularHome, configFile) : xdgAngularHome; +} + +function xdgConfigHomeOld(home: string): string { + // Check the configuration files in the old location that should be: + // - $XDG_CONFIG_HOME/.angular-config.json (if XDG_CONFIG_HOME is set) + // - $HOME/.config/angular/.angular-config.json (otherwise) + const p = process.env['XDG_CONFIG_HOME'] || path.join(home, '.config', 'angular'); + + return path.join(p, '.angular-config.json'); +} + +function projectFilePath(projectPath?: string): string | null { + // Find the configuration, either where specified, in the Angular CLI project + // (if it's in node_modules) or from the current process. + return ( + (projectPath && findUp(configNames, projectPath)) || + findUp(configNames, process.cwd()) || + findUp(configNames, __dirname) + ); +} + +function globalFilePath(): string | null { + const home = os.homedir(); + if (!home) { + return null; + } + + // follow XDG Base Directory spec + // note that createGlobalSettings() will continue creating + // global file in home directory, with this user will have + // choice to move change its location to meet XDG convention + const xdgConfig = xdgConfigHome(home, 'config.json'); + if (existsSync(xdgConfig)) { + return xdgConfig; + } + // NOTE: This check is for the old configuration location, for more + // information see https://github.com/angular/angular-cli/pull/20556 + const xdgConfigOld = xdgConfigHomeOld(home); + if (existsSync(xdgConfigOld)) { + /* eslint-disable no-console */ + console.warn( + `Old configuration location detected: ${xdgConfigOld}\n` + + `Please move the file to the new location ~/.config/angular/config.json`, + ); + + return xdgConfigOld; + } + + if (existsSync(defaultGlobalFilePath)) { + return defaultGlobalFilePath; + } + + return null; +} + +export class AngularWorkspace { + readonly basePath: string; + + constructor( + private readonly workspace: workspaces.WorkspaceDefinition, + readonly filePath: string, + ) { + this.basePath = path.dirname(filePath); + } + + get extensions(): Record { + return this.workspace.extensions; + } + + get projects(): workspaces.ProjectDefinitionCollection { + return this.workspace.projects; + } + + // Temporary helper functions to support refactoring + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getCli(): Record | undefined { + return this.workspace.extensions['cli'] as Record; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getProjectCli(projectName: string): Record | undefined { + const project = this.workspace.projects.get(projectName); + + return project?.extensions['cli'] as Record; + } + + save(): Promise { + return workspaces.writeWorkspace( + this.workspace, + createWorkspaceHost(), + this.filePath, + workspaces.WorkspaceFormat.JSON, + ); + } + + static async load(workspaceFilePath: string): Promise { + const result = await workspaces.readWorkspace( + workspaceFilePath, + createWorkspaceHost(), + workspaces.WorkspaceFormat.JSON, + ); + + return new AngularWorkspace(result.workspace, workspaceFilePath); + } +} + +const cachedWorkspaces = new Map(); + +export async function getWorkspace(level: 'global'): Promise; +export async function getWorkspace(level: 'local'): Promise; +export async function getWorkspace( + level: 'local' | 'global', +): Promise; + +export async function getWorkspace( + level: 'local' | 'global', +): Promise { + if (cachedWorkspaces.has(level)) { + return cachedWorkspaces.get(level); + } + + const configPath = level === 'local' ? projectFilePath() : globalFilePath(); + if (!configPath) { + if (level === 'global') { + // Unlike a local config, a global config is not mandatory. + // So we create an empty one in memory and keep it as such until it has been modified and saved. + const globalWorkspace = new AngularWorkspace( + { extensions: {}, projects: new workspaces.ProjectDefinitionCollection() }, + defaultGlobalFilePath, + ); + + cachedWorkspaces.set(level, globalWorkspace); + + return globalWorkspace; + } + + cachedWorkspaces.set(level, undefined); + + return undefined; + } + + try { + const workspace = await AngularWorkspace.load(configPath); + cachedWorkspaces.set(level, workspace); + + return workspace; + } catch (error) { + throw new Error( + `Workspace config file cannot be loaded: ${configPath}` + + `\n${error instanceof Error ? error.message : error}`, + ); + } +} + +/** + * This method will load the workspace configuration in raw JSON format. + * When `level` is `global` and file doesn't exists, it will be created. + * + * NB: This method is intended to be used only for `ng config`. + */ +export async function getWorkspaceRaw( + level: 'local' | 'global' = 'local', +): Promise<[JSONFile | null, string | null]> { + let configPath = level === 'local' ? projectFilePath() : globalFilePath(); + + if (!configPath) { + if (level === 'global') { + configPath = defaultGlobalFilePath; + // Config doesn't exist, force create it. + + const globalWorkspace = await getWorkspace('global'); + await globalWorkspace.save(); + } else { + return [null, null]; + } + } + + return [new JSONFile(configPath), configPath]; +} + +export async function validateWorkspace(data: json.JsonObject, isGlobal: boolean): Promise { + const schema = readAndParseJson(workspaceSchemaPath); + + // We should eventually have a dedicated global config schema and use that to validate. + const schemaToValidate: json.schema.JsonSchema = isGlobal + ? { + '$ref': '#/definitions/global', + definitions: schema['definitions'], + } + : schema; + + const { formats } = await import('@angular-devkit/schematics'); + const registry = new json.schema.CoreSchemaRegistry(formats.standardFormats); + const validator = await registry.compile(schemaToValidate); + const { success, errors } = await validator(data); + if (!success) { + throw new json.schema.SchemaValidationException(errors); + } +} + +function findProjectByPath(workspace: AngularWorkspace, location: string): string | null { + const isInside = (base: string, potential: string): boolean => { + const absoluteBase = path.resolve(workspace.basePath, base); + const absolutePotential = path.resolve(workspace.basePath, potential); + const relativePotential = path.relative(absoluteBase, absolutePotential); + if (!relativePotential.startsWith('..') && !path.isAbsolute(relativePotential)) { + return true; + } + + return false; + }; + + const projects = Array.from(workspace.projects) + .map(([name, project]) => [project.root, name] as [string, string]) + .filter((tuple) => isInside(tuple[0], location)) + // Sort tuples by depth, with the deeper ones first. Since the first member is a path and + // we filtered all invalid paths, the longest will be the deepest (and in case of equality + // the sort is stable and the first declared project will win). + .sort((a, b) => b[0].length - a[0].length); + + if (projects.length === 0) { + return null; + } else if (projects.length > 1) { + const found = new Set(); + const sameRoots = projects.filter((v) => { + if (!found.has(v[0])) { + found.add(v[0]); + + return false; + } + + return true; + }); + if (sameRoots.length > 0) { + // Ambiguous location - cannot determine a project + return null; + } + } + + return projects[0][1]; +} + +export function getProjectByCwd(workspace: AngularWorkspace): string | null { + if (workspace.projects.size === 1) { + // If there is only one project, return that one. + return Array.from(workspace.projects.keys())[0]; + } + + const project = findProjectByPath(workspace, process.cwd()); + if (project) { + return project; + } + + return null; +} + +export async function getConfiguredPackageManager(): Promise { + const getPackageManager = (source: json.JsonValue | undefined): PackageManager | null => { + if (isJsonObject(source)) { + const value = source['packageManager']; + if (value && typeof value === 'string') { + return value as PackageManager; + } + } + + return null; + }; + + let result: PackageManager | null = null; + const workspace = await getWorkspace('local'); + if (workspace) { + const project = getProjectByCwd(workspace); + if (project) { + result = getPackageManager(workspace.projects.get(project)?.extensions['cli']); + } + + result ??= getPackageManager(workspace.extensions['cli']); + } + + if (!result) { + const globalOptions = await getWorkspace('global'); + result = getPackageManager(globalOptions?.extensions['cli']); + } + + return result; +} + +export async function getSchematicDefaults( + collection: string, + schematic: string, + project?: string | null, +): Promise<{}> { + const result = {}; + const mergeOptions = (source: json.JsonValue | undefined): void => { + if (isJsonObject(source)) { + // Merge options from the qualified name + Object.assign(result, source[`${collection}:${schematic}`]); + + // Merge options from nested collection schematics + const collectionOptions = source[collection]; + if (isJsonObject(collectionOptions)) { + Object.assign(result, collectionOptions[schematic]); + } + } + }; + + // Global level schematic options + const globalOptions = await getWorkspace('global'); + mergeOptions(globalOptions?.extensions['schematics']); + + const workspace = await getWorkspace('local'); + if (workspace) { + // Workspace level schematic options + mergeOptions(workspace.extensions['schematics']); + + project = project || getProjectByCwd(workspace); + if (project) { + // Project level schematic options + mergeOptions(workspace.projects.get(project)?.extensions['schematics']); + } + } + + return result; +} + +export async function isWarningEnabled(warning: string): Promise { + const getWarning = (source: json.JsonValue | undefined): boolean | undefined => { + if (isJsonObject(source)) { + const warnings = source['warnings']; + if (isJsonObject(warnings)) { + const value = warnings[warning]; + if (typeof value == 'boolean') { + return value; + } + } + } + }; + + let result: boolean | undefined; + + const workspace = await getWorkspace('local'); + if (workspace) { + const project = getProjectByCwd(workspace); + if (project) { + result = getWarning(workspace.projects.get(project)?.extensions['cli']); + } + + result = result ?? getWarning(workspace.extensions['cli']); + } + + if (result === undefined) { + const globalOptions = await getWorkspace('global'); + result = getWarning(globalOptions?.extensions['cli']); + } + + // All warnings are enabled by default + return result ?? true; +} diff --git a/packages/angular/cli/src/utilities/environment-options.ts b/packages/angular/cli/src/utilities/environment-options.ts new file mode 100644 index 000000000000..0f01ce8b09cb --- /dev/null +++ b/packages/angular/cli/src/utilities/environment-options.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +function isPresent(variable: string | undefined): variable is string { + return typeof variable === 'string' && variable !== ''; +} + +function isDisabled(variable: string | undefined): boolean { + return isPresent(variable) && (variable === '0' || variable.toLowerCase() === 'false'); +} + +function isEnabled(variable: string | undefined): boolean { + return isPresent(variable) && (variable === '1' || variable.toLowerCase() === 'true'); +} + +function optional(variable: string | undefined): boolean | undefined { + if (!isPresent(variable)) { + return undefined; + } + + return isEnabled(variable); +} + +export const analyticsDisabled = isDisabled(process.env['NG_CLI_ANALYTICS']); +export const isCI = isEnabled(process.env['CI']); +export const disableVersionCheck = isEnabled(process.env['NG_DISABLE_VERSION_CHECK']); +export const ngDebug = isEnabled(process.env['NG_DEBUG']); +export const forceAutocomplete = optional(process.env['NG_FORCE_AUTOCOMPLETE']); diff --git a/packages/angular/cli/src/utilities/eol.ts b/packages/angular/cli/src/utilities/eol.ts new file mode 100644 index 000000000000..02e837649144 --- /dev/null +++ b/packages/angular/cli/src/utilities/eol.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { EOL } from 'node:os'; + +const CRLF = '\r\n'; +const LF = '\n'; + +export function getEOL(content: string): string { + const newlines = content.match(/(?:\r?\n)/g); + + if (newlines?.length) { + const crlf = newlines.filter((l) => l === CRLF).length; + const lf = newlines.length - crlf; + + return crlf > lf ? CRLF : LF; + } + + return EOL; +} diff --git a/packages/angular/cli/src/utilities/error.ts b/packages/angular/cli/src/utilities/error.ts new file mode 100644 index 000000000000..c00e13e79726 --- /dev/null +++ b/packages/angular/cli/src/utilities/error.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'assert'; + +export function assertIsError(value: unknown): asserts value is Error & { code?: string } { + const isError = + value instanceof Error || + // The following is needing to identify errors coming from RxJs. + (typeof value === 'object' && value && 'name' in value && 'message' in value); + assert(isError, 'catch clause variable is not an Error instance'); +} diff --git a/packages/angular/cli/utilities/find-up.ts b/packages/angular/cli/src/utilities/find-up.ts similarity index 91% rename from packages/angular/cli/utilities/find-up.ts rename to packages/angular/cli/src/utilities/find-up.ts index 3427d7ba15f4..ed0adb0f78bb 100644 --- a/packages/angular/cli/utilities/find-up.ts +++ b/packages/angular/cli/src/utilities/find-up.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { existsSync } from 'fs'; diff --git a/packages/angular/cli/utilities/json-file.ts b/packages/angular/cli/src/utilities/json-file.ts similarity index 93% rename from packages/angular/cli/utilities/json-file.ts rename to packages/angular/cli/src/utilities/json-file.ts index 1afa0b3f7041..f960462c4ecf 100644 --- a/packages/angular/cli/utilities/json-file.ts +++ b/packages/angular/cli/src/utilities/json-file.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { JsonValue } from '@angular-devkit/core'; @@ -19,6 +19,7 @@ import { parseTree, printParseErrorCode, } from 'jsonc-parser'; +import { getEOL } from './eol'; export type InsertionIndex = (properties: string[]) => number; export type JSONPath = (string | number)[]; @@ -26,6 +27,7 @@ export type JSONPath = (string | number)[]; /** @internal */ export class JSONFile { content: string; + private eol: string; constructor(private readonly path: string) { const buffer = readFileSync(this.path); @@ -34,6 +36,8 @@ export class JSONFile { } else { throw new Error(`Could not read '${path}'.`); } + + this.eol = getEOL(this.content); } private _jsonAst: Node | undefined; @@ -87,9 +91,11 @@ export class JSONFile { const edits = modify(this.content, jsonPath, value, { getInsertionIndex, + // TODO: use indentation from original file. formattingOptions: { insertSpaces: true, tabSize: 2, + eol: this.eol, }, }); diff --git a/packages/angular/cli/src/utilities/load-esm.ts b/packages/angular/cli/src/utilities/load-esm.ts new file mode 100644 index 000000000000..6a6220f66288 --- /dev/null +++ b/packages/angular/cli/src/utilities/load-esm.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Lazily compiled dynamic import loader function. + */ +let load: ((modulePath: string | URL) => Promise) | undefined; + +/** + * This uses a dynamic import to load a module which may be ESM. + * CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript + * will currently, unconditionally downlevel dynamic import into a require call. + * require calls cannot load ESM code and will result in a runtime error. To workaround + * this, a Function constructor is used to prevent TypeScript from changing the dynamic import. + * Once TypeScript provides support for keeping the dynamic import this workaround can + * be dropped. + * + * @param modulePath The path of the module to load. + * @returns A Promise that resolves to the dynamically imported module. + */ +export function loadEsmModule(modulePath: string | URL): Promise { + load ??= new Function('modulePath', `return import(modulePath);`) as Exclude< + typeof load, + undefined + >; + + return load(modulePath); +} diff --git a/packages/angular/cli/utilities/log-file.ts b/packages/angular/cli/src/utilities/log-file.ts similarity index 93% rename from packages/angular/cli/utilities/log-file.ts rename to packages/angular/cli/src/utilities/log-file.ts index 41dc036fc028..dbccaaf24879 100644 --- a/packages/angular/cli/utilities/log-file.ts +++ b/packages/angular/cli/src/utilities/log-file.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { appendFileSync, mkdtempSync, realpathSync } from 'fs'; diff --git a/packages/angular/cli/src/utilities/memoize.ts b/packages/angular/cli/src/utilities/memoize.ts new file mode 100644 index 000000000000..2ae55e4b383a --- /dev/null +++ b/packages/angular/cli/src/utilities/memoize.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * A decorator that memoizes methods and getters. + * + * **Note**: Be cautious where and how to use this decorator as the size of the cache will grow unbounded. + * + * @see https://en.wikipedia.org/wiki/Memoization + */ +export function memoize( + target: (this: This, ...args: Args) => Return, + context: ClassMemberDecoratorContext, +) { + if (context.kind !== 'method' && context.kind !== 'getter') { + throw new Error('Memoize decorator can only be used on methods or get accessors.'); + } + + const cache = new Map(); + + return function (this: This, ...args: Args): Return { + for (const arg of args) { + if (!isJSONSerializable(arg)) { + throw new Error( + `Argument ${isNonPrimitive(arg) ? arg.toString() : arg} is JSON serializable.`, + ); + } + } + + const key = JSON.stringify(args); + if (cache.has(key)) { + return cache.get(key) as Return; + } + + const result = target.apply(this, args); + cache.set(key, result); + + return result; + }; +} + +/** Method to check if value is a non primitive. */ +function isNonPrimitive(value: unknown): value is object | Function | symbol { + return ( + (value !== null && typeof value === 'object') || + typeof value === 'function' || + typeof value === 'symbol' + ); +} + +/** Method to check if the values are JSON serializable */ +function isJSONSerializable(value: unknown): boolean { + if (!isNonPrimitive(value)) { + // Can be seralized since it's a primitive. + return true; + } + + let nestedValues: unknown[] | undefined; + if (Array.isArray(value)) { + // It's an array, check each item. + nestedValues = value; + } else if (Object.prototype.toString.call(value) === '[object Object]') { + // It's a plain object, check each value. + nestedValues = Object.values(value); + } + + if (!nestedValues || nestedValues.some((v) => !isJSONSerializable(v))) { + return false; + } + + return true; +} diff --git a/packages/angular/cli/src/utilities/memoize_spec.ts b/packages/angular/cli/src/utilities/memoize_spec.ts new file mode 100644 index 000000000000..1c65340764e9 --- /dev/null +++ b/packages/angular/cli/src/utilities/memoize_spec.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { memoize } from './memoize'; + +describe('memoize', () => { + class Dummy { + @memoize + get random(): number { + return Math.random(); + } + + @memoize + getRandom(_parameter?: unknown): number { + return Math.random(); + } + + @memoize + async getRandomAsync(): Promise { + return Math.random(); + } + } + + it('should call method once', () => { + const dummy = new Dummy(); + const val1 = dummy.getRandom(); + const val2 = dummy.getRandom(); + + // Should return same value since memoized + expect(val1).toBe(val2); + }); + + it('should call method once (async)', async () => { + const dummy = new Dummy(); + const [val1, val2] = await Promise.all([dummy.getRandomAsync(), dummy.getRandomAsync()]); + + // Should return same value since memoized + expect(val1).toBe(val2); + }); + + it('should call getter once', () => { + const dummy = new Dummy(); + const val1 = dummy.random; + const val2 = dummy.random; + + // Should return same value since memoized + expect(val2).toBe(val1); + }); + + it('should call method when parameter changes', () => { + const dummy = new Dummy(); + const val1 = dummy.getRandom(1); + const val2 = dummy.getRandom(2); + const val3 = dummy.getRandom(1); + const val4 = dummy.getRandom(2); + + // Should return same value since memoized + expect(val1).not.toBe(val2); + expect(val1).toBe(val3); + expect(val2).toBe(val4); + }); + + it('should error when used on non getters and methods', () => { + const test = () => { + class DummyError { + @memoize + set random(_value: number) {} + } + + return new DummyError(); + }; + + expect(test).toThrowError('Memoize decorator can only be used on methods or get accessors.'); + }); + + describe('validate method arguments', () => { + it('should error when using Map', () => { + const test = () => new Dummy().getRandom(new Map()); + + expect(test).toThrowError(/Argument \[object Map\] is JSON serializable./); + }); + + it('should error when using Symbol', () => { + const test = () => new Dummy().getRandom(Symbol('')); + + expect(test).toThrowError(/Argument Symbol\(\) is JSON serializable/); + }); + + it('should error when using Function', () => { + const test = () => new Dummy().getRandom(function () {}); + + expect(test).toThrowError(/Argument function \(\) { } is JSON serializable/); + }); + + it('should error when using Map in an array', () => { + const test = () => new Dummy().getRandom([new Map(), true]); + + expect(test).toThrowError(/Argument \[object Map\],true is JSON serializable/); + }); + + it('should error when using Map in an Object', () => { + const test = () => new Dummy().getRandom({ foo: true, prop: new Map() }); + + expect(test).toThrowError(/Argument \[object Object\] is JSON serializable/); + }); + + it('should error when using Function in an Object', () => { + const test = () => new Dummy().getRandom({ foo: true, prop: function () {} }); + + expect(test).toThrowError(/Argument \[object Object\] is JSON serializable/); + }); + + it('should not error when using primitive values in an array', () => { + const test = () => new Dummy().getRandom([1, true, ['foo']]); + + expect(test).not.toThrow(); + }); + + it('should not error when using primitive values in an Object', () => { + const test = () => new Dummy().getRandom({ foo: true, prop: [1, true] }); + + expect(test).not.toThrow(); + }); + + it('should not error when using Boolean', () => { + const test = () => new Dummy().getRandom(true); + + expect(test).not.toThrow(); + }); + + it('should not error when using String', () => { + const test = () => new Dummy().getRandom('foo'); + + expect(test).not.toThrow(); + }); + + it('should not error when using Number', () => { + const test = () => new Dummy().getRandom(1); + + expect(test).not.toThrow(); + }); + + it('should not error when using null', () => { + const test = () => new Dummy().getRandom(null); + + expect(test).not.toThrow(); + }); + + it('should not error when using undefined', () => { + const test = () => new Dummy().getRandom(undefined); + + expect(test).not.toThrow(); + }); + }); +}); diff --git a/packages/angular/cli/src/utilities/package-manager.ts b/packages/angular/cli/src/utilities/package-manager.ts new file mode 100644 index 000000000000..28273c698013 --- /dev/null +++ b/packages/angular/cli/src/utilities/package-manager.ts @@ -0,0 +1,318 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { isJsonObject, json } from '@angular-devkit/core'; +import { execSync, spawn } from 'child_process'; +import { existsSync, promises as fs, realpathSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { PackageManager } from '../../lib/config/workspace-schema'; +import { AngularWorkspace, getProjectByCwd } from './config'; +import { memoize } from './memoize'; + +interface PackageManagerOptions { + saveDev: string; + install: string; + installAll?: string; + prefix: string; + noLockfile: string; +} + +export interface PackageManagerUtilsContext { + globalConfiguration: AngularWorkspace; + workspace?: AngularWorkspace; + root: string; +} + +export class PackageManagerUtils { + constructor(private readonly context: PackageManagerUtilsContext) {} + + /** Get the package manager name. */ + get name(): PackageManager { + return this.getName(); + } + + /** Get the package manager version. */ + get version(): string | undefined { + return this.getVersion(this.name); + } + + /** Install a single package. */ + async install( + packageName: string, + save: 'dependencies' | 'devDependencies' | true = true, + extraArgs: string[] = [], + cwd?: string, + ): Promise { + const packageManagerArgs = this.getArguments(); + const installArgs: string[] = [packageManagerArgs.install, packageName]; + + if (save === 'devDependencies') { + installArgs.push(packageManagerArgs.saveDev); + } + + return this.run([...installArgs, ...extraArgs], { cwd, silent: true }); + } + + /** Install all packages. */ + async installAll(extraArgs: string[] = [], cwd?: string): Promise { + const packageManagerArgs = this.getArguments(); + const installArgs: string[] = []; + if (packageManagerArgs.installAll) { + installArgs.push(packageManagerArgs.installAll); + } + + return this.run([...installArgs, ...extraArgs], { cwd, silent: true }); + } + + /** Install a single package temporary. */ + async installTemp( + packageName: string, + extraArgs?: string[], + ): Promise<{ + success: boolean; + tempNodeModules: string; + }> { + const tempPath = await fs.mkdtemp(join(realpathSync(tmpdir()), 'angular-cli-packages-')); + + // clean up temp directory on process exit + process.on('exit', () => { + try { + rmSync(tempPath, { recursive: true, maxRetries: 3 }); + } catch {} + }); + + // NPM will warn when a `package.json` is not found in the install directory + // Example: + // npm WARN enoent ENOENT: no such file or directory, open '/tmp/.ng-temp-packages-84Qi7y/package.json' + // npm WARN .ng-temp-packages-84Qi7y No description + // npm WARN .ng-temp-packages-84Qi7y No repository field. + // npm WARN .ng-temp-packages-84Qi7y No license field. + + // While we can use `npm init -y` we will end up needing to update the 'package.json' anyways + // because of missing fields. + await fs.writeFile( + join(tempPath, 'package.json'), + JSON.stringify({ + name: 'temp-cli-install', + description: 'temp-cli-install', + repository: 'temp-cli-install', + license: 'MIT', + }), + ); + + // setup prefix/global modules path + const packageManagerArgs = this.getArguments(); + const tempNodeModules = join(tempPath, 'node_modules'); + // Yarn will not append 'node_modules' to the path + const prefixPath = this.name === PackageManager.Yarn ? tempNodeModules : tempPath; + const installArgs: string[] = [ + ...(extraArgs ?? []), + `${packageManagerArgs.prefix}="${prefixPath}"`, + packageManagerArgs.noLockfile, + ]; + + return { + success: await this.install(packageName, true, installArgs, tempPath), + tempNodeModules, + }; + } + + private getArguments(): PackageManagerOptions { + switch (this.name) { + case PackageManager.Yarn: + return { + saveDev: '--dev', + install: 'add', + prefix: '--modules-folder', + noLockfile: '--no-lockfile', + }; + case PackageManager.Pnpm: + return { + saveDev: '--save-dev', + install: 'add', + installAll: 'install', + prefix: '--prefix', + noLockfile: '--no-lockfile', + }; + case PackageManager.Bun: + return { + saveDev: '--development', + install: 'add', + installAll: 'install', + prefix: '--cwd', + noLockfile: '', + }; + default: + return { + saveDev: '--save-dev', + install: 'install', + installAll: 'install', + prefix: '--prefix', + noLockfile: '--no-package-lock', + }; + } + } + + private async run( + args: string[], + options: { cwd?: string; silent?: boolean } = {}, + ): Promise { + const { cwd = process.cwd(), silent = false } = options; + + return new Promise((resolve) => { + const bufferedOutput: { stream: NodeJS.WriteStream; data: Buffer }[] = []; + + const childProcess = spawn(this.name, args, { + // Always pipe stderr to allow for failures to be reported + stdio: silent ? ['ignore', 'ignore', 'pipe'] : 'pipe', + shell: true, + cwd, + }).on('close', (code: number) => { + if (code === 0) { + resolve(true); + } else { + bufferedOutput.forEach(({ stream, data }) => stream.write(data)); + resolve(false); + } + }); + + childProcess.stdout?.on('data', (data: Buffer) => + bufferedOutput.push({ stream: process.stdout, data: data }), + ); + childProcess.stderr?.on('data', (data: Buffer) => + bufferedOutput.push({ stream: process.stderr, data: data }), + ); + }); + } + + @memoize + private getVersion(name: PackageManager): string | undefined { + try { + return execSync(`${name} --version`, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + env: { + ...process.env, + // NPM updater notifier will prevents the child process from closing until it timeout after 3 minutes. + NO_UPDATE_NOTIFIER: '1', + NPM_CONFIG_UPDATE_NOTIFIER: 'false', + }, + }).trim(); + } catch { + return undefined; + } + } + + @memoize + private getName(): PackageManager { + const packageManager = this.getConfiguredPackageManager(); + if (packageManager) { + return packageManager; + } + + const hasNpmLock = this.hasLockfile(PackageManager.Npm); + const hasYarnLock = this.hasLockfile(PackageManager.Yarn); + const hasPnpmLock = this.hasLockfile(PackageManager.Pnpm); + const hasBunLock = this.hasLockfile(PackageManager.Bun); + + // PERF NOTE: `this.getVersion` spawns the package a the child_process which can take around ~300ms at times. + // Therefore, we should only call this method when needed. IE: don't call `this.getVersion(PackageManager.Pnpm)` unless truly needed. + // The result of this method is not stored in a variable because it's memoized. + + if (hasNpmLock) { + // Has NPM lock file. + if (!hasYarnLock && !hasPnpmLock && !hasBunLock && this.getVersion(PackageManager.Npm)) { + // Only NPM lock file and NPM binary is available. + return PackageManager.Npm; + } + } else { + // No NPM lock file. + if (hasYarnLock && this.getVersion(PackageManager.Yarn)) { + // Yarn lock file and Yarn binary is available. + return PackageManager.Yarn; + } else if (hasPnpmLock && this.getVersion(PackageManager.Pnpm)) { + // PNPM lock file and PNPM binary is available. + return PackageManager.Pnpm; + } else if (hasBunLock && this.getVersion(PackageManager.Bun)) { + // Bun lock file and Bun binary is available. + return PackageManager.Bun; + } + } + + if (!this.getVersion(PackageManager.Npm)) { + // Doesn't have NPM installed. + const hasYarn = !!this.getVersion(PackageManager.Yarn); + const hasPnpm = !!this.getVersion(PackageManager.Pnpm); + const hasBun = !!this.getVersion(PackageManager.Bun); + + if (hasYarn && !hasPnpm && !hasBun) { + return PackageManager.Yarn; + } else if (hasPnpm && !hasYarn && !hasBun) { + return PackageManager.Pnpm; + } else if (hasBun && !hasYarn && !hasPnpm) { + return PackageManager.Bun; + } + } + + // TODO: This should eventually inform the user of ambiguous package manager usage. + // Potentially with a prompt to choose and optionally set as the default. + return PackageManager.Npm; + } + + private hasLockfile(packageManager: PackageManager): boolean { + let lockfileName: string; + switch (packageManager) { + case PackageManager.Yarn: + lockfileName = 'yarn.lock'; + break; + case PackageManager.Pnpm: + lockfileName = 'pnpm-lock.yaml'; + break; + case PackageManager.Bun: + lockfileName = 'bun.lockb'; + break; + case PackageManager.Npm: + default: + lockfileName = 'package-lock.json'; + break; + } + + return existsSync(join(this.context.root, lockfileName)); + } + + private getConfiguredPackageManager(): PackageManager | undefined { + const getPackageManager = (source: json.JsonValue | undefined): PackageManager | undefined => { + if (source && isJsonObject(source)) { + const value = source['packageManager']; + if (typeof value === 'string') { + return value as PackageManager; + } + } + + return undefined; + }; + + let result: PackageManager | undefined; + const { workspace: localWorkspace, globalConfiguration: globalWorkspace } = this.context; + if (localWorkspace) { + const project = getProjectByCwd(localWorkspace); + if (project) { + result = getPackageManager(localWorkspace.projects.get(project)?.extensions['cli']); + } + + result ??= getPackageManager(localWorkspace.extensions['cli']); + } + + if (!result) { + result = getPackageManager(globalWorkspace.extensions['cli']); + } + + return result; + } +} diff --git a/packages/angular/cli/utilities/package-metadata.ts b/packages/angular/cli/src/utilities/package-metadata.ts similarity index 77% rename from packages/angular/cli/utilities/package-metadata.ts rename to packages/angular/cli/src/utilities/package-metadata.ts index e4534561c96e..b10292f93e78 100644 --- a/packages/angular/cli/utilities/package-metadata.ts +++ b/packages/angular/cli/src/utilities/package-metadata.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; @@ -11,32 +11,19 @@ import * as lockfile from '@yarnpkg/lockfile'; import { existsSync, readFileSync } from 'fs'; import * as ini from 'ini'; import { homedir } from 'os'; -import * as pacote from 'pacote'; +import type { Manifest, Packument } from 'pacote'; import * as path from 'path'; -import { JsonSchemaForNpmPackageJsonFiles } from './package-json'; -const npmPackageJsonCache = new Map>>(); - -export interface NpmRepositoryPackageJson { - name: string; - requestedName: string; - description: string; - - 'dist-tags': { - [name: string]: string; - }; - versions: { - [version: string]: JsonSchemaForNpmPackageJsonFiles; - }; - time: { - modified: string; - created: string; +export interface PackageMetadata extends Packument, NgPackageManifestProperties { + tags: Record; + versions: Record; +} - [version: string]: string; - }; +export interface NpmRepositoryPackageJson extends PackageMetadata { + requestedName?: string; } -export type NgAddSaveDepedency = 'dependencies' | 'devDependencies' | boolean; +export type NgAddSaveDependency = 'dependencies' | 'devDependencies' | boolean; export interface PackageIdentifier { type: 'git' | 'tag' | 'version' | 'range' | 'file' | 'directory' | 'remote'; @@ -48,30 +35,20 @@ export interface PackageIdentifier { rawSpec: string; } -export interface PackageManifest { - name: string; - version: string; - license?: string; - private?: boolean; - deprecated?: boolean; - dependencies: Record; - devDependencies: Record; - peerDependencies: Record; - optionalDependencies: Record; +export interface NgPackageManifestProperties { 'ng-add'?: { - save?: NgAddSaveDepedency; + save?: NgAddSaveDependency; }; 'ng-update'?: { - migrations: string; - packageGroup: Record; + migrations?: string; + packageGroup?: string[] | Record; + packageGroupName?: string; + requirements?: string[] | Record; }; } -export interface PackageMetadata { - name: string; - tags: { [tag: string]: PackageManifest | undefined }; - versions: Record; - 'dist-tags'?: unknown; +export interface PackageManifest extends Manifest, NgPackageManifestProperties { + deprecated?: boolean; } interface PackageManagerOptions extends Record { @@ -79,6 +56,7 @@ interface PackageManagerOptions extends Record { } let npmrc: PackageManagerOptions; +const npmPackageJsonCache = new Map>>(); function ensureNpmrc(logger: logging.LoggerApi, usingYarn: boolean, verbose: boolean): void { if (!npmrc) { @@ -161,6 +139,18 @@ function readOptions( continue; } + if ( + normalizedName === 'registry' && + rcOptions['registry'] && + value === 'https://registry.yarnpkg.com' && + process.env['npm_config_user_agent']?.includes('yarn') + ) { + // When running `ng update` using yarn (`yarn ng update`), yarn will set the `npm_config_registry` env variable to `https://registry.yarnpkg.com` + // even when an RC file is present with a different repository. + // This causes the registry specified in the RC to always be overridden with the below logic. + continue; + } + normalizedName = normalizedName.replace(/(?!^)_/g, '-'); // don't replace _ at the start of the key.s envVariablesOptions[normalizedName] = value; } @@ -222,6 +212,10 @@ function normalizeOptions( } catch {} } break; + case 'before': + options['before'] = + typeof substitutedValue === 'string' ? new Date(substitutedValue) : substitutedValue; + break; default: options[key] = substitutedValue; break; @@ -231,18 +225,6 @@ function normalizeOptions( return options; } -function normalizeManifest(rawManifest: { name: string; version: string }): PackageManifest { - // TODO: Fully normalize and sanitize - - return { - dependencies: {}, - devDependencies: {}, - peerDependencies: {}, - optionalDependencies: {}, - ...rawManifest, - }; -} - export async function fetchPackageMetadata( name: string, logger: logging.LoggerApi, @@ -260,32 +242,27 @@ export async function fetchPackageMetadata( }; ensureNpmrc(logger, usingYarn, verbose); - - const response = await pacote.packument(name, { + const { packument } = await import('pacote'); + const response = await packument(name, { fullMetadata: true, ...npmrc, ...(registry ? { registry } : {}), }); + if (!response.versions) { + // While pacote type declares that versions cannot be undefined this is not the case. + response.versions = {}; + } + // Normalize the response const metadata: PackageMetadata = { - name: response.name, + ...response, tags: {}, - versions: {}, }; - if (response.versions) { - for (const [version, manifest] of Object.entries(response.versions)) { - metadata.versions[version] = normalizeManifest(manifest as { name: string; version: string }); - } - } - if (response['dist-tags']) { - // Store this for use with other npm utility packages - metadata['dist-tags'] = response['dist-tags']; - for (const [tag, version] of Object.entries(response['dist-tags'])) { - const manifest = metadata.versions[version as string]; + const manifest = metadata.versions[version]; if (manifest) { metadata.tags[tag] = manifest; } else if (verbose) { @@ -308,17 +285,18 @@ export async function fetchPackageManifest( ): Promise { const { usingYarn = false, verbose = false, registry } = options; ensureNpmrc(logger, usingYarn, verbose); + const { manifest } = await import('pacote'); - const response = await pacote.manifest(name, { + const response = await manifest(name, { fullMetadata: true, ...npmrc, ...(registry ? { registry } : {}), }); - return normalizeManifest(response); + return response; } -export function getNpmPackageJson( +export async function getNpmPackageJson( packageName: string, logger: logging.LoggerApi, options: { @@ -334,18 +312,18 @@ export function getNpmPackageJson( const { usingYarn = false, verbose = false, registry } = options; ensureNpmrc(logger, usingYarn, verbose); - - const resultPromise: Promise = pacote.packument(packageName, { + const { packument } = await import('pacote'); + const response = packument(packageName, { fullMetadata: true, ...npmrc, ...(registry ? { registry } : {}), - }); - - // TODO: find some way to test this - const response = resultPromise.catch((err) => { - logger.warn(err.message || err); + }).then((response) => { + // While pacote type declares that versions cannot be undefined this is not the case. + if (!response.versions) { + response.versions = {}; + } - return { requestedName: packageName }; + return response; }); npmPackageJsonCache.set(packageName, response); diff --git a/packages/angular/cli/utilities/package-tree.ts b/packages/angular/cli/src/utilities/package-tree.ts similarity index 92% rename from packages/angular/cli/utilities/package-tree.ts rename to packages/angular/cli/src/utilities/package-tree.ts index 9be8740a414b..923f1c732b4d 100644 --- a/packages/angular/cli/utilities/package-tree.ts +++ b/packages/angular/cli/src/utilities/package-tree.ts @@ -3,13 +3,13 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import * as fs from 'fs'; import { dirname, join } from 'path'; import * as resolve from 'resolve'; -import { NgAddSaveDepedency } from './package-metadata'; +import { NgAddSaveDependency } from './package-metadata'; interface PackageJson { name: string; @@ -22,7 +22,7 @@ interface PackageJson { migrations?: string; }; 'ng-add'?: { - save?: NgAddSaveDepedency; + save?: NgAddSaveDependency; }; } @@ -44,7 +44,7 @@ export interface PackageTreeNode { export async function readPackageJson(packageJsonPath: string): Promise { try { - return JSON.parse((await fs.promises.readFile(packageJsonPath)).toString()); + return JSON.parse((await fs.promises.readFile(packageJsonPath)).toString()) as PackageJson; } catch { return undefined; } diff --git a/packages/angular/cli/utilities/project.ts b/packages/angular/cli/src/utilities/project.ts similarity index 80% rename from packages/angular/cli/utilities/project.ts rename to packages/angular/cli/src/utilities/project.ts index db119818e723..b1c9cb14d458 100644 --- a/packages/angular/cli/utilities/project.ts +++ b/packages/angular/cli/src/utilities/project.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { normalize } from '@angular-devkit/core'; @@ -12,13 +12,13 @@ import * as os from 'os'; import * as path from 'path'; import { findUp } from './find-up'; +interface PackageDependencies { + dependencies?: Record; + devDependencies?: Record; +} + export function findWorkspaceFile(currentDirectory = process.cwd()): string | null { - const possibleConfigFiles = [ - 'angular.json', - '.angular.json', - 'angular-cli.json', - '.angular-cli.json', - ]; + const possibleConfigFiles = ['angular.json', '.angular.json']; const configFilePath = findUp(possibleConfigFiles, currentDirectory); if (configFilePath === null) { return null; @@ -32,7 +32,7 @@ export function findWorkspaceFile(currentDirectory = process.cwd()): string | nu try { const packageJsonText = fs.readFileSync(packageJsonPath, 'utf-8'); - const packageJson = JSON.parse(packageJsonText); + const packageJson = JSON.parse(packageJsonText) as PackageDependencies; if (!containsCliDep(packageJson)) { // No CLI dependency return null; @@ -46,10 +46,7 @@ export function findWorkspaceFile(currentDirectory = process.cwd()): string | nu return configFilePath; } -function containsCliDep(obj?: { - dependencies?: Record; - devDependencies?: Record; -}): boolean { +function containsCliDep(obj?: PackageDependencies): boolean { const pkgName = '@angular/cli'; if (!obj) { return false; diff --git a/packages/angular/cli/src/utilities/prompt.ts b/packages/angular/cli/src/utilities/prompt.ts new file mode 100644 index 000000000000..3d4e8c67ce09 --- /dev/null +++ b/packages/angular/cli/src/utilities/prompt.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { isTTY } from './tty'; + +export async function askConfirmation( + message: string, + defaultResponse: boolean, + noTTYResponse?: boolean, +): Promise { + if (!isTTY()) { + return noTTYResponse ?? defaultResponse; + } + + const { confirm } = await import('@inquirer/prompts'); + const answer = await confirm({ + message, + default: defaultResponse, + theme: { + prefix: '', + }, + }); + + return answer; +} + +export async function askQuestion( + message: string, + choices: { name: string; value: string | null }[], + defaultResponseIndex: number, + noTTYResponse: null | string, +): Promise { + if (!isTTY()) { + return noTTYResponse; + } + + const { select } = await import('@inquirer/prompts'); + const answer = await select({ + message, + choices, + default: defaultResponseIndex, + theme: { + prefix: '', + }, + }); + + return answer; +} + +export async function askChoices( + message: string, + choices: { name: string; value: string }[], + noTTYResponse: string[] | null, +): Promise { + if (!isTTY()) { + return noTTYResponse; + } + + const { checkbox } = await import('@inquirer/prompts'); + const answers = await checkbox({ + message, + choices, + theme: { + prefix: '', + }, + }); + + return answers; +} diff --git a/packages/angular/cli/src/utilities/tty.ts b/packages/angular/cli/src/utilities/tty.ts new file mode 100644 index 000000000000..db6543926941 --- /dev/null +++ b/packages/angular/cli/src/utilities/tty.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +function _isTruthy(value: undefined | string): boolean { + // Returns true if value is a string that is anything but 0 or false. + return value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE'; +} + +export function isTTY(stream: NodeJS.WriteStream = process.stdout): boolean { + // If we force TTY, we always return true. + const force = process.env['NG_FORCE_TTY']; + if (force !== undefined) { + return _isTruthy(force); + } + + return !!stream.isTTY && !_isTruthy(process.env['CI']); +} diff --git a/packages/angular/cli/src/utilities/version.ts b/packages/angular/cli/src/utilities/version.ts new file mode 100644 index 000000000000..71a6f4c70cee --- /dev/null +++ b/packages/angular/cli/src/utilities/version.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +// Same structure as used in framework packages +class Version { + public readonly major: string; + public readonly minor: string; + public readonly patch: string; + + constructor(public readonly full: string) { + const [major, minor, patch] = full.split('-', 1)[0].split('.', 3); + this.major = major; + this.minor = minor; + this.patch = patch; + } +} + +// TODO(bazel): Convert this to use build-time version stamping after flipping the build script to use bazel +// export const VERSION = new Version('0.0.0-PLACEHOLDER'); +export const VERSION = new Version( + ( + JSON.parse(readFileSync(resolve(__dirname, '../../package.json'), 'utf-8')) as { + version: string; + } + ).version, +); diff --git a/packages/angular/cli/utilities/INITIAL_COMMIT_MESSAGE.txt b/packages/angular/cli/utilities/INITIAL_COMMIT_MESSAGE.txt deleted file mode 100644 index 2f0b94d3b50c..000000000000 --- a/packages/angular/cli/utilities/INITIAL_COMMIT_MESSAGE.txt +++ /dev/null @@ -1,8 +0,0 @@ -chore: initial commit from @angular/cli - - _ _ ____ _ ___ - / \ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _| - / â–³ \ | '_ \ / _\` | | | | |/ _\` | '__| | | | | | | - / ___ \| | | | (_| | |_| | | (_| | | | |___| |___ | | -/_/ \_\_| |_|\__, |\__,_|_|\__,_|_| \____|_____|___| - |___/ diff --git a/packages/angular/cli/utilities/color.ts b/packages/angular/cli/utilities/color.ts deleted file mode 100644 index 8ddcadf3d1eb..000000000000 --- a/packages/angular/cli/utilities/color.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import * as ansiColors from 'ansi-colors'; -import { WriteStream } from 'tty'; - -type AnsiColors = typeof ansiColors; - -function supportColor(): boolean { - if (process.env.FORCE_COLOR !== undefined) { - // 2 colors: FORCE_COLOR = 0 (Disables colors), depth 1 - // 16 colors: FORCE_COLOR = 1, depth 4 - // 256 colors: FORCE_COLOR = 2, depth 8 - // 16,777,216 colors: FORCE_COLOR = 3, depth 16 - // See: https://nodejs.org/dist/latest-v12.x/docs/api/tty.html#tty_writestream_getcolordepth_env - // and https://github.com/nodejs/node/blob/b9f36062d7b5c5039498e98d2f2c180dca2a7065/lib/internal/tty.js#L106; - switch (process.env.FORCE_COLOR) { - case '': - case 'true': - case '1': - case '2': - case '3': - return true; - default: - return false; - } - } - - if (process.stdout instanceof WriteStream) { - return process.stdout.getColorDepth() > 1; - } - - return false; -} - -export function removeColor(text: string): string { - // This has been created because when colors.enabled is false unstyle doesn't work - // see: https://github.com/doowb/ansi-colors/blob/a4794363369d7b4d1872d248fc43a12761640d8e/index.js#L38 - return text.replace(ansiColors.ansiRegex, ''); -} - -// Create a separate instance to prevent unintended global changes to the color configuration -// Create function is not defined in the typings. See: https://github.com/doowb/ansi-colors/pull/44 -const colors = (ansiColors as AnsiColors & { create: () => AnsiColors }).create(); -colors.enabled = supportColor(); - -export { colors }; diff --git a/packages/angular/cli/utilities/config.ts b/packages/angular/cli/utilities/config.ts deleted file mode 100644 index d5ce06a835d4..000000000000 --- a/packages/angular/cli/utilities/config.ts +++ /dev/null @@ -1,482 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { json, workspaces } from '@angular-devkit/core'; -import { existsSync, readFileSync, statSync, writeFileSync } from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { findUp } from './find-up'; -import { JSONFile, readAndParseJson } from './json-file'; - -function isJsonObject(value: json.JsonValue | undefined): value is json.JsonObject { - return value !== undefined && json.isJsonObject(value); -} - -function createWorkspaceHost(): workspaces.WorkspaceHost { - return { - async readFile(path) { - return readFileSync(path, 'utf-8'); - }, - async writeFile(path, data) { - writeFileSync(path, data); - }, - async isDirectory(path) { - try { - return statSync(path).isDirectory(); - } catch { - return false; - } - }, - async isFile(path) { - try { - return statSync(path).isFile(); - } catch { - return false; - } - }, - }; -} - -function getSchemaLocation(): string { - return path.join(__dirname, '../lib/config/schema.json'); -} - -export const workspaceSchemaPath = getSchemaLocation(); - -const configNames = ['angular.json', '.angular.json']; -const globalFileName = '.angular-config.json'; - -function xdgConfigHome(home: string, configFile?: string): string { - // https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html - const xdgConfigHome = process.env['XDG_CONFIG_HOME'] || path.join(home, '.config'); - const xdgAngularHome = path.join(xdgConfigHome, 'angular'); - - return configFile ? path.join(xdgAngularHome, configFile) : xdgAngularHome; -} - -function xdgConfigHomeOld(home: string): string { - // Check the configuration files in the old location that should be: - // - $XDG_CONFIG_HOME/.angular-config.json (if XDG_CONFIG_HOME is set) - // - $HOME/.config/angular/.angular-config.json (otherwise) - const p = process.env['XDG_CONFIG_HOME'] || path.join(home, '.config', 'angular'); - - return path.join(p, '.angular-config.json'); -} - -function projectFilePath(projectPath?: string): string | null { - // Find the configuration, either where specified, in the Angular CLI project - // (if it's in node_modules) or from the current process. - return ( - (projectPath && findUp(configNames, projectPath)) || - findUp(configNames, process.cwd()) || - findUp(configNames, __dirname) - ); -} - -function globalFilePath(): string | null { - const home = os.homedir(); - if (!home) { - return null; - } - - // follow XDG Base Directory spec - // note that createGlobalSettings() will continue creating - // global file in home directory, with this user will have - // choice to move change its location to meet XDG convention - const xdgConfig = xdgConfigHome(home, 'config.json'); - if (existsSync(xdgConfig)) { - return xdgConfig; - } - // NOTE: This check is for the old configuration location, for more - // information see https://github.com/angular/angular-cli/pull/20556 - const xdgConfigOld = xdgConfigHomeOld(home); - if (existsSync(xdgConfigOld)) { - /* eslint-disable no-console */ - console.warn( - `Old configuration location detected: ${xdgConfigOld}\n` + - `Please move the file to the new location ~/.config/angular/config.json`, - ); - - return xdgConfigOld; - } - - const p = path.join(home, globalFileName); - if (existsSync(p)) { - return p; - } - - return null; -} - -export class AngularWorkspace { - readonly basePath: string; - - constructor(private workspace: workspaces.WorkspaceDefinition, readonly filePath: string) { - this.basePath = path.dirname(filePath); - } - - get extensions(): Record { - return this.workspace.extensions; - } - - get projects(): workspaces.ProjectDefinitionCollection { - return this.workspace.projects; - } - - // Temporary helper functions to support refactoring - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getCli(): Record { - return (this.workspace.extensions['cli'] as Record) || {}; - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - getProjectCli(projectName: string): Record { - const project = this.workspace.projects.get(projectName); - - return (project?.extensions['cli'] as Record) || {}; - } - - static async load(workspaceFilePath: string): Promise { - const oldConfigFileNames = ['.angular-cli.json', 'angular-cli.json']; - if (oldConfigFileNames.includes(path.basename(workspaceFilePath))) { - // 1.x file format - // Create an empty workspace to allow update to be used - return new AngularWorkspace( - { extensions: {}, projects: new workspaces.ProjectDefinitionCollection() }, - workspaceFilePath, - ); - } - - const result = await workspaces.readWorkspace( - workspaceFilePath, - createWorkspaceHost(), - workspaces.WorkspaceFormat.JSON, - ); - - return new AngularWorkspace(result.workspace, workspaceFilePath); - } -} - -const cachedWorkspaces = new Map(); - -export async function getWorkspace( - level: 'local' | 'global' = 'local', -): Promise { - const cached = cachedWorkspaces.get(level); - if (cached !== undefined) { - return cached; - } - - const configPath = level === 'local' ? projectFilePath() : globalFilePath(); - - if (!configPath) { - cachedWorkspaces.set(level, null); - - return null; - } - - try { - const workspace = await AngularWorkspace.load(configPath); - cachedWorkspaces.set(level, workspace); - - return workspace; - } catch (error) { - throw new Error( - `Workspace config file cannot be loaded: ${configPath}` + - `\n${error instanceof Error ? error.message : error}`, - ); - } -} - -export function createGlobalSettings(): string { - const home = os.homedir(); - if (!home) { - throw new Error('No home directory found.'); - } - - const globalPath = path.join(home, globalFileName); - writeFileSync(globalPath, JSON.stringify({ version: 1 })); - - return globalPath; -} - -export function getWorkspaceRaw( - level: 'local' | 'global' = 'local', -): [JSONFile | null, string | null] { - let configPath = level === 'local' ? projectFilePath() : globalFilePath(); - - if (!configPath) { - if (level === 'global') { - configPath = createGlobalSettings(); - } else { - return [null, null]; - } - } - - return [new JSONFile(configPath), configPath]; -} - -export async function validateWorkspace(data: json.JsonObject): Promise { - const schema = readAndParseJson( - path.join(__dirname, '../lib/config/schema.json'), - ) as json.schema.JsonSchema; - const { formats } = await import('@angular-devkit/schematics'); - const registry = new json.schema.CoreSchemaRegistry(formats.standardFormats); - const validator = await registry.compile(schema).toPromise(); - - const { success, errors } = await validator(data).toPromise(); - if (!success) { - throw new json.schema.SchemaValidationException(errors); - } -} - -function findProjectByPath(workspace: AngularWorkspace, location: string): string | null { - const isInside = (base: string, potential: string): boolean => { - const absoluteBase = path.resolve(workspace.basePath, base); - const absolutePotential = path.resolve(workspace.basePath, potential); - const relativePotential = path.relative(absoluteBase, absolutePotential); - if (!relativePotential.startsWith('..') && !path.isAbsolute(relativePotential)) { - return true; - } - - return false; - }; - - const projects = Array.from(workspace.projects) - .map(([name, project]) => [project.root, name] as [string, string]) - .filter((tuple) => isInside(tuple[0], location)) - // Sort tuples by depth, with the deeper ones first. Since the first member is a path and - // we filtered all invalid paths, the longest will be the deepest (and in case of equality - // the sort is stable and the first declared project will win). - .sort((a, b) => b[0].length - a[0].length); - - if (projects.length === 0) { - return null; - } else if (projects.length > 1) { - const found = new Set(); - const sameRoots = projects.filter((v) => { - if (!found.has(v[0])) { - found.add(v[0]); - - return false; - } - - return true; - }); - if (sameRoots.length > 0) { - // Ambiguous location - cannot determine a project - return null; - } - } - - return projects[0][1]; -} - -export function getProjectByCwd(workspace: AngularWorkspace): string | null { - if (workspace.projects.size === 1) { - // If there is only one project, return that one. - return Array.from(workspace.projects.keys())[0]; - } - - const project = findProjectByPath(workspace, process.cwd()); - if (project) { - return project; - } - - const defaultProject = workspace.extensions['defaultProject']; - if (defaultProject && typeof defaultProject === 'string') { - // If there is a default project name, return it. - return defaultProject; - } - - return null; -} - -export async function getConfiguredPackageManager(): Promise { - const getPackageManager = (source: json.JsonValue | undefined): string | undefined => { - if (isJsonObject(source)) { - const value = source['packageManager']; - if (value && typeof value === 'string') { - return value; - } - } - }; - - let result: string | undefined | null; - - const workspace = await getWorkspace('local'); - if (workspace) { - const project = getProjectByCwd(workspace); - if (project) { - result = getPackageManager(workspace.projects.get(project)?.extensions['cli']); - } - - result = result ?? getPackageManager(workspace.extensions['cli']); - } - - if (result === undefined) { - const globalOptions = await getWorkspace('global'); - result = getPackageManager(globalOptions?.extensions['cli']); - - if (!workspace && !globalOptions) { - // Only check legacy if updated workspace is not found - result = getLegacyPackageManager(); - } - } - - // Default to null - return result ?? null; -} - -export function migrateLegacyGlobalConfig(): boolean { - const homeDir = os.homedir(); - if (homeDir) { - const legacyGlobalConfigPath = path.join(homeDir, '.angular-cli.json'); - if (existsSync(legacyGlobalConfigPath)) { - const legacy = readAndParseJson(legacyGlobalConfigPath); - if (!isJsonObject(legacy)) { - return false; - } - - const cli: json.JsonObject = {}; - - if ( - legacy.packageManager && - typeof legacy.packageManager == 'string' && - legacy.packageManager !== 'default' - ) { - cli['packageManager'] = legacy.packageManager; - } - - if ( - isJsonObject(legacy.defaults) && - isJsonObject(legacy.defaults.schematics) && - typeof legacy.defaults.schematics.collection == 'string' - ) { - cli['defaultCollection'] = legacy.defaults.schematics.collection; - } - - if (isJsonObject(legacy.warnings)) { - const warnings: json.JsonObject = {}; - if (typeof legacy.warnings.versionMismatch == 'boolean') { - warnings['versionMismatch'] = legacy.warnings.versionMismatch; - } - - if (Object.getOwnPropertyNames(warnings).length > 0) { - cli['warnings'] = warnings; - } - } - - if (Object.getOwnPropertyNames(cli).length > 0) { - const globalPath = path.join(homeDir, globalFileName); - writeFileSync(globalPath, JSON.stringify({ version: 1, cli }, null, 2)); - - return true; - } - } - } - - return false; -} - -// Fallback, check for packageManager in config file in v1.* global config. -function getLegacyPackageManager(): string | null { - const homeDir = os.homedir(); - if (homeDir) { - const legacyGlobalConfigPath = path.join(homeDir, '.angular-cli.json'); - if (existsSync(legacyGlobalConfigPath)) { - const legacy = readAndParseJson(legacyGlobalConfigPath); - if (!isJsonObject(legacy)) { - return null; - } - - if ( - legacy.packageManager && - typeof legacy.packageManager === 'string' && - legacy.packageManager !== 'default' - ) { - return legacy.packageManager; - } - } - } - - return null; -} - -export async function getSchematicDefaults( - collection: string, - schematic: string, - project?: string | null, -): Promise<{}> { - const result = {}; - const mergeOptions = (source: json.JsonValue | undefined): void => { - if (isJsonObject(source)) { - // Merge options from the qualified name - Object.assign(result, source[`${collection}:${schematic}`]); - - // Merge options from nested collection schematics - const collectionOptions = source[collection]; - if (isJsonObject(collectionOptions)) { - Object.assign(result, collectionOptions[schematic]); - } - } - }; - - // Global level schematic options - const globalOptions = await getWorkspace('global'); - mergeOptions(globalOptions?.extensions['schematics']); - - const workspace = await getWorkspace('local'); - if (workspace) { - // Workspace level schematic options - mergeOptions(workspace.extensions['schematics']); - - project = project || getProjectByCwd(workspace); - if (project) { - // Project level schematic options - mergeOptions(workspace.projects.get(project)?.extensions['schematics']); - } - } - - return result; -} - -export async function isWarningEnabled(warning: string): Promise { - const getWarning = (source: json.JsonValue | undefined): boolean | undefined => { - if (isJsonObject(source)) { - const warnings = source['warnings']; - if (isJsonObject(warnings)) { - const value = warnings[warning]; - if (typeof value == 'boolean') { - return value; - } - } - } - }; - - let result: boolean | undefined; - - const workspace = await getWorkspace('local'); - if (workspace) { - const project = getProjectByCwd(workspace); - if (project) { - result = getWarning(workspace.projects.get(project)?.extensions['cli']); - } - - result = result ?? getWarning(workspace.extensions['cli']); - } - - if (result === undefined) { - const globalOptions = await getWorkspace('global'); - result = getWarning(globalOptions?.extensions['cli']); - } - - // All warnings are enabled by default - return result ?? true; -} diff --git a/packages/angular/cli/utilities/install-package.ts b/packages/angular/cli/utilities/install-package.ts deleted file mode 100644 index 8142135915a5..000000000000 --- a/packages/angular/cli/utilities/install-package.ts +++ /dev/null @@ -1,251 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { spawn, spawnSync } from 'child_process'; -import { existsSync, mkdtempSync, readFileSync, realpathSync, rmdirSync, writeFileSync } from 'fs'; -import { tmpdir } from 'os'; -import { join, resolve } from 'path'; -import { PackageManager } from '../lib/config/workspace-schema'; -import { NgAddSaveDepedency } from '../utilities/package-metadata'; -import { Spinner } from './spinner'; - -interface PackageManagerOptions { - silent: string; - saveDev: string; - install: string; - installAll?: string; - prefix: string; - noLockfile: string; -} - -export async function installAllPackages( - packageManager: PackageManager = PackageManager.Npm, - extraArgs: string[] = [], - cwd = process.cwd(), -): Promise<1 | 0> { - const packageManagerArgs = getPackageManagerArguments(packageManager); - - const installArgs: string[] = []; - if (packageManagerArgs.installAll) { - installArgs.push(packageManagerArgs.installAll); - } - installArgs.push(packageManagerArgs.silent); - - const spinner = new Spinner(); - spinner.start('Installing packages...'); - - const bufferedOutput: { stream: NodeJS.WriteStream; data: Buffer }[] = []; - - return new Promise((resolve, reject) => { - const childProcess = spawn(packageManager, [...installArgs, ...extraArgs], { - stdio: 'pipe', - shell: true, - cwd, - }).on('close', (code: number) => { - if (code === 0) { - spinner.succeed('Packages successfully installed.'); - resolve(0); - } else { - spinner.stop(); - bufferedOutput.forEach(({ stream, data }) => stream.write(data)); - spinner.fail('Package install failed, see above.'); - reject(1); - } - }); - - childProcess.stdout?.on('data', (data: Buffer) => - bufferedOutput.push({ stream: process.stdout, data: data }), - ); - childProcess.stderr?.on('data', (data: Buffer) => - bufferedOutput.push({ stream: process.stderr, data: data }), - ); - }); -} - -export async function installPackage( - packageName: string, - packageManager: PackageManager = PackageManager.Npm, - save: Exclude = true, - extraArgs: string[] = [], - cwd = process.cwd(), -): Promise<1 | 0> { - const packageManagerArgs = getPackageManagerArguments(packageManager); - - const installArgs: string[] = [ - packageManagerArgs.install, - packageName, - packageManagerArgs.silent, - ]; - - const spinner = new Spinner(); - spinner.start('Installing package...'); - - if (save === 'devDependencies') { - installArgs.push(packageManagerArgs.saveDev); - } - const bufferedOutput: { stream: NodeJS.WriteStream; data: Buffer }[] = []; - - return new Promise((resolve, reject) => { - const childProcess = spawn(packageManager, [...installArgs, ...extraArgs], { - stdio: 'pipe', - shell: true, - cwd, - }).on('close', (code: number) => { - if (code === 0) { - spinner.succeed('Package successfully installed.'); - resolve(0); - } else { - spinner.stop(); - bufferedOutput.forEach(({ stream, data }) => stream.write(data)); - spinner.fail('Package install failed, see above.'); - reject(1); - } - }); - - childProcess.stdout?.on('data', (data: Buffer) => - bufferedOutput.push({ stream: process.stdout, data: data }), - ); - childProcess.stderr?.on('data', (data: Buffer) => - bufferedOutput.push({ stream: process.stderr, data: data }), - ); - }); -} - -export async function installTempPackage( - packageName: string, - packageManager: PackageManager = PackageManager.Npm, - extraArgs?: string[], -): Promise<{ - status: 1 | 0; - tempNodeModules: string; -}> { - const tempPath = mkdtempSync(join(realpathSync(tmpdir()), 'angular-cli-packages-')); - - // clean up temp directory on process exit - process.on('exit', () => { - try { - rmdirSync(tempPath, { recursive: true, maxRetries: 3 }); - } catch {} - }); - - // NPM will warn when a `package.json` is not found in the install directory - // Example: - // npm WARN enoent ENOENT: no such file or directory, open '/tmp/.ng-temp-packages-84Qi7y/package.json' - // npm WARN .ng-temp-packages-84Qi7y No description - // npm WARN .ng-temp-packages-84Qi7y No repository field. - // npm WARN .ng-temp-packages-84Qi7y No license field. - - // While we can use `npm init -y` we will end up needing to update the 'package.json' anyways - // because of missing fields. - writeFileSync( - join(tempPath, 'package.json'), - JSON.stringify({ - name: 'temp-cli-install', - description: 'temp-cli-install', - repository: 'temp-cli-install', - license: 'MIT', - }), - ); - - // setup prefix/global modules path - const packageManagerArgs = getPackageManagerArguments(packageManager); - const tempNodeModules = join(tempPath, 'node_modules'); - // Yarn will not append 'node_modules' to the path - const prefixPath = packageManager === PackageManager.Yarn ? tempNodeModules : tempPath; - const installArgs: string[] = [ - ...(extraArgs || []), - `${packageManagerArgs.prefix}="${prefixPath}"`, - packageManagerArgs.noLockfile, - ]; - - return { - status: await installPackage(packageName, packageManager, true, installArgs, tempPath), - tempNodeModules, - }; -} - -export async function runTempPackageBin( - packageName: string, - packageManager: PackageManager = PackageManager.Npm, - args: string[] = [], -): Promise { - const { status: code, tempNodeModules } = await installTempPackage(packageName, packageManager); - if (code !== 0) { - return code; - } - - // Remove version/tag etc... from package name - // Ex: @angular/cli@latest -> @angular/cli - const packageNameNoVersion = packageName.substring(0, packageName.lastIndexOf('@')); - const pkgLocation = join(tempNodeModules, packageNameNoVersion); - const packageJsonPath = join(pkgLocation, 'package.json'); - - // Get a binary location for this package - let binPath: string | undefined; - if (existsSync(packageJsonPath)) { - const content = readFileSync(packageJsonPath, 'utf-8'); - if (content) { - const { bin = {} } = JSON.parse(content); - const binKeys = Object.keys(bin); - - if (binKeys.length) { - binPath = resolve(pkgLocation, bin[binKeys[0]]); - } - } - } - - if (!binPath) { - throw new Error(`Cannot locate bin for temporary package: ${packageNameNoVersion}.`); - } - - const { status, error } = spawnSync(process.execPath, [binPath, ...args], { - stdio: 'inherit', - env: { - ...process.env, - NG_DISABLE_VERSION_CHECK: 'true', - NG_CLI_ANALYTICS: 'false', - }, - }); - - if (status === null && error) { - throw error; - } - - return status || 0; -} - -function getPackageManagerArguments(packageManager: PackageManager): PackageManagerOptions { - switch (packageManager) { - case PackageManager.Yarn: - return { - silent: '--silent', - saveDev: '--dev', - install: 'add', - prefix: '--modules-folder', - noLockfile: '--no-lockfile', - }; - case PackageManager.Pnpm: - return { - silent: '--silent', - saveDev: '--save-dev', - install: 'add', - installAll: 'install', - prefix: '--prefix', - noLockfile: '--no-lockfile', - }; - default: - return { - silent: '--quiet', - saveDev: '--save-dev', - install: 'install', - installAll: 'install', - prefix: '--prefix', - noLockfile: '--no-package-lock', - }; - } -} diff --git a/packages/angular/cli/utilities/json-schema.ts b/packages/angular/cli/utilities/json-schema.ts deleted file mode 100644 index f396d4a063d9..000000000000 --- a/packages/angular/cli/utilities/json-schema.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { BaseException, json } from '@angular-devkit/core'; -import { ExportStringRef } from '@angular-devkit/schematics/tools'; -import { readFileSync } from 'fs'; -import { dirname, resolve } from 'path'; -import { - CommandConstructor, - CommandDescription, - CommandScope, - Option, - OptionType, - SubCommandDescription, - Value, -} from '../models/interface'; - -export class CommandJsonPathException extends BaseException { - constructor(public readonly path: string, public override readonly name: string) { - super(`File ${path} was not found while constructing the subcommand ${name}.`); - } -} - -function _getEnumFromValue( - value: json.JsonValue, - enumeration: E, - defaultValue: T, -): T { - if (typeof value !== 'string') { - return defaultValue; - } - - if (Object.values(enumeration).includes(value)) { - return value as unknown as T; - } - - return defaultValue; -} - -export async function parseJsonSchemaToSubCommandDescription( - name: string, - jsonPath: string, - registry: json.schema.SchemaRegistry, - schema: json.JsonObject, -): Promise { - const options = await parseJsonSchemaToOptions(registry, schema); - - const aliases: string[] = []; - if (json.isJsonArray(schema.$aliases)) { - schema.$aliases.forEach((value) => { - if (typeof value == 'string') { - aliases.push(value); - } - }); - } - if (json.isJsonArray(schema.aliases)) { - schema.aliases.forEach((value) => { - if (typeof value == 'string') { - aliases.push(value); - } - }); - } - if (typeof schema.alias == 'string') { - aliases.push(schema.alias); - } - - let longDescription = ''; - if (typeof schema.$longDescription == 'string' && schema.$longDescription) { - const ldPath = resolve(dirname(jsonPath), schema.$longDescription); - try { - longDescription = readFileSync(ldPath, 'utf-8'); - } catch (e) { - throw new CommandJsonPathException(ldPath, name); - } - } - let usageNotes = ''; - if (typeof schema.$usageNotes == 'string' && schema.$usageNotes) { - const unPath = resolve(dirname(jsonPath), schema.$usageNotes); - try { - usageNotes = readFileSync(unPath, 'utf-8'); - } catch (e) { - throw new CommandJsonPathException(unPath, name); - } - } - - const description = '' + (schema.description === undefined ? '' : schema.description); - - return { - name, - description, - ...(longDescription ? { longDescription } : {}), - ...(usageNotes ? { usageNotes } : {}), - options, - aliases, - }; -} - -export async function parseJsonSchemaToCommandDescription( - name: string, - jsonPath: string, - registry: json.schema.SchemaRegistry, - schema: json.JsonObject, -): Promise { - const subcommand = await parseJsonSchemaToSubCommandDescription(name, jsonPath, registry, schema); - - // Before doing any work, let's validate the implementation. - if (typeof schema.$impl != 'string') { - throw new Error(`Command ${name} has an invalid implementation.`); - } - const ref = new ExportStringRef(schema.$impl, dirname(jsonPath)); - const impl = ref.ref; - - if (impl === undefined || typeof impl !== 'function') { - throw new Error(`Command ${name} has an invalid implementation.`); - } - - const scope = _getEnumFromValue(schema.$scope, CommandScope, CommandScope.Default); - const hidden = !!schema.$hidden; - - return { - ...subcommand, - scope, - hidden, - impl, - }; -} - -export async function parseJsonSchemaToOptions( - registry: json.schema.SchemaRegistry, - schema: json.JsonObject, -): Promise { - const options: Option[] = []; - - function visitor( - current: json.JsonObject | json.JsonArray, - pointer: json.schema.JsonPointer, - parentSchema?: json.JsonObject | json.JsonArray, - ) { - if (!parentSchema) { - // Ignore root. - return; - } else if (pointer.split(/\/(?:properties|items|definitions)\//g).length > 2) { - // Ignore subitems (objects or arrays). - return; - } else if (json.isJsonArray(current)) { - return; - } - - if (pointer.indexOf('/not/') != -1) { - // We don't support anyOf/not. - throw new Error('The "not" keyword is not supported in JSON Schema.'); - } - - const ptr = json.schema.parseJsonPointer(pointer); - const name = ptr[ptr.length - 1]; - - if (ptr[ptr.length - 2] != 'properties') { - // Skip any non-property items. - return; - } - - const typeSet = json.schema.getTypesOfSchema(current); - - if (typeSet.size == 0) { - throw new Error('Cannot find type of schema.'); - } - - // We only support number, string or boolean (or array of those), so remove everything else. - const types = [...typeSet] - .filter((x) => { - switch (x) { - case 'boolean': - case 'number': - case 'string': - return true; - - case 'array': - // Only include arrays if they're boolean, string or number. - if ( - json.isJsonObject(current.items) && - typeof current.items.type == 'string' && - ['boolean', 'number', 'string'].includes(current.items.type) - ) { - return true; - } - - return false; - - default: - return false; - } - }) - .map((x) => _getEnumFromValue(x, OptionType, OptionType.String)); - - if (types.length == 0) { - // This means it's not usable on the command line. e.g. an Object. - return; - } - - // Only keep enum values we support (booleans, numbers and strings). - const enumValues = ((json.isJsonArray(current.enum) && current.enum) || []).filter((x) => { - switch (typeof x) { - case 'boolean': - case 'number': - case 'string': - return true; - - default: - return false; - } - }) as Value[]; - - let defaultValue: string | number | boolean | undefined = undefined; - if (current.default !== undefined) { - switch (types[0]) { - case 'string': - if (typeof current.default == 'string') { - defaultValue = current.default; - } - break; - case 'number': - if (typeof current.default == 'number') { - defaultValue = current.default; - } - break; - case 'boolean': - if (typeof current.default == 'boolean') { - defaultValue = current.default; - } - break; - } - } - - const type = types[0]; - const $default = current.$default; - const $defaultIndex = - json.isJsonObject($default) && $default['$source'] == 'argv' ? $default['index'] : undefined; - const positional: number | undefined = - typeof $defaultIndex == 'number' ? $defaultIndex : undefined; - - const required = json.isJsonArray(current.required) - ? current.required.indexOf(name) != -1 - : false; - const aliases = json.isJsonArray(current.aliases) - ? [...current.aliases].map((x) => '' + x) - : current.alias - ? ['' + current.alias] - : []; - const format = typeof current.format == 'string' ? current.format : undefined; - const visible = current.visible === undefined || current.visible === true; - const hidden = !!current.hidden || !visible; - - const xUserAnalytics = current['x-user-analytics']; - const userAnalytics = typeof xUserAnalytics == 'number' ? xUserAnalytics : undefined; - - // Deprecated is set only if it's true or a string. - const xDeprecated = current['x-deprecated']; - const deprecated = - xDeprecated === true || typeof xDeprecated === 'string' ? xDeprecated : undefined; - - const option: Option = { - name, - description: '' + (current.description === undefined ? '' : current.description), - ...(types.length == 1 ? { type } : { type, types }), - ...(defaultValue !== undefined ? { default: defaultValue } : {}), - ...(enumValues && enumValues.length > 0 ? { enum: enumValues } : {}), - required, - aliases, - ...(format !== undefined ? { format } : {}), - hidden, - ...(userAnalytics ? { userAnalytics } : {}), - ...(deprecated !== undefined ? { deprecated } : {}), - ...(positional !== undefined ? { positional } : {}), - }; - - options.push(option); - } - - const flattenedSchema = await registry.flatten(schema).toPromise(); - json.schema.visitJsonSchema(flattenedSchema, visitor); - - // Sort by positional. - return options.sort((a, b) => { - if (a.positional) { - if (b.positional) { - return a.positional - b.positional; - } else { - return 1; - } - } else if (b.positional) { - return -1; - } else { - return 0; - } - }); -} diff --git a/packages/angular/cli/utilities/json-schema_spec.ts b/packages/angular/cli/utilities/json-schema_spec.ts deleted file mode 100644 index f300cc4bc077..000000000000 --- a/packages/angular/cli/utilities/json-schema_spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { schema } from '@angular-devkit/core'; -import { readFileSync } from 'fs'; -import { join } from 'path'; -import { CommandJsonPathException, parseJsonSchemaToCommandDescription } from './json-schema'; - -describe('parseJsonSchemaToCommandDescription', () => { - let registry: schema.CoreSchemaRegistry; - const baseSchemaJson = { - '$schema': 'http://json-schema.org/schema', - '$id': 'ng-cli://commands/version.json', - 'description': 'Outputs Angular CLI version.', - '$longDescription': 'not a file ref', - - '$aliases': ['v'], - '$scope': 'all', - '$impl': './version-impl#VersionCommand', - - 'type': 'object', - 'allOf': [{ '$ref': './definitions.json#/definitions/base' }], - }; - - beforeEach(() => { - registry = new schema.CoreSchemaRegistry([]); - registry.registerUriHandler((uri: string) => { - if (uri.startsWith('ng-cli://')) { - const content = readFileSync( - join(__dirname, '..', uri.substr('ng-cli://'.length)), - 'utf-8', - ); - - return Promise.resolve(JSON.parse(content)); - } else { - return null; - } - }); - }); - - it(`should throw on invalid $longDescription path`, async () => { - const name = 'version'; - const schemaPath = join(__dirname, './bad-sample.json'); - const schemaJson = { ...baseSchemaJson, $longDescription: 'not a file ref' }; - try { - await parseJsonSchemaToCommandDescription(name, schemaPath, registry, schemaJson); - } catch (error) { - const refPath = join(__dirname, schemaJson.$longDescription); - expect(error).toEqual(new CommandJsonPathException(refPath, name)); - - return; - } - expect(true).toBe(false, 'function should have thrown'); - }); - - it(`should throw on invalid $usageNotes path`, async () => { - const name = 'version'; - const schemaPath = join(__dirname, './bad-sample.json'); - const schemaJson = { ...baseSchemaJson, $usageNotes: 'not a file ref' }; - try { - await parseJsonSchemaToCommandDescription(name, schemaPath, registry, schemaJson); - } catch (error) { - const refPath = join(__dirname, schemaJson.$usageNotes); - expect(error).toEqual(new CommandJsonPathException(refPath, name)); - - return; - } - expect(true).toBe(false, 'function should have thrown'); - }); -}); diff --git a/packages/angular/cli/utilities/package-json.ts b/packages/angular/cli/utilities/package-json.ts deleted file mode 100644 index 359f451ff9b7..000000000000 --- a/packages/angular/cli/utilities/package-json.ts +++ /dev/null @@ -1,266 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -/** - * This file was automatically generated by json-schema-to-typescript. - * DO NOT MODIFY IT BY HAND. Instead, modify the source JSONSchema file, - * and run json-schema-to-typescript to regenerate this file. - */ -/* eslint-disable */ - -export type JsonSchemaForNpmPackageJsonFiles = CoreProperties & - JspmDefinition & - ( - | { - bundleDependencies?: BundledDependency; - [k: string]: any; - } - | { - bundledDependencies?: BundledDependency; - [k: string]: any; - } - ) & { - [k: string]: any; - }; -/** - * A person who has been involved in creating or maintaining this package - */ -export type Person = - | { - [k: string]: any; - } - | string; -/** - * Run AFTER the package is published - */ -export type ScriptsPublishAfter = string; -/** - * Run AFTER the package is installed - */ -export type ScriptsInstallAfter = string; -/** - * Run BEFORE the package is uninstalled - */ -export type ScriptsUninstallBefore = string; -/** - * Run BEFORE bump the package version - */ -export type ScriptsVersionBefore = string; -/** - * Run by the 'npm test' command - */ -export type ScriptsTest = string; -/** - * Run by the 'npm stop' command - */ -export type ScriptsStop = string; -/** - * Run by the 'npm start' command - */ -export type ScriptsStart = string; -/** - * Run by the 'npm restart' command. Note: 'npm restart' will run the stop and start scripts if no restart script is provided. - */ -export type ScriptsRestart = string; -/** - * Array of package names that will be bundled when publishing the package. - */ -export type BundledDependency = string[]; - -export interface CoreProperties { - /** - * The name of the package. - */ - name?: string; - /** - * Version must be parseable by node-semver, which is bundled with npm as a dependency. - */ - version?: string; - /** - * This helps people discover your package, as it's listed in 'npm search'. - */ - description?: string; - /** - * This helps people discover your package as it's listed in 'npm search'. - */ - keywords?: string[]; - /** - * The url to the project homepage. - */ - homepage?: string; - /** - * The url to your project's issue tracker and / or the email address to which issues should be reported. These are helpful for people who encounter issues with your package. - */ - bugs?: - | { - [k: string]: any; - } - | string; - /** - * You should specify a license for your package so that people know how they are permitted to use it, and any restrictions you're placing on it. - */ - license?: string; - /** - * You should specify a license for your package so that people know how they are permitted to use it, and any restrictions you're placing on it. - */ - licenses?: { - type?: string; - url?: string; - [k: string]: any; - }[]; - author?: Person; - /** - * A list of people who contributed to this package. - */ - contributors?: Person[]; - /** - * A list of people who maintains this package. - */ - maintainers?: Person[]; - /** - * The 'files' field is an array of files to include in your project. If you name a folder in the array, then it will also include the files inside that folder. - */ - files?: string[]; - /** - * The main field is a module ID that is the primary entry point to your program. - */ - main?: string; - bin?: - | string - | { - [k: string]: any; - }; - /** - * Specify either a single file or an array of filenames to put in place for the man program to find. - */ - man?: string[]; - directories?: { - /** - * If you specify a 'bin' directory, then all the files in that folder will be used as the 'bin' hash. - */ - bin?: string; - /** - * Put markdown files in here. Eventually, these will be displayed nicely, maybe, someday. - */ - doc?: string; - /** - * Put example scripts in here. Someday, it might be exposed in some clever way. - */ - example?: string; - /** - * Tell people where the bulk of your library is. Nothing special is done with the lib folder in any way, but it's useful meta info. - */ - lib?: string; - /** - * A folder that is full of man pages. Sugar to generate a 'man' array by walking the folder. - */ - man?: string; - test?: string; - [k: string]: any; - }; - /** - * Specify the place where your code lives. This is helpful for people who want to contribute. - */ - repository?: - | { - [k: string]: any; - } - | string; - /** - * The 'scripts' member is an object hash of script commands that are run at various times in the lifecycle of your package. The key is the lifecycle event, and the value is the command to run at that point. - */ - scripts?: { - /** - * Run BEFORE the package is published (Also run on local npm install without any arguments) - */ - prepublish?: string; - publish?: ScriptsPublishAfter; - postpublish?: ScriptsPublishAfter; - /** - * Run BEFORE the package is installed - */ - preinstall?: string; - install?: ScriptsInstallAfter; - postinstall?: ScriptsInstallAfter; - preuninstall?: ScriptsUninstallBefore; - uninstall?: ScriptsUninstallBefore; - /** - * Run AFTER the package is uninstalled - */ - postuninstall?: string; - preversion?: ScriptsVersionBefore; - version?: ScriptsVersionBefore; - /** - * Run AFTER bump the package version - */ - postversion?: string; - pretest?: ScriptsTest; - test?: ScriptsTest; - posttest?: ScriptsTest; - prestop?: ScriptsStop; - stop?: ScriptsStop; - poststop?: ScriptsStop; - prestart?: ScriptsStart; - start?: ScriptsStart; - poststart?: ScriptsStart; - prerestart?: ScriptsRestart; - restart?: ScriptsRestart; - postrestart?: ScriptsRestart; - [k: string]: string | undefined; - }; - /** - * A 'config' hash can be used to set configuration parameters used in package scripts that persist across upgrades. - */ - config?: { - [k: string]: any; - }; - dependencies?: Dependency; - devDependencies?: Dependency; - optionalDependencies?: Dependency; - peerDependencies?: Dependency; - engines?: { - [k: string]: string; - }; - engineStrict?: boolean; - /** - * You can specify which operating systems your module will run on - */ - os?: string[]; - /** - * If your code only runs on certain cpu architectures, you can specify which ones. - */ - cpu?: string[]; - /** - * If your package is primarily a command-line application that should be installed globally, then set this value to true to provide a warning if it is installed locally. - */ - preferGlobal?: boolean; - /** - * If set to true, then npm will refuse to publish it. - */ - private?: boolean; - publishConfig?: { - [k: string]: any; - }; - dist?: { - shasum?: string; - tarball?: string; - [k: string]: any; - }; - readme?: string; - [k: string]: any; -} -/** - * Dependencies are specified with a simple hash of package name to version range. The version range is a string which has one or more space-separated descriptors. Dependencies can also be identified with a tarball or git URL. - */ -export interface Dependency { - [k: string]: string; -} -export interface JspmDefinition { - jspm?: CoreProperties; - [k: string]: any; -} diff --git a/packages/angular/cli/utilities/package-manager.ts b/packages/angular/cli/utilities/package-manager.ts deleted file mode 100644 index 2cb64781d854..000000000000 --- a/packages/angular/cli/utilities/package-manager.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { execSync } from 'child_process'; -import { existsSync } from 'fs'; -import { join } from 'path'; -import { satisfies, valid } from 'semver'; -import { PackageManager } from '../lib/config/workspace-schema'; -import { getConfiguredPackageManager } from './config'; - -function supports(name: string): boolean { - try { - execSync(`${name} --version`, { stdio: 'ignore' }); - - return true; - } catch { - return false; - } -} - -export function supportsYarn(): boolean { - return supports('yarn'); -} - -export function supportsNpm(): boolean { - return supports('npm'); -} - -export async function getPackageManager(root: string): Promise { - let packageManager = (await getConfiguredPackageManager()) as PackageManager | null; - if (packageManager) { - return packageManager; - } - - const hasYarn = supportsYarn(); - const hasYarnLock = existsSync(join(root, 'yarn.lock')); - const hasNpm = supportsNpm(); - const hasNpmLock = existsSync(join(root, 'package-lock.json')); - - if (hasYarn && hasYarnLock && !hasNpmLock) { - packageManager = PackageManager.Yarn; - } else if (hasNpm && hasNpmLock && !hasYarnLock) { - packageManager = PackageManager.Npm; - } else if (hasYarn && !hasNpm) { - packageManager = PackageManager.Yarn; - } else if (hasNpm && !hasYarn) { - packageManager = PackageManager.Npm; - } - - // TODO: This should eventually inform the user of ambiguous package manager usage. - // Potentially with a prompt to choose and optionally set as the default. - return packageManager || PackageManager.Npm; -} - -/** - * Checks if the npm version is a supported 7.x version. If not, display a warning. - */ -export async function ensureCompatibleNpm(root: string): Promise { - if ((await getPackageManager(root)) !== PackageManager.Npm) { - return; - } - - try { - const versionText = execSync('npm --version', { encoding: 'utf8', stdio: 'pipe' }).trim(); - const version = valid(versionText); - if (!version) { - return; - } - - if (satisfies(version, '>=7 <7.5.6')) { - // eslint-disable-next-line no-console - console.warn( - `npm version ${version} detected.` + - ' When using npm 7 with the Angular CLI, npm version 7.5.6 or higher is recommended.', - ); - } - } catch { - // npm is not installed - } -} diff --git a/packages/angular/cli/utilities/prompt.ts b/packages/angular/cli/utilities/prompt.ts deleted file mode 100644 index 97be8988662a..000000000000 --- a/packages/angular/cli/utilities/prompt.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import * as inquirer from 'inquirer'; -import { isTTY } from './tty'; - -export async function askConfirmation( - message: string, - defaultResponse: boolean, - noTTYResponse?: boolean, -): Promise { - if (!isTTY()) { - return noTTYResponse ?? defaultResponse; - } - - const question: inquirer.Question = { - type: 'confirm', - name: 'confirmation', - prefix: '', - message, - default: defaultResponse, - }; - - const answers = await inquirer.prompt([question]); - - return answers['confirmation']; -} diff --git a/packages/angular/cli/utilities/tty.ts b/packages/angular/cli/utilities/tty.ts deleted file mode 100644 index 1e5658ebfd57..000000000000 --- a/packages/angular/cli/utilities/tty.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @license - * Copyright Google LLC All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -function _isTruthy(value: undefined | string): boolean { - // Returns true if value is a string that is anything but 0 or false. - return value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE'; -} - -export function isTTY(): boolean { - // If we force TTY, we always return true. - const force = process.env['NG_FORCE_TTY']; - if (force !== undefined) { - return _isTruthy(force); - } - - return !!process.stdout.isTTY && !_isTruthy(process.env['CI']); -} diff --git a/packages/angular/create/BUILD.bazel b/packages/angular/create/BUILD.bazel new file mode 100644 index 000000000000..b4e42da1e4ed --- /dev/null +++ b/packages/angular/create/BUILD.bazel @@ -0,0 +1,36 @@ +# Copyright Google Inc. All Rights Reserved. +# +# Use of this source code is governed by an MIT-style license that can be +# found in the LICENSE file at https://angular.dev/license + +load("//tools:defaults.bzl", "pkg_npm", "ts_library") + +licenses(["notice"]) + +ts_library( + name = "create", + package_name = "@angular/create", + srcs = ["src/index.ts"], + deps = [ + "//packages/angular/cli:angular-cli", + "@npm//@types/node", + ], +) + +genrule( + name = "license", + srcs = ["//:LICENSE"], + outs = ["LICENSE"], + cmd = "cp $(execpath //:LICENSE) $@", +) + +pkg_npm( + name = "npm_package", + tags = ["release-package"], + visibility = ["//visibility:public"], + deps = [ + ":README.md", + ":create", + ":license", + ], +) diff --git a/packages/angular/create/README.md b/packages/angular/create/README.md new file mode 100644 index 000000000000..46135476e406 --- /dev/null +++ b/packages/angular/create/README.md @@ -0,0 +1,31 @@ +# `@angular/create` + +## Create an Angular CLI workspace + +Scaffold an Angular CLI workspace without needing to install the Angular CLI globally. All of the [ng new](https://angular.dev/cli/new) options and features are supported. + +## Usage + +### npm + +``` +npm init @angular@latest [project-name] -- [...options] +``` + +### yarn + +``` +yarn create @angular [project-name] [...options] +``` + +### pnpm + +``` +pnpm create @angular [project-name] [...options] +``` + +### bun + +``` +bun create @angular [project-name] [...options] +``` diff --git a/packages/angular/create/package.json b/packages/angular/create/package.json new file mode 100644 index 000000000000..a5ad3fce4ff9 --- /dev/null +++ b/packages/angular/create/package.json @@ -0,0 +1,16 @@ +{ + "name": "@angular/create", + "version": "0.0.0-PLACEHOLDER", + "description": "Scaffold an Angular CLI workspace.", + "keywords": [ + "angular", + "angular-cli", + "Angular CLI", + "code generation", + "schematics" + ], + "bin": "./src/index.js", + "dependencies": { + "@angular/cli": "0.0.0-PLACEHOLDER" + } +} diff --git a/packages/angular/create/src/index.ts b/packages/angular/create/src/index.ts new file mode 100644 index 000000000000..15e521ed964d --- /dev/null +++ b/packages/angular/create/src/index.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env node +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { spawnSync } from 'child_process'; +import { join } from 'path'; + +const binPath = join(require.resolve('@angular/cli/package.json'), '../bin/ng.js'); +const args = process.argv.slice(2); + +const hasPackageManagerArg = args.some((a) => a.startsWith('--package-manager')); +if (!hasPackageManagerArg) { + // Ex: yarn/1.22.18 npm/? node/v16.15.1 linux x64 + const packageManager = process.env['npm_config_user_agent']?.split('/')[0]; + if (packageManager && ['npm', 'pnpm', 'yarn', 'cnpm', 'bun'].includes(packageManager)) { + args.push('--package-manager', packageManager); + } +} + +// Invoke ng new with any parameters provided. +const { error } = spawnSync(process.execPath, [binPath, 'new', ...args], { + stdio: 'inherit', +}); + +if (error) { + // eslint-disable-next-line no-console + console.error(error); + process.exitCode = 1; +} diff --git a/packages/angular/pwa/BUILD.bazel b/packages/angular/pwa/BUILD.bazel index 37d9e0807fbf..5a8a3ecd3cca 100644 --- a/packages/angular/pwa/BUILD.bazel +++ b/packages/angular/pwa/BUILD.bazel @@ -1,39 +1,28 @@ # Copyright Google Inc. All Rights Reserved. # # Use of this source code is governed by an MIT-style license that can be -# found in the LICENSE file at https://angular.io/license +# found in the LICENSE file at https://angular.dev/license load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test") load("//tools:defaults.bzl", "pkg_npm", "ts_library") load("//tools:ts_json_schema.bzl", "ts_json_schema") -licenses(["notice"]) # MIT +licenses(["notice"]) package(default_visibility = ["//visibility:public"]) ts_library( name = "pwa", package_name = "@angular/pwa", - srcs = glob( - ["**/*.ts"], - # Currently, this library is used only with the rollup plugin. - # To make it simpler for downstream repositories to compile this, we - # neither provide compile-time deps as an `npm_install` rule, nor do we - # expect the downstream repository to install @types/webpack[-*] - # So we exclude files that depend on webpack typings. - exclude = [ - "pwa/files/**/*", - "**/*_spec.ts", - # NB: we need to exclude the nested node_modules that is laid out by yarn workspaces - "node_modules/**", - ], - ) + [ + srcs = [ + "pwa/index.ts", "//packages/angular/pwa:pwa/schema.ts", ], - data = glob( + data = [ + "collection.json", + "pwa/schema.json", + ] + glob( include = [ - "collection.json", - "pwa/schema.json", "pwa/files/**/*", ], ), @@ -41,7 +30,7 @@ ts_library( "//packages/angular_devkit/schematics", "//packages/schematics/angular", "@npm//@types/node", - "@npm//@types/parse5-html-rewriting-stream", + "@npm//parse5-html-rewriting-stream", ], ) @@ -54,11 +43,9 @@ ts_library( name = "pwa_test_lib", testonly = True, srcs = glob(["pwa/**/*_spec.ts"]), - # strict_checks = False, deps = [ ":pwa", "//packages/angular_devkit/schematics/testing", - "@npm//parse5-html-rewriting-stream", ], ) @@ -80,6 +67,7 @@ pkg_npm( "//packages/angular_devkit/schematics:package.json", "//packages/schematics/angular:package.json", ], + tags = ["release-package"], deps = [ ":README.md", ":license", diff --git a/packages/angular/pwa/README.md b/packages/angular/pwa/README.md index 9a2d8181fb8a..c7ecbdaa99af 100644 --- a/packages/angular/pwa/README.md +++ b/packages/angular/pwa/README.md @@ -1,22 +1,23 @@ # `@angular/pwa` -This is a [schematic](https://angular.io/guide/schematics) for adding -[Progress Web App](https://web.dev/progressive-web-apps/) support to an Angular app. Run the -schematic with the [Angular CLI](https://angular.io/cli): +This is a [schematic](https://angular.dev/tools/cli/schematics) for adding +[Progressive Web App](https://web.dev/progressive-web-apps/) support to an Angular project. Run the +schematic with the [Angular CLI](https://angular.dev/tools/cli): ```shell -ng add @angular/pwa +ng add @angular/pwa --project ``` -This makes a few changes to your project: +Executing the command mentioned above will perform the following actions: -1. Adds [`@angular/service-worker`](https://npmjs.com/@angular/service-worker) as a dependency. +1. Adds [`@angular/service-worker`](https://npmjs.com/@angular/service-worker) as a dependency to your project. 1. Enables service worker builds in the Angular CLI. -1. Imports and registers the service worker in the app module. -1. Adds a [web app manifest](https://developer.mozilla.org/en-US/docs/Web/Manifest). -1. Updates the `index.html` file to link to the manifest and set theme colors. -1. Adds required icons for the manifest. -1. Creates a config file `ngsw-config.json`, specifying caching behaviors and other settings. +1. Imports and registers the service worker in the application module. +1. Updates the `index.html` file: + - Includes a link to add the [manifest.webmanifest](https://developer.mozilla.org/en-US/docs/Web/Manifest) file. + - Adds a meta tag for `theme-color`. +1. Installs icon files to support the installed Progressive Web App (PWA). +1. Creates the service worker configuration file called `ngsw-config.json`, specifying caching behaviors and other settings. -See [Getting started with service workers](https://angular.io/guide/service-worker-getting-started) +See [Getting started with service workers](https://angular.dev/ecosystem/service-workers/getting-started) for more information. diff --git a/packages/angular/pwa/package.json b/packages/angular/pwa/package.json index 89a4e1fe04c8..778029e66822 100644 --- a/packages/angular/pwa/package.json +++ b/packages/angular/pwa/package.json @@ -14,6 +14,14 @@ "dependencies": { "@angular-devkit/schematics": "0.0.0-PLACEHOLDER", "@schematics/angular": "0.0.0-PLACEHOLDER", - "parse5-html-rewriting-stream": "6.0.1" + "parse5-html-rewriting-stream": "7.0.0" + }, + "peerDependencies": { + "@angular/cli": "^18.0.0" + }, + "peerDependenciesMeta": { + "@angular/cli": { + "optional": true + } } } diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-128x128.png b/packages/angular/pwa/pwa/files/assets/icons/icon-128x128.png index 9f9241f0be40..5a9a2ccdb34a 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-128x128.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-128x128.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-144x144.png b/packages/angular/pwa/pwa/files/assets/icons/icon-144x144.png index 4a5f8c16389c..11702cd7bd67 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-144x144.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-144x144.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-152x152.png b/packages/angular/pwa/pwa/files/assets/icons/icon-152x152.png index 34a1a8d64587..ff4e06b858a9 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-152x152.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-152x152.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-192x192.png b/packages/angular/pwa/pwa/files/assets/icons/icon-192x192.png index 9172e5dd29e4..afd36a48c681 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-192x192.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-192x192.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-384x384.png b/packages/angular/pwa/pwa/files/assets/icons/icon-384x384.png index e54e8d3eafe5..613ac793e063 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-384x384.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-384x384.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-512x512.png b/packages/angular/pwa/pwa/files/assets/icons/icon-512x512.png index 51ee297df1cb..7574990f2001 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-512x512.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-512x512.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-72x72.png b/packages/angular/pwa/pwa/files/assets/icons/icon-72x72.png index 2814a3f30caf..033724e15f54 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-72x72.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-72x72.png differ diff --git a/packages/angular/pwa/pwa/files/assets/icons/icon-96x96.png b/packages/angular/pwa/pwa/files/assets/icons/icon-96x96.png index d271025c4f22..3090dc2d8f93 100644 Binary files a/packages/angular/pwa/pwa/files/assets/icons/icon-96x96.png and b/packages/angular/pwa/pwa/files/assets/icons/icon-96x96.png differ diff --git a/packages/angular/pwa/pwa/files/assets/manifest.webmanifest b/packages/angular/pwa/pwa/files/assets/manifest.webmanifest new file mode 100644 index 000000000000..f8c1e3960511 --- /dev/null +++ b/packages/angular/pwa/pwa/files/assets/manifest.webmanifest @@ -0,0 +1,59 @@ +{ + "name": "<%= title %>", + "short_name": "<%= title %>", + "theme_color": "#1976d2", + "background_color": "#fafafa", + "display": "standalone", + "scope": "./", + "start_url": "./", + "icons": [ + { + "src": "<%= iconsPath %>/icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-384x384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "maskable any" + }, + { + "src": "<%= iconsPath %>/icon-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable any" + } + ] +} diff --git a/packages/angular/pwa/pwa/files/root/manifest.webmanifest b/packages/angular/pwa/pwa/files/root/manifest.webmanifest deleted file mode 100644 index 7d096fae01c5..000000000000 --- a/packages/angular/pwa/pwa/files/root/manifest.webmanifest +++ /dev/null @@ -1,59 +0,0 @@ -{ - "name": "<%= title %>", - "short_name": "<%= title %>", - "theme_color": "#1976d2", - "background_color": "#fafafa", - "display": "standalone", - "scope": "./", - "start_url": "./", - "icons": [ - { - "src": "assets/icons/icon-72x72.png", - "sizes": "72x72", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-96x96.png", - "sizes": "96x96", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-128x128.png", - "sizes": "128x128", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-144x144.png", - "sizes": "144x144", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-152x152.png", - "sizes": "152x152", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-192x192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-384x384.png", - "sizes": "384x384", - "type": "image/png", - "purpose": "maskable any" - }, - { - "src": "assets/icons/icon-512x512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable any" - } - ] -} diff --git a/packages/angular/pwa/pwa/index.ts b/packages/angular/pwa/pwa/index.ts index 0e6731adedc8..550e359e47f8 100644 --- a/packages/angular/pwa/pwa/index.ts +++ b/packages/angular/pwa/pwa/index.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { @@ -18,19 +18,21 @@ import { template, url, } from '@angular-devkit/schematics'; -import { getWorkspace, updateWorkspace } from '@schematics/angular/utility/workspace'; +import { readWorkspace, writeWorkspace } from '@schematics/angular/utility'; import { posix } from 'path'; -import { Readable, Writable } from 'stream'; +import { Readable } from 'stream'; +import { pipeline } from 'stream/promises'; import { Schema as PwaOptions } from './schema'; function updateIndexFile(path: string): Rule { return async (host: Tree) => { - const buffer = host.read(path); - if (buffer === null) { - throw new SchematicsException(`Could not read index file: ${path}`); - } + const originalContent = host.readText(path); + + const { RewritingStream } = await loadEsmModule( + 'parse5-html-rewriting-stream', + ); - const rewriter = new (await import('parse5-html-rewriting-stream')).default(); + const rewriter = new RewritingStream(); let needsNoScript = true; rewriter.on('startTag', (startTag) => { if (startTag.tagName === 'noscript') { @@ -53,30 +55,12 @@ function updateIndexFile(path: string): Rule { rewriter.emitEndTag(endTag); }); - return new Promise((resolve) => { - const input = new Readable({ - encoding: 'utf8', - read(): void { - this.push(buffer); - this.push(null); - }, - }); - - const chunks: Array = []; - const output = new Writable({ - write(chunk: string | Buffer, encoding: BufferEncoding, callback: Function): void { - chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk); - callback(); - }, - final(callback: (error?: Error) => void): void { - const full = Buffer.concat(chunks); - host.overwrite(path, full.toString()); - callback(); - resolve(); - }, - }); - - input.pipe(rewriter).pipe(output); + return pipeline(Readable.from(originalContent), rewriter, async function (source) { + const chunks = []; + for await (const chunk of source) { + chunks.push(Buffer.from(chunk)); + } + host.overwrite(path, Buffer.concat(chunks)); }); }; } @@ -87,7 +71,7 @@ export default function (options: PwaOptions): Rule { options.title = options.project; } - const workspace = await getWorkspace(host); + const workspace = await readWorkspace(host); if (!options.project) { throw new SchematicsException('Option "project" is required.'); @@ -110,30 +94,16 @@ export default function (options: PwaOptions): Rule { const buildTargets = []; const testTargets = []; for (const target of project.targets.values()) { - if (target.builder === '@angular-devkit/build-angular:browser') { + if ( + target.builder === '@angular-devkit/build-angular:browser' || + target.builder === '@angular-devkit/build-angular:application' + ) { buildTargets.push(target); } else if (target.builder === '@angular-devkit/build-angular:karma') { testTargets.push(target); } } - // Add manifest to asset configuration - const assetEntry = posix.join( - project.sourceRoot ?? posix.join(project.root, 'src'), - 'manifest.webmanifest', - ); - for (const target of [...buildTargets, ...testTargets]) { - if (target.options) { - if (Array.isArray(target.options.assets)) { - target.options.assets.push(assetEntry); - } else { - target.options.assets = [assetEntry]; - } - } else { - target.options = { assets: [assetEntry] }; - } - } - // Find all index.html files in build targets const indexFiles = new Set(); for (const target of buildTargets) { @@ -158,17 +128,54 @@ export default function (options: PwaOptions): Rule { // Setup service worker schematic options const { title, ...swOptions } = options; + await writeWorkspace(host, workspace); + let assetsDir = posix.join(sourcePath, 'assets'); + let iconsPath: string; + if (host.exists(assetsDir)) { + // Add manifest to asset configuration + const assetEntry = posix.join( + project.sourceRoot ?? posix.join(project.root, 'src'), + 'manifest.webmanifest', + ); + for (const target of [...buildTargets, ...testTargets]) { + if (target.options) { + if (Array.isArray(target.options.assets)) { + target.options.assets.push(assetEntry); + } else { + target.options.assets = [assetEntry]; + } + } else { + target.options = { assets: [assetEntry] }; + } + } + iconsPath = 'assets'; + } else { + assetsDir = posix.join(project.root, 'public'); + iconsPath = 'icons'; + } + return chain([ - updateWorkspace(workspace), externalSchematic('@schematics/angular', 'service-worker', swOptions), - mergeWith(apply(url('./files/root'), [template({ ...options }), move(sourcePath)])), mergeWith( - apply(url('./files/assets'), [ - template({ ...options }), - move(posix.join(sourcePath, 'assets')), - ]), + apply(url('./files/assets'), [template({ ...options, iconsPath }), move(assetsDir)]), ), ...[...indexFiles].map((path) => updateIndexFile(path)), ]); }; } + +/** + * This uses a dynamic import to load a module which may be ESM. + * CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript + * will currently, unconditionally downlevel dynamic import into a require call. + * require calls cannot load ESM code and will result in a runtime error. To workaround + * this, a Function constructor is used to prevent TypeScript from changing the dynamic import. + * Once TypeScript provides support for keeping the dynamic import this workaround can + * be dropped. + * + * @param modulePath The path of the module to load. + * @returns A Promise that resolves to the dynamically imported module. + */ +function loadEsmModule(modulePath: string | URL): Promise { + return new Function('modulePath', `return import(modulePath);`)(modulePath) as Promise; +} diff --git a/packages/angular/pwa/pwa/index_spec.ts b/packages/angular/pwa/pwa/index_spec.ts index 35d0a47026a3..3e0216b8cb2b 100644 --- a/packages/angular/pwa/pwa/index_spec.ts +++ b/packages/angular/pwa/pwa/index_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; @@ -39,130 +39,134 @@ describe('PWA Schematic', () => { }; beforeEach(async () => { - appTree = await schematicRunner - .runExternalSchematicAsync('@schematics/angular', 'workspace', workspaceOptions) - .toPromise(); - appTree = await schematicRunner - .runExternalSchematicAsync('@schematics/angular', 'application', appOptions, appTree) - .toPromise(); + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'workspace', + workspaceOptions, + ); + appTree = await schematicRunner.runExternalSchematic( + '@schematics/angular', + 'application', + appOptions, + appTree, + ); }); - it('should run the service worker schematic', (done) => { - schematicRunner - .runSchematicAsync('ng-add', defaultOptions, appTree) - .toPromise() - .then((tree) => { - const configText = tree.readContent('/angular.json'); - const config = JSON.parse(configText); - const swFlag = config.projects.bar.architect.build.options.serviceWorker; - expect(swFlag).toEqual(true); - done(); - }, done.fail); + it('should create icon files', async () => { + const dimensions = [72, 96, 128, 144, 152, 192, 384, 512]; + const iconPath = '/projects/bar/public/icons/icon-'; + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + + dimensions.forEach((d) => { + const path = `${iconPath}${d}x${d}.png`; + expect(tree.exists(path)).toBeTrue(); + }); }); - it('should create icon files', (done) => { - const dimensions = [72, 96, 128, 144, 152, 192, 384, 512]; - const iconPath = '/projects/bar/src/assets/icons/icon-'; - schematicRunner - .runSchematicAsync('ng-add', defaultOptions, appTree) - .toPromise() - .then((tree) => { - dimensions.forEach((d) => { - const path = `${iconPath}${d}x${d}.png`; - expect(tree.exists(path)).toEqual(true); - }); - done(); - }, done.fail); + it('should reference the icons in the manifest correctly', async () => { + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + const manifestText = tree.readContent('/projects/bar/public/manifest.webmanifest'); + const manifest = JSON.parse(manifestText); + for (const icon of manifest.icons) { + expect(icon.src).toMatch(/^icons\/icon-\d+x\d+.png/); + } }); - it('should create a manifest file', (done) => { - schematicRunner - .runSchematicAsync('ng-add', defaultOptions, appTree) - .toPromise() - .then((tree) => { - expect(tree.exists('/projects/bar/src/manifest.webmanifest')).toEqual(true); - done(); - }, done.fail); + it('should run the service worker schematic', async () => { + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + const configText = tree.readContent('/angular.json'); + const config = JSON.parse(configText); + const swFlag = config.projects.bar.architect.build.configurations.production.serviceWorker; + + expect(swFlag).toBe('projects/bar/ngsw-config.json'); }); - it('should set the name & short_name in the manifest file', (done) => { - schematicRunner - .runSchematicAsync('ng-add', defaultOptions, appTree) - .toPromise() - .then((tree) => { - const manifestText = tree.readContent('/projects/bar/src/manifest.webmanifest'); - const manifest = JSON.parse(manifestText); - - expect(manifest.name).toEqual(defaultOptions.title); - expect(manifest.short_name).toEqual(defaultOptions.title); - done(); - }, done.fail); + it('should create a manifest file', async () => { + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + expect(tree.exists('/projects/bar/public/manifest.webmanifest')).toBeTrue(); }); - it('should set the name & short_name in the manifest file when no title provided', (done) => { + it('should set the name & short_name in the manifest file', async () => { + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + + const manifestText = tree.readContent('/projects/bar/public/manifest.webmanifest'); + const manifest = JSON.parse(manifestText); + + expect(manifest.name).toEqual(defaultOptions.title); + expect(manifest.short_name).toEqual(defaultOptions.title); + }); + + it('should set the name & short_name in the manifest file when no title provided', async () => { const options = { ...defaultOptions, title: undefined }; - schematicRunner - .runSchematicAsync('ng-add', options, appTree) - .toPromise() - .then((tree) => { - const manifestText = tree.readContent('/projects/bar/src/manifest.webmanifest'); - const manifest = JSON.parse(manifestText); - - expect(manifest.name).toEqual(defaultOptions.project); - expect(manifest.short_name).toEqual(defaultOptions.project); - done(); - }, done.fail); + const tree = await schematicRunner.runSchematic('ng-add', options, appTree); + + const manifestText = tree.readContent('/projects/bar/public/manifest.webmanifest'); + const manifest = JSON.parse(manifestText); + + expect(manifest.name).toEqual(defaultOptions.project); + expect(manifest.short_name).toEqual(defaultOptions.project); }); - it('should update the index file', (done) => { - schematicRunner - .runSchematicAsync('ng-add', defaultOptions, appTree) - .toPromise() - .then((tree) => { - const content = tree.readContent('projects/bar/src/index.html'); - - expect(content).toMatch(//); - expect(content).toMatch(//); - expect(content).toMatch( - /