diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000000..dbfb5c068b
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,33 @@
+# Environment used to build and release the CLI.
+#
+# Copy to .env and fill in what you need; .env is gitignored, .env.example is not.
+# The build scripts load .env automatically, but a value already exported in the
+# environment always wins - CI secrets are never shadowed by a stray local file.
+
+
+# --- Analytics -------------------------------------------------------------
+# Measurement Protocol api secret, paired with the measurement id in
+# scripts/set-ga-id.js. Create one under
+# GA4 Admin -> Data Streams -> -> Measurement Protocol API secrets
+#
+# Deliberately not committed: this repository is public, so a committed secret
+# would make every contributor's `npm run pack.release` report into the
+# production property. Leave it unset locally - builds then report nothing.
+#
+# Set as the GA_API_SECRET repository secret for releases.
+GA_API_SECRET=
+
+# Same, for the dev measurement id. Unused until a dev id is set in
+# scripts/set-ga-id.js.
+GA_API_SECRET_DEV=
+
+
+# --- Publishing ------------------------------------------------------------
+# Only needed when the USE_NPM_TOKEN repository variable is "true". The default
+# path is OIDC trusted publishing, which needs no token at all.
+NPM_PUBLISH_TOKEN=
+
+# Used by the OpenSSF Scorecard workflow to read branch protection rules.
+SCORECARD_TOKEN=
+
+# GITHUB_TOKEN is provided by Actions automatically and never needs setting here.
diff --git a/.github/workflows/codeql-advanced.yml b/.github/workflows/codeql-advanced.yml
index 8e441dffc2..3a37f9baa4 100644
--- a/.github/workflows/codeql-advanced.yml
+++ b/.github/workflows/codeql-advanced.yml
@@ -60,7 +60,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
@@ -70,7 +70,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
+ uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -98,6 +98,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
+ uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml
index 4a514339ef..181ab43c29 100644
--- a/.github/workflows/dependency-review.yml
+++ b/.github/workflows/dependency-review.yml
@@ -17,6 +17,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4.3.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4.3.0
- name: 'Dependency Review'
- uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
+ uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
diff --git a/.github/workflows/npm_release_cli.yml b/.github/workflows/npm_release_cli.yml
index 13526d53b3..4354d9fa3b 100644
--- a/.github/workflows/npm_release_cli.yml
+++ b/.github/workflows/npm_release_cli.yml
@@ -11,15 +11,16 @@ on:
workflow_dispatch:
inputs:
release_type:
- description: 'Release type. "dev" publishes a -next prerelease without bumping package.json. patch/minor/major/prerelease bump package.json, commit + tag to main, then publish as a stable release.'
- type: choice
- options:
- - dev
- - patch
- - minor
- - major
- - prerelease
- default: patch
+ description: >-
+ Release to cut. Leave empty for a rolling "next" prerelease (no version
+ bump). "dev" publishes a -dev prerelease (no bump). A semver keyword
+ (patch/minor/major) or an explicit version (e.g. 9.1.0, 9.1.0-alpha.1)
+ bumps package.json, commits + tags v, then publishes a stable
+ release. A prerelease version publishes under the dist-tag matching its
+ prerelease id (alpha/beta/rc); a plain version publishes under "latest".
+ type: string
+ required: false
+ default: ''
permissions: read-all
@@ -39,16 +40,16 @@ jobs:
steps:
- name: Harden the runner (Audit all outbound calls)
- uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
+ uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22.14.0
registry-url: "https://registry.npmjs.org"
@@ -62,17 +63,24 @@ jobs:
echo NPM_VERSION=$NPM_VERSION >> $GITHUB_ENV
- name: Bump, commit and tag stable release (manual dispatch)
- if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_type != 'dev' }}
+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_type != '' && inputs.release_type != 'dev' }}
+ env:
+ # env indirection keeps the free-text dispatch input out of shell interpolation
+ RELEASE_INPUT: ${{ inputs.release_type }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- npm version ${{ inputs.release_type }} -m "chore: release v%s"
+ # npm version accepts a semver keyword (patch/minor/major) or an explicit
+ # version; strip an optional leading "v" so v9.1.0 and 9.1.0 both work.
+ npm version "${RELEASE_INPUT#v}" -m "chore: release v%s"
NPM_VERSION=$(node -e "console.log(require('./package.json').version);")
echo NPM_VERSION=$NPM_VERSION >> $GITHUB_ENV
git push origin HEAD:${GITHUB_REF_NAME} --follow-tags
- name: Bump version for dev release
- if: ${{ !contains(github.ref, 'refs/tags/') && (github.event_name != 'workflow_dispatch' || inputs.release_type == 'dev') }}
+ if: ${{ !contains(github.ref, 'refs/tags/') && (github.event_name != 'workflow_dispatch' || inputs.release_type == '' || inputs.release_type == 'dev') }}
+ env:
+ NPM_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_type == 'dev' && 'dev' || 'next' }}
run: |
NPM_VERSION=$(node ./scripts/get-next-version.js)
echo NPM_VERSION=$NPM_VERSION >> $GITHUB_ENV
@@ -80,9 +88,14 @@ jobs:
- name: Output NPM Version and tag
id: npm_version_output
+ env:
+ # true only for a manual dispatch that cut a real (bumped) release — not
+ # the empty "next" build or the "dev" channel. Computed in the GitHub
+ # expression context so the free-text input never reaches the shell.
+ IS_DISPATCH_RELEASE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_type != '' && inputs.release_type != 'dev' }}
run: |
NPM_TAG=$(node ./scripts/get-npm-tag.js)
- if [[ "${GITHUB_REF}" == refs/tags/* ]] || [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.release_type }}" != "dev" ]]; then
+ if [[ "${GITHUB_REF}" == refs/tags/* ]] || [[ "$IS_DISPATCH_RELEASE" == "true" ]]; then
IS_RELEASE=true
else
IS_RELEASE=false
@@ -91,8 +104,18 @@ jobs:
echo NPM_TAG=$NPM_TAG >> $GITHUB_OUTPUT
echo IS_RELEASE=$IS_RELEASE >> $GITHUB_OUTPUT
+ - name: Check analytics is configured
+ env:
+ GA_API_SECRET: ${{ secrets.GA_API_SECRET }}
+ run: |
+ if [ -z "$GA_API_SECRET" ]; then
+ echo "::warning::GA_API_SECRET is not set, so this release reports no analytics."
+ fi
+
- name: Build nativescript
- run: npm pack
+ env:
+ GA_API_SECRET: ${{ secrets.GA_API_SECRET }}
+ run: npm run pack.release
- name: Upload npm package artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
@@ -113,11 +136,11 @@ jobs:
NPM_TAG: ${{needs.build.outputs.npm_tag}}
steps:
- name: Harden the runner (Audit all outbound calls)
- uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
+ uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22.14.0
registry-url: "https://registry.npmjs.org"
@@ -164,16 +187,16 @@ jobs:
NPM_VERSION: ${{needs.build.outputs.npm_version}}
steps:
- name: Harden the runner (Audit all outbound calls)
- uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
+ uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
ref: v${{needs.build.outputs.npm_version}}
- - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22.14.0
diff --git a/.github/workflows/npm_release_doctor.yml b/.github/workflows/npm_release_doctor.yml
index 8366bc9d47..2c15546732 100644
--- a/.github/workflows/npm_release_doctor.yml
+++ b/.github/workflows/npm_release_doctor.yml
@@ -3,50 +3,238 @@ name: '@nativescript/doctor -> npm'
on:
push:
branches: [ 'main' ]
+ tags:
+ - '@nativescript/doctor@*'
paths:
- 'packages/doctor/**'
workflow_dispatch:
+ inputs:
+ release_type:
+ description: >-
+ Release to cut. Leave empty for a rolling "next" prerelease (no version
+ bump). "dev" publishes a -dev prerelease (no bump). A semver keyword
+ (patch/minor/major) or an explicit version (e.g. 2.1.0, 2.1.0-alpha.1)
+ bumps packages/doctor/package.json, commits + tags
+ @nativescript/doctor@, then publishes a stable release. A
+ prerelease version publishes under the dist-tag matching its prerelease
+ id (alpha/beta/rc); a plain version publishes under "latest".
+ type: string
+ required: false
+ default: ''
-defaults:
- run:
- working-directory: packages/doctor
+permissions: read-all
env:
NPM_TAG: 'next'
-permissions:
- contents: read
-
jobs:
- release:
+ build:
+ name: Build
runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ defaults:
+ run:
+ working-directory: packages/doctor
+ outputs:
+ npm_version: ${{ steps.npm_version_output.outputs.NPM_VERSION }}
+ npm_tag: ${{ steps.npm_version_output.outputs.NPM_TAG }}
+ is_release: ${{ steps.npm_version_output.outputs.IS_RELEASE }}
steps:
-
- name: Harden the runner (Audit all outbound calls)
- uses: step-security/harden-runner@fe104658747b27e96e4f7e80cd0a94068e53901d # v2.16.1
+ uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
with:
egress-policy: audit
- - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 22.14.0
+ registry-url: "https://registry.npmjs.org"
- name: Setup
run: npm install
- - name: Generate Version
+ - name: Get Current Version
run: |
- echo NPM_VERSION=$(node -e "console.log(require('./package.json').version);")-$NPM_TAG-$(date +"%m-%d-%Y")-$GITHUB_RUN_ID >> $GITHUB_ENV
+ NPM_VERSION=$(node -e "console.log(require('./package.json').version);")
+ echo NPM_VERSION=$NPM_VERSION >> $GITHUB_ENV
- - name: Bump Version
- run: npm --no-git-tag-version version $NPM_VERSION
+ - name: Bump, commit and tag stable release (manual dispatch)
+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.release_type != '' && inputs.release_type != 'dev' }}
+ env:
+ # env indirection keeps the free-text dispatch input out of shell interpolation
+ RELEASE_INPUT: ${{ inputs.release_type }}
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ # npm version accepts a semver keyword (patch/minor/major) or an explicit
+ # version; strip an optional leading "v" so v2.1.0 and 2.1.0 both work.
+ # The tag is written by hand: npm's own v tag namespace belongs
+ # to the CLI package at the repo root.
+ npm version "${RELEASE_INPUT#v}" --no-git-tag-version
+ NPM_VERSION=$(node -e "console.log(require('./package.json').version);")
+ echo NPM_VERSION=$NPM_VERSION >> $GITHUB_ENV
+ git add package.json package-lock.json
+ git commit -m "chore(doctor): release @nativescript/doctor@$NPM_VERSION"
+ git tag -a "@nativescript/doctor@$NPM_VERSION" -m "@nativescript/doctor@$NPM_VERSION"
+ git push origin HEAD:${GITHUB_REF_NAME} --follow-tags
+
+ - name: Bump version for dev release
+ if: ${{ !contains(github.ref, 'refs/tags/') && (github.event_name != 'workflow_dispatch' || inputs.release_type == '' || inputs.release_type == 'dev') }}
+ env:
+ NPM_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.release_type == 'dev' && 'dev' || 'next' }}
+ # the shared version scripts live at the repo root, whose dependencies
+ # are not installed here — semver resolves out of this package instead
+ NODE_PATH: ${{ github.workspace }}/packages/doctor/node_modules
+ run: |
+ LAST_TAG=$(git describe --tags --abbrev=0 --match='@nativescript/doctor@*' 2>/dev/null || true)
+ LAST_TAGGED_VERSION=${LAST_TAG##*@}
+ # before the first scoped tag exists, the published version in
+ # package.json is the only baseline to bump past
+ export LAST_TAGGED_VERSION=${LAST_TAGGED_VERSION:-$NPM_VERSION}
+ NPM_VERSION=$(node "$GITHUB_WORKSPACE/scripts/get-next-version.js")
+ echo NPM_VERSION=$NPM_VERSION >> $GITHUB_ENV
+ npm version $NPM_VERSION --no-git-tag-version
+
+ - name: Output NPM Version and tag
+ id: npm_version_output
+ env:
+ # true only for a manual dispatch that cut a real (bumped) release — not
+ # the empty "next" build or the "dev" channel. Computed in the GitHub
+ # expression context so the free-text input never reaches the shell.
+ IS_DISPATCH_RELEASE: ${{ github.event_name == 'workflow_dispatch' && inputs.release_type != '' && inputs.release_type != 'dev' }}
+ NODE_PATH: ${{ github.workspace }}/packages/doctor/node_modules
+ run: |
+ NPM_TAG=$(node "$GITHUB_WORKSPACE/scripts/get-npm-tag.js")
+ if [[ "${GITHUB_REF}" == refs/tags/* ]] || [[ "$IS_DISPATCH_RELEASE" == "true" ]]; then
+ IS_RELEASE=true
+ else
+ IS_RELEASE=false
+ fi
+ echo NPM_VERSION=$NPM_VERSION >> $GITHUB_OUTPUT
+ echo NPM_TAG=$NPM_TAG >> $GITHUB_OUTPUT
+ echo IS_RELEASE=$IS_RELEASE >> $GITHUB_OUTPUT
- name: Build @nativescript/doctor
run: npm pack
- - name: Publish @nativescript/doctor
+ - name: Upload npm package artifact
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ with:
+ name: npm-package-doctor
+ path: packages/doctor/nativescript-doctor-${{steps.npm_version_output.outputs.NPM_VERSION}}.tgz
+
+ publish:
+ runs-on: ubuntu-latest
+ environment: npm-publish
+ needs:
+ - build
+ permissions:
+ contents: read
+ id-token: write
+ env:
+ NPM_VERSION: ${{needs.build.outputs.npm_version}}
+ NPM_TAG: ${{needs.build.outputs.npm_tag}}
+ steps:
+ - name: Harden the runner (Audit all outbound calls)
+ uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
+ with:
+ egress-policy: audit
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 22.14.0
+ registry-url: "https://registry.npmjs.org"
+
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: npm-package-doctor
+ path: dist
+
+ - name: Update npm (required for OIDC trusted publishing)
+ run: |
+ npm install -g npm@^11.5.1
+ npm --version
+
+ - name: Publish package (OIDC trusted publishing)
+ if: ${{ vars.USE_NPM_TOKEN != 'true' }}
+ run: |
+ echo "Publishing @nativescript/doctor@$NPM_VERSION to NPM with tag $NPM_TAG via OIDC trusted publishing..."
+ unset NODE_AUTH_TOKEN
+ if [ -n "${NPM_CONFIG_USERCONFIG:-}" ]; then
+ rm -f "$NPM_CONFIG_USERCONFIG"
+ fi
+ npm publish ./dist/nativescript-doctor-${{env.NPM_VERSION}}.tgz --tag $NPM_TAG --access public --provenance
env:
- NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
+ NODE_AUTH_TOKEN: ""
+
+ - name: Publish package (granular token)
+ if: ${{ vars.USE_NPM_TOKEN == 'true' }}
run: |
- echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
- echo "Publishing @nativescript/doctor@$NPM_VERSION to NPM with tag $NPM_TAG..."
- npm publish nativescript-doctor-$NPM_VERSION.tgz --tag $NPM_TAG
+ echo "Publishing @nativescript/doctor@$NPM_VERSION to NPM with tag $NPM_TAG via granular token..."
+ npm publish ./dist/nativescript-doctor-${{env.NPM_VERSION}}.tgz --tag $NPM_TAG --access public --provenance
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
+
+ github-release:
+ runs-on: ubuntu-latest
+ # runs for tag pushes and for manual dispatches that bumped a stable release
+ if: ${{ needs.build.outputs.is_release == 'true' }}
+ permissions:
+ contents: write
+ needs:
+ - build
+ env:
+ NPM_VERSION: ${{needs.build.outputs.npm_version}}
+ steps:
+ - name: Harden the runner (Audit all outbound calls)
+ uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
+ with:
+ egress-policy: audit
+
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ ref: 'refs/tags/@nativescript/doctor@${{needs.build.outputs.npm_version}}'
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: 22.14.0
+
+ - name: Setup
+ working-directory: packages/doctor
+ run: npm i --ignore-scripts --no-package-lock
+
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: npm-package-doctor
+ path: dist
+
+ - name: Generate provenance statement
+ run: |
+ TGZ_PATH=$(ls dist/nativescript-doctor-*.tgz | head -n1)
+ TGZ_NAME=$(basename "$TGZ_PATH")
+ TGZ_SHA=$(sha256sum "$TGZ_PATH" | awk '{ print $1 }')
+ PROV_PATH="dist/${TGZ_NAME%.tgz}.intoto.jsonl"
+
+ cat > "$PROV_PATH" < "$GITHUB_WORKSPACE/body.md"
+
+ - uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1.21.0
+ with:
+ tag: '@nativescript/doctor@${{needs.build.outputs.npm_version}}'
+ artifacts: "dist/nativescript-doctor-*.tgz,dist/nativescript-doctor-*.intoto.jsonl"
+ bodyFile: "body.md"
+ prerelease: ${{needs.build.outputs.npm_tag != 'latest'}}
+ allowUpdates: true
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 58910b421f..768218fc1d 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -33,20 +33,19 @@ jobs:
steps:
- name: "Checkout code"
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4.3.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4.3.0
with:
persist-credentials: false
- name: "Run analysis"
- uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
+ uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
with:
results_file: results.sarif
results_format: sarif
- # (Optional) "write" PAT token. Uncomment the `repo_token` line below if:
+ # (Optional) PAT token. Add `repo_token: ${{ secrets.SCORECARD_TOKEN }}` if:
# - you want to enable the Branch-Protection check on a *public* repository, or
# - you are installing Scorecards on a *private* repository
# To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat.
- repo_token: ${{ secrets.SCORECARD_TOKEN }}
# Public repositories:
# - Publish results to OpenSSF REST API for easy access by consumers
@@ -68,6 +67,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
- uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
+ uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
- sarif_file: results.sarif
\ No newline at end of file
+ sarif_file: results.sarif
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000000..f08d69c90d
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,93 @@
+name: 'Tests'
+
+on:
+ pull_request:
+ push:
+ branches: [ 'main' ]
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ test:
+ name: Test (${{ matrix.os }}, Node ${{ matrix.node-version }})
+ runs-on: ${{ matrix.os }}
+
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ ubuntu-latest ]
+ node-version: [ '20.x', '22.x', '24.x' ]
+ include:
+ # A darwin leg is what covers the iOS-specific suites - they are gated
+ # on the platform and never execute anywhere else. One version is
+ # enough for that; 24 is the active LTS.
+ - os: macos-latest
+ node-version: '24.x'
+
+ steps:
+
+ - name: Harden the runner (Audit all outbound calls)
+ # only supported on the Ubuntu runners
+ if: runner.os == 'Linux'
+ uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
+ with:
+ egress-policy: audit
+
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ # nothing here needs git auth after checkout, and npm test runs
+ # repository code
+ persist-credentials: false
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'npm'
+
+ - name: Install
+ run: npm ci --ignore-scripts
+
+ - name: Test
+ run: npm test
+
+ doctor:
+ name: Doctor (Node ${{ matrix.node-version }})
+ runs-on: ubuntu-latest
+
+ strategy:
+ fail-fast: false
+ matrix:
+ # No darwin leg: unlike the CLI suites, these stub the platform through
+ # a fake HostInfo rather than probing it, so a second OS runs identical
+ # assertions.
+ node-version: [ '20.x', '22.x', '24.x' ]
+
+ steps:
+
+ - name: Harden the runner (Audit all outbound calls)
+ uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
+ with:
+ egress-policy: audit
+
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: ${{ matrix.node-version }}
+ cache: 'npm'
+ cache-dependency-path: packages/doctor/package-lock.json
+
+ - name: Install
+ working-directory: packages/doctor
+ run: npm ci
+
+ - name: Test
+ working-directory: packages/doctor
+ run: npm test
diff --git a/.gitignore b/.gitignore
index 0fd3dda24f..96e627f0c8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -87,5 +87,18 @@ lib/common/test-reports.xml
!lib/common/test-scripts/**
!lib/common/scripts/**
config/test-deps-versions-generated.json
-!scripts/get-next-version.js
-!scripts/get-npm-tag.js
\ No newline at end of file
+!scripts/*.js
+!dev/*.js
+
+# build output
+/dist
+
+# local secrets, filled in from .env.example
+.env
+.env.*
+!.env.example
+
+# Claude Code local state - worktrees and per-user settings. Ignoring the
+# contents rather than the directory keeps shared settings.json committable.
+.claude/*
+!.claude/settings.json
diff --git a/BuildPackage.cmd b/BuildPackage.cmd
deleted file mode 100644
index 03ed3ba392..0000000000
--- a/BuildPackage.cmd
+++ /dev/null
@@ -1,12 +0,0 @@
-call "c:\Program Files (x86)\nodejs\nodevars.bat"
-call npm.cmd install -g grunt-cli
-
-set NATIVESCRIPT_SKIP_POSTINSTALL_TASKS=1
-call grunt.cmd enableScripts:false
-call npm.cmd install
-call grunt.cmd enableScripts:true
-set NATIVESCRIPT_SKIP_POSTINSTALL_TASKS=
-
-call grunt.cmd pack --no-color
-
-call npm.cmd cache rm nativescript
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8cc5756fd9..c850110d71 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -33,7 +33,7 @@ Before you submit a Pull Request, consider the following guidelines.
```bash
git clone git@github.com:NativeScript/nativescript-cli.git
```
-* Run the setup script. This will initialize the git submodule, install the node dependencies and build with grunt.
+* Run the setup script. This will install the node dependencies and set up the git hooks.
```bash
npm run setup
```
@@ -44,15 +44,15 @@ Before you submit a Pull Request, consider the following guidelines.
* Create your patch and include appropriate test cases.
* Build your changes locally.
```bash
- ./node_modules/.bin/grunt
+ npm run build
```
* Ensure all the tests pass.
```bash
- ./node_modules/.bin/grunt test
+ npm test
```
-* Ensure that your code passes the linter.
+* Ensure that your code is formatted.
```bash
- ./node_modules/.bin/grunt lint
+ npm run prettier
```
* Commit your changes following the [commit message guidelines](https://github.com/NativeScript/NativeScript/blob/master/CONTRIBUTING.md#-commit-message-guidelines) (the commit message is used to generate release notes).
```bash
diff --git a/Gruntfile.js b/Gruntfile.js
deleted file mode 100644
index a08b50c319..0000000000
--- a/Gruntfile.js
+++ /dev/null
@@ -1,297 +0,0 @@
-const childProcess = require("child_process");
-const EOL = require("os").EOL;
-const path = require("path");
-const now = new Date().toISOString();
-const manifest = require('pacote').manifest;
-
-
-const ENVIRONMENTS = {
- live: "live",
- dev: "dev"
-};
-
-const GA_TRACKING_IDS = {
- [ENVIRONMENTS.dev]: "UA-111455-51",
- [ENVIRONMENTS.live]: "UA-111455-44"
-};
-
-const CONFIG_DATA = {
- filePath: "config/config.json",
- gaKey: "GA_TRACKING_ID"
-}
-
-function shallowCopy(obj) {
- var result = {};
- Object.keys(obj).forEach(function (key) {
- result[key] = obj[key];
- });
- return result;
-}
-
-var travis = process.env["TRAVIS"];
-var buildNumber = process.env["PACKAGE_VERSION"] || process.env["BUILD_NUMBER"] || "non-ci";
-
-module.exports = function (grunt) {
- grunt.initConfig({
- copyPackageTo: process.env["CopyPackageTo"] || ".",
-
- jobName: travis ? "travis" : (process.env["JOB_NAME"] || "local"),
- buildNumber: buildNumber,
- dateString: now.substr(0, now.indexOf("T")),
-
- pkg: grunt.file.readJSON("package.json"),
- ts: {
- options: grunt.file.readJSON("tsconfig.json").compilerOptions,
-
- devlib: {
- src: ["lib/**/*.ts", "!lib/common/node_modules/**/*.ts"],
- reference: "lib/.d.ts"
- },
-
- devall: {
- src: ["lib/**/*.ts", "test/**/*.ts", "!lib/common/node_modules/**/*.ts", "lib/common/test/unit-tests/**/*.ts", "definitions/**/*.ts", "!lib/common/test/.d.ts"],
- reference: "lib/.d.ts"
- },
-
- release_build: {
- src: ["lib/**/*.ts", "test/**/*.ts", "!lib/common/node_modules/**/*.ts"],
- reference: "lib/.d.ts",
- options: {
- sourceMap: false,
- removeComments: true
- }
- },
- },
-
- watch: {
- devall: {
- files: ["lib/**/*.ts", 'test/**/*.ts', "!lib/common/node_modules/**/*.ts", "!lib/common/messages/**/*.ts"],
- tasks: [
- 'ts:devall',
- 'shell:npm_test'
- ],
- options: {
- atBegin: true,
- interrupt: true
- }
- },
- ts: {
- files: ["lib/**/*.ts", 'test/**/*.ts', "!lib/common/node_modules/**/*.ts"],
- tasks: [
- 'ts:devall'
- ],
- options: {
- atBegin: true,
- interrupt: true
- }
- }
- },
-
- shell: {
- options: {
- stdout: true,
- stderr: true,
- failOnError: true
- },
-
- build_package: {
- command: "npm pack",
- options: {
- execOptions: {
- env: (function () {
- var env = shallowCopy(process.env);
- env["NATIVESCRIPT_SKIP_POSTINSTALL_TASKS"] = "1";
- return env;
- })()
- }
- }
- },
-
- npm_test: {
- command: "npm test"
- }
-
- },
-
- clean: {
- src: ["test/**/*.js*",
- "!test/files/**/*.js*",
- "lib/**/*.js*",
- "!test-scripts/**/*",
- "!lib/common/vendor/*.js",
- "!lib/common/**/*.json",
- "!lib/common/Gruntfile.js",
- "!lib/common/node_modules/**/*",
- "!lib/common/hooks/**/*.js",
- "!lib/common/bin/*.js",
- "!lib/common/test-scripts/**/*",
- "!lib/common/scripts/**/*",
- "!lib/common/test/resources/**/*",
- "*.tgz"]
- },
- template: {
- 'process-markdowns': {
- options: {
- data: {
- "isJekyll": true,
- "isHtml": true,
- "isConsole": true,
- "isWindows": true,
- "isMacOS": true,
- "isLinux": true,
- "constants": ""
- }
- },
- files: [{
- expand: true,
- cwd: "docs/man_pages/",
- src: "**/*.md",
- dest: "docs-cli/",
- ext: ".md"
- }]
- }
- }
- });
-
- grunt.loadNpmTasks("grunt-contrib-clean");
- grunt.loadNpmTasks("grunt-contrib-copy");
- grunt.loadNpmTasks("grunt-contrib-watch");
- grunt.loadNpmTasks("grunt-shell");
- grunt.loadNpmTasks("grunt-ts");
- grunt.loadNpmTasks("grunt-template");
-
- grunt.registerTask("set_package_version", function (version) {
- // NOTE: DO NOT call this task in npm's prepack script - it will change the version in package.json,
- // but npm will still try to publish the version that was originally specified in the package.json/
- // Also this may break some Jenkins builds as the produced package will have unexpected name.
- var buildVersion = version !== undefined ? version : buildNumber;
- if (process.env["BUILD_CAUSE_GHPRBCAUSE"]) {
- buildVersion = "PR" + buildVersion;
- }
-
- var packageJson = grunt.file.readJSON("package.json");
- var versionParts = packageJson.version.split("-");
-
- // The env is used in Jenkins job to produce package that will be releasd with "latest" tag in npm (i.e. strict version).
- if (!process.env["RELEASE_BUILD"]) {
- versionParts[1] = buildVersion;
- packageJson.version = versionParts.join("-");
- }
-
- grunt.file.write("package.json", JSON.stringify(packageJson, null, " "));
- });
-
- grunt.registerTask("tslint:build", function (version) {
- childProcess.execSync("npm run tslint", { stdio: "inherit" });
- });
-
- const setConfig = (key, value) => {
- const configJson = grunt.file.readJSON(CONFIG_DATA.filePath);
- configJson[key] = value;
- const stringConfigContent = JSON.stringify(configJson, null, " ") + EOL;
- grunt.file.write(CONFIG_DATA.filePath, stringConfigContent);
- }
-
- grunt.registerTask("set_live_ga_id", function () {
- setConfig(CONFIG_DATA.gaKey, GA_TRACKING_IDS[ENVIRONMENTS.live]);
- });
-
- grunt.registerTask("set_dev_ga_id", function () {
- setConfig(CONFIG_DATA.gaKey, GA_TRACKING_IDS[ENVIRONMENTS.dev]);
- });
-
- grunt.registerTask("verify_live_ga_id", function () {
- var configJson = grunt.file.readJSON(CONFIG_DATA.filePath);
-
- if (configJson[CONFIG_DATA.gaKey] !== GA_TRACKING_IDS[ENVIRONMENTS.live]) {
- throw new Error("Google Analytics id is not configured correctly.");
- }
- });
-
- grunt.registerTask("test", ["ts:devall", "shell:npm_test"]);
-
- registerTestingDependenciesTasks(grunt);
-
- grunt.registerTask("prepare", [
- "clean",
- "ts:release_build",
- "generate_unit_testing_dependencies",
- "verify_unit_testing_dependencies",
- "shell:npm_test",
-
- "set_live_ga_id",
- "verify_live_ga_id"
- ]);
- grunt.registerTask("pack", [
- "set_package_version",
- "shell:build_package"
- ]);
-
- grunt.registerTask("travisPack", function () {
- if (travis && process.env.TRAVIS_PULL_REQUEST_BRANCH) {
- return grunt.task.run("pack");
- }
-
- // Set correct version in Travis job, so the deploy will not publish strict version (for example 5.2.0).
- grunt.task.run("set_package_version");
- console.log(`Skipping pack step as the current build is not from PR, so it will be packed from the deploy provider.`);
- });
- grunt.registerTask("lint", ["tslint:build"]);
- grunt.registerTask("all", ["clean", "test", "lint"]);
- grunt.registerTask("rebuild", ["clean", "default"]);
- grunt.registerTask("default", ["ts:devlib", "generate_unit_testing_dependencies"]);
- grunt.registerTask("docs-jekyll", ['template']);
-};
-
-function registerTestingDependenciesTasks(grunt) {
- const configsBasePath = path.join(__dirname, "config");
- const generatedVersionFilePath = path.join(configsBasePath, "test-deps-versions-generated.json");
-
- grunt.registerTask("generate_unit_testing_dependencies", async function () {
- const done = this.async();
-
- const dependenciesVersions = {};
- let testDependencies;
-
- try {
- testDependencies = grunt.file.readJSON(path.join(configsBasePath, "test-dependencies.json"));
- } catch (err) {
- grunt.log.error("Could not read test-dependencies.json:", err);
- return done(false);
- }
-
- (async () => {
- try {
- for (const dep of testDependencies) {
- if (dep.version) {
- dependenciesVersions[dep.name] = dep.version;
- } else {
- dependenciesVersions[dep.name] = await latestVersion(dep.name);
- }
- }
- grunt.file.write(
- generatedVersionFilePath,
- JSON.stringify(dependenciesVersions, null, 2)
- );
- grunt.log.writeln("Wrote", generatedVersionFilePath);
- done();
- } catch (err) {
- grunt.log.error(err);
- done(false);
- }
- })();
- });
-
- grunt.registerTask("verify_unit_testing_dependencies", function () {
- if (!grunt.file.exists(generatedVersionFilePath)) {
- throw new Error("Unit testing dependencies are not configured.");
- }
- });
-}
-
-async function latestVersion(name) {
- // only fetches the package.json for the latest dist-tag
- const { version } = await manifest(name.toLowerCase(), { fullMetadata: false });
- return version;
-}
-
diff --git a/README.md b/README.md
index 7324ffcff2..67ae265a1c 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@
+
Get it using: `npm install -g nativescript`
diff --git a/bin/tns b/bin/tns
index fb5b323617..b66857aa8b 100755
--- a/bin/tns
+++ b/bin/tns
@@ -2,11 +2,75 @@
"use strict";
var path = require("path"),
- pathToLib = path.join(__dirname, "..", "lib"),
+ fs = require("fs"),
+ // In the published package dist/ is the root, so lib/ already sits next to
+ // bin/. In a source checkout lib/ holds only TypeScript and the compiled
+ // output lives under dist/.
+ distLib = path.join(__dirname, "..", "dist", "lib"),
+ pathToLib = fs.existsSync(distLib)
+ ? distLib
+ : path.join(__dirname, "..", "lib"),
pathToCommon = path.join(pathToLib, "common");
require(path.join(pathToCommon, "verify-node-version")).verifyNodeVersion();
+// Prefer a project-local CLI install when one is resolvable from the current
+// directory: the project pins its own nativescript version, and running the
+// invoked (typically global) copy against it means two CLI versions
+// disagreeing about one project. The probe must never break the CLI - any
+// failure falls through to the invoked copy. Opt out per invocation with
+// --no-local-cli or NS_CLI_NO_LOCAL=1; NS_CLI_LOCAL_DELEGATED marks the
+// handed-off process so the local copy never delegates again.
+var noLocalFlagIndex = process.argv.indexOf("--no-local-cli");
+if (noLocalFlagIndex !== -1) {
+ process.argv.splice(noLocalFlagIndex, 1);
+}
+
+if (
+ !process.env.NS_CLI_LOCAL_DELEGATED &&
+ !process.env.NS_CLI_NO_LOCAL &&
+ noLocalFlagIndex === -1
+) {
+ var localEntry = null;
+ var localVersion = null;
+ var localDir = null;
+ try {
+ var localPackageJsonPath = require.resolve("nativescript/package.json", {
+ paths: [process.cwd()],
+ });
+ var ownPackageJsonPath = path.join(__dirname, "..", "package.json");
+ // realpath both sides so an npm-linked or symlinked install of the
+ // same copy is not mistaken for a different one.
+ if (
+ fs.realpathSync(localPackageJsonPath) !==
+ fs.realpathSync(ownPackageJsonPath)
+ ) {
+ localDir = path.dirname(localPackageJsonPath);
+ var candidate = path.join(localDir, "bin", "tns");
+ if (fs.existsSync(candidate)) {
+ localEntry = candidate;
+ localVersion = require(localPackageJsonPath).version;
+ }
+ }
+ } catch (err) {
+ // No local install resolvable from cwd - run the invoked copy.
+ }
+
+ if (localEntry) {
+ process.env.NS_CLI_LOCAL_DELEGATED = "1";
+ // stderr, so scripts parsing stdout (e.g. `ns --version`) are unaffected.
+ console.error(
+ "Using the project-local nativescript@" +
+ localVersion +
+ " (" +
+ localDir +
+ ").",
+ );
+ require(localEntry);
+ return;
+ }
+}
+
var pathToCliExecutable = path.join(pathToLib, "nativescript-cli.js");
require(pathToCliExecutable);
diff --git a/config/config.json b/config/config.json
index 1872961f1d..e84563c574 100644
--- a/config/config.json
+++ b/config/config.json
@@ -4,5 +4,6 @@
"ANDROID_DEBUG_UI_MAC": "Google Chrome",
"USE_POD_SANDBOX": false,
"DISABLE_HOOKS": false,
- "GA_TRACKING_ID": "UA-111455-51"
+ "GA_MEASUREMENT_ID": "",
+ "GA_API_SECRET": ""
}
diff --git a/config/test-dependencies.json b/config/test-dependencies.json
index 343b1c22fa..6a2ff78a7d 100644
--- a/config/test-dependencies.json
+++ b/config/test-dependencies.json
@@ -1,15 +1,40 @@
[
{
- "name": "@jsdevtools/coverage-istanbul-loader"
+ "name": "vitest",
+ "framework": "vitest",
+ "version": "~4.1.10"
},
{
- "name": "karma"
+ "name": "@vitest/runner",
+ "framework": "vitest",
+ "version": "~4.1.10"
},
{
- "name": "karma-coverage"
+ "name": "@nativescript/unit-test-runner",
+ "framework": "vitest",
+ "version": "^5.0.0-alpha.0"
},
{
- "name": "karma-nativescript-launcher"
+ "name": "@valor/nativescript-websockets",
+ "framework": "vitest",
+ "version": "^2.0.3",
+ "saveInDependencies": true
+ },
+ {
+ "name": "@jsdevtools/coverage-istanbul-loader",
+ "frameworks": ["jasmine", "mocha", "qunit"]
+ },
+ {
+ "name": "karma",
+ "frameworks": ["jasmine", "mocha", "qunit"]
+ },
+ {
+ "name": "karma-coverage",
+ "frameworks": ["jasmine", "mocha", "qunit"]
+ },
+ {
+ "name": "karma-nativescript-launcher",
+ "frameworks": ["jasmine", "mocha", "qunit"]
},
{
"name": "mocha",
@@ -53,6 +78,7 @@
"projectType": ".ts"
},
{
- "name": "nyc"
+ "name": "nyc",
+ "frameworks": ["jasmine", "mocha", "qunit"]
}
-]
\ No newline at end of file
+]
diff --git a/contracts/package.json b/contracts/package.json
new file mode 100644
index 0000000000..4c4d479642
--- /dev/null
+++ b/contracts/package.json
@@ -0,0 +1,4 @@
+{
+ "main": "../lib/contracts/index.js",
+ "types": "../lib/contracts/index.d.ts"
+}
diff --git a/defining-commands.md b/defining-commands.md
new file mode 100644
index 0000000000..ef7fc024c1
--- /dev/null
+++ b/defining-commands.md
@@ -0,0 +1,352 @@
+Defining Commands
+=================
+
+`defineCommand` is the declarative way to add a command to the NativeScript
+CLI. A definition is a plain object: a name, an option schema, and a `run`
+function. The CLI compiles it into the command shape its registry expects, so a
+definition gets the same option parsing, hooks, analytics and help wiring as a
+hand-written command class — without a class, a constructor, or an
+`allowedParameters` array.
+
+This is purely additive. The legacy `ICommand` classes registered through
+`$injector.registerCommand` keep working exactly as before, and the two styles
+coexist in the same registry.
+
+At a glance
+-----------
+
+```ts
+import {
+ defineCommand,
+ booleanOption,
+ stringOption,
+} from "nativescript/contracts";
+
+export default defineCommand({
+ name: "widget|add",
+ description: "Adds a widget to the project",
+ options: {
+ overwrite: booleanOption({ default: false }),
+ output: stringOption({ alias: "o" }),
+ },
+ arguments: "any",
+ async run(ctx) {
+ // ctx.args -> string[] of positional arguments
+ // ctx.options -> { overwrite: boolean; output: string | undefined }
+ if (ctx.options.output) {
+ console.log(`adding ${ctx.args.join(", ")} to ${ctx.options.output}`);
+ }
+ },
+});
+```
+
+`defineCommand` validates the definition and returns it, tagged with a marker
+symbol so that any copy of the CLI can recognise it. `isCommandDefinition(value)`
+is the exported check, and it narrows to `DefinedCommand`. The tag survives a
+spread, so `{ ...baseDefinition, name: "widget|add2" }` is still recognised.
+
+`defineCommand` does not register anything by itself — see
+[Registering a definition](#registering-a-definition).
+
+Validation happens where you can see it
+---------------------------------------
+
+A definition is checked at the moment `defineCommand` is called, not when the
+command eventually runs. A misspelled field, a missing `run`, an option
+declared with something other than the four helpers, an `arguments` value
+outside `"none" | "any"` — each throws immediately, naming the command and the
+accepted form:
+
+```
+Invalid command definition for 'widget|add': unknown field(s) 'handler'; a
+definition accepts name, description, options, arguments, canExecute,
+disableAnalytics, enableHooks, run. Accepted form: defineCommand({ name:
+"widget|add", run(ctx) { ... } }) — with the optional fields description,
+options, arguments, canExecute, disableAnalytics and enableHooks.
+```
+
+Names and the command hierarchy
+-------------------------------
+
+`name` is either a single string or an array of strings, in which case every
+entry becomes an alias for the same command.
+
+The CLI's command registry is flat; the hierarchy the user types on the command
+line is encoded in the name with a `|` separator. `"widget|add"` is the command
+invoked as `ns widget add`, and `"widget|template|list"` is `ns widget template
+list`. Registering a hierarchical name automatically synthesises the parent
+dispatcher (`widget`), which routes to the right subcommand or prints help.
+
+A leading `*` on the last segment marks a **default subcommand**: `"widget|*add"`
+runs both for `ns widget add` and for a bare `ns widget`. This is the convention
+the CLI's own commands use (`run|*all`, `debug|*all`); the encoding is
+user-visible because it feeds shell autocompletion and generated help.
+
+A parent name cannot also be a command of its own. If `widget` is already
+registered as a flat command, registering `widget|add` leaves that command in
+place, warns naming both, and creates no dispatcher — so `ns widget add` will
+not route until one of the two is renamed.
+
+Options
+-------
+
+`options` is a schema keyed by the long option name — `output` is passed as
+`--output`. Declare each entry with one of the four helpers, which fix the
+value type:
+
+| Helper | Declared with `default` | Declared without |
+| --------------- | ----------------------- | ----------------------- |
+| `booleanOption` | `boolean` | `boolean \| undefined` |
+| `stringOption` | `string` | `string \| undefined` |
+| `numberOption` | `number` | `number \| undefined` |
+| `arrayOption` | `string[]` | `string[] \| undefined` |
+
+The two columns are the whole story of the option types: a flag the user did
+not pass is absent at runtime, so only a `default` makes the value on
+`ctx.options` always present. Declare a default whenever there is a sensible
+one and the `| undefined` disappears from the type.
+
+Each helper takes an optional spec:
+
+```ts
+options: {
+ // --release, absent means false
+ release: booleanOption({ default: false }),
+ // --output , also accepted as -o
+ output: stringOption({ alias: "o", description: "Output directory" }),
+ // --retries
+ retries: numberOption({ default: 3 }),
+ // --file a.ts --file b.ts
+ file: arrayOption(),
+ // kept out of analytics and logs
+ token: stringOption({ hasSensitiveValue: true }),
+}
+```
+
+- `default` — value used when the flag is absent.
+- `alias` — single-dash shorthand, or an array of them (`alias: ["o", "out"]`).
+- `hasSensitiveValue` — defaults to `false`; set it for anything that must not
+ be recorded. There is no reason not to be explicit about credentials, paths
+ containing user directories, and tokens.
+- `description` — reserved for generated help. It reaches the option parser but
+ nothing renders it yet.
+
+The schema types `ctx.options` and nothing else: `ctx.options` carries exactly
+the declared keys, and a typo is a compile error. Values that the CLI parses
+globally (`--path`, `--log`, …) are not exposed there; resolve the `options`
+service if you need them.
+
+### Sharing a schema between commands
+
+Extract the schema with `satisfies` rather than a type annotation. An
+annotation widens every entry back to the general spec type and the `default`
+information — and with it the non-optional value types — is lost:
+
+```ts
+const buildOptions = {
+ release: booleanOption({ default: false }),
+ output: stringOption({ alias: "o" }),
+} satisfies CommandOptionsSchema;
+```
+
+### Do not shadow a CLI-wide option
+
+`--verbose`, `--path`, `--log`, `--release`, `--env` and friends are declared by
+the CLI itself. Declaring one of those names in a command's schema makes the
+command's declaration win for the duration of that command, which means the
+same flag means different things depending on which command is running. The CLI
+warns at registration naming both sides of the collision; pick another name.
+
+Aliases count too, in both directions: an `alias: "p"` collides with `--path`'s
+shorthand just as `output: stringOption()` would collide with a CLI-wide
+`--output`.
+
+### How validation behaves
+
+Option validation is the CLI's existing behaviour, not something the definition
+opts into. Before a command runs, the parser is re-primed with that command's
+declared options and the command line is re-parsed:
+
+- Declared options are accepted and appear on `ctx.options`.
+- An option the CLI does not know — neither global nor declared by this command
+ — produces a warning: `The option '' is not supported. This will become
+an error in a future release.` The command still runs. Set
+ `NS_STRICT_OPTIONS=error` to preview the hard failure, which is what a future
+ release will do by default.
+- The same staging applies to value-shape violations: a string option passed
+ with no value, an array option passed nothing, a single-valued option passed
+ twice.
+
+So adding an option is a matter of adding a schema entry; forgetting to declare
+one that users pass is a warning today and a failure later, never a silent
+`undefined`.
+
+Positional arguments
+--------------------
+
+`arguments` declares whether the command takes positional arguments at all:
+
+- `"none"` (the default) — the command accepts no positional arguments. Passing
+ any is rejected with `This command doesn't accept parameters.`
+- `"any"` — positional arguments are accepted and handed to `run` as
+ `ctx.args`.
+
+Anything finer than that belongs in `canExecute`.
+
+### `canExecute` refines, it does not replace
+
+```ts
+defineCommand({
+ name: "widget|add",
+ arguments: "any",
+ async canExecute(ctx) {
+ return ctx.args.length === 1;
+ },
+ async run(ctx) {
+ /* ... */
+ },
+});
+```
+
+The two fields compose. The declared `arguments` policy is enforced first, and
+`canExecute` is consulted only for command lines that already satisfy it — so a
+definition that leaves `arguments` at `"none"` still rejects stray positional
+arguments even when it supplies a `canExecute`, and a `canExecute` that only
+inspects options cannot accidentally widen what the command accepts.
+
+`canExecute` receives a context of the same shape as `run`'s — the same
+`args`, the same declared options and the same `fail` — built freshly for the
+call, and returns a boolean (or a promise of one). Returning `false` aborts the
+command and prints a bare help suggestion; `ctx.fail(message)` aborts it with
+your own message, which is usually the friendlier choice.
+
+`canExecute` runs inside a dependency-injection context, on the same terms as
+`run`: `inject()` is valid up to the first `await`.
+
+The run context
+---------------
+
+`run(ctx)` receives:
+
+- `ctx.args` — `string[]`, the positional arguments left after the command name
+ (including any subcommand segments) has been consumed.
+- `ctx.options` — the current value of each declared option, read at the moment
+ the command executes.
+- `ctx.fail(message)` — fails the command with `message` and a usage help
+ suggestion.
+
+`run` may be synchronous or `async`; the CLI awaits the result and treats a
+rejection as a command failure.
+
+### Failing a command
+
+`ctx.fail(message)` is the idiomatic way to stop a command:
+
+```ts
+defineCommand({
+ name: "widget|add",
+ arguments: "any",
+ options: { output: stringOption() },
+ async run(ctx) {
+ if (!ctx.options.output) {
+ ctx.fail("--output is required.");
+ }
+
+ /* ... */
+ },
+});
+```
+
+It is available on the `canExecute` context as well, and it returns `never`, so
+it can end a branch without a `return`. The message must be a non-empty string.
+
+Throwing is equivalent and keeps working — `ctx.fail` is sugar over the
+`errors` service's `failWithHelp`, which is what adds the "Run `ns widget add
+--help`" line. Throw when you already have an `Error` to propagate; call
+`ctx.fail` when you are writing the message.
+
+`run` starts inside a dependency-injection context, so `inject()` works
+directly:
+
+```ts
+import { defineCommand, inject } from "nativescript/contracts";
+import { DoctorService } from "nativescript/contracts";
+
+export default defineCommand({
+ name: "widget|check",
+ async run() {
+ const doctorService = inject(DoctorService);
+ await doctorService.printWarnings();
+ },
+});
+```
+
+The injection context is synchronous: `inject()` is valid up to the first
+`await` in `run`, and not after it. Capture what you need at the top of `run`,
+or inject the `Injector` itself and use `injector.get()` for late lookups. See
+`dependency-injection.md`.
+
+Other flags
+-----------
+
+- `disableAnalytics: true` — skips analytics tracking for this command.
+- `enableHooks: false` — skips the before/after hooks that normally run around
+ the command. Hooks are enabled by default.
+
+Both are simply passed through to the command the CLI executes; omitting them
+leaves the CLI's defaults in place.
+
+Registering a definition
+------------------------
+
+Inside the CLI, a definition is registered with `registerCommandDefinition`:
+
+```ts
+import { registerCommandDefinition } from "../common/services/command-definition-adapter";
+import addWidgetCommand from "./add-widget";
+
+registerCommandDefinition(addWidgetCommand);
+```
+
+It takes a `DefinedCommand` — the result of `defineCommand`, marker and all —
+and rejects a bare object of the right shape, so a definition can never reach
+the registry without having been validated. It registers under every name the
+definition declares, through the `CommandRegistry` the target injector provides;
+pass a second argument to target a different injector (tests do this). The
+command instance is built by a factory on first resolution and cached.
+
+`registerCommandDefinition` lives in
+`lib/common/services/command-definition-adapter` rather than in
+`nativescript/contracts`, because it reaches into the CLI runtime — the
+side-effect-free contracts entry point deliberately does not pull it in.
+`defineCommand`, the option helpers and all the types are exported from both
+`nativescript/contracts` and `lib/common/define-command`.
+
+Extensions do not need `registerCommandDefinition` at all: a
+`nativescript.commands` manifest entry may point straight at a module that
+exports a definition, and the CLI adapts and registers it lazily under the
+manifest key (see [extensions.md](extensions.md)).
+
+Relationship to `ICommand`
+--------------------------
+
+A definition is compiled into an ordinary `ICommand`, so nothing downstream —
+the registry, the router, hooks, help, analytics — knows the difference. The
+mapping is:
+
+| Definition | `ICommand` |
+| --------------------------------- | -------------------------------------------------- |
+| `options` | `dashedOptions` |
+| `run` | `execute`, wrapped in an injection context |
+| `arguments`, `canExecute` | `canExecute`: policy enforced, then the refinement |
+| — | `allowedParameters`, always `[]` |
+| `disableAnalytics`, `enableHooks` | passed through unchanged |
+
+The compiled command always exposes `canExecute`, because `CommandsService`
+stops consulting `allowedParameters` as soon as a command has one — the adapter
+therefore enforces the `arguments` policy itself.
+
+Existing command classes need no migration. Reach for a definition when a
+command is mostly "parse these flags and do this"; a class still makes sense
+when a command needs constructor-injected collaborators shared across several
+methods, custom `ICommandParameter` validators, or a `postCommandAction`.
diff --git a/dependency-injection.md b/dependency-injection.md
new file mode 100644
index 0000000000..56164068cd
--- /dev/null
+++ b/dependency-injection.md
@@ -0,0 +1,357 @@
+Dependency Injection
+====================
+
+The NativeScript CLI is migrating from name-based dependency injection (the
+`$injector` global, where a constructor parameter named `$doctorService`
+resolves the service registered under the string `"doctorService"`) to a typed,
+token-based container. Both APIs are backed by **one container**, so they can be
+mixed freely: a service registered under a legacy string name is resolvable
+through its typed token and vice versa. The legacy `$injector` surface remains
+fully supported, is marked `@deprecated` in-editor, and its usage is traced at
+runtime so removal can be staged over releases.
+
+Inside the CLI, import from `lib/common/di`. Extension and hook authors import
+the same API from the `nativescript/contracts` subpath (see
+[For extension and hook authors](#for-extension-and-hook-authors)).
+
+At a glance
+-----------
+
+```ts
+import { inject, DoctorService } from "nativescript/contracts";
+
+class PlatformChecker {
+ private doctorService = inject(DoctorService); // typed, no decorators needed
+
+ async check(projectDir: string): Promise {
+ return this.doctorService.canExecuteLocalBuild({ projectDir });
+ }
+}
+```
+
+Services that have no typed token yet remain reachable by their registry name —
+`inject("logger")` — but that is the migration bridge, not the API: prefer the
+token wherever one exists, and mint a token rather than a new string name.
+
+Tokens: `@Contract`
+-------------------
+
+A token is an abstract class annotated with `@Contract`. The class is both the
+compile-time type and the runtime lookup key; the decorator records the token's
+canonical string name:
+
+```ts
+import { Contract } from "nativescript/contracts";
+
+@Contract({ name: "doctorService" }) // canonical form, no `$`
+export abstract class DoctorService {
+ abstract canExecuteLocalBuild(configuration?: {
+ platform?: string;
+ projectDir?: string;
+ }): Promise;
+}
+```
+
+Rules:
+
+- **The name is an explicit string literal** — never derived from `class.name`,
+ which changes under minification.
+- Names are minted at this single choke point: declaring two contracts with the
+ same name **throws at load time**, because a duplicate would silently alias
+ two tokens.
+- The options object leaves room for future fields without changing call sites.
+- Implementations do not become tokens by extending or implementing a contract;
+ only the decorated class itself is a token.
+
+Tokens for non-classes: `InjectionToken`
+----------------------------------------
+
+Some registrations have no class to decorate — an imported module namespace, a
+plain value, a function. `InjectionToken` is the typed key for those:
+
+```ts
+import { InjectionToken, inject } from "nativescript/contracts";
+
+export const XCODE = new InjectionToken(
+ "xcode",
+);
+
+class ProjectPatcher {
+ private xcode = inject(XCODE); // typeof import("nativescript-dev-xcode")
+}
+```
+
+The description **is** the registry name, exactly as a contract's name is, so a
+token is a typed alias onto an existing registration and nothing has to change
+where the value is registered — `injector.register("xcode", xcode)` keeps
+serving `inject(XCODE)`. A leading `$` in the description is stripped.
+
+Names are minted in the same registry `@Contract` uses, so a token and a
+contract cannot claim the same name: the second one **throws at load time**,
+rather than silently aliasing one registration under two tokens.
+
+Tokens are used anywhere a contract class is — `inject()`, `get()`, `provide()`
+and the provider literals:
+
+```ts
+{ provide: XCODE, useValue: xcode }
+{ provide: XCODE, useFactory: () => require("nativescript-dev-xcode") }
+```
+
+Prefer a `@Contract` class when the dependency is a service: it also carries
+the service's shape. Reach for `InjectionToken` only when there is nothing to
+decorate.
+
+Resolving: `inject()` and `Injector`
+------------------------------------
+
+`inject(token)` returns the singleton for a token from the current injection
+context. It is **synchronous by design** and valid only:
+
+- in field initializers,
+- in constructor bodies,
+- in provider factories,
+- inside an explicit `runInInjectionContext(injector, fn)`.
+
+It is **not** valid after an `await`. For late or conditional lookups,
+self-inject the `Injector` and use `get()`:
+
+```ts
+import { inject, Injector } from "nativescript/contracts";
+
+class EnvironmentChecker {
+ private injector = inject(Injector);
+
+ async check(projectDir: string) {
+ await somethingAsync();
+ return this.injector.get(DoctorService); // fine after await
+ }
+}
+```
+
+`Injector.get()` accepts a contract class, an `InjectionToken`, a string name,
+or a `$`-prefixed string name — all of them return the same instance. The
+string forms exist for interoperability with the legacy registry; use the token
+whenever one exists.
+
+Both `inject()` and `get()` take Angular-shaped options as their second
+argument:
+
+```ts
+inject(DoctorService, { optional: true }); // DoctorService | null — no throw
+inject("logger", { skipSelf: true }); // start at the parent: escapes a
+ // child scope's shadowing entry
+inject("options", { self: true }); // this level only — no fallthrough
+```
+
+`optional` covers not-found only; a found-but-misconfigured provider still
+throws. `self` and `skipSelf` cannot be combined. There is deliberately no
+`host` option: it is an Angular component-tree concept with no analog in the
+CLI's injector hierarchy.
+
+The injection context is shared process-wide. If a hook or extension module
+ends up resolving a *duplicated* copy of the CLI (a nested `nativescript`
+install, or a project-local copy under a globally-run CLI), its `inject()`
+still resolves against the running CLI's context — with a one-time warning,
+because a duplicated copy loads the CLI twice. Declaring `nativescript` as a
+`peerDependency` lets the running copy be shared instead.
+
+Registering: providers
+----------------------
+
+```ts
+import { provide, provideLazy, Injector } from "nativescript/contracts";
+
+const injector = new Injector([
+ // eager class binding; type-checked: the impl must satisfy the token
+ provide(DoctorService, DoctorServiceImpl),
+
+ // deferred loading: the module is require()d on first resolution only
+ provideLazy(DoctorService, () => require("./doctor-service").DoctorServiceImpl),
+
+ { provide: Config, useValue: { DISABLE_HOOKS: false } },
+ { provide: Dispatcher, useFactory: () => createDispatcher(), shared: false },
+]);
+
+// registration is also allowed after construction; re-registering a token
+// updates the existing record in place
+injector.register(provide(ProjectNameService, ProjectNameServiceImpl));
+```
+
+Provider kinds:
+
+| Kind | Shape | Notes |
+|---|---|---|
+| Class | `provide(Token, Impl)` / `{ provide, useClass }` | constructed with `new Impl()` inside an injection context, so `inject()` works in its fields |
+| Lazy class | `provideLazy(Token, () => Impl)` / `{ provide, useLazyClass }` | loader runs on first `get()` only — keeps startup lazy |
+| Value | `{ provide, useValue }` | registered instance; re-registering replaces the cached instance. With `shared: false` there is no resolver and `get()` throws — a preserved legacy quirk |
+| Factory | `{ provide, useFactory }` | called inside an injection context |
+
+`shared: false` makes a provider transient: every resolution constructs a fresh
+instance. Transient instances are still retained by the container so
+`dispose()` reaches them.
+
+String keys are accepted anywhere a token is (`{ provide: "logger", useValue }`)
+— that is how the legacy facade registers, and how per-call overrides address
+not-yet-migrated dependencies. New registrations should mint a `@Contract`
+class, or an `InjectionToken` when there is no class to decorate, instead of a
+new string name.
+
+For per-call construction with overrides (a fresh instance of a class with some
+dependencies replaced), use `createInstance`:
+
+```ts
+const debugService = injector.createInstance(IOSDeviceDebugService, [
+ { provide: "device", useValue: device },
+]);
+```
+
+Overrides shadow **one level deep only** — the direct dependencies of the class
+being constructed. Nested dependencies are constructed by the injector that
+owns them and never see the per-call providers.
+
+Resolution semantics
+--------------------
+
+- Lookup is **token identity first, token name on a miss**, checked per
+ injector level before delegating to the parent. Both keys index the same
+ provider record, so re-registering a service by its string name (as plugins
+ are documented to do with `$logger`) stays visible to `inject(Logger)`
+ consumers. This holds for `@Contract` classes and `InjectionToken`s alike.
+- A leading `$` is stripped from string tokens: `get("$fs")` and `get("fs")`
+ are the same registration.
+- The name fallback also makes **duplicated token copies interchangeable**: if
+ an extension's dependency tree carries its own copy of a contract class or
+ injection token, that copy resolves to the same provider by name. "Works
+ locally, breaks when installed" is not a failure mode of this design.
+- Cyclic dependencies fail with the full resolution path
+ (`Cyclic dependency detected on dependency 'a'. Resolution path: a -> b -> a`).
+
+Child scopes
+------------
+
+`injector.createChild(providers)` creates a scope that shadows its parent for
+the given tokens and falls through for everything else. Sibling scopes are
+isolated. Scopes are how per-invocation data (hook payloads, per-call
+overrides) is layered over the shared singletons without ever entering the
+root container.
+
+`forwardRef`
+------------
+
+Provider arrays are evaluated at module load. When a token is declared later in
+the same file (TDZ) or reached through a circular import, wrap the reference in
+a thunk; it is read only when the injector processes the provider:
+
+```ts
+import { forwardRef } from "nativescript/contracts";
+
+const providers = [
+ { provide: forwardRef(() => DoctorService), useClass: DoctorServiceImpl },
+];
+```
+
+`forwardRef` defers *references*, not construction — it cannot break an
+instantiation cycle between two services. For that, self-inject the `Injector`
+and resolve late (see above).
+
+Working alongside the legacy `$injector`
+----------------------------------------
+
+The `Yok` facade (`global.$injector`) IS an `Injector` — the class extends the
+token-based container — so the new API works on it directly:
+
+```ts
+$injector.resolve("doctorService") === $injector.get(DoctorService); // true
+$injector.register(provide(DoctorService, DoctorServiceImpl));
+runInInjectionContext($injector, () => inject(DoctorService));
+```
+
+- Legacy string names are permanent: a contract's token name is its interop
+ identity, used by hooks, plugins, and the public API. Nothing is deleted
+ per-service.
+- Every legacy member (`resolve`, `register`, `require*`, the command-registry
+ surface) carries `@deprecated` JSDoc naming its replacement.
+- Legacy usage at the external entry points (param-name hooks, require-time
+ extension registration, help templating) is reported through a deprecation
+ tracer. It logs at trace level today; set `NS_DEPRECATIONS=warn` or
+ `NS_DEPRECATIONS=error` to preview the stricter stages that later releases
+ will default to.
+
+For extension and hook authors
+------------------------------
+
+Depend on `nativescript` itself (as a `peerDependency`, plus a `devDependency`
+for local development) and import from the `contracts` subpath:
+
+```ts
+import { inject, DoctorService } from "nativescript/contracts";
+```
+
+- The subpath resolves through a directory `package.json` — the CLI's
+ `package.json` deliberately has **no `exports` map**, so any deep `require()`
+ paths you already use keep working.
+- The entry point is side-effect-free: importing it never boots a CLI runtime,
+ even from a duplicated copy in your dependency tree.
+- The existing `$injector`-based extension and hook mechanisms keep working
+ unchanged; the typed API is additive.
+
+Available contracts
+-------------------
+
+Growing as services migrate. Every token below is a typed alias onto the
+registration it names — resolving by token and resolving by the legacy name
+return the same instance.
+
+The contract is also the single source of truth for the service's shape: the
+ambient interface the CLI has always published extends it (`interface ILogger
+extends Logger {}`), so the two cannot drift apart. Add a member to the
+contract and every existing caller sees it.
+
+| Token | Legacy name |
+|---|---|
+| `ChildProcess` | `childProcess` |
+| `DevicesService` | `devicesService` |
+| `DoctorService` | `doctorService` |
+| `Errors` | `errors` |
+| `FileSystem` | `fs` |
+| `HostInfo` | `hostInfo` |
+| `HttpClient` | `httpClient` |
+| `Logger` | `logger` |
+| `PackageManager` | `packageManager` |
+| `ProjectData` | `projectData` |
+| `ProjectDataService` | `projectDataService` |
+| `ProjectNameService` | `projectNameService` |
+| `Prompter` | `prompter` |
+| `TempService` | `tempService` |
+| `ViteHmrPortService` | `viteHmrPortService` |
+
+And the injection tokens, for registrations that are not classes:
+
+| Token | Legacy name | Value |
+|---|---|---|
+| `XCODE` | `xcode` | the `nativescript-dev-xcode` module |
+| `PBXPROJ_DOM_XCODE` | `pbxprojDomXcode` | the `pbxproj-dom/xcode` module |
+
+Related guides
+--------------
+
+- [defining-commands.md](defining-commands.md) — declarative, typed commands via `defineCommand`.
+- [extensions.md](extensions.md) — extension authoring, including the `nativescript.commands` manifest.
+- [extending-cli.md](extending-cli.md) — hooks, including the typed `defineHook` API.
+
+Legacy → new quick reference
+----------------------------
+
+`di` below is any `Injector` you hold — including `$injector` itself, which
+extends `Injector`.
+
+| Legacy (`$injector`) | New |
+|---|---|
+| `resolve("name")` | `inject(Token)` in an injection context, or `di.get(Token)` |
+| `resolve(SomeClass)` / `resolve(SomeClass, { dep })` | `di.createInstance(SomeClass, [{ provide: "dep", useValue }])` |
+| `register("name", Impl)` | `di.register(provide(Token, Impl))` |
+| `register("name", instance)` | `di.register({ provide: Token, useValue: instance })` |
+| `register("name", Impl, false)` | `di.register({ provide: Token, useClass: Impl, shared: false })` |
+| `require("name", "./path")` | `provideLazy(Token, () => require("./path").Impl)` |
+| constructor param `$name` | `inject(Token)` field initializer |
diff --git a/dev/tsc-to-mocha-watch.js b/dev/tsc-to-mocha-watch.js
deleted file mode 100644
index 6fa25998fd..0000000000
--- a/dev/tsc-to-mocha-watch.js
+++ /dev/null
@@ -1,57 +0,0 @@
-// Run "tsc" with watch, upon successful compilation run mocha tests.
-
-var child_process = require("child_process");
-var spawn = child_process.spawn;
-var readline = require("readline");
-var chalk = require("chalk");
-
-var mocha = null;
-var mochal = null;
-var errors = 0;
-
-function compilationStarted() {
- if (mocha) {
- mocha.kill('SIGINT');
- }
- mocha = null;
- mochal = null;
- errors = 0;
-}
-function foundErrors() {
- errors ++;
-}
-function compilationComplete() {
- if (errors) {
- console.log(" " + chalk.red("TS errors. Will not start mocha."));
- return;
- } else {
- console.log(" " + chalk.gray("Run mocha."));
- }
- mocha = spawn("./node_modules/.bin/mocha", ["--colors"]);
- mocha.on('close', code => {
- if (code) {
- console.log(chalk.gray("mocha: ") + "Exited with " + code);
- } else {
- console.log(chalk.gray("mocha: ") + chalk.red("Exited with " + code));
- }
- mocha = null;
- mochal = null;
- });
- mochal = readline.createInterface({ input: mocha.stdout });
- mochal.on('line', line => {
- console.log(chalk.gray('mocha: ') + line);
- });
-}
-
-var tsc = spawn("./node_modules/.bin/tsc", ["--watch"]);
-var tscl = readline.createInterface({ input: tsc.stdout });
-tscl.on('line', line => {
- console.log(chalk.gray(" tsc: ") + line);
- if (line.indexOf("Compilation complete.") >= 0) {
- compilationComplete();
- } else if (line.indexOf("File change detected.") >= 0) {
- compilationStarted();
- } else if (line.indexOf(": error TS") >= 0) {
- foundErrors();
- }
-});
diff --git a/dev/tsc-to-vitest-watch.js b/dev/tsc-to-vitest-watch.js
new file mode 100644
index 0000000000..b2371d4f5a
--- /dev/null
+++ b/dev/tsc-to-vitest-watch.js
@@ -0,0 +1,37 @@
+// Run "tsc" in watch mode alongside vitest. Vitest runs the compiled output,
+// so on its own it never notices a .ts edit - tsc has to re-emit first, and
+// vitest picks the change up from there.
+
+const { spawn } = require("child_process");
+
+// shell: true so the node_modules/.bin shims resolve on Windows as well
+const spawnOptions = { stdio: "inherit", shell: true };
+
+const children = [
+ // --preserveWatchOutput keeps tsc from clearing the screen and wiping the
+ // test results out from under you on every recompile
+ spawn("tsc", ["--watch", "--preserveWatchOutput"], spawnOptions),
+ // --watch explicitly: vitest only infers watch mode when stdout is a TTY,
+ // and would otherwise run once and exit, taking tsc down with it
+ spawn("vitest", ["--watch"], spawnOptions),
+];
+
+let shuttingDown = false;
+
+function shutdown() {
+ if (shuttingDown) {
+ return;
+ }
+ shuttingDown = true;
+ for (const child of children) {
+ child.kill("SIGINT");
+ }
+}
+
+process.on("SIGINT", shutdown);
+process.on("SIGTERM", shutdown);
+
+for (const child of children) {
+ // if either side dies, don't leave the other running in the background
+ child.on("exit", shutdown);
+}
diff --git a/docs/build-jekyll-md.sh b/docs/build-jekyll-md.sh
index 766f974aa7..fdff872477 100755
--- a/docs/build-jekyll-md.sh
+++ b/docs/build-jekyll-md.sh
@@ -4,4 +4,4 @@ set -e
rm -rf docs-cli
npm install --ignore-scripts
-npx grunt docs-jekyll
+npm run docs-jekyll
diff --git a/docs/man_pages/config/config-set.md b/docs/man_pages/config/config-set.md
index ea7769f647..78e9652221 100644
--- a/docs/man_pages/config/config-set.md
+++ b/docs/man_pages/config/config-set.md
@@ -31,6 +31,8 @@ General | `$ ns config set `
* Setting whole objects is not supported. Update individual keys instead. For example, use:
`$ ns config set android.codeCache true`
+<% if(isHtml) { %>
+
### Related Commands
Command | Description
diff --git a/docs/man_pages/project/testing/build-android.md b/docs/man_pages/project/testing/build-android.md
index 2904ff952e..d537fa081c 100644
--- a/docs/man_pages/project/testing/build-android.md
+++ b/docs/man_pages/project/testing/build-android.md
@@ -13,7 +13,7 @@ Builds the project for Android and produces an APK that you can manually deploy
Usage | Synopsis
---|---
-General | `$ ns build android [--compileSdk ] [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release] [--static-bindings] [--copy-to ] [--env.*]] [--aab]`
+General | `$ ns build android [--compileSdk ] [--key-store-path --key-store-password --key-store-alias --key-store-alias-password ] [--release] [--copy-to ] [--env.*]] [--aab]`
### Options
@@ -34,6 +34,7 @@ General | `$ ns build android [--compileSdk ] [--key-store-path ` - Specifies the directory that contains the project. If not set, the project is searched for in the current directory and all directories above it.
diff --git a/docs/man_pages/project/testing/debug-android.md b/docs/man_pages/project/testing/debug-android.md
index cd34d57c66..44116cc3be 100644
--- a/docs/man_pages/project/testing/debug-android.md
+++ b/docs/man_pages/project/testing/debug-android.md
@@ -38,6 +38,7 @@ Attach the debug tools to a running app in the native emulator | `$ ns debug and
* `--env.sourceMap` - creates inline source maps.
* `--env.hiddenSourceMap` - creates sources maps in the root folder (useful for Crashlytics usage with bundled app in release).
* `--aab` - Specifies that the command will produce and deploy an Android App Bundle.
+* `--gradleFlavor` - Builds the given product flavor, when the app declares any. `--gradleFlavor foo` runs the `assembleFooDebug`/`assembleFooRelease` gradle task instead of `assembleDebug`/`assembleRelease`.
* `--force` - If set, skips the application compatibility checks and forces `npm i` to ensure all dependencies are installed. Otherwise, the command will check the application compatibility with the current CLI version and could fail requiring `ns migrate`.
<% if(isHtml) { %>
diff --git a/docs/man_pages/project/testing/run-android.md b/docs/man_pages/project/testing/run-android.md
index c895cd8103..ab651eff42 100644
--- a/docs/man_pages/project/testing/run-android.md
+++ b/docs/man_pages/project/testing/run-android.md
@@ -43,6 +43,7 @@ Start a default emulator if none are running, or run application on all connecte
* `--env.sourceMap` - creates inline source maps.
* `--env.hiddenSourceMap` - creates sources maps in the root folder (useful for Crashlytics usage with bundled app in release).
* `--aab` - Specifies that the command will produce and deploy an Android App Bundle.
+* `--gradleFlavor` - Builds the given product flavor, when the app declares any. `--gradleFlavor foo` runs the `assembleFooDebug`/`assembleFooRelease` gradle task instead of `assembleDebug`/`assembleRelease`.
* `--force` - If set, skips the application compatibility checks and forces `npm i` to ensure all dependencies are installed. Otherwise, the command will check the application compatibility with the current CLI version and could fail requiring `ns migrate`.
<% if(isHtml) { %>
diff --git a/docs/man_pages/project/testing/test-android.md b/docs/man_pages/project/testing/test-android.md
index efa789469a..46d3f460a5 100644
--- a/docs/man_pages/project/testing/test-android.md
+++ b/docs/man_pages/project/testing/test-android.md
@@ -43,4 +43,5 @@ Command | Description
--------|------------
[test init](test-init.html) | Configures your project for unit testing with a selected framework.
[test ios](test-ios.html) | Runs the tests in your project on iOS devices or the iOS Simulator.
+[test visionos](test-visionos.html) | Runs the tests in your project in the visionOS Simulator or on Apple Vision Pro devices.
<% } %>
diff --git a/docs/man_pages/project/testing/test-init.md b/docs/man_pages/project/testing/test-init.md
index c19827d161..638295d649 100644
--- a/docs/man_pages/project/testing/test-init.md
+++ b/docs/man_pages/project/testing/test-init.md
@@ -9,6 +9,8 @@ position: 21
Configures your project for unit testing with a selected framework. This operation installs the @nativescript/unit-test-runner npm module and its dependencies and creates a `tests` folder in the `app` directory.
+The recommended framework is `vitest`, which runs your specs inside real NativeScript runtimes on device/emulator and also supports UI testing. The Karma-based frameworks (jasmine, mocha, qunit) are deprecated and will be removed in a future release.
+
### Commands
Usage | Synopsis
@@ -17,7 +19,7 @@ General | `$ ns test init [--framework ]`
### Options
-* `--framework ` - Sets the unit testing framework to install. The following frameworks are available: mocha, jasmine and qunit.
+* `--framework ` - Sets the unit testing framework to install. The following frameworks are available: vitest (recommended), mocha, jasmine and qunit (deprecated).
<% if(isHtml) { %>
@@ -31,4 +33,5 @@ Command | Description
--------|------------
[test android](test-android.html) | Runs the tests in your project on Android devices or native emulators.
[test ios](test-ios.html) | Runs the tests in your project on iOS devices or the iOS Simulator.
+[test visionos](test-visionos.html) | Runs the tests in your project in the visionOS Simulator or on Apple Vision Pro devices.
<% } %>
\ No newline at end of file
diff --git a/docs/man_pages/project/testing/test-ios.md b/docs/man_pages/project/testing/test-ios.md
index 8a421559a8..dac2ccfccb 100644
--- a/docs/man_pages/project/testing/test-ios.md
+++ b/docs/man_pages/project/testing/test-ios.md
@@ -45,4 +45,5 @@ Command | Description
--------|------------
[test init](test-init.html) | Configures your project for unit testing with a selected framework.
[test android](test-android.html) | Runs the tests in your project on Android devices or native emulators.
+[test visionos](test-visionos.html) | Runs the tests in your project in the visionOS Simulator or on Apple Vision Pro devices.
<% } %>
diff --git a/docs/man_pages/project/testing/test-visionos.md b/docs/man_pages/project/testing/test-visionos.md
new file mode 100644
index 0000000000..af48d8175c
--- /dev/null
+++ b/docs/man_pages/project/testing/test-visionos.md
@@ -0,0 +1,46 @@
+<% if (isJekyll) { %>---
+title: ns test visionos
+position: 24
+---<% } %>
+
+# ns test visionos
+
+### Description
+
+Runs the tests in your project in the visionOS Simulator or on connected Apple Vision Pro devices.<% if(isConsole && isMacOS) { %> Your project must already be configured for unit testing with the Vitest framework by running `$ ns test init --framework vitest`.<% } %> Unit testing on visionOS requires the Vitest testing framework; the deprecated Karma-based frameworks are not supported on this platform.
+
+<% if(isConsole && (isLinux || isWindows)) { %>WARNING: You can run this command only on macOS systems. To view the complete help for this command, run `$ ns help test visionos`<% } %>
+
+### Commands
+
+Usage | Synopsis
+------|-------
+Run tests in the visionOS Simulator | `$ ns test visionos`
+Run tests on a selected device | `$ ns test visionos --device `
+
+<% if((isConsole && isMacOS) || isHtml) { %>
+
+### Options
+
+* `--device` - Specifies the serial number or the index of the connected device on which you want to run tests. To list all connected devices, grouped by platform, run `$ ns device`. `` is the device index or identifier as listed by the `$ ns device` command.
+* `--env.codeCoverage` - If set, collects code coverage for the test run.
+* `--force` - If set, skips the application compatibility checks and forces `npm i` to ensure all dependencies are installed. Otherwise, the command will check the application compatibility with the current CLI version and could fail requiring `ns migrate`.
+
+<% } %>
+
+<% if(isHtml) { %>
+
+### Prerequisites
+
+* Verify that [you have configured your project for unit testing](test-init.html) with the Vitest framework.
+* Verify that [you have stored your unit tests in `app` → `tests`](http://docs.nativescript.org/testing).
+* Verify that [you have configured your system and devices properly](http://docs.nativescript.org/testing).
+
+### Related Commands
+
+Command | Description
+--------|------------
+[test init](test-init.html) | Configures your project for unit testing with a selected framework.
+[test android](test-android.html) | Runs the tests in your project on Android devices or native emulators.
+[test ios](test-ios.html) | Runs the tests in your project on iOS devices or the iOS Simulator.
+<% } %>
diff --git a/docs/man_pages/project/testing/test.md b/docs/man_pages/project/testing/test.md
index 5da36cbe25..3a77080cae 100644
--- a/docs/man_pages/project/testing/test.md
+++ b/docs/man_pages/project/testing/test.md
@@ -28,7 +28,8 @@ Usage | Synopsis
<% if((isConsole && isMacOS) || isHtml) { %>### Arguments
`` is the target mobile platform on which you want to run the tests. You can set the following target platforms.
* `android` - Runs the tests in your project on connected Android devices or Android emulators.
-* `ios` - Runs the tests in your project on connected iOS devices.<% } %>
+* `ios` - Runs the tests in your project on connected iOS devices.
+* `visionos` - Runs the tests in your project in the visionOS Simulator or on connected Apple Vision Pro devices. Requires the Vitest testing framework.<% } %>
<% if(isHtml) { %>
@@ -45,4 +46,5 @@ Command | Description
[test init](test-init.html) | Configures your project for unit testing with a selected framework.
[test android](test-android.html) | Runs the tests in your project on Android devices or native emulators.
[test ios](test-ios.html) | Runs the tests in your project on iOS devices or the iOS Simulator.
+[test visionos](test-visionos.html) | Runs the tests in your project in the visionOS Simulator or on Apple Vision Pro devices.
<% } %>
diff --git a/extending-cli.md b/extending-cli.md
index 3a7b5387b5..8808a4fde1 100644
--- a/extending-cli.md
+++ b/extending-cli.md
@@ -11,7 +11,7 @@ For the NativeScript CLI to execute your hooks, you must place them in the `hook
You can attach the hook before or after `prepare` operations or to `--watch` operations.
-Note that `watch` hooks can be executed only at the time of running `--watch` operations. The `watch` hooks are the last thing executed before launching the file system watcher which tracks for changes to your code.
+Note that `watch` hooks can be executed only at the time of running `--watch` operations. The `before-watch` hooks are the last thing executed before launching the file system watcher which tracks for changes to your code.
Your hooks must conform to the following naming and placement conventions:
@@ -36,27 +36,29 @@ Your hooks must conform to the following naming and placement conventions:
├── hook1 (this is an executable file)
└── hook2 (this is an executable file)
```
-* If you want to attach a hook for `--watch` operations, you must place the hook in the root of the `hooks` subdirectory. The file must be named `watch`. For example:
+* If you want to attach a hook for `--watch` operations, you must place the hook in the root of the `hooks` subdirectory. The file must be named `before-watch` or `after-watch`. For example:
```
my-app/
├── index.js
├── package.json
└── hooks/
- └── watch.js (this is a Node.js script)
+ └── before-watch.js (this is a Node.js script)
```
-* If you want to attach multiple hooks for `--watch` operations, you must place them inside a `watch` subdirectory of the `hooks` subdirectory. You can specify any meaningful name for the the hooks inside the subdirectory. For example:
+* If you want to attach multiple hooks for `--watch` operations, you must place them inside a `before-watch` or `after-watch` subdirectory of the `hooks` subdirectory. You can specify any meaningful name for the the hooks inside the subdirectory. For example:
```
my-app/
├── index.js
├── package.json
└── hooks/
- └── watch (a directory)
+ └── before-watch (a directory)
├── hook1 (this is an executable file)
└── hook2 (this is an executable file)
```
+ A file named plainly `watch` is never executed: like every other hook point, the watch hooks are addressed by the `before-`/`after-` names above.
+
> **NOTE:** When multiple hooks are attached to a single event (i.e. multiple hooks are stored in dedicated subdirectories), at the specified time, the CLI executes each hook one by one. However, the order of hook execution is not strict and might change over command executions.
Execute Hooks as Child Process
@@ -77,7 +79,134 @@ Execute Hooks In-Process
When your hook is a Node.js script, the CLI executes it in-process. This gives you access to the entire internal state of the CLI and all of its functions.
-The CLI assumes that this is a CommonJS module and calls its single exported function with four parameters. The type of the parameters is described in the `.d.ts` files which are part of the CLI source code [here](https://github.com/NativeScript/nativescript-cli/tree/master/lib/definitions) and [here](https://github.com/telerik/mobile-cli-lib/tree/master/definitions).
+The CLI assumes that this is a CommonJS module and calls the hook it exports — either a hook definition (see below) or a plain function.
+
+## Writing a hook
+
+Export a hook definition built with `defineHook`. It takes the hook point in the usual naming convention (`before-prepare`, `after-watch`) and a `run` handler that receives a context object.
+
+```JavaScript
+const { defineHook, inject, DoctorService } = require("nativescript/contracts");
+
+module.exports = defineHook({
+ name: "before-prepare",
+ run: async (ctx) => {
+ const doctorService = inject(DoctorService);
+ await doctorService.canExecuteLocalBuild();
+ },
+});
+```
+
+`defineHook(name, run)` is shorthand for the same definition:
+
+```JavaScript
+module.exports = defineHook("before-prepare", async (ctx) => { /* ... */ });
+```
+
+`defineHook` validates its input immediately: a missing or non-string `name`, a missing or non-function `run`, and unknown fields all throw at definition time, naming the definition and both accepted forms.
+
+The `name` decides when the hook fires and must match the hook point the file is placed at. A definition whose `name` disagrees with its location is **skipped with a warning** rather than run at the wrong point. Export exactly one definition (or one plain function) per file — an array export is rejected.
+
+Services come from `inject()` — the same API used everywhere else (see [dependency-injection.md](dependency-injection.md)):
+
+* `inject()` is valid in the synchronous part of the handler — not after an `await`. Resolve what you need up front; for late lookups, grab the container first: `const injector = inject(Injector)` (`Injector` is exported from `nativescript/contracts` too), then `injector.get(...)` later.
+* Tokens resolve by class first and by their canonical name on a miss, so this works even if your dependency tree carries its own copy of `nativescript` — a duplicated token class still resolves to the running CLI's service.
+* Only a first tranche of services has typed tokens so far ([dependency-injection.md](dependency-injection.md#available-contracts) lists them); a service without a token is reachable by its registry name — `inject("logger")` — as a migration bridge.
+* If you build your hook in TypeScript, add `nativescript` as a `devDependency` and import the same names: `import { defineHook, inject, DoctorService } from "nativescript/contracts"`. An `.mjs` hook can `export default defineHook(...)`.
+
+### `ctx.payload`
+
+`ctx.payload` holds the parameters of the CLI operation being hooked; its shape depends on the hook point. It is the CLI's own object, so mutating it influences the operation:
+
+```JavaScript
+module.exports = defineHook("before-build-task-args", (ctx) => {
+ ctx.payload.args.push("--offline");
+});
+```
+
+Not every invocation carries one. The `before-`/`after-` hooks fired around command dispatch (`before-build`, `after-run`, …) pass no arguments at all, so `ctx.payload` is `undefined` there. Treat it as optional — in TypeScript it is typed `TPayload | undefined`:
+
+```TypeScript
+import { defineHook } from "nativescript/contracts";
+
+export default defineHook<{ args: string[] }>("before-build-task-args", (ctx) => {
+ ctx.payload?.args.push("--offline");
+});
+```
+
+### `ctx.wrap(middleware)`
+
+`ctx.wrap(middleware)` puts a middleware around the hooked method. The middleware receives the method's arguments and a `next` callback; call `next` to continue, or return without calling it to short-circuit the method entirely.
+
+```JavaScript
+module.exports = defineHook("before-prepare", (ctx) => {
+ ctx.wrap(async (args, next) => {
+ const result = await next(...args);
+ return result;
+ });
+});
+```
+
+Only a hook point that actually folds middlewares around a method can honor `wrap()`, so it is available **only in the before-phase of the wrappable hook points** listed below. Calling it anywhere else — from any `after-` hook, or from a before-hook at a non-wrappable point — throws an error naming the hook point instead of registering a middleware that would never run.
+
+The wrappable hook points are:
+
+`before-buildAndroid` · `before-buildAndroidPlugin` · `before-buildIOS` · `before-checkEnvironment` · `before-checkForChanges` · `before-install` · `before-prepare` · `before-prepareNativeApp` · `before-resolveCommand` · `before-watch` · `before-watchPatterns`
+
+### `ctx.fail(message)` and `ctx.skip(message)`
+
+Both end the handler immediately — nothing after the call runs — and differ in what happens to the command.
+
+`ctx.fail(message)` fails the command, printing `message` as the error:
+
+```JavaScript
+module.exports = defineHook("before-prepare", (ctx) => {
+ ctx.fail("The generated bundle is missing; run the bundler first.");
+});
+```
+
+`ctx.skip(message)` prints `message` as a warning and lets the command continue:
+
+```JavaScript
+module.exports = defineHook("before-prepare", (ctx) => {
+ ctx.skip("Nothing to prepare.");
+});
+```
+
+The message is required in practice — calling either without one falls back to a message naming the hook point and the method.
+
+### Plain function hooks
+
+Exporting a plain function is still supported. It runs in an injection context too, so `inject()` works the same way; declare a `hookArgs` parameter if you need the payload.
+
+```JavaScript
+const { inject, DoctorService } = require("nativescript/contracts");
+
+module.exports = function (hookArgs) {
+ const doctorService = inject(DoctorService);
+ return doctorService.canExecuteLocalBuild();
+};
+```
+
+## The hook contract
+
+The hook must return a Promise. If the hook succeeds, it must fullfil the promise, but the fullfilment value is ignored.
+The hook can also reject the promise with an instance of Error. The returned error can carry two members that together downgrade the rejection to a warning.
+
+Member | Type | Description
+---|---|---
+`errorAsWarning` | Boolean | Must be exactly `true`. The CLI prints the error.message colored as a warning and continues executing the current command.
+`stopExecution` | Boolean | Must be present and of type Boolean. It only enables the check — setting it alone, with either value, changes nothing.
+
+**Both** members are required: the CLI continues only when `errorAsWarning === true` *and* `stopExecution` is a Boolean. Otherwise it prints the returned error colored as a fatal error and stops executing the current command.
+
+A plain-function hook can also return a function, which the CLI folds into a middleware chain around the hooked method.
+
+With `defineHook` neither convention is needed, and neither applies: `ctx.fail`/`ctx.skip` replace throwing an error carrying `stopExecution`/`errorAsWarning`, and `ctx.wrap` replaces returning a function. A definition whose `run` returns a function is warned about — the returned function is not used as a middleware.
+
+## Legacy: parameter-name injection
+
+Historically, a hook received CLI services by naming them as parameters: the CLI parses the exported function's parameter names and injects the service registered under each name. Existing hooks written this way keep working unchanged, but **new hooks should use the pattern above** — parameter-name service injection is slated for deprecation, and hooks that use it are reported through the CLI's deprecation tracer (visible with `--log trace`, or as warnings with `NS_DEPRECATIONS=warn`).
Parameter | Type | Description
---|---|---
@@ -85,18 +214,8 @@ Parameter | Type | Description
`$projectData` | IProjectData | Contains data about the project, such as project directory, ID, dependencies, etc.
`$usbLiveSyncService` | ILiveSyncService | Use this variable to check whether a LiveSync or normal build is in progress.
`hookArgs` | Any | Contains all the parameters of the original function in the CLI which is being hooked.
-
-The hook must return a Promise. If the hook succeeds, it must fullfil the promise, but the fullfilment value is ignored.
-The hook can also reject the promise with an instance of Error. The returned error can have two optional members controlling the CLI.
-
-Member | Type | Description
----|---|---
-`stopExecution` | Boolean | Set this to `false` to let the CLI continue executing this command.
-`errorAsWarning` | Boolean | Set this to treat the returned error as warning. The CLI prints the error.message colored as a warning and continues executing the current command.
-
-If these two members are not set, the CLI prints the returned error colored as fatal error and stops executing the current command.
-
-Furthermore, the global variable `$injector` of type `IInjector` provides access to the CLI Dependency Injector, through which all code services are available.
+
+The type of the parameters is described in the `.d.ts` files which are part of the CLI source code [here](https://github.com/NativeScript/nativescript-cli/tree/master/lib/definitions). Any registered service name is injectable, not only the ones listed; the global variable `$injector` of type `IInjector` likewise remains available. A parameter the CLI cannot resolve causes the hook to be skipped with a warning.
Commands with Hooking Support
==============================
diff --git a/extensions.md b/extensions.md
new file mode 100644
index 0000000000..93f882f1fb
--- /dev/null
+++ b/extensions.md
@@ -0,0 +1,289 @@
+Writing a CLI Extension
+=======================
+
+An extension adds new commands to the NativeScript CLI. Extensions are ordinary
+npm packages.
+
+```bash
+ns extension install
+ns extension uninstall
+```
+
+Installed extensions live in the CLI profile directory, under
+`extensions/node_modules/`, and every CLI invocation consults each
+of them. That makes the manifest below the most important file in an extension:
+it is what the CLI reads on startup, and it decides whether your code is loaded
+eagerly or only when one of your commands is actually executed.
+
+Depending on the CLI
+--------------------
+
+An extension that imports anything from the CLI — `defineCommand`, `inject`, the
+types — needs `nativescript` declared twice:
+
+```json
+{
+ "name": "nativescript-hello",
+ "version": "1.0.0",
+ "keywords": ["nativescript:extension"],
+ "peerDependencies": {
+ "nativescript": ">=9.1.0"
+ },
+ "devDependencies": {
+ "nativescript": "^9.1.0"
+ }
+}
+```
+
+- The **peer dependency** declares which CLI versions the extension works with,
+ and keeps package managers from installing a second copy of the CLI next to
+ your extension. Your code must run against the _running_ CLI: a second copy
+ brings its own injector, and services resolved from it are not the ones
+ executing the command.
+- The **dev dependency** is what makes `require("nativescript/contracts")`
+ resolve while you build and test the extension. It is not installed for your
+ users.
+- Developing against a **prerelease** CLI? Semver ranges without a prerelease
+ tag never match one — `9.1.0-alpha.15` does not satisfy `>=9.1.0` — so pin
+ the exact prerelease as your dev dependency and keep the stable floor in
+ `peerDependencies`.
+
+Never declare `nativescript` as a plain dependency.
+
+`nativescript/contracts` is the entry point extensions import from. It is
+side-effect free — importing it does not boot a CLI — and it exports
+`defineCommand`, `inject`, the option helpers and the public types.
+
+The `nativescript:extension` keyword makes the package discoverable: the CLI
+searches npm for it when it needs to suggest an extension for an unknown command
+(see [Suggesting an extension](#suggesting-an-extension-for-an-unknown-command)).
+
+> Extensions are installed per user today, and are available from every project
+> on the machine. Installing them as project `devDependencies` — pinned per
+> project, reproducible in CI, shared with the team — is the direction this is
+> heading; declaring the peer dependency now is what makes an extension ready
+> for it.
+
+Declaring commands
+------------------
+
+Commands are declared in the `commands` key of the `nativescript` key of the
+extension's `package.json`. Two shapes are accepted.
+
+### A map of command name to module (recommended)
+
+```json
+{
+ "nativescript": {
+ "commands": {
+ "hello|world": "./dist/commands/hello-world.js",
+ "hello|*default": "./dist/commands/hello.js"
+ }
+ }
+}
+```
+
+Each key is a command name; each value says where the module implementing it
+lives, resolved relative to the extension's root directory. A value is either
+the path itself or an object carrying it under `path`:
+
+```json
+{
+ "nativescript": {
+ "commands": {
+ "hello|world": { "path": "./dist/commands/hello-world.js" }
+ }
+ }
+}
+```
+
+The two forms mean exactly the same thing today. Keys the CLI does not
+recognise inside the object form are ignored, so the object can carry
+information a later CLI understands without breaking the one you have
+installed.
+
+Declaring commands this way is strongly preferred:
+
+- **Per-command lazy loading.** Nothing in the extension is loaded when the CLI
+ starts. A command's module is required the first time that command is
+ resolved, so `ns build android` never pays the cost of loading an unrelated
+ extension. With a large or dependency-heavy extension installed, that is the
+ difference between a noticeable startup delay on every command and none.
+ Dispatching `ns hello world` loads only `hello-world.js` — not the sibling
+ `hello.js`, and not the extension's main entry.
+- **Early, named conflict detection.** Two extensions claiming the same command
+ name is reported as a warning that names both extensions and the contested
+ command, and the extension that claimed it first keeps working. Under the
+ legacy shape the same collision surfaces as an opaque
+ `module '...' require'd twice.` failure from whichever extension happened to
+ load second.
+- **The CLI knows what you contribute without running you.** The declared
+ command names are what the install suggestion for an unknown command matches
+ against, and they are available to the CLI as metadata about the installed
+ extension.
+
+Malformed entries are skipped rather than fatal: an entry whose command name is
+not a non-empty string, or whose value carries no usable module path, is
+reported as a warning naming the extension and the offending entry, and the
+extension's remaining commands are still registered.
+
+An empty map opts out of loading entirely:
+
+```json
+{
+ "nativescript": {
+ "commands": {}
+ }
+}
+```
+
+The extension contributes no commands, and — unlike omitting the key — its main
+entry is never required. Use it for an extension that only ships documentation
+or assets.
+
+### An array of command names (legacy)
+
+```json
+{
+ "nativescript": {
+ "commands": ["hello|world", "hello|*default"]
+ }
+}
+```
+
+The array is a discovery aid only — it lists the names the CLI may suggest your
+extension for, but it says nothing about where the implementations live. An
+extension declaring commands this way (or omitting the `commands` key
+altogether) is loaded the old way: the CLI `require()`s the package's main entry
+on **every** invocation and expects the module's top-level code to register
+everything through the injector global.
+
+This path remains supported for published extensions, but it is tracked for
+eventual deprecation and new extensions should not use it — declare the map
+instead. Run any command with `--log trace` to see which installed extensions
+still rely on it, or set `NS_DEPRECATIONS=warn` to have those reports printed
+as warnings.
+
+Writing a command module
+------------------------
+
+The recommended shape is a module exporting a `defineCommand` definition (see
+[defining-commands.md](defining-commands.md)) — the CLI adapts and registers it
+under the manifest key when the command is first executed, and the module needs
+no registration side effects at all:
+
+```js
+// dist/commands/hello-world.js
+const { defineCommand, inject } = require("nativescript/contracts");
+
+module.exports = defineCommand({
+ name: "hello|world",
+ arguments: "any",
+ async run(ctx) {
+ inject("logger").info(`Hello, ${ctx.args[0] || "world"}!`);
+ },
+});
+```
+
+`inject()` resolves a CLI service against the injector running the command, and
+works anywhere inside `run` up to the first `await`. It is why the peer
+dependency above matters: with a second copy of the CLI installed alongside your
+extension, `inject()` warns and points at the duplicate.
+
+A definition exported as `module.exports.default` (what a TypeScript or ESM
+build emits) is picked up too.
+
+Legacy modules — command classes that register themselves at load time through
+the injector global, with parameter-name constructor injection — keep working
+when a manifest entry points at them, so existing extensions can adopt the map
+without rewriting their commands. Both of those mechanisms are deprecated
+(see [dependency-injection.md](dependency-injection.md)); write new modules as
+definitions.
+
+If a module named by a manifest entry neither exports a definition nor registers
+the command itself, executing that command fails with an error naming the
+extension, the command and the module — the entry points at the wrong file, or
+the file is not doing what the entry promises.
+
+Command names
+-------------
+
+Command names use `|` to express hierarchy, so `"hello|world"` is invoked as
+`ns hello world`. Prefixing the last segment with `*` marks a default
+subcommand: `"hello|*default"` runs both for `ns hello default` and for a bare
+`ns hello`. Names must be lower case — the CLI matches what the user typed in
+lower case, so a key with an upper-case letter could never be reached, and is
+rejected with a warning.
+
+**The manifest key decides how a command is invoked.** It has to: the CLI routes
+`ns hello world` to your module before that module has been loaded, so the key
+is the only name it can know. A `name` inside the definition is metadata — it is
+what `registerCommandDefinition` uses when a module registers itself, and it is
+useful documentation, but a manifest entry overrides it. If the two disagree the
+CLI warns, naming both, and runs the command under the manifest key.
+
+An alias is a second entry pointing at the same module:
+
+```json
+{
+ "nativescript": {
+ "commands": {
+ "hello|world": "./dist/commands/hello-world.js",
+ "hello|w": "./dist/commands/hello-world.js"
+ }
+ }
+}
+```
+
+Both names route to the same module, which is loaded once.
+
+When two extensions want the same command
+-----------------------------------------
+
+The first extension to claim a command name keeps it; later claimants are
+reported with a warning naming both extensions and the command, and their entry
+is skipped. A name the CLI itself provides is never taken over — the extension
+is told the command is already provided by the CLI.
+
+"First" is the order extensions are loaded in, which is the order they appear in
+the `dependencies` of the profile directory's `extensions/package.json` — npm
+keeps that alphabetically sorted, so in practice the alphabetically first
+extension name wins. The exception is `ns extension install`: that invocation
+loads the freshly installed extension after all the others, so a conflict it
+would win on the next invocation goes the other way that one time.
+
+Suggesting an extension for an unknown command
+----------------------------------------------
+
+When a user types a command the CLI does not know, it searches npm for packages
+carrying the `nativescript:extension` keyword, reads the `nativescript.commands`
+key of each candidate's published `package.json`, and matches it against the
+words the user typed — longest match first, so `ns valid command with args`
+matches a declared `valid|command|with` before `valid|command`. A declared
+default command also matches its short form: an extension declaring
+`hello|*default` is suggested for a bare `ns hello`.
+
+Both manifest shapes participate in this matching. If a match is found, the CLI
+tells the user which extension provides the command and how to install it:
+
+```text
+The command hello world is registered in extension nativescript-hello.
+You can install it by executing 'ns extension install nativescript-hello'
+```
+
+Documentation
+-------------
+
+Point the `docs` key of the `nativescript` key at a directory of `.md` files to
+have the CLI's help system pick up the help for your commands.
+
+```json
+{
+ "nativescript": {
+ "docs": "./docs",
+ "commands": {
+ "hello|world": "./dist/commands/hello-world.js"
+ }
+ }
+}
+```
diff --git a/lib/android-tools-info.ts b/lib/android-tools-info.ts
index 5ceb095968..afd46f56ab 100644
--- a/lib/android-tools-info.ts
+++ b/lib/android-tools-info.ts
@@ -17,7 +17,7 @@ export class AndroidToolsInfo implements IAndroidToolsInfo {
private $errors: IErrors,
private $logger: ILogger,
private $options: IOptions,
- protected $staticConfig: Config.IStaticConfig
+ protected $staticConfig: Config.IStaticConfig,
) {}
@cache()
@@ -29,18 +29,18 @@ export class AndroidToolsInfo implements IAndroidToolsInfo {
infoData.androidHomeEnvVar = androidToolsInfo.androidHome;
infoData.compileSdkVersion = this.getCompileSdkVersion(
infoData.installedTargets,
- infoData.compileSdkVersion
+ infoData.compileSdkVersion,
);
infoData.targetSdkVersion = this.getTargetSdk(infoData.compileSdkVersion);
infoData.generateTypings = this.shouldGenerateTypings();
this.$logger.trace(
"Installed Android Targets are: ",
- infoData.installedTargets
+ infoData.installedTargets,
);
this.$logger.trace(
"Selected buildToolsVersion is:",
- infoData.buildToolsVersion
+ infoData.buildToolsVersion,
);
return infoData;
@@ -55,7 +55,7 @@ export class AndroidToolsInfo implements IAndroidToolsInfo {
androidToolsInfo
.validateInfo({ projectDir: options.projectDir })
.map((warning) =>
- this.printMessage(warning.warning, showWarningsAsErrors)
+ this.printMessage(warning.warning, showWarningsAsErrors),
).length > 0;
if (options && options.validateTargetSdk) {
@@ -78,7 +78,7 @@ export class AndroidToolsInfo implements IAndroidToolsInfo {
projectDir: options.projectDir,
})
.map((warning) =>
- this.printMessage(warning.warning, options.showWarningsAsErrors)
+ this.printMessage(warning.warning, options.showWarningsAsErrors),
).length > 0;
if (!detectedErrors) {
@@ -95,7 +95,7 @@ export class AndroidToolsInfo implements IAndroidToolsInfo {
public validateJavacVersion(
installedJavacVersion: string,
- options?: IAndroidToolsInfoOptions
+ options?: IAndroidToolsInfoOptions,
): boolean {
const showWarningsAsErrors = options && options.showWarningsAsErrors;
@@ -103,7 +103,7 @@ export class AndroidToolsInfo implements IAndroidToolsInfo {
androidToolsInfo
.validateJavacVersion(installedJavacVersion)
.map((warning) =>
- this.printMessage(warning.warning, showWarningsAsErrors)
+ this.printMessage(warning.warning, showWarningsAsErrors),
).length > 0
);
}
@@ -118,8 +118,8 @@ export class AndroidToolsInfo implements IAndroidToolsInfo {
`Error while executing '${path.join(
androidToolsInfo.androidHome,
"platform-tools",
- "adb"
- )} help'. Error is: ${err.message}`
+ "adb",
+ )} help'. Error is: ${err.message}`,
);
}
@@ -128,7 +128,7 @@ export class AndroidToolsInfo implements IAndroidToolsInfo {
@cache()
public validateAndroidHomeEnvVariable(
- options?: IAndroidToolsInfoOptions
+ options?: IAndroidToolsInfoOptions,
): boolean {
const showWarningsAsErrors = options && options.showWarningsAsErrors;
@@ -136,7 +136,7 @@ export class AndroidToolsInfo implements IAndroidToolsInfo {
androidToolsInfo
.validateAndroidHomeEnvVariable()
.map((warning) =>
- this.printMessage(warning.warning, showWarningsAsErrors)
+ this.printMessage(warning.warning, showWarningsAsErrors),
).length > 0
);
}
@@ -163,15 +163,30 @@ export class AndroidToolsInfo implements IAndroidToolsInfo {
private getCompileSdkVersion(
installedTargets: string[],
- latestCompileSdk: number
+ latestCompileSdk: number,
): number {
const userSpecifiedCompileSdk = this.$options.compileSdk;
if (userSpecifiedCompileSdk) {
const androidCompileSdk = `${androidToolsInfo.ANDROID_TARGET_PREFIX}-${userSpecifiedCompileSdk}`;
- if (!_.includes(installedTargets, androidCompileSdk)) {
+ // SDK platforms newer than android-36 may install into directories named
+ // "android-." (e.g. "android-37.0") with no plain
+ // "android-" directory, so installed targets are matched on their
+ // API level rather than the exact directory name.
+ const isTargetInstalled = _.some(installedTargets, (target) => {
+ if (target === androidCompileSdk) {
+ return true;
+ }
+
+ const targetMatch = target.match(/^android-(\d+)(?:\.\d+)?$/);
+ return (
+ targetMatch &&
+ parseInt(targetMatch[1], 10) === userSpecifiedCompileSdk
+ );
+ });
+ if (!isTargetInstalled) {
this.$errors.fail(
- `You have specified '${userSpecifiedCompileSdk}' for compile sdk, but it is not installed on your system.`
+ `You have specified '${userSpecifiedCompileSdk}' for compile sdk, but it is not installed on your system.`,
);
}
diff --git a/lib/bootstrap.ts b/lib/bootstrap.ts
index 3281d84930..634c916e47 100644
--- a/lib/bootstrap.ts
+++ b/lib/bootstrap.ts
@@ -60,6 +60,7 @@ injector.require("iOSProvisionService", "./services/ios-provision-service");
injector.require("xcconfigService", "./services/xcconfig-service");
injector.require("iOSSigningService", "./services/ios/ios-signing-service");
injector.require("spmService", "./services/ios/spm-service");
+injector.require("spmPbxprojService", "./services/ios/spm-pbxproj-service");
injector.require(
"xcodebuildArgsService",
"./services/ios/xcodebuild-args-service",
@@ -202,12 +203,16 @@ injector.requireCommand("deploy", "./commands/deploy");
injector.requireCommand("embed", "./commands/embedding/embed");
injector.require("testExecutionService", "./services/test-execution-service");
+injector.require(
+ "vitestExecutionService",
+ "./services/vitest-execution-service",
+);
injector.requireCommand("dev-test|android", "./commands/test");
injector.requireCommand("dev-test|ios", "./commands/test");
injector.requireCommand("test|android", "./commands/test");
injector.requireCommand("test|ios", "./commands/test");
-// injector.requireCommand("test|vision", "./commands/test");
-// injector.requireCommand("test|visionos", "./commands/test");
+injector.requireCommand("test|vision", "./commands/test");
+injector.requireCommand("test|visionos", "./commands/test");
injector.requireCommand("test|init", "./commands/test-init");
injector.requireCommand("dev-generate-help", "./commands/generate-help");
@@ -304,10 +309,6 @@ injector.require("deployCommandHelper", "./helpers/deploy-command-helper");
injector.require("platformCommandHelper", "./helpers/platform-command-helper");
injector.require("optionsTracker", "./helpers/options-track-helper");
-injector.requirePublicClass(
- "localBuildService",
- "./services/local-build-service",
-);
injector.require("LiveSyncSocket", "./services/livesync/livesync-socket");
injector.requirePublicClass(
"androidLivesyncTool",
@@ -407,25 +408,21 @@ injector.require(
injector.require("hmrStatusService", "./services/hmr-status-service");
injector.require("pacoteService", "./services/pacote-service");
-injector.require(
- "qrCodeTerminalService",
- "./services/qr-code-terminal-service",
-);
injector.require(
"testInitializationService",
"./services/test-initialization-service",
);
-injector.require(
- "networkConnectivityValidator",
- "./helpers/network-connectivity-validator",
-);
injector.requirePublic("cleanupService", "./services/cleanup-service");
injector.require(
"bundlerCompilerService",
"./services/bundler/bundler-compiler-service",
);
+injector.require(
+ "viteHmrPortService",
+ "./services/bundler/vite-hmr-port-service",
+);
injector.require(
"applePortalSessionService",
@@ -479,5 +476,5 @@ injector.requireCommand(
],
"./commands/native-add",
);
-injector.requireCommand(["widget", "widget|ios"], "./commands/widget");
+injector.requireCommand(["widget|ios"], "./commands/widget");
require("./key-commands/bootstrap");
diff --git a/lib/commands/build.ts b/lib/commands/build.ts
index f3c3c1adb3..7216e8a3fc 100644
--- a/lib/commands/build.ts
+++ b/lib/commands/build.ts
@@ -13,7 +13,8 @@ import {
import { IPlatformsDataService } from "../definitions/platform";
import { IBuildController, IBuildDataService } from "../definitions/build";
import { IMigrateController } from "../definitions/migrate";
-import { IErrors, OptionType } from "../common/declarations";
+import { IErrors } from "../common/declarations";
+import { OptionType } from "../common/enums";
import { ICommandParameter, ICommand } from "../common/definitions/commands";
import { injector } from "../common/yok";
diff --git a/lib/commands/clean.ts b/lib/commands/clean.ts
index 62a00bf8ce..d891ead677 100644
--- a/lib/commands/clean.ts
+++ b/lib/commands/clean.ts
@@ -6,6 +6,7 @@ import {
IProjectCleanupResult,
IProjectCleanupService,
IProjectConfigService,
+ IProjectData,
IProjectService,
} from "../definitions/project";
@@ -83,6 +84,7 @@ export class CleanCommand implements ICommand {
constructor(
private $projectCleanupService: IProjectCleanupService,
private $projectConfigService: IProjectConfigService,
+ private $projectData: IProjectData,
private $terminalSpinnerService: ITerminalSpinnerService,
private $projectService: IProjectService,
private $prompter: IPrompter,
@@ -108,7 +110,7 @@ export class CleanCommand implements ICommand {
let pathsToClean = [
constants.HOOKS_DIR_NAME,
- constants.PLATFORMS_DIR_NAME,
+ this.$projectData.getBuildRelativeDirectoryPath(),
constants.NODE_MODULES_FOLDER_NAME,
];
diff --git a/lib/commands/debug.ts b/lib/commands/debug.ts
index c7478ab09f..bd894e9019 100644
--- a/lib/commands/debug.ts
+++ b/lib/commands/debug.ts
@@ -17,6 +17,7 @@ import { ICleanupService } from "../definitions/cleanup-service";
import { IInjector } from "../common/definitions/yok";
import { injector } from "../common/yok";
import * as _ from "lodash";
+import { SystemWarningsSeverity } from "../definitions/system-warnings";
export class DebugPlatformCommand
extends ValidatePlatformCommandBase
diff --git a/lib/commands/deploy.ts b/lib/commands/deploy.ts
index a06f6ea46f..2d2da7614f 100644
--- a/lib/commands/deploy.ts
+++ b/lib/commands/deploy.ts
@@ -10,12 +10,14 @@ import { IPlatformValidationService, IOptions } from "../declarations";
import { IPlatformsDataService } from "../definitions/platform";
import { IMigrateController } from "../definitions/migrate";
import { ICommand, ICommandParameter } from "../common/definitions/commands";
-import { OptionType, IErrors } from "../common/declarations";
+import { IErrors } from "../common/declarations";
+import { OptionType } from "../common/enums";
import { injector } from "../common/yok";
export class DeployOnDeviceCommand
extends ValidatePlatformCommandBase
- implements ICommand {
+ implements ICommand
+{
public allowedParameters: ICommandParameter[] = [];
public dashedOptions = {
@@ -36,13 +38,13 @@ export class DeployOnDeviceCommand
private $mobileHelper: Mobile.IMobileHelper,
$platformsDataService: IPlatformsDataService,
private $deployCommandHelper: DeployCommandHelper,
- private $migrateController: IMigrateController
+ private $migrateController: IMigrateController,
) {
super(
$options,
$platformsDataService,
$platformValidationService,
- $projectData
+ $projectData,
);
this.$projectData.initializeProjectData();
}
diff --git a/lib/commands/prepare.ts b/lib/commands/prepare.ts
index e726b02861..03323b9e6c 100644
--- a/lib/commands/prepare.ts
+++ b/lib/commands/prepare.ts
@@ -6,7 +6,7 @@ import { IOptions, IPlatformValidationService } from "../declarations";
import { IPlatformsDataService } from "../definitions/platform";
import { IMigrateController } from "../definitions/migrate";
import { ICommand, ICommandParameter } from "../common/definitions/commands";
-import { OptionType } from "../common/declarations";
+import { OptionType } from "../common/enums";
import { injector } from "../common/yok";
export class PrepareCommand
@@ -22,8 +22,7 @@ export class PrepareCommand
hasSensitiveValue: false,
},
hmr: { type: OptionType.Boolean, default: false, hasSensitiveValue: false },
-
- whatever: {
+ skipNative: {
type: OptionType.Boolean,
default: false,
hasSensitiveValue: false,
@@ -38,13 +37,13 @@ export class PrepareCommand
public $platformCommandParameter: ICommandParameter,
public $platformsDataService: IPlatformsDataService,
public $prepareDataService: PrepareDataService,
- public $migrateController: IMigrateController
+ public $migrateController: IMigrateController,
) {
super(
$options,
$platformsDataService,
$platformValidationService,
- $projectData
+ $projectData,
);
this.$projectData.initializeProjectData();
}
@@ -55,7 +54,7 @@ export class PrepareCommand
const prepareData = this.$prepareDataService.getPrepareData(
this.$projectData.projectDir,
platform,
- this.$options
+ this.$options,
);
await this.$prepareController.prepare(prepareData);
}
@@ -68,7 +67,7 @@ export class PrepareCommand
this.$options.provision,
this.$options.teamId,
this.$projectData,
- platform
+ platform,
));
if (!this.$options.force) {
diff --git a/lib/commands/preview.ts b/lib/commands/preview.ts
index 872267b260..64bbb9e8eb 100644
--- a/lib/commands/preview.ts
+++ b/lib/commands/preview.ts
@@ -12,6 +12,7 @@ const PREVIEW_CLI_PACKAGE = "@nativescript/preview-cli";
export class PreviewCommand implements ICommand {
allowedParameters: ICommandParameter[] = [];
+ skipOptionsValidation = true;
constructor(
private $logger: ILogger,
@@ -19,7 +20,7 @@ export class PreviewCommand implements ICommand {
private $projectData: IProjectData,
private $packageManager: IPackageManager,
private $childProcess: IChildProcess,
- private $options: IOptions
+ private $options: IOptions,
) {}
private getPreviewCLIPath(): string {
@@ -37,7 +38,7 @@ export class PreviewCommand implements ICommand {
{
"save-dev": true,
"save-exact": true,
- } as any
+ } as any,
);
}
@@ -78,7 +79,7 @@ export class PreviewCommand implements ICommand {
color.cyan(" ./node_modules/.bin/preview-cli"),
"",
"And if you are still having issues, try again - or reach out on Discord/open an issue on GitHub.",
- ].join("\n")
+ ].join("\n"),
);
this.$errors.fail("Running preview failed.");
@@ -93,7 +94,7 @@ export class PreviewCommand implements ICommand {
[previewCLIBinPath, ...commandArgs],
{
stdio: "inherit",
- }
+ },
);
}
diff --git a/lib/commands/test-init.ts b/lib/commands/test-init.ts
index 32b55ad04c..8cf06ebd4e 100644
--- a/lib/commands/test-init.ts
+++ b/lib/commands/test-init.ts
@@ -26,6 +26,70 @@ class TestInitCommand implements ICommand {
mocha: ["chai"],
};
+ /**
+ * Android blocks cleartext traffic by default (API 28+), which would
+ * reject the runner's ws:// connection to the host. Scope the exception
+ * to the emulator loopback alias and adb-reverse loopback only.
+ */
+ private ensureAndroidNetworkSecurityConfig(bufferedLogs: string[]): void {
+ const manifestPath = path.join(
+ this.$projectData.appResourcesDirectoryPath,
+ "Android",
+ "src",
+ "main",
+ "AndroidManifest.xml",
+ );
+ if (!this.$fs.exists(manifestPath)) {
+ bufferedLogs.push(
+ color.yellow(
+ "Could not locate App_Resources/Android/src/main/AndroidManifest.xml. For Android test runs, allow cleartext traffic to 10.0.2.2 and 127.0.0.1 via a network security config.",
+ ),
+ );
+ return;
+ }
+
+ const manifestContent = this.$fs.readText(manifestPath);
+ if (manifestContent.indexOf("networkSecurityConfig") !== -1) {
+ bufferedLogs.push(
+ color.yellow(
+ "AndroidManifest.xml already sets android:networkSecurityConfig — make sure it permits cleartext traffic to 10.0.2.2 and 127.0.0.1 for test runs.",
+ ),
+ );
+ return;
+ }
+
+ const xmlDirectory = path.join(
+ this.$projectData.appResourcesDirectoryPath,
+ "Android",
+ "src",
+ "main",
+ "res",
+ "xml",
+ );
+ this.$fs.ensureDirectoryExists(xmlDirectory);
+ const securityConfigPath = path.join(xmlDirectory, "network_security.xml");
+ if (!this.$fs.exists(securityConfigPath)) {
+ this.$fs.copyFile(
+ this.$resources.resolvePath("test/network_security.xml"),
+ securityConfigPath,
+ );
+ bufferedLogs.push(
+ `Added ${color.yellow("App_Resources/Android/src/main/res/xml/network_security.xml")}`,
+ );
+ }
+
+ this.$fs.writeFile(
+ manifestPath,
+ manifestContent.replace(
+ /
!moduleToInstall.projectType ||
- moduleToInstall.projectType === projectFilesExtension
+ moduleToInstall.projectType === projectFilesExtension,
);
for (const mod of modulesToInstall) {
let moduleToInstall = mod.name;
moduleToInstall += `@${mod.version}`;
await this.$packageManager.install(moduleToInstall, projectDir, {
- "save-dev": true,
+ // Packages with native code must land in "dependencies" — the CLI
+ // integrates plugin platform files (pods, aars) only from there.
+ ...(mod.saveInDependencies ? { save: true } : { "save-dev": true }),
"save-exact": true,
optional: false,
disableNpmInstall: this.$options.disableNpmInstall,
@@ -97,16 +162,40 @@ class TestInitCommand implements ICommand {
const modulePackageJsonContent = this.$fs.readJson(modulePackageJsonPath);
const modulePeerDependencies =
modulePackageJsonContent.peerDependencies || {};
+ const modulePeerDependenciesMeta =
+ modulePackageJsonContent.peerDependenciesMeta || {};
+ const projectPackageJson = this.$fs.readJson(
+ path.join(projectDir, "package.json"),
+ );
+ const installedProjectDependencies = {
+ ...projectPackageJson.dependencies,
+ ...projectPackageJson.devDependencies,
+ };
for (const peerDependency in modulePeerDependencies) {
const isPeerDependencyExcluded = _.includes(
mod.excludedPeerDependencies,
- peerDependency
+ peerDependency,
);
if (isPeerDependencyExcluded) {
continue;
}
+ if (
+ modulePeerDependenciesMeta[peerDependency] &&
+ modulePeerDependenciesMeta[peerDependency].optional
+ ) {
+ continue;
+ }
+
+ // Reinstalling an already-declared package would move it to
+ // devDependencies — for packages with native code (e.g.
+ // @nativescript/core) that strips their platform files from the
+ // native build.
+ if (installedProjectDependencies[peerDependency]) {
+ continue;
+ }
+
const dependencyVersion = modulePeerDependencies[peerDependency] || "*";
// catch errors when a peerDependency is already installed
@@ -122,7 +211,7 @@ class TestInitCommand implements ICommand {
frameworkPath: this.$options.frameworkPath,
ignoreScripts: this.$options.ignoreScripts,
path: this.$options.path,
- }
+ },
);
} catch (e) {
this.$logger.error(e.message);
@@ -130,10 +219,16 @@ class TestInitCommand implements ICommand {
}
}
- await this.$pluginsService.add(
- "@nativescript/unit-test-runner",
- this.$projectData
- );
+ const isVitest = frameworkToInstall === "vitest";
+
+ if (!isVitest) {
+ // The Karma client only exists in the v4 line — v5+ is Vitest-only, so
+ // an unpinned install would break these setups once v5 is `latest`.
+ await this.$pluginsService.add(
+ "@nativescript/unit-test-runner@^4.0.0",
+ this.$projectData,
+ );
+ }
this.$logger.clearScreen();
@@ -142,11 +237,11 @@ class TestInitCommand implements ICommand {
const testsDir = path.join(this.$projectData.appDirectoryPath, "tests");
const projectTestsDir = path.relative(
this.$projectData.projectDir,
- testsDir
+ testsDir,
);
const relativeTestsDir = path.relative(
this.$projectData.appDirectoryPath,
- testsDir
+ testsDir,
);
let shouldCreateSampleTests = true;
if (this.$fs.exists(testsDir)) {
@@ -157,80 +252,135 @@ class TestInitCommand implements ICommand {
`Note: The "${projectTestsDir}" directory already exists, will not create example tests in the project.`,
`You may create "${specFilenamePattern}" files anywhere you'd like.`,
"",
- ].join("\n")
- )
+ ].join("\n"),
+ ),
);
shouldCreateSampleTests = false;
}
this.$fs.ensureDirectoryExists(testsDir);
- const frameworks = [frameworkToInstall]
- .concat(this.karmaConfigAdditionalFrameworks[frameworkToInstall] || [])
- .map((fw) => `'${fw}'`)
- .join(", ");
- const testFiles = `'${fromWindowsRelativePathToUnix(
- relativeTestsDir
- )}/**/*${projectFilesExtension}'`;
- const karmaConfTemplate = this.$resources.readText("test/karma.conf.js");
- const karmaConf = _.template(karmaConfTemplate)({
- frameworks,
- testFiles,
- basePath: this.$projectData.getAppDirectoryRelativePath(),
- });
-
- this.$fs.writeFile(path.join(projectDir, "karma.conf.js"), karmaConf);
+ if (isVitest) {
+ const vitestConfigResourcePath = this.$resources.resolvePath(
+ "test/vitest.config.mts",
+ );
+ this.$fs.copyFile(
+ vitestConfigResourcePath,
+ path.join(projectDir, "vitest.config.mts"),
+ );
+ bufferedLogs.push(`Added/replaced ${color.yellow("vitest.config.mts")}`);
+ this.ensureAndroidNetworkSecurityConfig(bufferedLogs);
+ } else {
+ const frameworks = [frameworkToInstall]
+ .concat(this.karmaConfigAdditionalFrameworks[frameworkToInstall] || [])
+ .map((fw) => `'${fw}'`)
+ .join(", ");
+ const testFiles = `'${fromWindowsRelativePathToUnix(
+ relativeTestsDir,
+ )}/**/*${projectFilesExtension}'`;
+ const karmaConfTemplate = this.$resources.readText("test/karma.conf.js");
+ const karmaConf = _.template(karmaConfTemplate)({
+ frameworks,
+ testFiles,
+ basePath: this.$projectData.getAppDirectoryRelativePath(),
+ });
+
+ this.$fs.writeFile(path.join(projectDir, "karma.conf.js"), karmaConf);
+ }
const exampleFilePath = this.$resources.resolvePath(
- `test/example.${frameworkToInstall}${projectFilesExtension}`
+ `test/example.${frameworkToInstall}${projectFilesExtension}`,
);
const targetExampleTestPath = path.join(
testsDir,
- `example.spec${projectFilesExtension}`
+ `example.spec${projectFilesExtension}`,
);
if (shouldCreateSampleTests && this.$fs.exists(exampleFilePath)) {
this.$fs.copyFile(exampleFilePath, targetExampleTestPath);
const targetExampleTestRelativePath = path.relative(
projectDir,
- targetExampleTestPath
+ targetExampleTestPath,
);
bufferedLogs.push(
- `Added example test: ${color.yellow(targetExampleTestRelativePath)}`
+ `Added example test: ${color.yellow(targetExampleTestRelativePath)}`,
);
}
// test main entry
const testMainResourcesPath = this.$resources.resolvePath(
- `test/test-main${projectFilesExtension}`
+ isVitest
+ ? `test/test-main.vitest${projectFilesExtension}`
+ : `test/test-main${projectFilesExtension}`,
);
const testMainPath = path.join(
this.$projectData.appDirectoryPath,
- `test${projectFilesExtension}`
+ `test${projectFilesExtension}`,
);
if (!this.$fs.exists(testMainPath)) {
this.$fs.copyFile(testMainResourcesPath, testMainPath);
const testMainRelativePath = path.relative(projectDir, testMainPath);
bufferedLogs.push(
- `Main test entrypoint created: ${color.yellow(testMainRelativePath)}`
+ `Main test entrypoint created: ${color.yellow(testMainRelativePath)}`,
);
}
- const testTsConfigTemplate = this.$resources.readText(
- "test/tsconfig.spec.json"
- );
- const testTsConfig = _.template(testTsConfigTemplate)({
- basePath: this.$projectData.getAppDirectoryRelativePath(),
- });
+ if (!isVitest || projectFilesExtension === ".ts") {
+ const testTsConfigTemplate = this.$resources.readText(
+ "test/tsconfig.spec.json",
+ );
+ const testTsConfig = _.template(testTsConfigTemplate)({
+ basePath: this.$projectData.getAppDirectoryRelativePath(),
+ });
- this.$fs.writeFile(
- path.join(projectDir, "tsconfig.spec.json"),
- testTsConfig
- );
- bufferedLogs.push(`Added/replaced ${color.yellow("tsconfig.spec.json")}`);
+ this.$fs.writeFile(
+ path.join(projectDir, "tsconfig.spec.json"),
+ testTsConfig,
+ );
+ bufferedLogs.push(`Added/replaced ${color.yellow("tsconfig.spec.json")}`);
+ }
const greyDollarSign = color.grey("$");
+ const closingNotes = isVitest
+ ? [
+ color.yellow(
+ `Note: emulator/simulator test runs connect over the local loopback. When testing on a physical Android device, keep it connected over USB (adb reverse is set up automatically); for a physical iOS or visionOS device, pass a reachable 'url' to the coordinator in your test entry.`,
+ ),
+ "",
+ "",
+ `You can now run your tests:`,
+ "",
+ ` ${greyDollarSign} ${color.green("ns test ios")}`,
+ ` ${greyDollarSign} ${color.green("ns test android")}`,
+ ` ${greyDollarSign} ${color.green("ns test visionos")}`,
+ "",
+ `or directly through Vitest (editor extensions, CI):`,
+ "",
+ ` ${greyDollarSign} ${color.green("NS_PLATFORM=ios npx vitest run")}`,
+ "",
+ ]
+ : [
+ color.yellow(
+ `Note: @nativescript/unit-test-runner was included in "dependencies" as a convenience to automatically adjust your app's Info.plist on iOS and AndroidManifest.xml on Android to ensure the socket connects properly.`,
+ ),
+ "",
+ color.yellow(
+ `For production you may want to move to "devDependencies" and manage the settings yourself.`,
+ ),
+ "",
+ color.yellow(
+ `Karma-based unit testing is deprecated and will be removed in a future release. Consider '$ ns test init --framework vitest'.`,
+ ),
+ "",
+ "",
+ `You can now run your tests:`,
+ "",
+ ` ${greyDollarSign} ${color.green("ns test ios")}`,
+ ` ${greyDollarSign} ${color.green("ns test android")}`,
+ "",
+ ];
+
this.$logger.info(
[
[
@@ -241,21 +391,8 @@ class TestInitCommand implements ICommand {
"",
...bufferedLogs,
"",
- color.yellow(
- `Note: @nativescript/unit-test-runner was included in "dependencies" as a convenience to automatically adjust your app's Info.plist on iOS and AndroidManifest.xml on Android to ensure the socket connects properly.`
- ),
- "",
- color.yellow(
- `For production you may want to move to "devDependencies" and manage the settings yourself.`
- ),
- "",
- "",
- `You can now run your tests:`,
- "",
- ` ${greyDollarSign} ${color.green("ns test ios")}`,
- ` ${greyDollarSign} ${color.green("ns test android")}`,
- "",
- ].join("\n")
+ ...closingNotes,
+ ].join("\n"),
);
}
}
diff --git a/lib/commands/test.ts b/lib/commands/test.ts
index 2e12127477..646af87e3e 100644
--- a/lib/commands/test.ts
+++ b/lib/commands/test.ts
@@ -3,18 +3,21 @@ import {
ANDROID_RELEASE_BUILD_ERROR_MESSAGE,
ANDROID_APP_BUNDLE_SIGNING_ERROR_MESSAGE,
} from "../constants";
-import { IProjectData, ITestExecutionService } from "../definitions/project";
+import {
+ IProjectData,
+ ITestExecutionService,
+ IVitestExecutionService,
+} from "../definitions/project";
import { IOptions } from "../declarations";
import { IPlatformEnvironmentRequirements } from "../definitions/platform";
import { IMigrateController } from "../definitions/migrate";
import { ICommandParameter, ICommand } from "../common/definitions/commands";
import {
- OptionType,
IAnalyticsService,
IErrors,
IDictionary,
- ErrorCodes,
} from "../common/declarations";
+import { ErrorCodes, OptionType } from "../common/enums";
import { ICleanupService } from "../definitions/cleanup-service";
import { injector } from "../common/yok";
@@ -27,6 +30,7 @@ abstract class TestCommandBase {
protected abstract platform: string;
protected abstract $projectData: IProjectData;
protected abstract $testExecutionService: ITestExecutionService;
+ protected abstract $vitestExecutionService: IVitestExecutionService;
protected abstract $analyticsService: IAnalyticsService;
protected abstract $options: IOptions;
protected abstract $platformEnvironmentRequirements: IPlatformEnvironmentRequirements;
@@ -35,8 +39,22 @@ abstract class TestCommandBase {
protected abstract $liveSyncCommandHelper: ILiveSyncCommandHelper;
protected abstract $devicesService: Mobile.IDevicesService;
protected abstract $migrateController: IMigrateController;
+ protected abstract $logger: ILogger;
public async execute(args: string[]): Promise {
+ if (this.$vitestExecutionService.isVitestProject(this.$projectData)) {
+ await this.$vitestExecutionService.startTestRun(
+ this.platform,
+ this.$projectData,
+ );
+ process.exit(0);
+ }
+
+ this.$logger.warn(
+ "Karma-based unit testing is deprecated and will be removed in a future release. " +
+ "Re-initialize your tests with '$ ns test init --framework vitest' to migrate.",
+ );
+
let devices = [];
if (this.$options.debugBrk) {
await this.$devicesService.initialize({
@@ -47,19 +65,18 @@ abstract class TestCommandBase {
sdk: this.$options.sdk,
});
- const selectedDeviceForDebug = await this.$devicesService.pickSingleDevice(
- {
+ const selectedDeviceForDebug =
+ await this.$devicesService.pickSingleDevice({
onlyEmulators: this.$options.emulator,
onlyDevices: this.$options.forDevice,
deviceId: this.$options.device,
- }
- );
+ });
devices = [selectedDeviceForDebug];
// const debugData = this.getDebugData(platform, projectData, deployOptions, { device: selectedDeviceForDebug.deviceInfo.identifier });
// await this.$debugService.debug(debugData, this.$options);
} else {
devices = await this.$liveSyncCommandHelper.getDeviceInstances(
- this.platform
+ this.platform,
);
}
@@ -69,25 +86,26 @@ abstract class TestCommandBase {
this.$options.env.unitTesting = true;
const liveSyncInfo = this.$liveSyncCommandHelper.getLiveSyncData(
- this.$projectData.projectDir
+ this.$projectData.projectDir,
);
const deviceDebugMap: IDictionary = {};
devices.forEach(
(device) =>
- (deviceDebugMap[device.deviceInfo.identifier] = this.$options.debugBrk)
+ (deviceDebugMap[device.deviceInfo.identifier] = this.$options.debugBrk),
);
- const deviceDescriptors = await this.$liveSyncCommandHelper.createDeviceDescriptors(
- devices,
- this.platform,
- { deviceDebugMap }
- );
+ const deviceDescriptors =
+ await this.$liveSyncCommandHelper.createDeviceDescriptors(
+ devices,
+ this.platform,
+ { deviceDebugMap },
+ );
await this.$testExecutionService.startKarmaServer(
this.platform,
liveSyncInfo,
- deviceDescriptors
+ deviceDescriptors,
);
// if we got here, it means karma exited with exit code 0 (success)
process.exit(0);
@@ -100,7 +118,7 @@ abstract class TestCommandBase {
// because the Runtime does not watch for the `/data/local/tmp-livesync-in-progress` file deletion.
// The App is closing itself after each test execution and the bug will be reproducible on each LiveSync.
this.$errors.fail(
- "The `--hmr` option is not supported for this command."
+ "The `--hmr` option is not supported for this command.",
);
}
@@ -112,23 +130,35 @@ abstract class TestCommandBase {
this.$projectData.initializeProjectData();
this.$analyticsService.setShouldDispose(
- this.$options.justlaunch || !this.$options.watch
+ this.$options.justlaunch || !this.$options.watch,
);
this.$cleanupService.setShouldDispose(
- this.$options.justlaunch || !this.$options.watch
+ this.$options.justlaunch || !this.$options.watch,
);
- const output = await this.$platformEnvironmentRequirements.checkEnvironmentRequirements(
- {
+ const output =
+ await this.$platformEnvironmentRequirements.checkEnvironmentRequirements({
platform: this.platform,
projectDir: this.$projectData.projectDir,
options: this.$options,
+ });
+
+ if (this.$vitestExecutionService.isVitestProject(this.$projectData)) {
+ const canStartTestRun = this.$vitestExecutionService.canStartTestRun(
+ this.$projectData,
+ );
+ if (!canStartTestRun) {
+ this.$errors.fail({
+ formatStr:
+ "Error: In order to run unit tests, your project must already be configured by running $ ns test init.",
+ errorCode: ErrorCodes.TESTS_INIT_REQUIRED,
+ });
}
- );
+ return output.canExecute && canStartTestRun;
+ }
- const canStartKarmaServer = await this.$testExecutionService.canStartKarmaServer(
- this.$projectData
- );
+ const canStartKarmaServer =
+ await this.$testExecutionService.canStartKarmaServer(this.$projectData);
if (!canStartKarmaServer) {
this.$errors.fail({
formatStr:
@@ -147,6 +177,7 @@ class TestAndroidCommand extends TestCommandBase implements ICommand {
constructor(
protected $projectData: IProjectData,
protected $testExecutionService: ITestExecutionService,
+ protected $vitestExecutionService: IVitestExecutionService,
protected $analyticsService: IAnalyticsService,
protected $options: IOptions,
protected $platformEnvironmentRequirements: IPlatformEnvironmentRequirements,
@@ -154,7 +185,8 @@ class TestAndroidCommand extends TestCommandBase implements ICommand {
protected $cleanupService: ICleanupService,
protected $liveSyncCommandHelper: ILiveSyncCommandHelper,
protected $devicesService: Mobile.IDevicesService,
- protected $migrateController: IMigrateController
+ protected $migrateController: IMigrateController,
+ protected $logger: ILogger,
) {
super();
}
@@ -188,6 +220,7 @@ class TestIosCommand extends TestCommandBase implements ICommand {
constructor(
protected $projectData: IProjectData,
protected $testExecutionService: ITestExecutionService,
+ protected $vitestExecutionService: IVitestExecutionService,
protected $analyticsService: IAnalyticsService,
protected $options: IOptions,
protected $platformEnvironmentRequirements: IPlatformEnvironmentRequirements,
@@ -195,11 +228,63 @@ class TestIosCommand extends TestCommandBase implements ICommand {
protected $cleanupService: ICleanupService,
protected $liveSyncCommandHelper: ILiveSyncCommandHelper,
protected $devicesService: Mobile.IDevicesService,
- protected $migrateController: IMigrateController
+ protected $migrateController: IMigrateController,
+ protected $logger: ILogger,
) {
super();
}
}
+class TestVisionOSCommand extends TestIosCommand {
+ protected platform = "visionOS";
+
+ // The injector discovers dependencies by parsing constructor source text,
+ // so an inherited constructor would resolve to zero dependencies.
+ constructor(
+ protected $projectData: IProjectData,
+ protected $testExecutionService: ITestExecutionService,
+ protected $vitestExecutionService: IVitestExecutionService,
+ protected $analyticsService: IAnalyticsService,
+ protected $options: IOptions,
+ protected $platformEnvironmentRequirements: IPlatformEnvironmentRequirements,
+ protected $errors: IErrors,
+ protected $cleanupService: ICleanupService,
+ protected $liveSyncCommandHelper: ILiveSyncCommandHelper,
+ protected $devicesService: Mobile.IDevicesService,
+ protected $migrateController: IMigrateController,
+ protected $logger: ILogger,
+ ) {
+ super(
+ $projectData,
+ $testExecutionService,
+ $vitestExecutionService,
+ $analyticsService,
+ $options,
+ $platformEnvironmentRequirements,
+ $errors,
+ $cleanupService,
+ $liveSyncCommandHelper,
+ $devicesService,
+ $migrateController,
+ $logger,
+ );
+ }
+
+ async canExecute(args: string[]): Promise {
+ this.$projectData.initializeProjectData();
+ // The Karma runner (v4 line) never supported visionOS — only the Vitest
+ // path can drive it.
+ if (!this.$vitestExecutionService.isVitestProject(this.$projectData)) {
+ this.$errors.fail(
+ "visionOS unit testing requires the Vitest runner. Run '$ ns test init --framework vitest' to configure your project.",
+ );
+ }
+
+ return super.canExecute(args);
+ }
+}
+
injector.registerCommand("test|android", TestAndroidCommand);
injector.registerCommand("test|ios", TestIosCommand);
+injector.registerCommand("test|vision", TestVisionOSCommand);
+injector.registerCommand("test|visionos", TestVisionOSCommand);
diff --git a/lib/commands/typings.ts b/lib/commands/typings.ts
index 9159e6f5f5..d2e296fa91 100644
--- a/lib/commands/typings.ts
+++ b/lib/commands/typings.ts
@@ -161,8 +161,7 @@ export class TypingsCommand implements ICommand {
);
const dtsGeneratorPath = path.resolve(
- this.$projectData.projectDir,
- "platforms",
+ this.$projectData.platformsDir,
"android",
"build-tools",
"dts-generator.jar",
diff --git a/lib/commands/widget.ts b/lib/commands/widget.ts
index 5a12e69fe6..5d511d9a6e 100644
--- a/lib/commands/widget.ts
+++ b/lib/commands/widget.ts
@@ -934,5 +934,6 @@ declare class AppleWidgetUtils extends NSObject {
}
}
-injector.registerCommand(["widget"], WidgetCommand);
+// No flat "widget": the subcommand registration below synthesizes the parent
+// dispatcher, and WidgetCommand serves as WidgetIOSCommand's base class.
injector.registerCommand(["widget|ios"], WidgetIOSCommand);
diff --git a/lib/common/bootstrap.ts b/lib/common/bootstrap.ts
index 9905f452b9..287586c134 100644
--- a/lib/common/bootstrap.ts
+++ b/lib/common/bootstrap.ts
@@ -9,7 +9,6 @@ injector.requirePublic("fs", "./file-system");
injector.require("hostInfo", "./host-info");
injector.require("osInfo", "./os-info");
-injector.require("dispatcher", "./dispatchers");
injector.require("commandDispatcher", "./dispatchers");
injector.require("resources", "./resource-loader");
@@ -42,15 +41,15 @@ injector.requireCommand("autocomplete|status", "./commands/autocompletion");
injector.requireCommand(
["device|*list", "devices|*list"],
- "./commands/device/list-devices"
+ "./commands/device/list-devices",
);
injector.requireCommand(
["device|android", "devices|android"],
- "./commands/device/list-devices"
+ "./commands/device/list-devices",
);
injector.requireCommand(
["device|ios", "devices|ios"],
- "./commands/device/list-devices"
+ "./commands/device/list-devices",
);
injector.requireCommand("device|log", "./commands/device/device-log-stream");
@@ -58,11 +57,11 @@ injector.requireCommand("device|run", "./commands/device/run-application");
injector.requireCommand("device|stop", "./commands/device/stop-application");
injector.requireCommand(
"device|list-applications",
- "./commands/device/list-applications"
+ "./commands/device/list-applications",
);
injector.requireCommand(
"device|uninstall",
- "./commands/device/uninstall-application"
+ "./commands/device/uninstall-application",
);
injector.requireCommand("device|list-files", "./commands/device/list-files");
injector.requireCommand("device|get-file", "./commands/device/get-file");
@@ -70,84 +69,83 @@ injector.requireCommand("device|put-file", "./commands/device/put-file");
injector.require(
"iosDeviceOperations",
- "./mobile/ios/device/ios-device-operations"
+ "./mobile/ios/device/ios-device-operations",
);
injector.require("deviceDiscovery", "./mobile/mobile-core/device-discovery");
injector.require(
"iOSDeviceDiscovery",
- "./mobile/mobile-core/ios-device-discovery"
+ "./mobile/mobile-core/ios-device-discovery",
);
injector.require(
"iOSSimulatorDiscovery",
- "./mobile/mobile-core/ios-simulator-discovery"
+ "./mobile/mobile-core/ios-simulator-discovery",
);
injector.require(
"androidDeviceDiscovery",
- "./mobile/mobile-core/android-device-discovery"
+ "./mobile/mobile-core/android-device-discovery",
);
injector.require(
"androidEmulatorDiscovery",
- "./mobile/mobile-core/android-emulator-discovery"
+ "./mobile/mobile-core/android-emulator-discovery",
);
injector.require("iOSDevice", "./mobile/ios/device/ios-device");
injector.require(
"iOSDeviceProductNameMapper",
- "./mobile/ios/ios-device-product-name-mapper"
+ "./mobile/ios/ios-device-product-name-mapper",
);
injector.require("androidDevice", "./mobile/android/android-device");
injector.require("adb", "./mobile/android/android-debug-bridge");
injector.require(
"androidDebugBridgeResultHandler",
- "./mobile/android/android-debug-bridge-result-handler"
+ "./mobile/android/android-debug-bridge-result-handler",
);
injector.require(
"androidVirtualDeviceService",
- "./mobile/android/android-virtual-device-service"
+ "./mobile/android/android-virtual-device-service",
);
injector.require(
"androidIniFileParser",
- "./mobile/android/android-ini-file-parser"
+ "./mobile/android/android-ini-file-parser",
);
injector.require(
"androidGenymotionService",
- "./mobile/android/genymotion/genymotion-service"
+ "./mobile/android/genymotion/genymotion-service",
);
injector.require(
"virtualBoxService",
- "./mobile/android/genymotion/virtualbox-service"
+ "./mobile/android/genymotion/virtualbox-service",
);
injector.require("logcatHelper", "./mobile/android/logcat-helper");
injector.require("iOSSimResolver", "./mobile/ios/simulator/ios-sim-resolver");
injector.require(
"iOSSimulatorLogProvider",
- "./mobile/ios/simulator/ios-simulator-log-provider"
+ "./mobile/ios/simulator/ios-simulator-log-provider",
);
injector.require(
"localToDevicePathDataFactory",
- "./mobile/local-to-device-path-data-factory"
+ "./mobile/local-to-device-path-data-factory",
);
injector.requirePublic(
"devicesService",
- "./mobile/mobile-core/devices-service"
+ "./mobile/mobile-core/devices-service",
);
injector.requirePublic(
"androidProcessService",
- "./mobile/mobile-core/android-process-service"
+ "./mobile/mobile-core/android-process-service",
);
injector.require("projectNameValidator", "./validators/project-name-validator");
injector.require(
"androidEmulatorServices",
- "./mobile/android/android-emulator-services"
+ "./mobile/android/android-emulator-services",
);
injector.require(
"iOSEmulatorServices",
- "./mobile/ios/simulator/ios-emulator-services"
+ "./mobile/ios/simulator/ios-emulator-services",
);
-injector.require("wp8EmulatorServices", "./mobile/wp8/wp8-emulator-services");
injector.require("autoCompletionService", "./services/auto-completion-service");
injector.requirePublic("settingsService", "./services/settings-service");
@@ -157,18 +155,18 @@ injector.require("mobileHelper", "./mobile/mobile-helper");
injector.require("emulatorHelper", "./mobile/emulator-helper");
injector.require(
"devicePlatformsConstants",
- "./mobile/device-platforms-constants"
+ "./mobile/device-platforms-constants",
);
injector.require("helpService", "./services/help-service");
injector.require(
"messageContractGenerator",
- "./services/message-contract-generator"
+ "./services/message-contract-generator",
);
injector.require("proxyService", "./services/proxy-service");
injector.requireCommand("dev-preuninstall", "./commands/preuninstall");
injector.requireCommand(
"dev-generate-messages",
- "./commands/generate-messages"
+ "./commands/generate-messages",
);
injector.requireCommand("doctor|*all", "./commands/doctor");
injector.requireCommand("doctor|ios", "./commands/doctor");
@@ -190,5 +188,4 @@ injector.require("projectFilesManager", "./services/project-files-manager");
injector.require("xcodeSelectService", "./services/xcode-select-service");
injector.require("net", "./services/net-service");
-injector.require("qr", "./services/qr");
injector.require(["lockfile", "lockService"], "./services/lock-service");
diff --git a/lib/common/codeGeneration/code-entity.ts b/lib/common/codeGeneration/code-entity.ts
index 95ddd1147f..c9d927cf5b 100644
--- a/lib/common/codeGeneration/code-entity.ts
+++ b/lib/common/codeGeneration/code-entity.ts
@@ -1,5 +1,4 @@
import * as _ from "lodash";
-import { injector } from "../yok";
import { CodeGeneration } from "./code-generation";
export enum CodeEntityType {
@@ -22,7 +21,6 @@ export class Line implements CodeGeneration.ILine {
return new Line(content);
}
}
-injector.register("swaggerLine", Line);
export class Block implements CodeGeneration.IBlock {
public opener: string;
@@ -55,4 +53,3 @@ export class Block implements CodeGeneration.IBlock {
this.codeEntities.push(line);
}
}
-injector.register("swaggerBlock", Block);
diff --git a/lib/common/codeGeneration/code-printer.ts b/lib/common/codeGeneration/code-printer.ts
index 53e1c772ba..64acf4a5e2 100644
--- a/lib/common/codeGeneration/code-printer.ts
+++ b/lib/common/codeGeneration/code-printer.ts
@@ -2,7 +2,6 @@ import * as _ from "lodash";
import { EOL } from "os";
import { CodeEntityType } from "./code-entity";
import { CodeGeneration } from "./code-generation";
-import { injector } from "../yok";
export class CodePrinter {
private static INDENT_CHAR = "\t";
@@ -12,7 +11,7 @@ export class CodePrinter {
public composeBlock(
block: CodeGeneration.IBlock,
- indentSize?: number
+ indentSize?: number,
): string {
indentSize = indentSize === undefined ? 0 : indentSize;
let content = this.getIndentation(indentSize);
@@ -27,12 +26,12 @@ export class CodePrinter {
if (codeEntity.codeEntityType === CodeEntityType.Line) {
content += this.composeLine(
codeEntity,
- indentSize + 1
+ indentSize + 1,
);
} else if (codeEntity.codeEntityType === CodeEntityType.Block) {
content += this.composeBlock(
codeEntity,
- indentSize + 1
+ indentSize + 1,
);
}
});
@@ -59,4 +58,3 @@ export class CodePrinter {
return content;
}
}
-injector.register("swaggerCodePrinter", CodePrinter);
diff --git a/lib/common/contracts/command-registry.ts b/lib/common/contracts/command-registry.ts
new file mode 100644
index 0000000000..d6484f4e7d
--- /dev/null
+++ b/lib/common/contracts/command-registry.ts
@@ -0,0 +1,82 @@
+import { Contract } from "../di/contract";
+import type { ICommand } from "../definitions/commands";
+
+export interface DeferredCommandOptions {
+ /**
+ * Names the registrant in conflict and failure reports. Re-registering the
+ * same command under the same owner is a no-op rather than a conflict.
+ */
+ owner: string;
+ /** Where the implementation comes from; named when loading it fails. */
+ source: string;
+ /**
+ * Runs on first resolution of the command. It must leave a real resolver on
+ * the command name — by exporting a definition the caller registers, or by
+ * registering the command itself.
+ */
+ load: () => void;
+}
+
+/** Why a deferred registration did not take effect. */
+export type DeferredCommandRejection =
+ /** The name can never be dispatched; `detail` says why. */
+ | { reason: "invalid-name"; detail: string }
+ /** Another owner registered the same command first. */
+ | { reason: "claimed"; owner: string }
+ /** The CLI itself provides the command. */
+ | { reason: "built-in" }
+ /** The name is in use as the dispatcher for subcommands under it. */
+ | { reason: "subcommand-parent" }
+ /**
+ * The name's direct parent is a command of its own, so no dispatcher can be
+ * built for it and the name could never be reached.
+ */
+ | { reason: "parent-is-command"; parent: string };
+
+/**
+ * Outcome of a deferred registration. Callers branch on `rejection.reason`
+ * rather than on message text, so the wording of the report stays theirs.
+ */
+export interface DeferredCommandResult {
+ registered: boolean;
+ /** Set exactly when `registered` is false. */
+ rejection?: DeferredCommandRejection;
+}
+
+/**
+ * The command-registry face of the injector facade. Transitional contract: it
+ * mirrors what consumers call today, so that extracting the registry from the
+ * facade later is a provider swap for this token, not a consumer migration.
+ * Members slated for replacement keep their deprecation markers.
+ */
+@Contract({ name: "commandRegistry" })
+export abstract class CommandRegistry {
+ /**
+ * @deprecated Path-based command registration; use registerDeferredCommand,
+ * which routes without loading and reports conflicts structurally.
+ */
+ abstract requireCommand(names: string | string[], file: string): void;
+ abstract registerCommand(names: string | string[], resolver: any): void;
+ /**
+ * Claims a command name for an owner without loading anything: routing —
+ * including the dispatcher of a hierarchical parent — is built from the name
+ * alone, and `load` runs only when that one command is resolved.
+ */
+ abstract registerDeferredCommand(
+ name: string,
+ options: DeferredCommandOptions,
+ ): DeferredCommandResult;
+ abstract resolveCommand(name: string): ICommand;
+ abstract getRegisteredCommandsNames(includeDev: boolean): string[];
+ abstract getChildrenCommandsNames(commandName: string): string[];
+ abstract buildHierarchicalCommand(
+ parentCommandName: string,
+ commandLineArguments: string[],
+ ): any;
+ /** Side-effecting: fails with help output on a bad subcommand. */
+ abstract isValidHierarchicalCommand(
+ commandName: string,
+ commandArguments: string[],
+ ): Promise;
+ abstract isDefaultCommand(commandName: string): boolean;
+}
diff --git a/lib/common/contracts/index.ts b/lib/common/contracts/index.ts
new file mode 100644
index 0000000000..29e06c2109
--- /dev/null
+++ b/lib/common/contracts/index.ts
@@ -0,0 +1,15 @@
+// Internal subsystem contracts of the injector facade. Deliberately NOT
+// re-exported from nativescript/contracts: promoting one to the public
+// surface is a one-line decision that should be made per contract, not by
+// default. Each token resolves to the facade itself until its subsystem is
+// physically extracted — at which point the provider is swapped and consumers
+// keep working unchanged.
+export { CommandRegistry } from "./command-registry";
+export type {
+ DeferredCommandOptions,
+ DeferredCommandRejection,
+ DeferredCommandResult,
+} from "./command-registry";
+export { KeyCommandRegistry } from "./key-command-registry";
+export { ModuleRegistry } from "./module-registry";
+export { PublicApiBuilder } from "./public-api-builder";
diff --git a/lib/common/contracts/key-command-registry.ts b/lib/common/contracts/key-command-registry.ts
new file mode 100644
index 0000000000..f16e1ee97d
--- /dev/null
+++ b/lib/common/contracts/key-command-registry.ts
@@ -0,0 +1,15 @@
+import { Contract } from "../di/contract";
+import type { IKeyCommand, IValidKeyName } from "../definitions/key-commands";
+
+/**
+ * The key-command face of the injector facade (the `keyCommands.` namespace).
+ * Kept separate from CommandRegistry because the two registries are redesigned
+ * on different tracks.
+ */
+@Contract({ name: "keyCommandRegistry" })
+export abstract class KeyCommandRegistry {
+ abstract requireKeyCommand(name: IValidKeyName, file: string): void;
+ abstract registerKeyCommand(name: IValidKeyName, resolver: any): void;
+ abstract resolveKeyCommand(name: string): IKeyCommand;
+ abstract getRegisteredKeyCommandsNames(): string[];
+}
diff --git a/lib/common/contracts/module-registry.ts b/lib/common/contracts/module-registry.ts
new file mode 100644
index 0000000000..2ebca751ab
--- /dev/null
+++ b/lib/common/contracts/module-registry.ts
@@ -0,0 +1,13 @@
+import { Contract } from "../di/contract";
+
+/**
+ * @deprecated The lazy module-loader face of the injector facade — the
+ * require-time path map. Wholly replaced by provideLazy(); this contract
+ * exists so its remaining consumers are typed against exactly what they use
+ * until the bootstrap migrates.
+ */
+@Contract({ name: "moduleRegistry" })
+export abstract class ModuleRegistry {
+ abstract require(names: string | string[], file: string): void;
+ abstract overrideAlreadyRequiredModule: boolean;
+}
diff --git a/lib/common/contracts/public-api-builder.ts b/lib/common/contracts/public-api-builder.ts
new file mode 100644
index 0000000000..6ddb51cc53
--- /dev/null
+++ b/lib/common/contracts/public-api-builder.ts
@@ -0,0 +1,13 @@
+import { Contract } from "../di/contract";
+
+/**
+ * The public-API-builder face of the injector facade: the machinery behind
+ * `require('nativescript')`. Bound by the compatibility constraints of the
+ * published library surface; do not add new entries through it.
+ */
+@Contract({ name: "publicApiBuilder" })
+export abstract class PublicApiBuilder {
+ abstract requirePublic(names: string | string[], file: string): void;
+ abstract requirePublicClass(names: string | string[], file: string): void;
+ abstract publicApi: any;
+}
diff --git a/lib/common/declarations.d.ts b/lib/common/declarations.d.ts
index 9102cdea65..df4a19a1fb 100644
--- a/lib/common/declarations.d.ts
+++ b/lib/common/declarations.d.ts
@@ -4,7 +4,12 @@ import {
IEventActionData,
IGoogleAnalyticsData,
} from "./definitions/google-analytics";
-import * as child_process from "child_process";
+import type { DoctorService } from "../contracts/doctor-service";
+import type { ChildProcess } from "../contracts/child-process";
+import type { Errors } from "../contracts/errors";
+import type { FileSystem } from "../contracts/file-system";
+import type { HostInfo } from "../contracts/host-info";
+import type { HttpClient } from "../contracts/http-client";
// tslint:disable-next-line:interface-name
interface Object {
@@ -35,10 +40,6 @@ interface IiTunesConnectApplicationType {
* Their values are the names of the methods in universnal-analytics that have to be called to track this type of data.
* Also known as Hit Type: https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#t
*/
-declare const enum GoogleAnalyticsDataType {
- Page = "pageview",
- Event = "event",
-}
/**
* Descibes iTunes Connect applications
@@ -166,13 +167,8 @@ declare module Server {
error?: Error;
}
- interface IHttpClient {
- httpRequest(url: string): Promise;
- httpRequest(
- options: any,
- proxySettings?: IProxySettings
- ): Promise;
- }
+ /** @deprecated Kept so existing annotations compile; use the {@link HttpClient} contract. */
+ interface IHttpClient extends HttpClient {}
interface IRequestResponseData {
statusCode: number;
@@ -196,79 +192,14 @@ interface IShouldDispose {
/**
* Describes the type of data sent to analytics service.
*/
-declare const enum TrackingTypes {
- /**
- * Defines that the data contains information for initialization of a new Analytics monitor.
- */
- Initialization = "initialization",
-
- /**
- * Defines that the data contains exception that should be tracked.
- */
- Exception = "exception",
-
- /**
- * Defines that the data contains the answer of the question if user allows to be tracked.
- */
- AcceptTrackFeatureUsage = "acceptTrackFeatureUsage",
-
- /**
- * Defines data that will be tracked to Google Analytics.
- */
- GoogleAnalyticsData = "googleAnalyticsData",
-
- /**
- * Defines that the broker process should send all the pending information to Analytics.
- * After that the process should send information it has finished tracking and die gracefully.
- */
- FinishTracking = "FinishTracking",
-}
/**
* Describes the status of the current Analytics status, i.e. has the user allowed to be tracked.
*/
-declare const enum AnalyticsStatus {
- /**
- * User has allowed to be tracked.
- */
- enabled = "enabled",
-
- /**
- * User has declined to be tracked.
- */
- disabled = "disabled",
-
- /**
- * User has not been asked to allow feature and error tracking.
- */
- notConfirmed = "not confirmed",
-}
/**
* Describes types of options that manage -- flags.
*/
-declare const enum OptionType {
- /**
- * String option
- */
- String = "string",
- /**
- * Boolean option
- */
- Boolean = "boolean",
- /**
- * Number option
- */
- Number = "number",
- /**
- * Array option
- */
- Array = "array",
- /**
- * Object option
- */
- Object = "object",
-}
/**
* Describes options that can be passed to fs.readFile method.
@@ -285,303 +216,8 @@ interface IReadFileOptions {
flag?: string;
}
-interface IFileSystem {
- zipFiles(
- zipFile: string,
- files: string[],
- zipPathCallback: (path: string) => string
- ): Promise;
- unzip(
- zipFile: string,
- destinationDir: string,
- options?: { overwriteExisitingFiles?: boolean; caseSensitive?: boolean },
- fileFilters?: string[]
- ): Promise;
-
- /**
- * Test whether or not the given path exists by checking with the file system.
- * @param {string} path Path to be checked.
- * @returns {boolean} True if path exists, false otherwise.
- */
- exists(path: string): boolean;
-
- /**
- * Deletes a file.
- * @param {string} path Path to be deleted.
- * @returns {void} undefined
- */
- deleteFile(path: string): void;
-
- /**
- * Deletes whole directory.
- * @param {string} directory Path to directory that has to be deleted.
- * @returns {void}
- */
- deleteDirectory(directory: string): void;
-
- /**
- * Deletes whole directory without throwing exceptions.
- * @param {string} directory Path to directory that has to be deleted.
- * @returns {void}
- */
- deleteDirectorySafe(directory: string): void;
-
- /**
- * Returns the size of specified file.
- * @param {string} path Path to file.
- * @returns {number} File size in bytes.
- */
- getFileSize(path: string): number;
-
- /**
- * Returns the size of specified path (recurses into all sub-directories if the path is a directory).
- * @param {string} path Path to file or directory.
- * @returns {number} File size in bytes.
- */
- getSize(path: string): number;
-
- /**
- * Change file timestamps of the file referenced by the supplied path.
- * @param {string} path File path
- * @param {Date} atime Access time
- * @param {Date} mtime Modified time
- * @returns {void}
- */
- utimes(path: string, atime: Date, mtime: Date): void;
-
- futureFromEvent(
- eventEmitter: NodeJS.EventEmitter,
- event: string
- ): Promise;
-
- /**
- * Create a new directory and any necessary subdirectories at specified location.
- * @param {string} path Directory to be created.
- * @returns {void}
- */
- createDirectory(path: string): void;
-
- /**
- * Reads contents of directory and returns an array of filenames excluding '.' and '..'.
- * @param {string} path Path to directory to be checked.
- * @retruns {string[]} Array of filenames excluding '.' and '..'
- */
- readDirectory(path: string): string[];
-
- /**
- * Reads the entire contents of a file.
- * @param {string} filename Path to the file that has to be read.
- * @param {string} options Options used for reading the file - encoding and flags.
- * @returns {string|Buffer} Content of the file as buffer. In case encoding is specified, the content is returned as string.
- */
- readFile(filename: string, options?: IReadFileOptions): string | Buffer;
-
- /**
- * Reads the entire contents of a file and returns the result as string.
- * @param {string} filename Path to the file that has to be read.
- * @param {IReadFileOptions | string} encoding Options used for reading the file - encoding and flags. If options are not passed, utf8 is used.
- * @returns {string} Content of the file as string.
- */
- readText(filename: string, encoding?: IReadFileOptions | string): string;
-
- /**
- * Reads the entire content of a file and parses it to JSON object.
- * @param {string} filename Path to the file that has to be read.
- * @param {string} encoding File encoding, defaults to utf8.
- * @returns {string} Content of the file as JSON object.
- */
- readJson(filename: string, encoding?: string): any;
-
- readStdin(): Promise;
-
- /**
- * Writes data to a file, replacing the file if it already exists. data can be a string or a buffer.
- * @param {string} filename Path to file to be created.
- * @param {string | Buffer} data Data to be written to file.
- * @param {string} encoding @optional File encoding, defaults to utf8.
- * @returns {void}
- */
- writeFile(filename: string, data: string | Buffer, encoding?: string): void;
-
- /**
- * Appends data to a file, creating the file if it does not yet exist. Data can be a string or a buffer.
- * @param {string} filename Path to file to be created.
- * @param {string | Buffer} data Data to be appended to file.
- * @param {string} encoding @optional File encoding, defaults to utf8.
- * @returns {void}
- */
- appendFile(filename: string, data: string | Buffer, encoding?: string): void;
-
- /**
- * Writes JSON data to file.
- * @param {string} filename Path to file to be created.
- * @param {any} data JSON data to be written to file.
- * @param {string} space Identation that will be used for the file.
- * @param {string} encoding @optional File encoding, defaults to utf8.
- * @returns {void}
- */
- writeJson(
- filename: string,
- data: any,
- space?: string,
- encoding?: string
- ): void;
-
- /**
- * Copies a file.
- * @param {string} sourceFileName The original file that has to be copied.
- * @param {string} destinationFileName The filepath where the file should be copied.
- * @returns {void}
- */
- copyFile(sourceFileName: string, destinationFileName: string): void;
-
- /**
- * Returns unique file name based on the passed name by checkin if it exists and adding numbers to the passed name until a non-existent file is found.
- * @param {string} baseName The name based on which the unique name will be generated.
- * @returns {string} Unique filename. In case baseName does not exist, it will be returned.
- */
- getUniqueFileName(baseName: string): string;
-
- /**
- * Checks if specified directory is empty.
- * @param {string} directoryPath The directory that will be checked.
- * @returns {boolean} True in case the directory is empty. False otherwise.
- */
- isEmptyDir(directoryPath: string): boolean;
-
- isRelativePath(
- path: string
- ): boolean /* feels so lonely here, I don't have a Future */;
-
- /**
- * Checks if directory exists and if not - creates it.
- * @param {string} directoryPath Directory path.
- * @returns {void}
- */
- ensureDirectoryExists(directoryPath: string): void;
-
- /**
- * Renames file/directory. This method throws error in case the original file name does not exist.
- * @param {string} oldPath The original filename.
- * @param {string} newPath New filename.
- * @returns {string} void.
- */
- rename(oldPath: string, newPath: string): void;
-
- /**
- * Renames specified file to the specified name only in case it exists.
- * Used to skip ENOENT errors when rename is called directly.
- * @param {string} oldPath Path to original file that has to be renamed. If this file does not exists, no operation is executed.
- * @param {string} newPath The path where the file will be moved.
- * @return {boolean} True in case of successful rename. False in case the file does not exist.
- */
- renameIfExists(oldPath: string, newPath: string): boolean;
-
- /**
- * Returns information about the specified file.
- * In case the passed path is symlink, the returned information is about the original file.
- * @param {string} path Path to file for which the information will be taken.
- * @returns {IFsStats} Inforamation about the specified file.
- */
- getFsStats(path: string): IFsStats;
-
- /**
- * Returns information about the specified file.
- * In case the passed path is symlink, the returned information is about the symlink itself.
- * @param {string} path Path to file for which the information will be taken.
- * @returns {IFsStats} Inforamation about the specified file.
- */
- getLsStats(path: string): IFsStats;
-
- symlink(sourcePath: string, destinationPath: string, type: "file"): void;
- symlink(sourcePath: string, destinationPath: string, type: "dir"): void;
- symlink(sourcePath: string, destinationPath: string, type: "junction"): void;
-
- /**
- * Creates a symbolic link.
- * Symbolic links are interpreted at run time as if the contents of the
- * link had been substituted into the path being followed to find a file
- * or directory.
- * @param {string} sourcePath The original path of the file/dir.
- * @param {string} destinationPath The destination where symlink will be created.
- * @param {string} type "file", "dir" or "junction". Default is 'file'.
- * Type option is only available on Windows (ignored on other platforms).
- * Note that Windows junction points require the destination path to be absolute.
- * When using 'junction', the target argument will automatically be normalized to absolute path.
- * @returns {void}
- */
- symlink(sourcePath: string, destinationPath: string, type?: string): void;
-
- createReadStream(
- path: string,
- options?: {
- flags?: string;
- encoding?: string;
- fd?: number;
- mode?: number;
- bufferSize?: number;
- start?: number;
- end?: number;
- highWaterMark?: number;
- }
- ): NodeJS.ReadableStream;
- createWriteStream(
- path: string,
- options?: {
- flags?: string;
- encoding?: string;
- string?: string;
- }
- ): any;
-
- /**
- * Changes file mode of the specified file. In case it is a symlink, the original file's mode is modified.
- * @param {string} path Filepath to be modified.
- * @param {number | string} mode File mode.
- * @returns {void}
- */
- chmod(path: string, mode: number | string): void;
-
- setCurrentUserAsOwner(path: string, owner: string): Promise;
- enumerateFilesInDirectorySync(
- directoryPath: string,
- filterCallback?: (file: string, stat: IFsStats) => boolean,
- opts?: { enumerateDirectories?: boolean; includeEmptyDirectories?: boolean }
- ): string[];
-
- /**
- * Hashes a file's contents.
- * @param {string} fileName Path to file
- * @param {Object} options algorithm and digest encoding. Default values are sha1 for algorithm and hex for encoding
- * @return {Promise} The computed shasum
- */
- getFileShasum(
- fileName: string,
- options?: { algorithm?: string; encoding?: "hex" | "base64" }
- ): Promise;
-
- // shell.js wrappers
- /**
- * @param {string} options Options, can be undefined or a combination of "-r" (recursive) and "-f" (force)
- * @param {string[]} files files and direcories to delete
- */
- rm(options: string, ...files: string[]): void;
-
- /**
- * Deletes all empty parent directories.
- * @param {string} directory The directory from which this method will start looking for empty parents.
- * @returns {void}
- */
- deleteEmptyParents(directory: string): void;
-
- /**
- * Return the canonicalized absolute pathname.
- * NOTE: The method accepts second argument, but it's type and usage is different in Node 4 and Node 6. Once we drop support for Node 4, we can use the second argument as well.
- * @param {string} filePath Path to file which should be resolved.
- * @returns {string} The canonicalized absolute path to file.
- */
- realpath(filePath: string): string;
-}
+/** @deprecated Kept so existing annotations compile; use the {@link FileSystem} contract. */
+interface IFileSystem extends FileSystem {}
// duplicated from fs.Stats, because I cannot import it here
interface IFsStats {
@@ -611,26 +247,8 @@ interface IOpener {
open(filename: string, appname?: string): void;
}
-interface IErrors {
- fail(formatStr: string, ...args: any[]): never;
- fail(opts: IFailOptions, ...args: any[]): never;
- /**
- * @deprecated: use `fail` instead
- */
- failWithoutHelp(message: string, ...args: any[]): never;
- /**
- * @deprecated: use `fail` instead
- */
- failWithoutHelp(opts: IFailOptions, ...args: any[]): never;
- failWithHelp(formatStr: string, ...args: any[]): never;
- failWithHelp(opts: IFailOptions, ...args: any[]): never;
- beginCommand(
- action: () => Promise,
- printCommandHelp: () => Promise
- ): Promise;
- verifyHeap(message: string): void;
- printCallStack: boolean;
-}
+/** @deprecated Kept so existing annotations compile; use the {@link Errors} contract. */
+interface IErrors extends Errors {}
interface IFailOptions {
name?: string;
@@ -656,23 +274,6 @@ interface ICommandOptions {
disableCommandHelpSuggestion?: boolean;
}
-declare const enum ErrorCodes {
- UNCAUGHT = 120,
- UNKNOWN = 127,
- INVALID_ARGUMENT = 128,
- RESOURCE_PROBLEM = 129,
- KARMA_FAIL = 130,
- UNHANDLED_REJECTION_FAILURE = 131,
- DELETED_KILL_FILE = 132,
- TESTS_INIT_REQUIRED = 133,
- ALL_DEVICES_DISCONNECTED = 134,
-}
-
-interface IFutureDispatcher {
- run(): void;
- dispatch(action: () => Promise): void;
-}
-
interface ICommandDispatcher {
dispatchCommand(): Promise;
}
@@ -682,66 +283,8 @@ interface ICancellationService extends IDisposable {
end(name: string): void;
}
-interface IQueue {
- enqueue(item: T): void;
- dequeue(): Promise;
-}
-
-interface IChildProcess extends NodeJS.EventEmitter {
- exec(
- command: string,
- options?: any,
- execOptions?: IExecOptions
- ): Promise;
- execFile(command: string, args: string[]): Promise;
- spawn(
- command: string,
- args?: string[],
- options?: any
- ): child_process.ChildProcess; // it returns child_process.ChildProcess you can safely cast to it
- spawnFromEvent(
- command: string,
- args: string[],
- event: string,
- options?: any,
- spawnFromEventOptions?: ISpawnFromEventOptions
- ): Promise;
- trySpawnFromCloseEvent(
- command: string,
- args: string[],
- options?: any,
- spawnFromEventOptions?: ISpawnFromEventOptions
- ): Promise;
- tryExecuteApplication(
- command: string,
- args: string[],
- event: string,
- errorMessage: string,
- condition?: (childProcess: any) => boolean
- ): Promise;
- /**
- * This is a special case of the child_process.spawn() functionality for spawning Node.js processes.
- * In addition to having all the methods in a normal ChildProcess instance, the returned object has a communication channel built-in.
- * Note: Unlike the fork() POSIX system call, child_process.fork() does not clone the current process.
- * @param {string} modulePath String The module to run in the child
- * @param {string[]} args Array List of string arguments You can access them in the child with 'process.argv'.
- * @param {string} options Object
- * @return {child_process} ChildProcess object.
- */
- fork(
- modulePath: string,
- args?: string[],
- options?: {
- cwd?: string;
- env?: any;
- execPath?: string;
- execArgv?: string[];
- silent?: boolean;
- uid?: number;
- gid?: number;
- }
- ): any;
-}
+/** @deprecated Kept so existing annotations compile; use the {@link ChildProcess} contract. */
+interface IChildProcess extends ChildProcess {}
interface IExecOptions {
showStderr: boolean;
@@ -781,7 +324,7 @@ interface IAnalyticsService {
getStatusMessage(
settingName: string,
jsonFormat: boolean,
- readableSettingName: string
+ readableSettingName: string,
): Promise;
isEnabled(settingName: string): Promise;
finishTracking(): Promise;
@@ -828,7 +371,7 @@ interface IPrompterOptions extends IAllowEmpty {
type IPrompterAnswers = { [id in T]: any };
interface IPrompterQuestion<
- T extends IPrompterAnswers = IPrompterAnswers
+ T extends IPrompterAnswers = IPrompterAnswers,
> {
type?: string;
name?: string;
@@ -839,7 +382,7 @@ interface IPrompterQuestion<
filter?(input: any, answers: T): any;
validate?(
input: any,
- answers?: T
+ answers?: T,
): boolean | string | Promise;
}
@@ -902,15 +445,26 @@ interface IAutoCompletionService {
isObsoleteAutoCompletionEnabled(): boolean;
}
+interface IHookExecutionOptions {
+ /**
+ * Set by call sites that fold the returned middlewares around a method (the
+ * `@hook` decorator). Where nothing consumes them, `ctx.wrap()` rejects
+ * instead of registering a middleware that would never run.
+ */
+ consumesMiddlewares?: boolean;
+}
+
interface IHooksService {
hookArgsName: string;
+ /** Resolves with the middlewares hooks registered through `ctx.wrap()`. */
executeBeforeHooks(
commandName: string,
- hookArguments?: IDictionary
- ): Promise;
+ hookArguments?: IDictionary,
+ options?: IHookExecutionOptions,
+ ): Promise;
executeAfterHooks(
commandName: string,
- hookArguments?: IDictionary
+ hookArguments?: IDictionary,
): Promise;
}
@@ -938,9 +492,7 @@ interface IRejectUnauthorized {
* Proxy settings required for http request.
*/
interface IProxySettings
- extends IRejectUnauthorized,
- ICredentials,
- IProxySettingsBase {
+ extends IRejectUnauthorized, ICredentials, IProxySettingsBase {
/**
* Hostname of the machine used for proxy.
*/
@@ -1001,10 +553,6 @@ interface IProxyService {
getInfo(): Promise;
}
-interface IQrCodeGenerator {
- generateDataUri(data: string): Promise;
-}
-
interface IQrCodeImageData {
/**
* The original URL used for generating QR code image.
@@ -1077,7 +625,7 @@ interface ISystemWarning {
interface ISysInfo {
getSysInfo(
- config?: NativeScriptDoctor.ISysInfoConfig
+ config?: NativeScriptDoctor.ISysInfoConfig,
): Promise;
/**
* Returns the currently installed version of Xcode.
@@ -1137,17 +685,8 @@ interface ISysInfo {
getXcodeWarning(): Promise;
}
-interface IHostInfo {
- isWindows: boolean;
- isWindows64: boolean;
- isWindows32: boolean;
- isDarwin: boolean;
- isLinux: boolean;
- isLinux64: boolean;
- dotNetVersion(): Promise;
- isDotNet40Installed(message: string): Promise;
- getMacOSVersion(): Promise;
-}
+/** @deprecated Kept so existing annotations compile; use the {@link HostInfo} contract. */
+interface IHostInfo extends HostInfo {}
// tslint:disable-next-line:interface-name
interface GenericFunction extends Function {
@@ -1283,44 +822,8 @@ interface IDashedOption {
* Code behind of the "doctor" command
* @interface
*/
-interface IDoctorService {
- /**
- * Verifies the host OS configuration and prints warnings to the users
- * @param configOptions: defines if the result should be tracked by Analytics
- * @returns {Promise}
- */
- printWarnings(configOptions?: {
- trackResult?: boolean;
- projectDir?: string;
- runtimeVersion?: string;
- options?: IOptions;
- forceCheck?: boolean;
- platform?: string;
- }): Promise;
- /**
- * Runs the setup script on host machine
- * @returns {Promise}
- */
- runSetupScript(): Promise;
- /**
- * Checks if the envrironment is properly configured and it is possible to execute local builds
- * @returns {Promise} true if the environment is properly configured for local builds
- * @param {object} configuration
- */
- canExecuteLocalBuild(configuration?: {
- platform?: string;
- projectDir?: string;
- runtimeVersion?: string;
- forceCheck?: boolean;
- }): Promise;
-
- /**
- * Checks and notifies users for deprecated short imports in their applications.
- * @param {string} projectDir Path to the application.
- * @returns {void}
- */
- checkForDeprecatedShortImportsInAppDir(projectDir: string): void;
-}
+/** @deprecated Kept so existing annotations compile; use the {@link DoctorService} contract. */
+interface IDoctorService extends DoctorService {}
interface IUtils {
getParsedTimeout(defaultTimeout: number): number;
@@ -1438,14 +941,14 @@ interface IProjectFilesManager {
projectFilesPath: string,
excludedProjectDirsAndFiles?: string[],
filter?: (filePath: string, stat: IFsStats) => boolean,
- opts?: any
+ opts?: any,
): string[];
/**
* Checks if the file is excluded
*/
isFileExcluded(
filePath: string,
- excludedProjectDirsAndFiles?: string[]
+ excludedProjectDirsAndFiles?: string[],
): boolean;
/**
* Returns an object that maps every local file path to device file path
@@ -1456,7 +959,7 @@ interface IProjectFilesManager {
projectFilesPath: string,
files: string[],
excludedProjectDirsAndFiles: string[],
- projectFilesConfig?: IProjectFilesConfig
+ projectFilesConfig?: IProjectFilesConfig,
): Promise;
/**
@@ -1471,7 +974,7 @@ interface IProjectFilesManager {
directoryPath: string,
platform: string,
projectFilesConfig?: IProjectFilesConfig,
- excludedDirs?: string[]
+ excludedDirs?: string[],
): void;
}
@@ -1487,7 +990,7 @@ interface IProjectFilesProvider {
filePath: string,
platform: string,
projectData: any,
- projectFilesConfig?: IProjectFilesConfig
+ projectFilesConfig?: IProjectFilesConfig,
): string;
/**
@@ -1500,7 +1003,7 @@ interface IProjectFilesProvider {
getProjectFileInfo(
filePath: string,
platform: string,
- projectFilesConfig: IProjectFilesConfig
+ projectFilesConfig: IProjectFilesConfig,
): IProjectFileInfo;
/**
* Parses file by removing platform or configuration from its name.
@@ -1510,7 +1013,7 @@ interface IProjectFilesProvider {
*/
getPreparedFilePath(
filePath: string,
- projectFilesConfig: IProjectFilesConfig
+ projectFilesConfig: IProjectFilesConfig,
): string;
}
@@ -1612,7 +1115,7 @@ interface INet {
* @returns {boolean} true in case port is in LISTEN state, false otherwise.
*/
waitForPortToListen(
- waitForPortListenData: IWaitForPortListenData
+ waitForPortListenData: IWaitForPortListenData,
): Promise;
}
@@ -1621,6 +1124,12 @@ interface IDependencyInformation {
version?: string;
projectType?: string;
excludedPeerDependencies?: string[];
+ /**
+ * Install into "dependencies" instead of "devDependencies". Required for
+ * packages with native code — the CLI integrates plugin platform files
+ * (pods, aars) only for regular dependencies.
+ */
+ saveInDependencies?: boolean;
}
/**
@@ -1680,7 +1189,7 @@ interface IiOSNotificationService {
awaitNotification(
deviceIdentifier: string,
socket: number,
- timeout: number
+ timeout: number,
): Promise;
/**
@@ -1693,7 +1202,7 @@ interface IiOSNotificationService {
postNotification(
deviceIdentifier: string,
notification: string,
- commandType?: string
+ commandType?: string,
): Promise;
}
diff --git a/lib/common/define-command.ts b/lib/common/define-command.ts
new file mode 100644
index 0000000000..c5cf28e5fe
--- /dev/null
+++ b/lib/common/define-command.ts
@@ -0,0 +1,344 @@
+/**
+ * The declarative command API. Types and pure factories only — this module is
+ * re-exported from `nativescript/contracts` and must stay side-effect-free, so
+ * it may not import lib/common/yok (whose import creates global.$injector).
+ * The runtime bridge onto the legacy registry lives in
+ * lib/common/services/command-definition-adapter.
+ */
+
+/**
+ * Symbol.for so that a definition produced by one copy of the CLI is still
+ * recognised by another — extensions bundle their own node_modules. `unique
+ * symbol` so the marker can also be spelled in the branded return type.
+ */
+export const COMMAND_DEFINITION_MARKER: unique symbol = Symbol.for(
+ "nativescript:cli:commandDefinition",
+);
+
+export type CommandOptionType = "boolean" | "string" | "number" | "array";
+
+export interface CommandOptionSpec {
+ type: CommandOptionType;
+ /** Value used when the flag is absent from the command line. */
+ default?: TValue;
+ /** Single-dash shorthand, e.g. `-o` for `--output`. */
+ alias?: string | string[];
+ /** Keeps the value out of analytics and logs. Defaults to false. */
+ hasSensitiveValue?: boolean;
+ /** Reserved for generated help; nothing renders it yet. */
+ description?: string;
+}
+
+/**
+ * A spec whose `default` is required. The required property is what
+ * `CommandOptionValues` keys off to drop `| undefined` from the value type, so
+ * it may not be relaxed to an optional one.
+ */
+export interface DefaultedCommandOptionSpec<
+ TValue = any,
+> extends CommandOptionSpec {
+ default: TValue;
+}
+
+/** The parts of an option spec a caller supplies; `type` comes from the helper. */
+export type CommandOptionSpecInit = Omit<
+ CommandOptionSpec,
+ "type"
+>;
+
+export interface CommandOptionsSchema {
+ [optionName: string]: CommandOptionSpec;
+}
+
+/**
+ * An option the command line omitted is absent at runtime, so only a spec that
+ * declares a `default` yields a value that is always there.
+ */
+type CommandOptionValue =
+ TSpec extends CommandOptionSpec
+ ? TSpec extends { default: any }
+ ? TValue
+ : TValue | undefined
+ : any;
+
+export type CommandOptionValues = {
+ [K in keyof TSchema]: CommandOptionValue;
+};
+
+export interface CommandContext {
+ /** Positional arguments, after the command name has been consumed. */
+ args: string[];
+ /** Current value of every option declared in the schema, and nothing else. */
+ options: CommandOptionValues;
+ /** Fails the command with `message` and the usage help suggestion. */
+ fail(message: string): never;
+}
+
+export interface CommandDefinition {
+ /** `"widget|add"`; `|` separates hierarchy levels. Several names alias one command. */
+ name: string | string[];
+ description?: string;
+ options?: TSchema;
+ /**
+ * `"none"` (the default) rejects positional arguments; `"any"` accepts them.
+ * Anything finer belongs in `canExecute`, which runs after this policy.
+ */
+ arguments?: "none" | "any";
+ canExecute?(context: CommandContext): Promise | boolean;
+ disableAnalytics?: boolean;
+ enableHooks?: boolean;
+ run(context: CommandContext): Promise | void;
+}
+
+/**
+ * What `defineCommand` returns: a definition carrying the marker in its type,
+ * so `registerCommandDefinition` can require a definition that went through
+ * define-time validation rather than any object of the right shape.
+ */
+export type DefinedCommand =
+ CommandDefinition & {
+ readonly [COMMAND_DEFINITION_MARKER]: true;
+ };
+
+interface IOptionHelper {
+ (
+ init: CommandOptionSpecInit & { default: TValue },
+ ): DefaultedCommandOptionSpec;
+ (init?: CommandOptionSpecInit): CommandOptionSpec;
+}
+
+const optionHelper = (type: CommandOptionType): IOptionHelper =>
+ >((init: CommandOptionSpecInit = {}) => ({
+ ...init,
+ type,
+ }));
+
+export const booleanOption = optionHelper("boolean");
+export const stringOption = optionHelper("string");
+export const numberOption = optionHelper("number");
+export const arrayOption = optionHelper("array");
+
+const DEFINITION_FIELDS = [
+ "name",
+ "description",
+ "options",
+ "arguments",
+ "canExecute",
+ "disableAnalytics",
+ "enableHooks",
+ "run",
+];
+
+const OPTION_SPEC_FIELDS = [
+ "type",
+ "default",
+ "alias",
+ "hasSensitiveValue",
+ "description",
+];
+
+const OPTION_TYPES: CommandOptionType[] = [
+ "boolean",
+ "string",
+ "number",
+ "array",
+];
+
+const ACCEPTED_FORM =
+ 'defineCommand({ name: "widget|add", run(ctx) { ... } }) — with the ' +
+ "optional fields description, options, arguments, canExecute, " +
+ "disableAnalytics and enableHooks.";
+
+const describeDefinition = (definition: any): string => {
+ const name = definition && definition.name;
+ if (typeof name === "string" && name.length) {
+ return `'${name}'`;
+ }
+
+ if (Array.isArray(name) && typeof name[0] === "string" && name[0].length) {
+ return `'${name[0]}'`;
+ }
+
+ return "an unnamed command";
+};
+
+const invalid = (definition: any, problem: string): never => {
+ throw new Error(
+ `Invalid command definition for ${describeDefinition(definition)}: ` +
+ `${problem}. Accepted form: ${ACCEPTED_FORM}`,
+ );
+};
+
+const isPlainObject = (value: any): boolean =>
+ !!value && typeof value === "object" && !Array.isArray(value);
+
+const validateName = (definition: any): void => {
+ const name = definition.name;
+ const isUsableName = (value: any) =>
+ typeof value === "string" && value.trim().length > 0;
+
+ if (isUsableName(name)) {
+ return;
+ }
+
+ if (Array.isArray(name) && name.length && name.every(isUsableName)) {
+ return;
+ }
+
+ invalid(
+ definition,
+ "'name' must be a non-empty string, or an array of non-empty strings for a command with aliases",
+ );
+};
+
+const validateOptionSpec = (
+ definition: any,
+ optionName: string,
+ spec: any,
+): void => {
+ if (!isPlainObject(spec)) {
+ invalid(
+ definition,
+ `option '${optionName}' must be declared with one of booleanOption(), stringOption(), numberOption() or arrayOption()`,
+ );
+ }
+
+ if (OPTION_TYPES.indexOf(spec.type) === -1) {
+ invalid(
+ definition,
+ `option '${optionName}' has type '${spec.type}'; the supported types are ${OPTION_TYPES.join(
+ ", ",
+ )} — declare it with one of booleanOption(), stringOption(), numberOption() or arrayOption()`,
+ );
+ }
+
+ const unknownFields = Object.keys(spec).filter(
+ (field) => OPTION_SPEC_FIELDS.indexOf(field) === -1,
+ );
+ if (unknownFields.length) {
+ invalid(
+ definition,
+ `option '${optionName}' has unknown field(s) ${unknownFields
+ .map((field) => `'${field}'`)
+ .join(", ")}; an option spec accepts ${OPTION_SPEC_FIELDS.join(", ")}`,
+ );
+ }
+
+ const aliasIsUsable =
+ spec.alias === undefined ||
+ typeof spec.alias === "string" ||
+ (Array.isArray(spec.alias) &&
+ spec.alias.length > 0 &&
+ spec.alias.every((entry: any) => typeof entry === "string"));
+ if (!aliasIsUsable) {
+ invalid(
+ definition,
+ `option '${optionName}' declares an 'alias' that is neither a string nor a non-empty array of strings`,
+ );
+ }
+
+ if (
+ spec.hasSensitiveValue !== undefined &&
+ typeof spec.hasSensitiveValue !== "boolean"
+ ) {
+ invalid(
+ definition,
+ `option '${optionName}' declares a non-boolean 'hasSensitiveValue'`,
+ );
+ }
+
+ if (spec.description !== undefined && typeof spec.description !== "string") {
+ invalid(
+ definition,
+ `option '${optionName}' declares a non-string 'description'`,
+ );
+ }
+};
+
+const validateDefinition = (definition: any): void => {
+ if (!isPlainObject(definition)) {
+ invalid(definition, "expected an object");
+ }
+
+ const unknownFields = Object.keys(definition).filter(
+ (field) => DEFINITION_FIELDS.indexOf(field) === -1,
+ );
+ if (unknownFields.length) {
+ invalid(
+ definition,
+ `unknown field(s) ${unknownFields
+ .map((field) => `'${field}'`)
+ .join(", ")}; a definition accepts ${DEFINITION_FIELDS.join(", ")}`,
+ );
+ }
+
+ validateName(definition);
+
+ if (typeof definition.run !== "function") {
+ invalid(definition, "'run' must be a function");
+ }
+
+ if (
+ definition.arguments !== undefined &&
+ definition.arguments !== "none" &&
+ definition.arguments !== "any"
+ ) {
+ invalid(
+ definition,
+ `'arguments' is '${definition.arguments}'; it must be "none" or "any"`,
+ );
+ }
+
+ if (
+ definition.canExecute !== undefined &&
+ typeof definition.canExecute !== "function"
+ ) {
+ invalid(definition, "'canExecute' must be a function");
+ }
+
+ for (const flag of ["disableAnalytics", "enableHooks"]) {
+ if (
+ definition[flag] !== undefined &&
+ typeof definition[flag] !== "boolean"
+ ) {
+ invalid(definition, `'${flag}' must be a boolean`);
+ }
+ }
+
+ if (definition.description !== undefined) {
+ if (typeof definition.description !== "string") {
+ invalid(definition, "'description' must be a string");
+ }
+ }
+
+ if (definition.options !== undefined) {
+ if (!isPlainObject(definition.options)) {
+ invalid(
+ definition,
+ "'options' must be an object keyed by the long option name",
+ );
+ }
+
+ for (const optionName of Object.keys(definition.options)) {
+ validateOptionSpec(
+ definition,
+ optionName,
+ definition.options[optionName],
+ );
+ }
+ }
+};
+
+export function defineCommand(
+ definition: CommandDefinition,
+): DefinedCommand {
+ validateDefinition(definition);
+
+ const marked: any = { ...definition };
+ marked[COMMAND_DEFINITION_MARKER] = true;
+ return marked;
+}
+
+export function isCommandDefinition(value: any): value is DefinedCommand {
+ return !!value && (value)[COMMAND_DEFINITION_MARKER] === true;
+}
diff --git a/lib/common/define-hook.ts b/lib/common/define-hook.ts
new file mode 100644
index 0000000000..c4f0b8e03f
--- /dev/null
+++ b/lib/common/define-hook.ts
@@ -0,0 +1,257 @@
+/**
+ * The typed hook-authoring API. Kept import-free so that a hook (or an
+ * extension carrying its own copy of the CLI) can load it without booting a
+ * second runtime — importing lib/common/yok creates global.$injector.
+ */
+
+/**
+ * `Symbol.for` rather than a module-local symbol: an extension may resolve a
+ * duplicated copy of the CLI from its own node_modules, and the running CLI
+ * still has to recognize definitions minted by that copy.
+ *
+ * Assigned as a plain enumerable property so that `{ ...definition }` keeps the
+ * marker; symbols stay invisible to Object.keys/for..in/JSON either way.
+ */
+export const HOOK_DEFINITION_MARKER = Symbol.for(
+ "nativescript:cli:hookDefinition",
+);
+
+/**
+ * Wraps the method the hook point decorates. `next` continues the chain — call
+ * it with `args` to run the original, or skip it to short-circuit.
+ */
+export type HookMiddleware = (
+ args: any[],
+ next: (...args: any[]) => any,
+) => any;
+
+export interface HookContext {
+ /**
+ * The payload of the operation being hooked. Its shape depends on the hook
+ * point, and it is the caller's own object: mutating it is a supported
+ * channel for influencing the operation. Hook points fired by command
+ * dispatch carry no payload at all, hence `undefined`.
+ */
+ payload: TPayload | undefined;
+
+ /**
+ * Registers a middleware around the method this hook point decorates.
+ * Available only to before-hooks of the hook points that fold middlewares
+ * around a method; elsewhere it throws rather than dropping the middleware.
+ */
+ wrap(middleware: HookMiddleware): void;
+
+ /**
+ * Ends the handler and fails the command with `message`.
+ *
+ * Typed `never` because it stops the handler by throwing, so nothing after
+ * the call runs.
+ */
+ fail(message: string): never;
+
+ /**
+ * Ends the handler and logs `message` as a warning; the command continues.
+ *
+ * Typed `never` because it stops the handler by throwing, so nothing after
+ * the call runs — only the command outlives it.
+ */
+ skip(message: string): never;
+}
+
+export type HookHandler = (
+ ctx: HookContext,
+) => void | Promise;
+
+/** The object bag accepted by `defineHook`. */
+export interface HookDefinitionInput {
+ /** Hook point, in the hyphen convention: `before-prepare`, `after-watch`. */
+ name: string;
+ run: HookHandler;
+}
+
+export interface HookDefinition {
+ /** Hook point, in the hyphen convention: `before-prepare`, `after-watch`. */
+ readonly name: string;
+ readonly run: HookHandler;
+}
+
+export interface HookInvocation {
+ context: HookContext;
+ /** Populated by `ctx.wrap()` while the handler runs. */
+ middlewares: HookMiddleware[];
+}
+
+const DEFINITION_FIELDS = ["name", "run"];
+
+const ACCEPTED_FORMS =
+ 'defineHook({ name: "before-prepare", run: (ctx) => {} }) or ' +
+ 'defineHook("before-prepare", (ctx) => {})';
+
+function describeDefinition(name: any): string {
+ return typeof name === "string" && name.length
+ ? JSON.stringify(name)
+ : "";
+}
+
+function failToDefine(message: string): never {
+ throw new Error(`${message} Accepted forms: ${ACCEPTED_FORMS}.`);
+}
+
+export function defineHook(
+ definition: HookDefinitionInput,
+): HookDefinition;
+export function defineHook(
+ name: string,
+ run: HookHandler,
+): HookDefinition;
+export function defineHook(
+ nameOrDefinition: string | HookDefinitionInput,
+ run?: HookHandler,
+): HookDefinition {
+ const input = normalizeDefinitionInput(nameOrDefinition, run);
+ const definition: any = { name: input.name, run: input.run };
+ definition[HOOK_DEFINITION_MARKER] = true;
+
+ return definition;
+}
+
+function normalizeDefinitionInput(
+ nameOrDefinition: string | HookDefinitionInput,
+ run?: HookHandler,
+): HookDefinitionInput {
+ if (typeof nameOrDefinition === "string") {
+ if (!nameOrDefinition.length) {
+ failToDefine("defineHook() requires a non-empty hook point name.");
+ }
+
+ if (typeof run !== "function") {
+ failToDefine(
+ `defineHook(${describeDefinition(nameOrDefinition)}) requires a handler function as its second argument.`,
+ );
+ }
+
+ return { name: nameOrDefinition, run };
+ }
+
+ if (
+ !nameOrDefinition ||
+ typeof nameOrDefinition !== "object" ||
+ Array.isArray(nameOrDefinition)
+ ) {
+ failToDefine("defineHook() was called with an unsupported argument.");
+ }
+
+ const unknownFields = Object.keys(nameOrDefinition).filter(
+ (field) => DEFINITION_FIELDS.indexOf(field) === -1,
+ );
+ if (unknownFields.length) {
+ failToDefine(
+ `defineHook(${describeDefinition(nameOrDefinition.name)}) received unknown ` +
+ `field${unknownFields.length > 1 ? "s" : ""} ` +
+ `${unknownFields.map((field) => JSON.stringify(field)).join(", ")}. ` +
+ `Supported fields: ${DEFINITION_FIELDS.map((field) => JSON.stringify(field)).join(", ")}.`,
+ );
+ }
+
+ if (typeof nameOrDefinition.name !== "string" || !nameOrDefinition.name) {
+ failToDefine(
+ 'defineHook() requires a non-empty "name" naming the hook point.',
+ );
+ }
+
+ if (typeof nameOrDefinition.run !== "function") {
+ failToDefine(
+ `defineHook(${describeDefinition(nameOrDefinition.name)}) requires "run" to be a function.`,
+ );
+ }
+
+ return { name: nameOrDefinition.name, run: nameOrDefinition.run };
+}
+
+export function isHookDefinition(
+ value: any,
+): value is HookDefinition {
+ return (
+ !!value &&
+ (typeof value === "object" || typeof value === "function") &&
+ value[HOOK_DEFINITION_MARKER] === true &&
+ typeof value.run === "function" &&
+ typeof value.name === "string"
+ );
+}
+
+export interface HookInvocationOptions {
+ /** The hook point the definition runs at; used in diagnostics. */
+ hookName: string;
+ /**
+ * Whether the caller folds the collected middlewares around a method. Only
+ * the `@hook`-decorated before-points do; everywhere else `ctx.wrap()` has
+ * nothing to wrap and says so instead of silently dropping the middleware.
+ */
+ consumesMiddlewares?: boolean;
+}
+
+/**
+ * Derives the context from the raw hook argument bag: the `hookArgs` wrapper
+ * when the hook point supplies one, the bag itself for hook points that pass
+ * their keys at the top level, and nothing when there is no payload.
+ */
+export function createHookInvocation(
+ hookArguments: any,
+ options: HookInvocationOptions,
+): HookInvocation {
+ const { hookName, consumesMiddlewares } = options;
+ const middlewares: HookMiddleware[] = [];
+ const context: HookContext = {
+ payload: derivePayload(hookArguments),
+ wrap(middleware: HookMiddleware): void {
+ if (!consumesMiddlewares) {
+ throw new Error(
+ `ctx.wrap() is not available at the "${hookName}" hook point: nothing folds the middleware around a method there, so it would never run.`,
+ );
+ }
+
+ if (typeof middleware !== "function") {
+ throw new Error(
+ `ctx.wrap() expects a function at the "${hookName}" hook point.`,
+ );
+ }
+
+ middlewares.push(middleware);
+ },
+ fail(message: string): never {
+ throw new Error(hookMessage(message, hookName, "fail"));
+ },
+ skip(message: string): never {
+ const error: any = new Error(hookMessage(message, hookName, "skip"));
+ // The pair the hooks service checks for to downgrade a rejection.
+ error.stopExecution = false;
+ error.errorAsWarning = true;
+ throw error;
+ },
+ };
+
+ return { context, middlewares };
+}
+
+function hookMessage(
+ message: string,
+ hookName: string,
+ method: string,
+): string {
+ return typeof message === "string" && message.trim().length
+ ? message
+ : `The "${hookName}" hook called ctx.${method}() without a message.`;
+}
+
+function derivePayload(hookArguments: any): any {
+ if (!hookArguments || typeof hookArguments !== "object") {
+ return undefined;
+ }
+
+ if ("hookArgs" in hookArguments) {
+ return hookArguments["hookArgs"];
+ }
+
+ return Object.keys(hookArguments).length ? hookArguments : undefined;
+}
diff --git a/lib/common/definitions/commands.d.ts b/lib/common/definitions/commands.d.ts
index 32511970f4..b2e43470e6 100644
--- a/lib/common/definitions/commands.d.ts
+++ b/lib/common/definitions/commands.d.ts
@@ -16,6 +16,13 @@ interface ICommand extends ICommandOptions {
dashedOptions?: IDictionary;
isHierarchicalCommand?: boolean;
+ /**
+ * Set on commands that forward their options to another CLI: the options
+ * they accept are not knowable from this CLI's option dictionary, so
+ * validating them here would reject the other CLI's flags.
+ */
+ skipOptionsValidation?: boolean;
+
/**
* Describes the action that will be executed after the command succeeds.
* @param {string[]} args Arguments passed to the command.
diff --git a/lib/common/definitions/extensibility.d.ts b/lib/common/definitions/extensibility.d.ts
index fbfd3be6d5..dceac2005c 100644
--- a/lib/common/definitions/extensibility.d.ts
+++ b/lib/common/definitions/extensibility.d.ts
@@ -30,6 +30,13 @@ interface IExtensionData extends IExtensionName {
* Full path to the directory of the installed extension.
*/
pathToExtension: string;
+
+ /**
+ * Names of the commands the extension contributes, as declared in the commands key of the nativescript key of its package.json.
+ * The key may be a map of command name to the module implementing it, in which case these are its keys, or the legacy array of command names, in which case these are its entries.
+ * The property is not set when the extension declares no commands.
+ */
+ commands?: string[];
}
/**
diff --git a/lib/common/definitions/key-commands.d.ts b/lib/common/definitions/key-commands.ts
similarity index 90%
rename from lib/common/definitions/key-commands.d.ts
rename to lib/common/definitions/key-commands.ts
index 2c4522b929..ff08673439 100644
--- a/lib/common/definitions/key-commands.d.ts
+++ b/lib/common/definitions/key-commands.ts
@@ -29,7 +29,7 @@ export type IKeysLowerCase =
export type IKeysUpperCase = Uppercase;
-export const enum SpecialKeys {
+export enum SpecialKeys {
CtrlC = "\u0003",
QuestionMark = "?",
}
@@ -41,11 +41,11 @@ export type IValidKeyName = IKeysLowerCase | IKeysUpperCase | IKeysSpecial;
export interface IKeyCommandHelper {
attachKeyCommands: (
platform: IKeyCommandPlatform,
- processType: SupportedProcessType
+ processType: SupportedProcessType,
) => void;
- addOverride(key: IValidKeyName, execute: () => Promise);
- removeOverride(key: IValidKeyName);
+ addOverride(key: IValidKeyName, execute: () => Promise): void;
+ removeOverride(key: IValidKeyName): void;
printCommands(platform: IKeyCommandPlatform): void;
}
diff --git a/lib/common/definitions/logger.d.ts b/lib/common/definitions/logger.d.ts
index d0f6d7835c..48089eb558 100644
--- a/lib/common/definitions/logger.d.ts
+++ b/lib/common/definitions/logger.d.ts
@@ -2,6 +2,7 @@ import { Layout, LoggingEvent, Configuration, Level } from "log4js";
import { EventEmitter } from "events";
import { LoggerLevel } from "../../constants";
import { IDictionary } from "../declarations";
+import type { Logger } from "../../contracts/logger";
declare global {
interface IAppenderOptions extends IDictionary {
@@ -14,28 +15,14 @@ declare global {
appenderOptions?: IAppenderOptions;
}
- interface ILogger {
- initialize(opts?: ILoggerOptions): void;
- initializeCliLogger(opts?: ILoggerOptions): void;
- getLevel(): string;
- fatal(formatStr?: any, ...args: any[]): void;
- error(formatStr?: any, ...args: any[]): void;
- warn(formatStr?: any, ...args: any[]): void;
- info(formatStr?: any, ...args: any[]): void;
- debug(formatStr?: any, ...args: any[]): void;
- trace(formatStr?: any, ...args: any[]): void;
- printMarkdown(...args: any[]): void;
- prepare(item: any): string;
- isVerbose(): boolean;
- clearScreen(): void;
- }
+ /** @deprecated Kept so existing annotations compile; use the {@link Logger} contract. */
+ interface ILogger extends Logger {}
interface Log4JSAppenderConfiguration extends Configuration {
layout: Layout;
}
- interface Log4JSEmitAppenderConfiguration
- extends Log4JSAppenderConfiguration {
+ interface Log4JSEmitAppenderConfiguration extends Log4JSAppenderConfiguration {
emitter: EventEmitter;
}
}
diff --git a/lib/common/definitions/mobile.d.ts b/lib/common/definitions/mobile.d.ts
index 0d4b58638b..bb522079fa 100644
--- a/lib/common/definitions/mobile.d.ts
+++ b/lib/common/definitions/mobile.d.ts
@@ -11,6 +11,7 @@ import {
IHasEmulatorOption,
IDisposable,
} from "../declarations";
+import type { DevicesService } from "../../contracts/devices-service";
declare global {
export module Mobile {
@@ -258,8 +259,7 @@ declare global {
* Describes different options for filtering device logs.
*/
interface IDeviceLogOptions
- extends IDictionary,
- Partial {
+ extends IDictionary, Partial {
/**
* Process id of the application on the device.
*/
@@ -284,8 +284,7 @@ declare global {
* Describes required methods for getting iOS Simulator's logs.
*/
interface IiOSSimulatorLogProvider
- extends NodeJS.EventEmitter,
- IShouldDispose {
+ extends NodeJS.EventEmitter, IShouldDispose {
/**
* Starts the process for getting simulator logs and emits and DEVICE_LOG_EVENT_NAME event.
* @param {string} deviceId The unique identifier of the device.
@@ -429,6 +428,16 @@ declare global {
interface IDeviceFileSystem {
listFiles(devicePath: string, appIdentifier?: string): Promise;
+ /**
+ * Returns the entries of a directory inside the application's
+ * sandbox, or null when the directory cannot be read. Currently
+ * implemented only for physical iOS devices (AFC), where it backs
+ * the post-transfer livesync verification.
+ */
+ getDirectoryEntries?(
+ devicePath: string,
+ appIdentifier: string,
+ ): Promise;
getFile(
deviceFilePath: string,
appIdentifier: string,
@@ -523,7 +532,7 @@ declare global {
}
interface IiOSSimulatorDiscovery extends IDeviceDiscovery {
- checkForAvailableSimulators(): Promise;
+ checkForAvailableSimulators(): Promise;
}
interface IAndroidDeviceDiscovery extends IDeviceDiscovery {
@@ -533,8 +542,7 @@ declare global {
/**
* Describes options that can be passed to devices service's initialization method.
*/
- interface IDevicesServicesInitializationOptions
- extends Partial {
+ interface IDevicesServicesInitializationOptions extends Partial {
/**
* If passed will start an emulator if necesasry.
*/
@@ -580,107 +588,8 @@ declare global {
[platform: string]: string;
}
- interface IDevicesService extends NodeJS.EventEmitter, IPlatform {
- hasDevices: boolean;
- deviceCount: number;
-
- execute(
- action: (device: Mobile.IDevice) => Promise,
- canExecute?: (dev: Mobile.IDevice) => boolean,
- options?: { allowNoDevices?: boolean },
- ): Promise[]>;
-
- /**
- * Initializes DevicesService, so after that device operations could be executed.
- * @param {IDevicesServicesInitializationOptions} data Defines the options which will be used for whole devicesService.
- * @return {Promise}
- */
- initialize(data?: IDevicesServicesInitializationOptions): Promise;
-
- /**
- * Add an IDeviceDiscovery instance which will from now on report devices. The instance should implement IDeviceDiscovery and raise "deviceFound" and "deviceLost" events.
- * @param {IDeviceDiscovery} deviceDiscovery Instance, implementing IDeviceDiscovery and raising raise "deviceFound" and "deviceLost" events.
- * @return {void}
- */
- addDeviceDiscovery(deviceDiscovery: IDeviceDiscovery): void;
- getDevices(): Mobile.IDeviceInfo[];
-
- /**
- * Gets device instance by specified identifier or number.
- * @param {string} deviceOption The specified device identifier or number.
- * @returns {Promise} Instance of IDevice.
- */
- getDevice(deviceOption: string): Promise;
- getDevicesForPlatform(platform: string): Mobile.IDevice[];
- getDeviceInstances(): Mobile.IDevice[];
- getDeviceByDeviceOption(): Mobile.IDevice;
- isAndroidDevice(device: Mobile.IDevice): boolean;
- isiOSDevice(device: Mobile.IDevice): boolean;
- isiOSSimulator(device: Mobile.IDevice): boolean;
- isOnlyiOSSimultorRunning(): boolean;
- isAppInstalledOnDevices(
- deviceIdentifiers: string[],
- appIdentifier: string,
- framework: string,
- projectDir: string,
- ): Promise[];
- setLogLevel(logLevel: string, deviceIdentifier?: string): void;
- deployOnDevices(
- deviceIdentifiers: string[],
- packageFile: string,
- packageName: string,
- framework: string,
- projectDir: string,
- ): Promise[];
- getDeviceByIdentifier(identifier: string): Mobile.IDevice;
- mapAbstractToTcpPort(
- deviceIdentifier: string,
- appIdentifier: string,
- framework: string,
- ): Promise;
- getDebuggableApps(
- deviceIdentifiers: string[],
- ): Promise[];
- getDebuggableViews(
- deviceIdentifier: string,
- appIdentifier: string,
- ): Promise;
-
- /**
- * Returns all applications installed on the specified device.
- * @param {string} deviceIdentifer The identifier of the device for which to get installed applications.
- * @returns {Promise} Array of all application identifiers of the apps installed on device.
- */
- getInstalledApplications(deviceIdentifier: string): Promise;
-
- /**
- * Returns all available iOS and/or Android emulators.
- * @param options The options that can be passed to filter the result.
- * @returns {Promise} Dictionary with the following format: { ios: { devices: Mobile.IDeviceInfo[], errors: string[] }, android: { devices: Mobile.IDeviceInfo[], errors: string[]}}.
- */
- getEmulatorImages(
- options?: Mobile.IListEmulatorsOptions,
- ): Promise;
-
- /**
- * Starts an emulator by provided options.
- * @param options
- * @returns {Promise} - Returns array of errors.
- */
- startEmulator(options?: IStartEmulatorOptions): Promise;
-
- /**
- * Returns a single device based on the specified options. If more than one devices are matching,
- * prompts the user for a manual choice or returns the first one for non interactive terminals.
- */
- pickSingleDevice(
- options: IPickSingleDeviceOptions,
- ): Promise;
-
- getPlatformsFromDeviceDescriptors(
- deviceDescriptors: ILiveSyncDeviceDescriptor[],
- ): string[];
- }
+ /** @deprecated Kept so existing annotations compile; use the {@link DevicesService} contract. */
+ interface IDevicesService extends DevicesService {}
interface IPickSingleDeviceOptions {
/**
@@ -1261,8 +1170,7 @@ declare global {
}
interface IDeviceLookingOptions
- extends IHasEmulatorOption,
- IHasDetectionInterval {
+ extends IHasEmulatorOption, IHasDetectionInterval {
shouldReturnImmediateResult: boolean;
platform: string;
fullDiscovery?: boolean;
@@ -1388,8 +1296,7 @@ declare global {
/**
* Describes information about application on device.
*/
- interface IDeviceApplicationInformation
- extends IDeviceApplicationInformationBase {
+ interface IDeviceApplicationInformation extends IDeviceApplicationInformationBase {
/**
* The framework of the project (Cordova or NativeScript).
*/
diff --git a/lib/common/definitions/yok.d.ts b/lib/common/definitions/yok.d.ts
index 08c663bec5..89c544444b 100644
--- a/lib/common/definitions/yok.d.ts
+++ b/lib/common/definitions/yok.d.ts
@@ -1,56 +1,64 @@
-import { IDisposable, IDictionary } from "../declarations";
-import { ICommand } from "./commands";
-import { IKeyCommand, IValidKeyName } from "./key-commands";
+import { IDictionary } from "../declarations";
+import { Injector } from "../di/injector";
+import { Provider } from "../di/providers";
+import { CommandRegistry } from "../contracts/command-registry";
+import { KeyCommandRegistry } from "../contracts/key-command-registry";
+import { ModuleRegistry } from "../contracts/module-registry";
+import { PublicApiBuilder } from "../contracts/public-api-builder";
-interface IInjector extends IDisposable {
- require(name: string, file: string): void;
- require(names: string[], file: string): void;
- requirePublic(names: string | string[], file: string): void;
- requirePublicClass(names: string | string[], file: string): void;
- requireCommand(name: string, file: string): void;
- requireCommand(names: string[], file: string): void;
- requireKeyCommand(name: IValidKeyName, file: string): void;
+/**
+ * The legacy injector facade surface. It extends the token-based `Injector` —
+ * the facade IS an injector — and adds the legacy subsystems, whose members
+ * are individually @deprecated. Only the `Yok` class hierarchy implements
+ * this; the interface survives until the hook/extension deprecation completes.
+ */
+interface IInjector
+ extends
+ Injector,
+ CommandRegistry,
+ KeyCommandRegistry,
+ ModuleRegistry,
+ PublicApiBuilder {
/**
* Resolves an implementation by constructor function.
* The injector will create new instances for every call.
+ * @deprecated Use Injector.createInstance.
*/
resolve(ctor: Function, ctorArguments?: { [key: string]: any }): any;
+ /**
+ * @deprecated Use Injector.createInstance.
+ */
resolve(ctor: Function, ctorArguments?: { [key: string]: any }): T;
/**
* Resolves an implementation by name.
* The injector will create only one instance per name and return the same instance on subsequent calls.
+ * @deprecated Use inject(Token) in an injection context, or Injector.get.
*/
resolve(name: string, ctorArguments?: IDictionary): any;
+ /**
+ * @deprecated Use inject(Token) in an injection context, or Injector.get.
+ */
resolve(name: string, ctorArguments?: IDictionary): T;
- resolveCommand(name: string): ICommand;
- resolveKeyCommand(key: string): IKeyCommand;
+ /**
+ * @deprecated Legacy name-based registration. Use the Provider overload or
+ * provide(); a contract's token name keeps string spellings resolvable.
+ */
register(name: string, resolver: any, shared?: boolean): void;
- registerCommand(name: string, resolver: any): void;
- registerCommand(names: string[], resolver: any): void;
- registerKeyCommand(key: IValidKeyName, resolver: any): void;
- getRegisteredCommandsNames(includeDev: boolean): string[];
- getRegisteredKeyCommandsNames(): string[];
+ register(providers: Provider | Provider[]): void;
+ /**
+ * @deprecated String-reflective help templating; removable only together
+ * with the help-template pipeline.
+ */
dynamicCallRegex: RegExp;
- dynamicCall(call: string, args?: any[]): Promise;
- isDefaultCommand(commandName: string): boolean;
- isValidHierarchicalCommand(
- commandName: string,
- commandArguments: string[]
- ): Promise;
- getChildrenCommandsNames(commandName: string): string[];
- buildHierarchicalCommand(
- parentCommandName: string,
- commandLineArguments: string[]
- ): any;
- publicApi: any;
-
/**
- * Defines if it's allowed to override already required module.
- * This can be used in order to allow redefinition of modules, for example $logger can be replaced by a plugin.
- * Default value is false.
+ * @deprecated See dynamicCallRegex.
*/
- overrideAlreadyRequiredModule: boolean;
+ dynamicCall(call: string, args?: any[]): Promise;
}
+/**
+ * @deprecated The process-wide legacy injector global. New code receives the
+ * container via inject(Injector) from lib/common/di.
+ */
declare var $injector: IInjector;
diff --git a/lib/common/deprecation.ts b/lib/common/deprecation.ts
new file mode 100644
index 0000000000..5913b18d97
--- /dev/null
+++ b/lib/common/deprecation.ts
@@ -0,0 +1,95 @@
+/**
+ * Central reporting point for invocations of legacy/deprecated CLI APIs.
+ *
+ * The severity is a single dial so the same call sites can be staged over
+ * releases: trace (observe usage) → warn (tell users) → error (removal).
+ * `NS_DEPRECATIONS=warn|error` previews a stricter stage ahead of the default.
+ */
+
+type DeprecationStage = "trace" | "warn" | "error";
+
+const DEFAULT_STAGE: DeprecationStage = "trace";
+
+interface IDeprecationLogger {
+ trace(...args: any[]): void;
+ warn(...args: any[]): void;
+}
+
+export interface IDeprecationReport {
+ /** Stable identifier of the deprecated API, e.g. "hooks.param-name-signature". */
+ api: string;
+ /** Distinguishes call sites of one API, e.g. a hook path or extension name. */
+ detail?: string;
+ /**
+ * Logger to report through. When omitted, the logger is resolved lazily from
+ * the global injector at call time — never at import time — and the report
+ * is dropped if no logger is resolvable yet.
+ */
+ logger?: IDeprecationLogger;
+}
+
+const reported = new Set();
+
+export function reportDeprecation(report: IDeprecationReport): void {
+ const stage = getDeprecationStage();
+ const message = formatMessage(report);
+
+ if (stage === "error") {
+ throw new Error(message);
+ }
+
+ const key = report.detail ? `${report.api}::${report.detail}` : report.api;
+ if (reported.has(key)) {
+ return;
+ }
+
+ const logger = report.logger || tryResolveGlobalLogger();
+ if (!logger) {
+ // Deliberately not latched: a report dropped for lack of a logger must
+ // still be deliverable once one becomes resolvable.
+ return;
+ }
+ reported.add(key);
+
+ if (stage === "warn") {
+ logger.warn(message);
+ } else {
+ logger.trace(message);
+ }
+}
+
+/** Test seam: reports are deduplicated once per process otherwise. */
+export function clearReportedDeprecations(): void {
+ reported.clear();
+}
+
+function formatMessage(report: IDeprecationReport): string {
+ const detail = report.detail ? ` (${report.detail})` : "";
+ return (
+ `Legacy CLI API used: ${report.api}${detail}. ` +
+ `This API is planned for deprecation in a future release; ` +
+ `set NS_DEPRECATIONS=warn or NS_DEPRECATIONS=error to preview stricter handling.`
+ );
+}
+
+function getDeprecationStage(): DeprecationStage {
+ const value = (process.env.NS_DEPRECATIONS || "").toLowerCase();
+ if (value === "warn" || value === "error" || value === "trace") {
+ return value;
+ }
+ return DEFAULT_STAGE;
+}
+
+function tryResolveGlobalLogger(): IDeprecationLogger | null {
+ try {
+ // Required at call time: yok imports this module, so a static import
+ // would be a cycle. Every reporting site already runs with yok loaded.
+ const injector = require("./yok").getInjector();
+ if (!injector) {
+ return null;
+ }
+ return injector.resolve("logger");
+ } catch (err) {
+ return null;
+ }
+}
diff --git a/lib/common/di/contract.ts b/lib/common/di/contract.ts
new file mode 100644
index 0000000000..cc03be9f02
--- /dev/null
+++ b/lib/common/di/contract.ts
@@ -0,0 +1,88 @@
+/**
+ * The name is stored under a `Symbol.for` key deliberately: extensions install
+ * into their own node_modules tree, so a duplicated copy of this module (and
+ * of any contract class) must write and read the same property key. A unique
+ * `Symbol()` would make duplicate copies mutually invisible and break the
+ * name-fallback lookup in `Injector.get()`.
+ */
+export const CONTRACT_NAME = Symbol.for("nativescript:di:contractName");
+
+export interface IContractOptions {
+ /**
+ * Canonical token name, without the `$` prefix. Must be an explicit string
+ * literal — never derive it from `class.name`, which changes under
+ * minification.
+ */
+ name: string;
+}
+
+// Per module instance on purpose: a duplicated CLI copy in an extensions tree
+// carries its own registry, so contracts redeclared by another copy never
+// false-positive here.
+const mintedNames = new Map();
+
+function describeOwner(owner: object): string {
+ if (typeof owner === "function") {
+ return `contract '${owner.name || ""}'`;
+ }
+ return "an injection token";
+}
+
+/**
+ * Claims a token name for `owner`. Both `@Contract` and `InjectionToken` mint
+ * here so the two kinds share one namespace: a contract and a token that claim
+ * the same name would be two tokens silently aliasing one registration.
+ */
+export function mintTokenName(name: string, owner: object): void {
+ const existing = mintedNames.get(name);
+ if (existing && existing !== owner) {
+ throw new Error(
+ `Token name '${name}' is already used by ${describeOwner(existing)}. ` +
+ `Token names must be unique — a duplicate silently aliases two tokens.`,
+ );
+ }
+ mintedNames.set(name, owner);
+}
+
+/**
+ * Marks an abstract class as a DI token. The decorated class resolves by
+ * object identity first and by its name on a miss, so duplicated copies of a
+ * contract remain interchangeable across node_modules trees.
+ */
+export function Contract(
+ options: IContractOptions,
+): (target: Function) => void {
+ const { name } = options;
+ return (target: Function): void => {
+ mintTokenName(name, target);
+ Object.defineProperty(target, CONTRACT_NAME, {
+ value: name,
+ writable: false,
+ enumerable: false,
+ configurable: false,
+ });
+ };
+}
+
+/**
+ * Reads the decorator-set name. Own-property check only: an implementation
+ * class extending a contract inherits the property, but must not itself act
+ * as a token.
+ */
+export function getContractName(token: any): string | undefined {
+ if (
+ typeof token === "function" &&
+ Object.prototype.hasOwnProperty.call(token, CONTRACT_NAME)
+ ) {
+ return (token)[CONTRACT_NAME];
+ }
+ return undefined;
+}
+
+/**
+ * Test seam — the duplicate-name registry (contracts and injection tokens
+ * alike) otherwise persists per process.
+ */
+export function clearMintedContractNames(): void {
+ mintedNames.clear();
+}
diff --git a/lib/common/di/forward-ref.ts b/lib/common/di/forward-ref.ts
new file mode 100644
index 0000000000..882559d0d3
--- /dev/null
+++ b/lib/common/di/forward-ref.ts
@@ -0,0 +1,25 @@
+// `Symbol.for` so a duplicated CLI copy in an extensions tree marks thunks
+// with the same key this copy reads — mirrors the CONTRACT_NAME reasoning.
+const FORWARD_REF = Symbol.for("nativescript:di:forwardRef");
+
+/**
+ * Defers a token reference until the container reads it — for provider arrays
+ * evaluated at module load, where a class declared later in the file (TDZ) or
+ * reached through a circular import is not yet a usable binding. Same
+ * semantics as Angular's forwardRef; resolved at registration and lookup.
+ *
+ * This defers *references*, not construction: it cannot break an
+ * instantiation cycle between two services. For that, inject the Injector and
+ * resolve late.
+ */
+export function forwardRef(fn: () => T): T {
+ (fn)[FORWARD_REF] = true;
+ return fn;
+}
+
+export function resolveForwardRef(token: T): T {
+ if (typeof token === "function" && (token)[FORWARD_REF] === true) {
+ return (token)();
+ }
+ return token;
+}
diff --git a/lib/common/di/index.ts b/lib/common/di/index.ts
new file mode 100644
index 0000000000..8a3ca29b4f
--- /dev/null
+++ b/lib/common/di/index.ts
@@ -0,0 +1,30 @@
+export { Injector } from "./injector";
+export type { InjectOptions } from "./injector";
+export { inject, runInInjectionContext } from "./inject";
+export { forwardRef, resolveForwardRef } from "./forward-ref";
+export {
+ Contract,
+ getContractName,
+ CONTRACT_NAME,
+ clearMintedContractNames,
+} from "./contract";
+export type { IContractOptions } from "./contract";
+export {
+ InjectionToken,
+ getInjectionTokenName,
+ INJECTION_TOKEN_NAME,
+} from "./injection-token";
+export { provide, provideLazy } from "./providers";
+export type {
+ Provider,
+ InternalProvider,
+ ProviderToken,
+ Type,
+ AbstractType,
+ IClassProvider,
+ IValueProvider,
+ IFactoryProvider,
+ ILazyClassProvider,
+ ILegacyClassProvider,
+ ILazyRequireProvider,
+} from "./providers";
diff --git a/lib/common/di/inject.ts b/lib/common/di/inject.ts
new file mode 100644
index 0000000000..c29be1bed2
--- /dev/null
+++ b/lib/common/di/inject.ts
@@ -0,0 +1,77 @@
+import type { Injector, InjectOptions } from "./injector";
+import type { ProviderToken } from "./providers";
+
+/**
+ * The injection context lives on globalThis under a `Symbol.for` key rather
+ * than in a module-local variable: a hook or extension module can resolve a
+ * DIFFERENT copy of this file than the one the running CLI set the context
+ * through (a nested nativescript install, or a project-local copy under a
+ * globally-run CLI), and a module-local slot would make that copy's inject()
+ * throw despite being synchronously inside a valid context.
+ */
+const CONTEXT_SLOT = Symbol.for("nativescript:di:injectionContext");
+
+interface IInjectionContextFrame {
+ injector: Injector;
+ /** Identifies which loaded copy of this module set the frame. */
+ owner: object;
+}
+
+// One per loaded copy of this module — the cross-copy detection marker.
+const COPY_ID = {};
+
+let reportedCrossCopyUse = false;
+
+function currentFrame(): IInjectionContextFrame | null {
+ return (globalThis)[CONTEXT_SLOT] || null;
+}
+
+export function inject(token: ProviderToken): T;
+export function inject(
+ token: ProviderToken,
+ options: InjectOptions & { optional: true },
+): T | null;
+export function inject(
+ token: ProviderToken,
+ options: InjectOptions,
+): T;
+export function inject(
+ token: ProviderToken,
+ options?: InjectOptions,
+): T | null {
+ const frame = currentFrame();
+ if (!frame) {
+ throw new Error(
+ "inject() can only be called from an injection context — a field " +
+ "initializer, a constructor, or a provider factory running under " +
+ "runInInjectionContext(). It is not valid after an await; inject " +
+ "the Injector itself and use injector.get() for late lookups.",
+ );
+ }
+
+ if (frame.owner !== COPY_ID && !reportedCrossCopyUse) {
+ reportedCrossCopyUse = true;
+ const logger = frame.injector.get("logger", { optional: true });
+ if (logger) {
+ logger.warn(
+ `A second copy of the NativeScript CLI (${__dirname}) is serving ` +
+ `inject() in this process. This works, but loads the CLI twice; ` +
+ `extensions and projects should declare nativescript as a ` +
+ `peerDependency so the running copy is shared.`,
+ );
+ }
+ }
+
+ return frame.injector.get(token, options);
+}
+
+export function runInInjectionContext(injector: Injector, fn: () => T): T {
+ const g = globalThis;
+ const previous = g[CONTEXT_SLOT];
+ g[CONTEXT_SLOT] = { injector, owner: COPY_ID };
+ try {
+ return fn();
+ } finally {
+ g[CONTEXT_SLOT] = previous;
+ }
+}
diff --git a/lib/common/di/injection-token.ts b/lib/common/di/injection-token.ts
new file mode 100644
index 0000000000..fa812d7564
--- /dev/null
+++ b/lib/common/di/injection-token.ts
@@ -0,0 +1,72 @@
+import { mintTokenName } from "./contract";
+
+/**
+ * Mirrors CONTRACT_NAME's `Symbol.for` reasoning: an extension's duplicated
+ * copy of this module must read the marker off tokens minted by the running
+ * copy, which a unique `Symbol()` would hide.
+ */
+export const INJECTION_TOKEN_NAME = Symbol.for(
+ "nativescript:di:injectionTokenName",
+);
+
+/**
+ * A typed DI token for a dependency that is not a class — an imported module
+ * namespace, a plain value, a function. `@Contract` covers services, which
+ * have an abstract class to decorate; this covers everything else.
+ *
+ * The description doubles as the legacy registry name, exactly as a contract's
+ * name does, so a token is a typed alias over the registration it names:
+ *
+ * ```ts
+ * const XCODE = new InjectionToken(
+ * "xcode",
+ * );
+ * inject(XCODE); // finds register("xcode", …) untouched
+ * ```
+ */
+export class InjectionToken {
+ /**
+ * Phantom, never assigned: with no member mentioning `T` the type parameter
+ * is erased and every token becomes assignable to every other one.
+ */
+ declare private readonly resolvedType: T;
+
+ /**
+ * @param description Canonical registry name. A leading `$` is stripped, so
+ * the token always keys the same record the string spellings do.
+ */
+ constructor(description: string) {
+ const name = description[0] === "$" ? description.slice(1) : description;
+ mintTokenName(name, this);
+ Object.defineProperty(this, INJECTION_TOKEN_NAME, {
+ value: name,
+ writable: false,
+ enumerable: false,
+ configurable: false,
+ });
+ }
+
+ public get description(): string {
+ return (this)[INJECTION_TOKEN_NAME];
+ }
+
+ public toString(): string {
+ return `InjectionToken(${this.description})`;
+ }
+}
+
+/**
+ * Reads the constructor-set name. Own-property check, and by marker rather
+ * than `instanceof`, so tokens minted by a duplicated copy of this module are
+ * still recognized.
+ */
+export function getInjectionTokenName(token: any): string | undefined {
+ if (
+ token !== null &&
+ typeof token === "object" &&
+ Object.prototype.hasOwnProperty.call(token, INJECTION_TOKEN_NAME)
+ ) {
+ return token[INJECTION_TOKEN_NAME];
+ }
+ return undefined;
+}
diff --git a/lib/common/di/injector.ts b/lib/common/di/injector.ts
new file mode 100644
index 0000000000..3f5a1f2470
--- /dev/null
+++ b/lib/common/di/injector.ts
@@ -0,0 +1,435 @@
+import { annotate } from "../helpers";
+import { getContractName } from "./contract";
+import { resolveForwardRef } from "./forward-ref";
+import { runInInjectionContext } from "./inject";
+import { getInjectionTokenName } from "./injection-token";
+import type {
+ InternalProvider,
+ Provider,
+ ProviderToken,
+ Type,
+} from "./providers";
+
+type TokenKey = string | object;
+
+export interface InjectOptions {
+ /** Resolve to null instead of throwing when the token is not registered. */
+ optional?: boolean;
+ /** Resolve at this injector's level only — no parent fallthrough. */
+ self?: boolean;
+ /**
+ * Start resolution at the parent — escapes a child scope's shadowing
+ * entry (e.g. a hook payload key that collides with a service name).
+ */
+ skipSelf?: boolean;
+}
+
+type ProviderKind = "value" | "class" | "factory" | "lazyClass" | "legacyClass";
+
+interface IProviderRecord {
+ displayName: string;
+ kind?: ProviderKind;
+ shared: boolean;
+ useValue?: any;
+ useClass?: Type;
+ useFactory?: () => any;
+ useLazyClass?: () => Type;
+ useLegacyClass?: Function;
+ /**
+ * Deferred side-effect loader (Yok's require path). Consumed on first
+ * resolution; it is expected to register a real resolver onto this record.
+ */
+ pendingLoader?: () => void;
+ /** Every produced instance is retained, transients included — dispose() walks them. */
+ instances: any[];
+ constructing: boolean;
+}
+
+// Shared across the whole injector tree so cycle reports show the full path
+// even when resolution hops between parent and child scopes.
+const resolutionStack: string[] = [];
+
+export class Injector {
+ private providers = new Map();
+ private instantiationOrder: any[] = [];
+
+ constructor(
+ providers: Provider[] = [],
+ private parent?: Injector,
+ ) {
+ this.register({ provide: Injector, useValue: this });
+ this.register(providers);
+ }
+
+ /**
+ * Resolution is two-step per injector level: the token itself, then its
+ * decorator-set name — and only on a full local miss does lookup delegate
+ * to the parent. The per-level order is what lets a child scope's
+ * string-keyed entry (a per-call override, a hook payload) shadow a parent
+ * provider for class-token consumers too.
+ *
+ * `ctorArguments` is the legacy per-call bag: raw Yok semantics (own-key
+ * check, no `$` normalization), applied only to the construction the call
+ * itself triggers — it never propagates to nested resolutions.
+ */
+ // T defaults to any so string tokens (which give inference no source and
+ // would otherwise land on unknown) stay ergonomic during the migration.
+ public get(token: ProviderToken): T;
+ public get(
+ token: ProviderToken,
+ options: InjectOptions & { optional: true },
+ ): T | null;
+ public get(token: ProviderToken, options: InjectOptions): T;
+ public get(
+ token: ProviderToken,
+ options?: InjectOptions,
+ ): T | null {
+ if (options && options.self && options.skipSelf) {
+ throw new Error("inject options cannot combine self and skipSelf");
+ }
+ token = resolveForwardRef(token);
+ const found = this.findRecord(token, options);
+ if (!found) {
+ if (options && options.optional) {
+ return null;
+ }
+ throw new Error("unable to resolve " + displayNameOf(token));
+ }
+ return found.owner.instantiate(found.record);
+ }
+
+ /**
+ * The legacy facade's channel for Yok's `resolve(name, bag)` sites: the
+ * bag applies to the construction this call itself triggers, with raw
+ * own-key semantics, and never propagates to nested resolutions. Not part
+ * of the public API — new code passes per-call providers to
+ * createInstance instead.
+ */
+ protected getWithLegacyArguments(
+ token: ProviderToken,
+ ctorArguments?: { [key: string]: any },
+ ): any {
+ token = resolveForwardRef(token);
+ const found = this.findRecord(token);
+ if (!found) {
+ throw new Error("unable to resolve " + displayNameOf(token));
+ }
+ return found.owner.instantiate(found.record, ctorArguments);
+ }
+
+ public createChild(providers: Provider[] = []): Injector {
+ return new Injector(providers, this);
+ }
+
+ /**
+ * Constructs a class that need not be registered, resolving its annotated
+ * parameters against this injector (plus the given per-call providers,
+ * which shadow one level deep only). Products are deliberately NOT
+ * retained for disposal — Yok never retained by-class resolutions either.
+ */
+ public createInstance(
+ cls: Type | Function,
+ providers: Provider[] = [],
+ ctorArguments?: { [key: string]: any },
+ ): T {
+ const scope = providers.length ? this.createChild(providers) : this;
+ return scope.constructLegacy(cls, ctorArguments);
+ }
+
+ /** Merge-mutate: re-registering a key updates the existing record in place. */
+ public register(providers: InternalProvider | InternalProvider[]): void {
+ const list = Array.isArray(providers) ? providers : [providers];
+ for (const provider of list) {
+ const keys = this.keysFor(provider.provide);
+ let record: IProviderRecord | undefined;
+ for (const key of keys) {
+ record = this.providers.get(key);
+ if (record) {
+ break;
+ }
+ }
+ if (!record) {
+ record = {
+ displayName: displayNameOf(provider.provide),
+ shared: true,
+ instances: [],
+ constructing: false,
+ };
+ }
+ this.applyProvider(record, provider);
+ for (const key of keys) {
+ this.providers.set(key, record);
+ }
+ }
+ }
+
+ /** Own-level string keys, optionally filtered by prefix — feeds command-name enumeration. */
+ public getRegisteredNames(prefix: string = ""): string[] {
+ const names: string[] = [];
+ for (const key of this.providers.keys()) {
+ if (typeof key === "string" && key.startsWith(prefix)) {
+ names.push(key);
+ }
+ }
+ return names;
+ }
+
+ public has(token: ProviderToken): boolean {
+ return !!this.findRecord(token);
+ }
+
+ /**
+ * Whether the token can actually produce a value. A record carrying only a
+ * pending loader answers `has()` but resolves to an error, so the deferred
+ * paths use this to tell "loaded and registered" from "loaded and silent".
+ */
+ protected hasResolver(token: ProviderToken): boolean {
+ const found = this.findRecord(token);
+ return !!found && found.record.kind !== undefined;
+ }
+
+ /** First cached instance for a token, without triggering construction. */
+ public peek(token: ProviderToken): any {
+ const found = this.findRecord(token);
+ return found ? found.record.instances[0] : undefined;
+ }
+
+ /** Deletes the local record under every key that aliases it. */
+ public remove(token: ProviderToken): void {
+ const record = this.findRecordLocal(token);
+ if (!record) {
+ return;
+ }
+ const keys: TokenKey[] = [];
+ for (const [key, value] of this.providers) {
+ if (value === record) {
+ keys.push(key);
+ }
+ }
+ for (const key of keys) {
+ this.providers.delete(key);
+ }
+ }
+
+ /**
+ * Reverse instantiation order, then registration-provided values. Sync,
+ * like Yok's — the exit paths that call this do not await it.
+ */
+ public dispose(exclude: any[] = []): void {
+ const seen = new Set(exclude);
+ const disposeOne = (instance: any) => {
+ if (!instance || seen.has(instance) || instance === this) {
+ return;
+ }
+ seen.add(instance);
+ if (typeof instance.dispose === "function") {
+ instance.dispose();
+ }
+ };
+
+ for (let i = this.instantiationOrder.length - 1; i >= 0; i--) {
+ disposeOne(this.instantiationOrder[i]);
+ }
+ for (const record of new Set(this.providers.values())) {
+ for (const instance of record.instances) {
+ disposeOne(instance);
+ }
+ }
+ }
+
+ private keysFor(token: ProviderToken): TokenKey[] {
+ token = resolveForwardRef(token);
+ if (typeof token === "string") {
+ return [normalizeName(token)];
+ }
+ const name = tokenNameOf(token);
+ return name !== undefined ? [token, name] : [token];
+ }
+
+ private applyProvider(
+ record: IProviderRecord,
+ provider: InternalProvider,
+ ): void {
+ record.shared = provider.shared === undefined ? true : provider.shared;
+
+ if ("useLazyRequire" in provider) {
+ record.pendingLoader = provider.useLazyRequire;
+ return;
+ }
+
+ record.useValue = undefined;
+ record.useClass = undefined;
+ record.useFactory = undefined;
+ record.useLazyClass = undefined;
+ record.useLegacyClass = undefined;
+
+ if ("useValue" in provider) {
+ record.kind = "value";
+ record.useValue = provider.useValue;
+ if (record.shared) {
+ record.instances[0] = provider.useValue;
+ } else {
+ record.instances.push(provider.useValue);
+ }
+ } else if ("useClass" in provider) {
+ record.kind = "class";
+ record.useClass = provider.useClass;
+ } else if ("useFactory" in provider) {
+ record.kind = "factory";
+ record.useFactory = provider.useFactory;
+ } else if ("useLazyClass" in provider) {
+ record.kind = "lazyClass";
+ record.useLazyClass = provider.useLazyClass;
+ } else if ("useLegacyClass" in provider) {
+ record.kind = "legacyClass";
+ record.useLegacyClass = provider.useLegacyClass;
+ }
+ }
+
+ private findRecordLocal(token: ProviderToken): IProviderRecord | undefined {
+ token = resolveForwardRef(token);
+ if (typeof token === "string") {
+ return this.providers.get(normalizeName(token));
+ }
+ const direct = this.providers.get(token);
+ if (direct) {
+ return direct;
+ }
+ const name = tokenNameOf(token);
+ return name !== undefined ? this.providers.get(name) : undefined;
+ }
+
+ // self/skipSelf apply to the entry level only: the parent walk below is
+ // always an ordinary full lookup from that injector on.
+ private findRecord(
+ token: ProviderToken,
+ options?: InjectOptions,
+ ): { record: IProviderRecord; owner: Injector } | undefined {
+ if (!options || !options.skipSelf) {
+ const local = this.findRecordLocal(token);
+ if (local) {
+ return { record: local, owner: this };
+ }
+ if (options && options.self) {
+ return undefined;
+ }
+ }
+ return this.parent ? this.parent.findRecord(token) : undefined;
+ }
+
+ private instantiate(
+ record: IProviderRecord,
+ ctorArguments?: { [key: string]: any },
+ ): any {
+ if (record.pendingLoader) {
+ const loader = record.pendingLoader;
+ // Cleared only after a successful run so a failing loader (missing
+ // module, broken require) is retried on the next resolution.
+ loader();
+ record.pendingLoader = undefined;
+ }
+
+ if (record.shared && record.instances.length) {
+ return record.instances[0];
+ }
+
+ if (
+ record.kind === undefined ||
+ (record.kind === "value" && !record.shared)
+ ) {
+ throw new Error("no resolver registered for " + record.displayName);
+ }
+
+ if (record.kind === "value") {
+ return record.instances[0];
+ }
+
+ if (record.constructing) {
+ const cyclePath = resolutionStack.concat(record.displayName).join(" -> ");
+ throw new Error(
+ `Cyclic dependency detected on dependency '${record.displayName}'. Resolution path: ${cyclePath}`,
+ );
+ }
+
+ record.constructing = true;
+ resolutionStack.push(record.displayName);
+ let instance: any;
+ try {
+ instance = this.construct(record, ctorArguments);
+ } finally {
+ resolutionStack.pop();
+ record.constructing = false;
+ }
+
+ record.instances.push(instance);
+ this.instantiationOrder.push(instance);
+ return instance;
+ }
+
+ private construct(
+ record: IProviderRecord,
+ ctorArguments?: { [key: string]: any },
+ ): any {
+ switch (record.kind) {
+ case "class":
+ return runInInjectionContext(this, () => new record.useClass());
+ case "factory":
+ return runInInjectionContext(this, () => record.useFactory());
+ case "lazyClass": {
+ if (!record.useClass) {
+ record.useClass = record.useLazyClass();
+ }
+ const cls = record.useClass;
+ return runInInjectionContext(this, () => new cls());
+ }
+ case "legacyClass":
+ return this.constructLegacy(record.useLegacyClass, ctorArguments);
+ }
+ }
+
+ private constructLegacy(
+ ctor: any,
+ ctorArguments?: { [key: string]: any },
+ ): any {
+ annotate(ctor);
+
+ const resolvedArgs = ctor.$inject.args.map((paramName: string) =>
+ ctorArguments &&
+ Object.prototype.hasOwnProperty.call(ctorArguments, paramName)
+ ? ctorArguments[paramName]
+ : this.get(paramName),
+ );
+
+ const name = ctor.$inject.name;
+ return runInInjectionContext(this, () =>
+ name && name[0] === name[0].toUpperCase()
+ ? new ctor(...resolvedArgs)
+ : ctor.apply(null, resolvedArgs),
+ );
+ }
+}
+
+function normalizeName(name: string): string {
+ return name[0] === "$" ? name.slice(1) : name;
+}
+
+/** The name a non-string token aliases in the legacy registry, if it has one. */
+function tokenNameOf(token: ProviderToken): string | undefined {
+ const injectionTokenName = getInjectionTokenName(token);
+ return injectionTokenName !== undefined
+ ? injectionTokenName
+ : getContractName(token);
+}
+
+function displayNameOf(token: ProviderToken): string {
+ if (typeof token === "string") {
+ return normalizeName(token);
+ }
+ const injectionTokenName = getInjectionTokenName(token);
+ if (injectionTokenName !== undefined) {
+ return `InjectionToken(${injectionTokenName})`;
+ }
+ return (
+ getContractName(token) || (token).name || ""
+ );
+}
diff --git a/lib/common/di/providers.ts b/lib/common/di/providers.ts
new file mode 100644
index 0000000000..61c8d738d4
--- /dev/null
+++ b/lib/common/di/providers.ts
@@ -0,0 +1,70 @@
+import type { InjectionToken } from "./injection-token";
+
+export type Type = new (...args: any[]) => T;
+export type AbstractType = abstract new (...args: any[]) => T;
+
+export type ProviderToken =
+ string | Type