diff --git a/.github/workflows/dart_skills_lint_release.yaml b/.github/workflows/dart_skills_lint_release.yaml new file mode 100644 index 0000000..38befa8 --- /dev/null +++ b/.github/workflows/dart_skills_lint_release.yaml @@ -0,0 +1,155 @@ +name: dart_skills_lint release +permissions: read-all + +on: + push: + tags: + - 'dart_skills_lint-v*' + +jobs: + build_binaries: + strategy: + fail-fast: false + matrix: + include: + - os: macos-14 + target: macos-arm64 + - os: macos-13 + target: macos-x64 + - os: ubuntu-22.04 + target: linux-x64 + - os: ubuntu-22.04-arm + target: linux-arm64 + runs-on: ${{ matrix.os }} + defaults: + run: + working-directory: tool/dart_skills_lint + shell: bash + steps: + - uses: actions/checkout@v6 + + - uses: dart-lang/setup-dart@v1 + with: + sdk: stable + + - name: Resolve dependencies + run: dart pub get + + - name: Compile native binary + run: | + set -euo pipefail + mkdir -p dist + dart compile exe bin/cli.dart -o "dist/dart_skills_lint-${TARGET}" + chmod +x "dist/dart_skills_lint-${TARGET}" + env: + TARGET: ${{ matrix.target }} + + - name: Smoke-test binary + run: ./dist/dart_skills_lint-${{ matrix.target }} --help + + - name: Package and hash + run: | + set -euo pipefail + cd dist + tar -czf "dart_skills_lint-${TARGET}.tar.gz" "dart_skills_lint-${TARGET}" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "dart_skills_lint-${TARGET}.tar.gz" > "dart_skills_lint-${TARGET}.tar.gz.sha256" + else + shasum -a 256 "dart_skills_lint-${TARGET}.tar.gz" > "dart_skills_lint-${TARGET}.tar.gz.sha256" + fi + env: + TARGET: ${{ matrix.target }} + + - name: Upload binary artifact + uses: actions/upload-artifact@v7 + with: + name: dart_skills_lint-${{ matrix.target }} + path: | + tool/dart_skills_lint/dist/dart_skills_lint-${{ matrix.target }}.tar.gz + tool/dart_skills_lint/dist/dart_skills_lint-${{ matrix.target }}.tar.gz.sha256 + if-no-files-found: error + retention-days: 7 + + release: + needs: build_binaries + runs-on: ubuntu-latest + permissions: + contents: write + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v6 + + - name: Download all binary artifacts + uses: actions/download-artifact@v8 + with: + path: dist + merge-multiple: true + + - name: Build SHA256SUMS aggregate file + working-directory: dist + run: | + set -euo pipefail + : > SHA256SUMS + for f in *.tar.gz.sha256; do + cat "$f" >> SHA256SUMS + done + rm -f *.tar.gz.sha256 + echo "--- SHA256SUMS ---" + cat SHA256SUMS + + - name: Extract release metadata + id: meta + run: | + set -euo pipefail + # Tag pattern: dart_skills_lint-v. Strip prefix to get the version. + TAG="${GITHUB_REF_NAME}" + VERSION="${TAG#dart_skills_lint-v}" + if [ "$VERSION" = "$TAG" ]; then + echo "ERROR: tag '$TAG' does not match expected pattern 'dart_skills_lint-v'" >&2 + exit 1 + fi + # Verify pubspec matches. + PUBSPEC_VERSION=$(awk -F': *' '/^version:/ {print $2; exit}' tool/dart_skills_lint/pubspec.yaml) + if [ "$VERSION" != "$PUBSPEC_VERSION" ]; then + echo "ERROR: tag version '$VERSION' does not match pubspec.yaml version '$PUBSPEC_VERSION'" >&2 + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + # Pre-release detection for the GitHub Release flag. + case "$VERSION" in + *-dev.*|*-alpha.*|*-beta.*|*-rc.*|*-preview.*) echo "prerelease=true" >> "$GITHUB_OUTPUT" ;; + *) echo "prerelease=false" >> "$GITHUB_OUTPUT" ;; + esac + + - name: Extract release notes from CHANGELOG + run: | + set -euo pipefail + awk -v ver="## ${VERSION}" ' + $0 == ver { found = 1; next } + /^## / && found { exit } + found { print } + ' tool/dart_skills_lint/CHANGELOG.md > release-notes.md + if [ ! -s release-notes.md ]; then + echo "ERROR: no CHANGELOG entry found for version '${VERSION}'" >&2 + echo "Expected a heading line: ## ${VERSION}" >&2 + exit 1 + fi + echo "--- release notes ---" + cat release-notes.md + env: + VERSION: ${{ steps.meta.outputs.version }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3 + with: + tag_name: ${{ github.ref_name }} + name: dart_skills_lint v${{ steps.meta.outputs.version }} + body_path: release-notes.md + prerelease: ${{ steps.meta.outputs.prerelease }} + files: | + dist/*.tar.gz + dist/SHA256SUMS + tool/dart_skills_lint/scripts/install.sh + fail_on_unmatched_files: true diff --git a/.github/workflows/dart_skills_lint_workflow.yaml b/.github/workflows/dart_skills_lint_workflow.yaml index 4cf45f9..63642f9 100644 --- a/.github/workflows/dart_skills_lint_workflow.yaml +++ b/.github/workflows/dart_skills_lint_workflow.yaml @@ -61,7 +61,7 @@ jobs: # Action steps do not inherit defaults.run.working-directory, so this # path is relative to the repository root. - name: Enforce coverage threshold - uses: VeryGoodOpenSource/very_good_coverage@v3 + uses: VeryGoodOpenSource/very_good_coverage@c953fca3e24a915e111cc6f55f03f756dcb3964c # v3 with: path: tool/dart_skills_lint/coverage/lcov.info min_coverage: 73 diff --git a/tool/dart_skills_lint/CHANGELOG.md b/tool/dart_skills_lint/CHANGELOG.md index 27337b3..7974098 100644 --- a/tool/dart_skills_lint/CHANGELOG.md +++ b/tool/dart_skills_lint/CHANGELOG.md @@ -1,3 +1,18 @@ +## 0.4.0-dev.1 + +- **Pre-release.** Pin explicitly (`dart_skills_lint: 0.4.0-dev.1`) to + try the preview — caret ranges will not auto-pick this up. +- Native binaries for macOS arm64, macOS x64, Linux x64, and Linux + arm64 are now published to GitHub Releases. Install without the + Dart SDK via `curl -fsSL .../install.sh | bash`, or download the + tarball directly and verify its SHA256. +- macOS preview binaries are not yet code-signed; clear the + quarantine flag with + `xattr -d com.apple.quarantine $(which dart_skills_lint)` on + first launch. +- The `dart pub global activate dart_skills_lint` and + `dev_dependencies:` install paths are unchanged. + ## 0.3.1 - `--fix` now writes fixes to disk; pair with `--dry-run` diff --git a/tool/dart_skills_lint/README.md b/tool/dart_skills_lint/README.md index 834816a..37b6004 100644 --- a/tool/dart_skills_lint/README.md +++ b/tool/dart_skills_lint/README.md @@ -25,25 +25,94 @@ For a full definition of the skill standard, see the [Agent Skills Specification ## Installation -Add `dart_skills_lint` to your Dart project or activate it globally. +`dart_skills_lint` ships as both a standalone native binary (no Dart +SDK required) and as a Dart package on pub.dev. Pick the path that +matches your environment. -### 1. As a project dependency -Add it to your `pubspec.yaml` (once published on pub.dev): +> **Homebrew note.** A `brew install dart-skills-lint` path is on the +> roadmap; it will land after `dart_skills_lint` migrates to its own +> dedicated repository. Until then, the install paths below cover all +> supported platforms. + +### 1. Dart developers — pub.dev + +If you already have the Dart SDK installed, the standard pub.dev paths +still work and are unchanged. + +#### As a project dev_dependency + +Add to your `pubspec.yaml`: +```yaml +dev_dependencies: + dart_skills_lint: ^0.3.0 +``` + +To opt into the preview track, pin to the exact version: ```yaml dev_dependencies: - dart_skills_lint: ^0.2.0 + dart_skills_lint: 0.4.0-dev.1 ``` -Then run: + +Then: ```bash dart pub get ``` -### 2. Globally activated -If you want to use it across multiple projects without adding it to each `pubspec.yaml`: +#### Globally activated + +For multiple projects without per-project pubspec entries: ```bash dart pub global activate dart_skills_lint ``` +### 2. `install.sh` — Linux + macOS, no Dart required + +The recommended path for CI runners and laptops without the Dart SDK +on PATH. Downloads the matching prebuilt binary from the latest GitHub +Release, verifies its SHA256, and installs to `/usr/local/bin` (with a +`sudo` fallback). Supports macOS arm64 + x64 and Linux x64 + arm64. + +```bash +curl -fsSL https://github.com/flutter/skills/releases/latest/download/install.sh | bash +``` + +Optional env vars (set before the `bash` part): +- `INSTALL_DIR` — install destination (default `/usr/local/bin`). +- `VERSION` — pin a specific release like `0.4.0-dev.1` (default `latest`). +- `REPO` — alternate source repo (default `flutter/skills`). + +#### macOS first-launch note + +Preview binaries are not yet code-signed. The first time you run the +binary, macOS Gatekeeper will block it ("cannot be opened because the +developer cannot be verified"). Remove the quarantine flag once: + +```bash +xattr -d com.apple.quarantine "$(which dart_skills_lint)" +``` + +This step goes away once notarized builds ship. + +### 3. Direct download — Linux + macOS, no install script + +For environments where piping a script to `bash` isn't acceptable. +Grab the tarball for your platform from +[the latest GitHub Release](https://github.com/flutter/skills/releases/latest) +and verify its SHA256 against the release's `SHA256SUMS` asset. + +```bash +TARGET="linux-x64" # or: macos-arm64, macos-x64, linux-arm64 +VERSION="0.4.0-dev.1" +BASE="https://github.com/flutter/skills/releases/download/dart_skills_lint-v${VERSION}" +curl -fsSLO "${BASE}/dart_skills_lint-${TARGET}.tar.gz" +curl -fsSLO "${BASE}/SHA256SUMS" +grep " dart_skills_lint-${TARGET}.tar.gz$" SHA256SUMS | sha256sum -c - +tar -xzf "dart_skills_lint-${TARGET}.tar.gz" +sudo install -m 0755 "dart_skills_lint-${TARGET}" /usr/local/bin/dart_skills_lint +``` + +On macOS, replace `sha256sum -c -` with `shasum -a 256 -c -`. + ## Usage `dart_skills_lint` runs as a command-line tool, configured by flags or by diff --git a/tool/dart_skills_lint/pubspec.yaml b/tool/dart_skills_lint/pubspec.yaml index e528b20..95be53a 100644 --- a/tool/dart_skills_lint/pubspec.yaml +++ b/tool/dart_skills_lint/pubspec.yaml @@ -3,7 +3,7 @@ description: >- A static analysis linter for Agent Skills (SKILL.md) written in Dart. Validates frontmatter, naming, paths, and structure for use in CI and pre-commit hooks. -version: 0.3.1 +version: 0.4.0-dev.1 resolution: workspace repository: https://github.com/flutter/skills issue_tracker: https://github.com/flutter/skills/issues diff --git a/tool/dart_skills_lint/scripts/install.sh b/tool/dart_skills_lint/scripts/install.sh new file mode 100755 index 0000000..d550e9e --- /dev/null +++ b/tool/dart_skills_lint/scripts/install.sh @@ -0,0 +1,159 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +# for details. All rights reserved. Use of this source code is governed by a +# BSD-style license that can be found in the LICENSE file. +# +# install.sh — Install the dart_skills_lint native binary. +# +# Usage (default repo + latest version): +# curl -fsSL https://github.com/flutter/skills/releases/latest/download/install.sh | bash +# +# Pin a specific version or alternate repo: +# curl -fsSL .../install.sh | REPO=other-org/other-repo VERSION=0.4.0-dev.1 bash +# +# Env vars: +# REPO GitHub owner/repo (default: flutter/skills). +# VERSION "latest" or a specific version like 0.4.0-dev.1 (default: latest). +# INSTALL_DIR Install destination (default: /usr/local/bin). + +set -euo pipefail + +REPO="${REPO:-flutter/skills}" +VERSION="${VERSION:-latest}" +INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" +BIN_NAME="dart_skills_lint" + +err() { echo "install.sh: error: $*" >&2; exit 1; } +info() { echo "install.sh: $*"; } + +# --- Detect platform --------------------------------------------------------- +# Single source of truth: every "Supported: ..." message and the final +# platform check derive from this list, so adding a build target only +# requires touching one constant. +SUPPORTED_TARGETS="macos-arm64 macos-x64 linux-x64 linux-arm64" +err_unsupported() { err "$1. Supported platforms: ${SUPPORTED_TARGETS// /, }."; } + +case "$(uname -s)" in + Darwin) os="macos" ;; + Linux) os="linux" ;; + *) err_unsupported "unsupported OS '$(uname -s)'" ;; +esac + +case "$(uname -m)" in + arm64|aarch64) arch="arm64" ;; + x86_64|amd64) arch="x64" ;; + *) err_unsupported "unsupported architecture '$(uname -m)'" ;; +esac + +target="${os}-${arch}" +case " $SUPPORTED_TARGETS " in + *" $target "*) ;; + *) err_unsupported "no published binary for platform '${target}'" ;; +esac + +# --- Required tools --------------------------------------------------------- +require() { command -v "$1" >/dev/null 2>&1 || err "required tool '$1' not found on PATH."; } +require curl +require tar +require awk + +if command -v sha256sum >/dev/null 2>&1; then + shasum_cmd() { sha256sum "$@"; } +elif command -v shasum >/dev/null 2>&1; then + shasum_cmd() { shasum -a 256 "$@"; } +else + err "required tools 'sha256sum' or 'shasum' not found on PATH. Install one to verify the binary." +fi + +# --- Resolve URLs ---------------------------------------------------------- +if [ "$VERSION" = "latest" ]; then + base_url="https://github.com/${REPO}/releases/latest/download" +else + tag="dart_skills_lint-v${VERSION}" + base_url="https://github.com/${REPO}/releases/download/${tag}" +fi +archive="${BIN_NAME}-${target}.tar.gz" +archive_url="${base_url}/${archive}" +sums_url="${base_url}/SHA256SUMS" + +# --- Download into a tempdir, cleaned up on exit ----------------------------- +tmpdir="$(mktemp -d "${TMPDIR:-/tmp}/dart-skills-lint-install.XXXXXX")" +# Guard trap to prevent running rm -rf on empty/unbound tmpdir if trap triggers prematurely. +trap '[ -n "${tmpdir:-}" ] && rm -rf "$tmpdir"' EXIT INT TERM + +info "downloading ${archive} from ${REPO} (${VERSION})" +curl -fsSL --retry 3 -o "${tmpdir}/${archive}" "$archive_url" \ + || err "could not download ${archive_url}" +curl -fsSL --retry 3 -o "${tmpdir}/SHA256SUMS" "$sums_url" \ + || err "could not download ${sums_url}" + +# --- Verify SHA256 ---------------------------------------------------------- +# Strip the optional leading '*' that `sha256sum -b` (binary mode) puts before +# the filename, so SHA256SUMS files from either text or binary mode work. +expected_sha="$(awk -v fname="$archive" ' + { sub(/^\*/, "", $2) } + $2 == fname { print $1; exit } +' "${tmpdir}/SHA256SUMS")" +[ -n "$expected_sha" ] || err "no SHA256 entry for '${archive}' in SHA256SUMS." + +actual_sha="$(shasum_cmd "${tmpdir}/${archive}" | awk '{print $1}')" +if [ "$expected_sha" != "$actual_sha" ]; then + err "SHA256 mismatch for ${archive}. Expected ${expected_sha}, got ${actual_sha}." +fi +info "checksum verified" + +# --- Extract ---------------------------------------------------------------- +( cd "$tmpdir" && tar -xzf "$archive" ) +extracted="${tmpdir}/${BIN_NAME}-${target}" +[ -x "$extracted" ] || err "extracted file ${extracted} not found or not executable." + +# --- Install ---------------------------------------------------------------- +install_path="${INSTALL_DIR}/${BIN_NAME}" + +needs_sudo=0 +if [ -d "$INSTALL_DIR" ]; then + [ -w "$INSTALL_DIR" ] || needs_sudo=1 +else + parent="$(dirname "$INSTALL_DIR")" + [ -d "$parent" ] && [ -w "$parent" ] || needs_sudo=1 +fi + +if [ "$needs_sudo" = "0" ]; then + mkdir -p "$INSTALL_DIR" + install -m 0755 "$extracted" "$install_path" +elif command -v sudo >/dev/null 2>&1; then + info "${INSTALL_DIR} is not writable; using sudo" + sudo mkdir -p "$INSTALL_DIR" + sudo install -m 0755 "$extracted" "$install_path" +else + err "${INSTALL_DIR} is not writable and 'sudo' is not available. Set INSTALL_DIR to a writable path and re-run." +fi + +# --- macOS Gatekeeper note (preview binaries are unsigned) ------------------ +# Print BEFORE the launch check so users see the workaround even if Gatekeeper +# blocks the --help invocation below. +if [ "$os" = "macos" ]; then + cat </dev/null 2>&1; then + info "installed ${BIN_NAME} → ${install_path}" + info "run '${BIN_NAME} --help' to get started" +elif [ "$os" = "macos" ]; then + info "installed ${BIN_NAME} → ${install_path}" + info "launch check failed — likely Gatekeeper. See the note above to clear quarantine, then run '${BIN_NAME} --help'." +else + err "installed binary at ${install_path} failed to launch." +fi diff --git a/tool/dart_skills_lint/test/install_script_test.dart b/tool/dart_skills_lint/test/install_script_test.dart new file mode 100644 index 0000000..30b07f0 --- /dev/null +++ b/tool/dart_skills_lint/test/install_script_test.dart @@ -0,0 +1,461 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'dart:io'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:test_process/test_process.dart'; + +/// A dummy SHA256 checksum that represents an invalid/corrupted release file hash. +const _corruptedHash = '0000000000000000000000000000000000000000000000000000000000000000'; + +/// A mock implementation of the `curl` command line utility. +/// +/// Simulates downloading a release artifact by copying the source file from +/// `MOCK_RELEASE_DIR` to the target output path specified by `-o`. +/// +/// It ignores other standard `curl` flags. +const _mockCurlScript = r''' +#!/bin/bash +set -eu +outfile="" +url="" +while [ $# -gt 0 ]; do + case "$1" in + -o) outfile="$2"; shift ;; + --retry) shift ;; + -*) ;; + *) url="$1" ;; + esac + shift +done +filename="$(basename "$url")" +src_file="${MOCK_RELEASE_DIR}/$filename" +if [ -f "$src_file" ]; then + cp "$src_file" "$outfile" +else + echo "mock curl: error: source file $src_file not found ($url)" >&2 + exit 1 +fi +'''; + +/// A mock implementation of the `uname` command line utility. +/// +/// Outputs the mocked OS (`MOCK_UNAME_S`) or CPU architecture (`MOCK_UNAME_M`) +/// depending on whether it is executed with `-s` or `-m` flags. +const _mockUnameScript = r''' +#!/bin/bash +if [ "$1" = "-s" ]; then + echo "${MOCK_UNAME_S:-Darwin}" +elif [ "$1" = "-m" ]; then + echo "${MOCK_UNAME_M:-arm64}" +fi +'''; + +void main() { + group('install.sh integration', () { + late Directory tempDir; + late Directory mockBinDir; + late Directory mockReleaseDir; + late Directory installDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('install_sh_test.'); + mockBinDir = await Directory(p.join(tempDir.path, 'bin')).create(); + mockReleaseDir = await Directory(p.join(tempDir.path, 'mock_release')).create(); + installDir = await Directory(p.join(tempDir.path, 'install')).create(); + + // Write mock uname script + final unameFile = File(p.join(mockBinDir.path, 'uname')); + await unameFile.writeAsString(_mockUnameScript); + final ProcessResult chmodUnameResult = await Process.run('chmod', ['+x', unameFile.path]); + expect( + chmodUnameResult.exitCode, + 0, + reason: 'chmod failed for uname mock: ${chmodUnameResult.stderr}', + ); + + // Write mock curl script + final curlFile = File(p.join(mockBinDir.path, 'curl')); + await curlFile.writeAsString(_mockCurlScript); + final ProcessResult chmodCurlResult = await Process.run('chmod', ['+x', curlFile.path]); + expect( + chmodCurlResult.exitCode, + 0, + reason: 'chmod failed for curl mock: ${chmodCurlResult.stderr}', + ); + }); + + tearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + /// Simulates a packaged GitHub release asset by writing a dummy binary, + /// compressing it to a `.tar.gz` archive in the mock release directory, + /// and generating the corresponding `SHA256SUMS` checksum file. + /// + /// If [shouldCorruptHash] is true, the `SHA256SUMS` file will be written with + /// an invalid hash to test checksum verification failure paths. + Future createMockRelease({ + required String os, + required String arch, + required String binaryContent, + bool shouldCorruptHash = false, + }) async { + final target = '$os-$arch'; + final binaryName = 'dart_skills_lint-$target'; + final archiveName = 'dart_skills_lint-$target.tar.gz'; + + // Create dummy binary file + final dummyBin = File(p.join(tempDir.path, binaryName)); + await dummyBin.writeAsString(binaryContent); + final ProcessResult chmodBinResult = await Process.run('chmod', ['+x', dummyBin.path]); + expect( + chmodBinResult.exitCode, + 0, + reason: 'chmod failed for dummy binary: ${chmodBinResult.stderr}', + ); + + // Package it into tar.gz + final ProcessResult tarResult = await Process.run('tar', [ + '-czf', + p.join(mockReleaseDir.path, archiveName), + '-C', + tempDir.path, + binaryName, + ]); + expect(tarResult.exitCode, 0, reason: 'tar packaging failed: ${tarResult.stderr}'); + + // Get SHA256 sum + var hash = ''; + // TODO(reidbaker): Re-add CertUtil checksum verification for Windows hosts. https://github.com/flutter/skills/issues/164 + final ProcessResult shaProcess = await Process.run('shasum', [ + '-a', + '256', + p.join(mockReleaseDir.path, archiveName), + ]); + if (shaProcess.exitCode == 0) { + hash = shaProcess.stdout.toString().trim().split(' ')[0]; + } else { + final ProcessResult sha256Process = await Process.run('sha256sum', [ + p.join(mockReleaseDir.path, archiveName), + ]); + if (sha256Process.exitCode == 0) { + hash = sha256Process.stdout.toString().trim().split(' ')[0]; + } + } + + if (hash.isEmpty) { + throw StateError('Could not calculate SHA256 hash using shasum or sha256sum.'); + } + + final String finalHash = shouldCorruptHash ? _corruptedHash : hash; + + final sha256sums = File(p.join(mockReleaseDir.path, 'SHA256SUMS')); + await sha256sums.writeAsString('$finalHash $archiveName\n'); + } + + Future runInstallScriptTest({ + required String os, + required String arch, + required String mockUnameS, + required String mockUnameM, + required bool simulateLaunchFailure, + required int expectedExitCode, + required bool expectInstalled, + }) async { + const version = '0.4.0-test'; + final binaryContent = simulateLaunchFailure + ? '#!/usr/bin/env bash\nexit 1\n' + : '#!/usr/bin/env bash\necho "mock-cli-help"\n'; + + await createMockRelease(os: os, arch: arch, binaryContent: binaryContent); + + // TODO(reidbaker): Use Windows path separator (;) when running on Windows hosts. https://github.com/flutter/skills/issues/164 + final newPath = '${mockBinDir.path}:${Platform.environment['PATH']}'; + final String packageRoot = _getPackageRoot(); + final String scriptPath = p.join(packageRoot, 'scripts', 'install.sh'); + + final TestProcess process = await TestProcess.start( + 'bash', + [scriptPath], + environment: { + 'PATH': newPath, + 'MOCK_UNAME_S': mockUnameS, + 'MOCK_UNAME_M': mockUnameM, + 'MOCK_RELEASE_DIR': mockReleaseDir.path, + 'INSTALL_DIR': installDir.path, + 'VERSION': version, + }, + ); + + await process.shouldExit(expectedExitCode); + + final installedFile = File(p.join(installDir.path, 'dart_skills_lint')); + expect(installedFile.existsSync(), equals(expectInstalled)); + + if (expectInstalled && expectedExitCode == 0) { + if (simulateLaunchFailure) { + final List stdout = await process.stdout.rest.toList(); + expect( + stdout.any((line) => line.contains('launch check failed — likely Gatekeeper')), + isTrue, + ); + } else { + final ProcessResult runResult = await Process.run(installedFile.path, ['--help']); + expect(runResult.stdout.toString().trim(), equals('mock-cli-help')); + } + } else if (expectedExitCode == 1 && simulateLaunchFailure) { + final List stderr = await process.stderr.rest.toList(); + expect(stderr.any((line) => line.contains('failed to launch')), isTrue); + } + } + + test('successful installation on macos-arm64', () async { + await runInstallScriptTest( + os: 'macos', + arch: 'arm64', + mockUnameS: 'Darwin', + mockUnameM: 'arm64', + simulateLaunchFailure: false, + expectedExitCode: 0, + expectInstalled: true, + ); + }); + + test('successful installation on linux-x64', () async { + await runInstallScriptTest( + os: 'linux', + arch: 'x64', + mockUnameS: 'Linux', + mockUnameM: 'x86_64', + simulateLaunchFailure: false, + expectedExitCode: 0, + expectInstalled: true, + ); + }); + + test('fails on linux if installed binary fails launch check', () async { + await runInstallScriptTest( + os: 'linux', + arch: 'x64', + mockUnameS: 'Linux', + mockUnameM: 'x86_64', + simulateLaunchFailure: true, + expectedExitCode: 1, + expectInstalled: true, + ); + }); + + test('succeeds on macos even if installed binary fails launch check', () async { + await runInstallScriptTest( + os: 'macos', + arch: 'arm64', + mockUnameS: 'Darwin', + mockUnameM: 'arm64', + simulateLaunchFailure: true, + expectedExitCode: 0, + expectInstalled: true, + ); + }); + + test('fails if checksum mismatch', () async { + const version = '0.4.0-test'; + await createMockRelease( + os: 'macos', + arch: 'arm64', + binaryContent: 'dummy', + shouldCorruptHash: true, + ); + + // TODO(reidbaker): Use Windows path separator (;) when running on Windows hosts. https://github.com/flutter/skills/issues/164 + final newPath = '${mockBinDir.path}:${Platform.environment['PATH']}'; + final String packageRoot = _getPackageRoot(); + final String scriptPath = p.join(packageRoot, 'scripts', 'install.sh'); + + final TestProcess process = await TestProcess.start( + 'bash', + [scriptPath], + environment: { + 'PATH': newPath, + 'MOCK_UNAME_S': 'Darwin', + 'MOCK_UNAME_M': 'arm64', + 'MOCK_RELEASE_DIR': mockReleaseDir.path, + 'INSTALL_DIR': installDir.path, + 'VERSION': version, + }, + ); + + final List stderr = await process.stderr.rest.toList(); + expect(stderr.any((line) => line.contains('SHA256 mismatch')), isTrue); + await process.shouldExit(1); + }); + + group('missing required tools', () { + final requiredTools = ['curl', 'tar', 'awk']; + + for (var i = 0; i < requiredTools.length; i++) { + final String missingTool = requiredTools[i]; + + test('fails if required tool $missingTool is missing', () async { + // Create a directory containing mock uname and all required tools before this one + final Directory testBinDir = await Directory( + p.join(tempDir.path, 'bin_$missingTool'), + ).create(); + + // Always copy mock uname + final mockUnameFile = File(p.join(mockBinDir.path, 'uname')); + await mockUnameFile.copy(p.join(testBinDir.path, 'uname')); + final ProcessResult chmodUnameResult = await Process.run('chmod', [ + '+x', + p.join(testBinDir.path, 'uname'), + ]); + expect(chmodUnameResult.exitCode, 0); + + // Copy all mock tools prior to this one in the dependency order + for (var j = 0; j < i; j++) { + final String toolToCopy = requiredTools[j]; + if (toolToCopy == 'curl') { + final mockCurlFile = File(p.join(mockBinDir.path, 'curl')); + await mockCurlFile.copy(p.join(testBinDir.path, 'curl')); + final ProcessResult chmodCurlResult = await Process.run('chmod', [ + '+x', + p.join(testBinDir.path, 'curl'), + ]); + expect(chmodCurlResult.exitCode, 0); + } else { + // Write a dummy script for other tools (like tar) so they pass command -v check + final dummyFile = File(p.join(testBinDir.path, toolToCopy)); + await dummyFile.writeAsString('#!/bin/bash\nexit 0\n'); + final ProcessResult chmodDummyResult = await Process.run('chmod', [ + '+x', + dummyFile.path, + ]); + expect(chmodDummyResult.exitCode, 0); + } + } + + final String packageRoot = _getPackageRoot(); + final String scriptPath = p.join(packageRoot, 'scripts', 'install.sh'); + + final TestProcess process = await TestProcess.start( + '/bin/bash', + [scriptPath], + environment: { + 'PATH': testBinDir.path, + 'MOCK_UNAME_S': 'Darwin', + 'MOCK_UNAME_M': 'arm64', + 'INSTALL_DIR': installDir.path, + }, + ); + + final List stderr = await process.stderr.rest.toList(); + expect( + stderr.any((line) => line.contains("required tool '$missingTool' not found on PATH")), + isTrue, + ); + await process.shouldExit(1); + }); + } + + test('fails if both sha256sum and shasum are missing', () async { + final Directory testBinDir = await Directory(p.join(tempDir.path, 'bin_no_hash')).create(); + + // Copy mock uname, curl, and dummy tar, awk + final mockUnameFile = File(p.join(mockBinDir.path, 'uname')); + await mockUnameFile.copy(p.join(testBinDir.path, 'uname')); + final ProcessResult chmodUname = await Process.run('chmod', [ + '+x', + p.join(testBinDir.path, 'uname'), + ]); + expect(chmodUname.exitCode, 0); + + final mockCurlFile = File(p.join(mockBinDir.path, 'curl')); + await mockCurlFile.copy(p.join(testBinDir.path, 'curl')); + final ProcessResult chmodCurl = await Process.run('chmod', [ + '+x', + p.join(testBinDir.path, 'curl'), + ]); + expect(chmodCurl.exitCode, 0); + + final dummyTar = File(p.join(testBinDir.path, 'tar')); + await dummyTar.writeAsString('#!/bin/bash\nexit 0\n'); + final ProcessResult chmodTar = await Process.run('chmod', ['+x', dummyTar.path]); + expect(chmodTar.exitCode, 0); + + final dummyAwk = File(p.join(testBinDir.path, 'awk')); + await dummyAwk.writeAsString('#!/bin/bash\nexit 0\n'); + final ProcessResult chmodAwk = await Process.run('chmod', ['+x', dummyAwk.path]); + expect(chmodAwk.exitCode, 0); + + final String packageRoot = _getPackageRoot(); + final String scriptPath = p.join(packageRoot, 'scripts', 'install.sh'); + + final TestProcess process = await TestProcess.start( + '/bin/bash', + [scriptPath], + environment: { + 'PATH': testBinDir.path, + 'MOCK_UNAME_S': 'Darwin', + 'MOCK_UNAME_M': 'arm64', + 'INSTALL_DIR': installDir.path, + }, + ); + + final List stderr = await process.stderr.rest.toList(); + expect( + stderr.any( + (line) => + line.contains('sha256sum') && line.contains('shasum') && line.contains('not found'), + ), + isTrue, + ); + await process.shouldExit(1); + }); + }); + + test('fails on unsupported architecture', () async { + // TODO(reidbaker): Use Windows path separator (;) when running on Windows hosts. https://github.com/flutter/skills/issues/164 + final newPath = '${mockBinDir.path}:${Platform.environment['PATH']}'; + final String packageRoot = _getPackageRoot(); + final String scriptPath = p.join(packageRoot, 'scripts', 'install.sh'); + + final TestProcess process = await TestProcess.start( + 'bash', + [scriptPath], + environment: { + 'PATH': newPath, + 'MOCK_UNAME_S': 'Linux', + 'MOCK_UNAME_M': 'i386', + 'INSTALL_DIR': installDir.path, + }, + ); + + final List stderr = await process.stderr.rest.toList(); + expect(stderr.any((line) => line.contains('unsupported architecture')), isTrue); + await process.shouldExit(1); + }); + // TODO(reidbaker): Support running install.sh tests on Windows hosts. https://github.com/flutter/skills/issues/164 + }, skip: Platform.isWindows ? 'install.sh is not supported on Windows' : null); +} + +String _getPackageRoot() { + final String currentPath = Directory.current.path; + var dir = Directory(currentPath); + while (dir.path != '/' && dir.path.isNotEmpty) { + final pubspec = File(p.join(dir.path, 'pubspec.yaml')); + if (pubspec.existsSync() && pubspec.readAsStringSync().contains('name: dart_skills_lint')) { + return dir.path; + } + dir = dir.parent; + } + // Fallback to searching subdirectories + final subdir = Directory(p.join(currentPath, 'tool', 'dart_skills_lint')); + if (subdir.existsSync()) { + return subdir.path; + } + return currentPath; +}