diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index bb0c804..bc5b4c7 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -2,8 +2,8 @@ name: Pull Request Validation on: pull_request: - branches: [ main ] - types: [ opened, synchronize, reopened, ready_for_review ] + branches: [main] + types: [opened, synchronize, reopened, ready_for_review] permissions: contents: read @@ -19,9 +19,6 @@ jobs: uses: helpers4/action/conventional-commits@main with: checkout: "true" - scopes: "angular-dev|auto-header|CI-CD|deprecated|deps|deps-dev|dotfiles-sync|essential-dev|g\ - it-absorb|github-dev|package-auto-install|peon-ping|shell-history-p\ - er-project|typescript-dev|vite-plus" pr-comment: "error" - name: Set status @@ -106,8 +103,8 @@ jobs: ~/.aws/config ~/.kube/config ~/.docker/config.json - name: "Test feature '${{ matrix.features }}' against '${{ matrix.baseImage }}'" - run: devcontainer features test --features ${{ matrix.features }} --base-image - ${{ matrix.baseImage }} . + run: | + devcontainer features test --features ${{ matrix.features }} --base-image ${{ matrix.baseImage }} . shellcheck: runs-on: ubuntu-latest @@ -134,7 +131,7 @@ jobs: pr-comment: runs-on: ubuntu-latest - needs: [ conventional-commits, test-features, shellcheck ] + needs: [conventional-commits, test-features, shellcheck] if: always() steps: - name: Update PR comment diff --git a/.vscode/copilot-commit.md b/.vscode/copilot-commit.md new file mode 100644 index 0000000..a7b8c3d --- /dev/null +++ b/.vscode/copilot-commit.md @@ -0,0 +1,46 @@ + + + +Commit messages for the **devcontainer** repository must use Conventional Commits + gitmoji: + +`(): ` + +**Allowed scopes** (pick one, or omit the scope entirely): +- `angular-dev` +- `auto-header` +- `deps` +- `deps-dev` +- `CI-CD` +- `deprecated` +- `dotfiles-sync` +- `essential-dev` +- `git-absorb` +- `github-dev` +- `package-auto-install` +- `peon-ping` +- `shell-history-per-project` +- `typescript-dev` +- `vite-plus` + +Never invent a scope that is not in the list above. + +**Type โ†’ gitmoji** โ€” pick the most specific emoji that fits the change: + +| Type | Primary | More specific alternatives | +|------|---------|---------------------------| +| `feat` | โœจ | ๐Ÿšธ UX ยท โ™ฟ๏ธ a11y ยท ๐ŸŒ i18n ยท ๐Ÿ’ฌ text/literals | +| `fix` | ๐Ÿ› | ๐Ÿš‘๏ธ hotfix ยท ๐Ÿ”’๏ธ security ยท ๐Ÿฉน trivial ยท ๐Ÿฅ… caught errors ยท ๐Ÿšจ linter warnings ยท โœ๏ธ typo | +| `docs` | ๐Ÿ“ | ๐Ÿ’ก source comments ยท ๐Ÿ“„ license | +| `refactor` | โ™ป๏ธ | ๐ŸŽจ structure ยท ๐Ÿ”ฅ remove code ยท โšฐ๏ธ dead code ยท ๐Ÿšš move/rename | +| `test` | โœ… | ๐Ÿงช add failing test ยท ๐Ÿ’š fix CI test | +| `chore` | ๐Ÿ”ง | ๐Ÿ”– tag/release ยท ๐Ÿ“Œ pin deps ยท ๐Ÿฉบ healthcheck ยท ๐Ÿ™ˆ gitignore | +| `perf` | โšก๏ธ | | +| `style` | ๐Ÿ’„ | ๐ŸŽจ code style | +| `ci` | ๐Ÿ‘ท | ๐Ÿ’š fix CI | +| `build` | ๐Ÿ“ฆ๏ธ | โž• add dep ยท โž– remove dep ยท โฌ†๏ธ upgrade dep ยท โฌ‡๏ธ downgrade dep | +| `revert` | โช๏ธ | | + +**Rules:** +- Always include exactly **one** emoji, placed between `:` and the description +- Description: โ‰ค72 chars ยท English ยท lowercase ยท imperative mood ยท no trailing period +- Multiple logical changes โ†’ keep the dominant type in the subject line and use a bullet list in the body diff --git a/.vscode/settings.json b/.vscode/settings.json index f31cedd..d4a1a8d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,5 +15,10 @@ "shell-history-per-project", "typescript-dev", "vite-plus" + ], + "github.copilot.chat.commitMessageGeneration.instructions": [ + { + "file": ".vscode/copilot-commit.md" + } ] } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 7f90de3..cb78097 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ Follow [Conventional Commits](https://www.conventionalcommits.org/) with a gitmo **Format:** `(): ` -**Scopes:** angular-dev, auto-header, dotfiles-sync, essential-dev, git-absorb, github-dev, package-auto-install, peon-ping, shell-history-per-project, typescript-dev, vite-plus, CI-CD +**Scopes:** defined in `.vscode/settings.json` (`conventionalCommits.scopes`) | Type | Primary | Alternatives (gitmoji.dev) | When to use | |------|---------|---------------------------|-------------| diff --git a/src/dotfiles-sync/README.md b/src/dotfiles-sync/README.md index 4badab5..7875fc0 100644 --- a/src/dotfiles-sync/README.md +++ b/src/dotfiles-sync/README.md @@ -33,7 +33,7 @@ That's it. The feature auto-detects the environment and adapts its behavior. | Option | Type | Default | Description | |--------|------|---------|-------------| | `username` | string | `node` | Container username that receives synchronized config files | -| `syncGhAuth` | boolean | `false` | Copy `~/.config/gh/hosts.yml` (GitHub OAuth token used by `gh` CLI) into the container's `$HOME`. Skipped on cloud environments (Codespaces / Gitpod / DevPod inject their own token). When `false`, the file is bind-mounted into `/tmp/dotfiles-sync` (Feature `mounts` cannot be conditional) but **never copied** to `$HOME` and never read by anything else. Prefer the [`github-dev`](../github-dev/README.md) feature with `GH_TOKEN` for fine-grained PATs. | +| `syncGhAuth` | boolean | `false` | Copy `~/.config/gh/hosts.yml` (GitHub OAuth token used by `gh` CLI) into the container's `$HOME`. Skipped on cloud environments (Codespaces / Gitpod / DevPod inject their own token). When `false`, the file is bind-mounted into `/mnt/h4dotfiles` (Feature `mounts` cannot be conditional) but **never copied** to `$HOME` and never read by anything else. Prefer the [`github-dev`](../github-dev/README.md) feature with `GH_TOKEN` for fine-grained PATs. | | `syncAwsConfig` | boolean | `false` | Sync `~/.aws/config` (profiles only โ€” `~/.aws/credentials` is **never** synced). | | `syncKubeConfig` | boolean | `false` | Sync `~/.kube/config` (cluster credentials and tokens). Skipped on cloud environments. | | `syncDockerConfig` | boolean | `false` | Sync `~/.docker/config.json` (registry auth tokens). Skipped on cloud environments. | @@ -62,7 +62,7 @@ That's it. The feature auto-detects the environment and adapts its behavior. | Local Path | Option | Notes | |------------|--------|-------| -| `~/.config/gh/hosts.yml` | `syncGhAuth` | GitHub OAuth token used by `gh`. The file is bind-mounted into `/tmp/dotfiles-sync` unconditionally (Feature `mounts` cannot be gated on options) but **only copied to `$HOME` when `syncGhAuth: true`**. Skipped on cloud environments. For fine-grained PATs, prefer [`github-dev`](../github-dev/README.md) + `GH_TOKEN`. | +| `~/.config/gh/hosts.yml` | `syncGhAuth` | GitHub OAuth token used by `gh`. The file is bind-mounted into `/mnt/h4dotfiles` unconditionally (Feature `mounts` cannot be gated on options) but **only copied to `$HOME` when `syncGhAuth: true`**. Skipped on cloud environments. For fine-grained PATs, prefer [`github-dev`](../github-dev/README.md) + `GH_TOKEN`. | | `~/.aws/config` | `syncAwsConfig` | AWS profiles. `~/.aws/credentials` (long-lived access keys) is **not bind-mounted** and never synced. | | `~/.kube/config` | `syncKubeConfig` | Kubernetes cluster credentials. Skipped on cloud environments. | | `~/.docker/config.json` | `syncDockerConfig` | Docker registry auth tokens. Skipped on cloud environments. | @@ -104,7 +104,7 @@ That's it. The feature auto-detects the environment and adapts its behavior. The token is copied with `chmod 600` and only if `~/.config/gh/hosts.yml` does not already exist in the container. Skipped on Codespaces / Gitpod / DevPod (the platform injects its own token). - **Security note** โ€” because DevContainer Feature `mounts` cannot be conditional on options, `~/.config/gh/hosts.yml` is bind-mounted into `/tmp/dotfiles-sync/.config/gh/hosts.yml` whether or not you opt in. Nothing reads that path unless `syncGhAuth: true`, but if your threat model considers any in-container exposure unacceptable, use approach 1 or 3 instead. + **Security note** โ€” because DevContainer Feature `mounts` cannot be conditional on options, `~/.config/gh/hosts.yml` is bind-mounted into `/mnt/h4dotfiles/.config/gh/hosts.yml` whether or not you opt in. Nothing reads that path unless `syncGhAuth: true`, but if your threat model considers any in-container exposure unacceptable, use approach 1 or 3 instead. 3. **`gh auth login` inside the container** โ€” token stays in the container only. @@ -175,7 +175,7 @@ fi Socket detection priority at runtime: -1. Stable socket (`/tmp/dotfiles-sync/.ssh/agent.sock`) if mounted +1. Stable socket (`/mnt/h4dotfiles/.ssh/agent.sock`) if mounted 2. VS Code native forwarding (`$SSH_AUTH_SOCK`) 3. Legacy `/ssh-agent` @@ -193,7 +193,7 @@ Works out of the box. Docker Desktop resolves WSL paths transparently. Works in most cases. Docker Desktop automatically translates `C:\Users\` paths from bind mounts into the container. However: -- **`HOME` must be defined** on the host. Most Windows setups have it, but if only `USERPROFILE` is set (no `HOME`), the bind mounts will silently fail โ€” the staging directory `/tmp/dotfiles-sync/` will be empty and no files will be synced. In that case, add `HOME` to your environment variables with the same value as `USERPROFILE`. +- **`HOME` must be defined** on the host. Most Windows setups have it, but if only `USERPROFILE` is set (no `HOME`), the bind mounts will silently fail โ€” the staging directory `/mnt/h4dotfiles/` will be empty and no files will be synced. In that case, add `HOME` to your environment variables with the same value as `USERPROFILE`. - **CRLF line endings** โ€” if `core.autocrlf=true` is set on your Windows Git install, `.gitconfig` and `.npmrc` on disk may contain CRLF. The `.gitconfig` merge uses `git config --list` which normalizes line endings correctly. The `.npmrc` merge reads the file line-by-line via bash which also handles CRLF, but extra `\r` characters may appear in values โ€” if npm auth fails, run `dos2unix ~/.npmrc` inside the container. - **SSH agent forwarding** โ€” Docker Desktop does not forward the Windows OpenSSH agent socket into containers. SSH auth inside the container will rely on copied key files (`.ssh/id_*`) rather than a live agent. `ssh-add -l` will likely show `Could not open a connection to your authentication agent` โ€” this is expected. Key-based operations (git clone, push) will still work via the copied keys. @@ -253,6 +253,7 @@ ssh-add -l ## Version History -- **v1.0.2**: Added `syncGhAuth` opt-in to copy `~/.config/gh/hosts.yml` (GitHub OAuth token used by `gh` CLI) into `$HOME`. Default `false`, skipped on cloud environments. The file is bind-mounted into `/tmp/dotfiles-sync` regardless (Feature `mounts` cannot be conditional) but only copied to `$HOME` when the option is enabled. For fine-grained PATs prefer the `github-dev` feature with `GH_TOKEN`. +- **v1.0.3**: Fixed incompatibility with `docker-in-docker` feature โ€” staging directory moved from `/tmp/dotfiles-sync/` to `/mnt/h4dotfiles/` to avoid being hidden by the tmpfs that `docker-in-docker` mounts on `/tmp` at container start. +- **v1.0.2**: Added `syncGhAuth` opt-in to copy `~/.config/gh/hosts.yml` (GitHub OAuth token used by `gh` CLI) into `$HOME`. Default `false`, skipped on cloud environments. The file is bind-mounted into `/tmp/dotfiles-sync/` regardless (Feature `mounts` cannot be conditional) but only copied to `$HOME` when the option is enabled. For fine-grained PATs prefer the `github-dev` feature with `GH_TOKEN`. - **v1.0.1**: Stop bind-mounting the `~/.config/gh` directory. Only `~/.config/gh/config.yml` (CLI preferences) is mounted. Added 3 opt-in booleans for sensitive files: `syncAwsConfig`, `syncKubeConfig`, `syncDockerConfig` โ€” all default `false` and skipped on cloud environments. Added low-risk dotfiles (gitignore_global, git/ignore, git/attributes, yarnrc.yml, pnpm/rc, cargo/config.toml, pip/pip.conf) with copy-if-absent strategy. `~/.aws/credentials` is never bind-mounted. - **v1.0.0**: Initial release โ€” successor to `local-mounts`. Multi-environment detection (macOS, Linux, WSL, Codespaces, Gitpod, DevPod), merge strategy for all config files, GPG skip on cloud environments, configurable source paths. diff --git a/src/dotfiles-sync/devcontainer-feature.json b/src/dotfiles-sync/devcontainer-feature.json index ce36971..8eaf6a5 100644 --- a/src/dotfiles-sync/devcontainer-feature.json +++ b/src/dotfiles-sync/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "dotfiles-sync", - "version": "1.0.2", + "version": "1.0.3", "name": "Dotfiles Sync", "description": "Syncs local Git, SSH, GPG, npm, gh CLI prefs, cargo, pip, pnpm/yarn config files into the devcontainer. Optionally syncs cloud credentials (AWS, kube, Docker, gh OAuth token) โ€” opt-in only. Works on macOS, Linux, Windows (WSL), Codespaces, Gitpod, DevPod. Merges instead of overwriting.", "documentationURL": "https://github.com/helpers4/devcontainer/tree/main/src/dotfiles-sync", @@ -34,77 +34,77 @@ "mounts": [ { "source": "${localEnv:HOME}/.gitconfig", - "target": "/tmp/dotfiles-sync/.gitconfig", + "target": "/mnt/h4dotfiles/.gitconfig", "type": "bind" }, { "source": "${localEnv:HOME}/.gitignore_global", - "target": "/tmp/dotfiles-sync/.gitignore_global", + "target": "/mnt/h4dotfiles/.gitignore_global", "type": "bind" }, { "source": "${localEnv:HOME}/.config/git", - "target": "/tmp/dotfiles-sync/.config/git", + "target": "/mnt/h4dotfiles/.config/git", "type": "bind" }, { "source": "${localEnv:HOME}/.ssh", - "target": "/tmp/dotfiles-sync/.ssh", + "target": "/mnt/h4dotfiles/.ssh", "type": "bind" }, { "source": "${localEnv:HOME}/.gnupg", - "target": "/tmp/dotfiles-sync/.gnupg", + "target": "/mnt/h4dotfiles/.gnupg", "type": "bind" }, { "source": "${localEnv:HOME}/.npmrc", - "target": "/tmp/dotfiles-sync/.npmrc", + "target": "/mnt/h4dotfiles/.npmrc", "type": "bind" }, { "source": "${localEnv:HOME}/.yarnrc.yml", - "target": "/tmp/dotfiles-sync/.yarnrc.yml", + "target": "/mnt/h4dotfiles/.yarnrc.yml", "type": "bind" }, { "source": "${localEnv:HOME}/.config/pnpm/rc", - "target": "/tmp/dotfiles-sync/.config/pnpm/rc", + "target": "/mnt/h4dotfiles/.config/pnpm/rc", "type": "bind" }, { "source": "${localEnv:HOME}/.config/gh/config.yml", - "target": "/tmp/dotfiles-sync/.config/gh/config.yml", + "target": "/mnt/h4dotfiles/.config/gh/config.yml", "type": "bind" }, { "source": "${localEnv:HOME}/.config/gh/hosts.yml", - "target": "/tmp/dotfiles-sync/.config/gh/hosts.yml", + "target": "/mnt/h4dotfiles/.config/gh/hosts.yml", "type": "bind" }, { "source": "${localEnv:HOME}/.cargo/config.toml", - "target": "/tmp/dotfiles-sync/.cargo/config.toml", + "target": "/mnt/h4dotfiles/.cargo/config.toml", "type": "bind" }, { "source": "${localEnv:HOME}/.config/pip/pip.conf", - "target": "/tmp/dotfiles-sync/.config/pip/pip.conf", + "target": "/mnt/h4dotfiles/.config/pip/pip.conf", "type": "bind" }, { "source": "${localEnv:HOME}/.aws/config", - "target": "/tmp/dotfiles-sync/.aws/config", + "target": "/mnt/h4dotfiles/.aws/config", "type": "bind" }, { "source": "${localEnv:HOME}/.kube/config", - "target": "/tmp/dotfiles-sync/.kube/config", + "target": "/mnt/h4dotfiles/.kube/config", "type": "bind" }, { "source": "${localEnv:HOME}/.docker/config.json", - "target": "/tmp/dotfiles-sync/.docker/config.json", + "target": "/mnt/h4dotfiles/.docker/config.json", "type": "bind" } ], @@ -115,4 +115,4 @@ "installsAfter": [ "ghcr.io/devcontainers/features/common-utils" ] -} +} \ No newline at end of file diff --git a/src/dotfiles-sync/install.sh b/src/dotfiles-sync/install.sh index 65b2644..5823493 100755 --- a/src/dotfiles-sync/install.sh +++ b/src/dotfiles-sync/install.sh @@ -15,7 +15,7 @@ SYNC_GH_AUTH="${_BUILD_ARG_DOTFILES_SYNC_SYNCGHAUTH:-"${SYNCGHAUTH:-"false"}"}" SYNC_AWS_CONFIG="${_BUILD_ARG_DOTFILES_SYNC_SYNCAWSCONFIG:-"${SYNCAWSCONFIG:-"false"}"}" SYNC_KUBE_CONFIG="${_BUILD_ARG_DOTFILES_SYNC_SYNCKUBECONFIG:-"${SYNCKUBECONFIG:-"false"}"}" SYNC_DOCKER_CONFIG="${_BUILD_ARG_DOTFILES_SYNC_SYNCDOCKERCONFIG:-"${SYNCDOCKERCONFIG:-"false"}"}" -SOURCE_HOME="/tmp/dotfiles-sync" +SOURCE_HOME="/mnt/h4dotfiles" # Resolve target home robustly if getent passwd "${USERNAME}" >/dev/null 2>&1; then @@ -96,7 +96,7 @@ cat > /etc/profile.d/dotfiles-sync-ssh.sh << 'PROFILE_EOF' # dotfiles-sync: SSH agent socket detection (runtime) # ssh-add -l exits: 0 = keys loaded, 1 = no keys, 2 = cannot connect # Accept 0 and 1 (agent alive), reject only 2 (dead/missing agent) -_DOTFILES_SYNC_SSH_SOCK="/tmp/dotfiles-sync/.ssh/agent.sock" +_DOTFILES_SYNC_SSH_SOCK="/mnt/h4dotfiles/.ssh/agent.sock" _dotfiles_sync_ssh_responds() { local _rc=0 @@ -126,8 +126,8 @@ echo "SSH agent detection installed (/etc/profile.d/dotfiles-sync-ssh.sh)" cat > /etc/profile.d/dotfiles-sync-sync.sh << 'PROFILE_EOF' # dotfiles-sync: one-time file sync fallback # Runs sync on first shell if postStartCommand hasn't completed yet -_DOTFILES_SYNC_MARKER="/tmp/.dotfiles-sync-synced" -if [ ! -f "$_DOTFILES_SYNC_MARKER" ] && [ -d "/tmp/dotfiles-sync" ]; then +_DOTFILES_SYNC_MARKER="/mnt/h4dotfiles/.synced" +if [ ! -f "$_DOTFILES_SYNC_MARKER" ] && [ -d "/mnt/h4dotfiles" ]; then /usr/local/share/dotfiles-sync/sync-files.sh 2>/dev/null || true fi unset _DOTFILES_SYNC_MARKER diff --git a/src/dotfiles-sync/sync-files.sh b/src/dotfiles-sync/sync-files.sh index 1ea23e8..6b4bb22 100755 --- a/src/dotfiles-sync/sync-files.sh +++ b/src/dotfiles-sync/sync-files.sh @@ -432,6 +432,6 @@ if [ "$(id -u)" -eq 0 ] && getent passwd "${USERNAME}" >/dev/null 2>&1; then fi # Signal sync completed (used by profile.d fallback) -touch /tmp/.dotfiles-sync-synced 2>/dev/null || true +touch /mnt/h4dotfiles/.synced 2>/dev/null || true echo "dotfiles-sync: sync complete" diff --git a/src/package-auto-install/README.md b/src/package-auto-install/README.md index 6a72eb7..fa80bba 100644 --- a/src/package-auto-install/README.md +++ b/src/package-auto-install/README.md @@ -10,6 +10,7 @@ Automatically detects and runs npm/yarn/pnpm install in non-interactive mode aft - **Smart command selection**: Uses `npm ci`, `pnpm install --frozen-lockfile`, or `yarn install --immutable` when lockfiles exist - **Flexible configuration**: Override package manager, command, and working directory - **Skip if exists**: Optionally skip installation if node_modules already exists +- **Multi-root support**: Auto-discover VS Code/Cursor `.code-workspace` and IntelliJ `.idea/modules.xml` project files to install across all workspace folders ## Usage @@ -46,9 +47,11 @@ If you have this in your devcontainer.json, you can now remove it: |--------|------|---------|-------------| | `command` | string | `auto` | Installation command: `install`, `ci`, or `auto` to detect | | `packageManager` | string | `auto` | Package manager: `npm`, `yarn`, `pnpm`, or `auto` to detect | -| `workingDirectory` | string | `/workspaces/${localWorkspaceFolderBasename}` | Directory where to run install | +| `workingDirectory` | string | `/workspaces/${localWorkspaceFolderBasename}` | Directory where to run install. Overridden by `directories`. Used as fallback scan root when `autoDiscover` finds no workspace files. | | `skipIfNodeModulesExists` | boolean | `false` | Skip if node_modules exists | | `additionalArgs` | string | `""` | Additional arguments for install command | +| `directories` | string | `""` | Comma-separated list of directories to install in. Overrides `workingDirectory` and `autoDiscover`. | +| `autoDiscover` | boolean | `false` | Scan `/workspaces` for VS Code/Cursor `.code-workspace` and IntelliJ `.idea/modules.xml` files and install in every discovered folder with a `package.json`. | ## Examples @@ -113,6 +116,42 @@ If you have this in your devcontainer.json, you can now remove it: } ``` +### VS Code / Cursor multi-root workspace + +When your devcontainer uses a `.code-workspace` file with multiple folders, enable `autoDiscover` to install packages in every discovered folder: + +```json +{ + "features": { + "ghcr.io/helpers4/devcontainer/package-auto-install:1": { + "autoDiscover": true + } + } +} +``` + +The feature scans `/workspaces` (depth 3) for `*.code-workspace` files, parses the `folders[].path` array, resolves relative paths, and runs the appropriate package manager in each folder that contains a `package.json`. Each folder may use a different package manager โ€” detection runs independently per folder. + +### IntelliJ IDEA multi-module project + +`autoDiscover: true` also scans for `.idea/modules.xml` files (depth 4) and extracts module root directories from the `filepath` attributes. + +### Explicit list of directories + +For any IDE that does not have a parseable workspace file (Zed, Neovim, etc.) or when you want precise control: + +```json +{ + "features": { + "ghcr.io/helpers4/devcontainer/package-auto-install:1": { + "directories": "/workspaces/frontend,/workspaces/backend,/workspaces/shared" + } + } +} +``` + +`directories` takes precedence over both `workingDirectory` and `autoDiscover`. + ## How It Works Corepack Support (Node 24+) @@ -199,13 +238,11 @@ You can manually run the installation script: /usr/local/bin/devcontainer-package-install ``` -## Future Enhancements +## Version History -Planned features: -- Support for monorepos (multiple package.json locations) -- Custom post-install scripts -- Conditional installation based on file changes -- Cache optimization +- **v1.0.2**: Added `autoDiscover` (scan VS Code/Cursor `.code-workspace` and IntelliJ `.idea/modules.xml`) and `directories` (explicit comma-separated list) options for multi-root workspace support. Each folder runs package manager detection independently. +- **v1.0.1**: Added corepack support for Node 24+ (`packageManager` field in package.json). +- **v1.0.0**: Initial release. ## License diff --git a/src/package-auto-install/devcontainer-feature.json b/src/package-auto-install/devcontainer-feature.json index 8eb781f..0725e23 100644 --- a/src/package-auto-install/devcontainer-feature.json +++ b/src/package-auto-install/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "package-auto-install", - "version": "1.0.1", + "version": "1.0.2", "name": "Automatic Package Installation", "description": "Automatically detects and runs npm/yarn/pnpm install in non-interactive mode after container creation.", "documentationURL": "https://github.com/helpers4/devcontainer/tree/main/src/package-auto-install", @@ -40,6 +40,16 @@ "type": "string", "default": "", "description": "Additional arguments to pass to the install command" + }, + "directories": { + "type": "string", + "default": "", + "description": "Comma-separated list of directories to run install in. Overrides workingDirectory and autoDiscover." + }, + "autoDiscover": { + "type": "boolean", + "default": false, + "description": "Scan /workspaces for VS Code/Cursor (.code-workspace) and IntelliJ (.idea/modules.xml) project files and install packages in every discovered root that contains a package.json." } }, "postCreateCommand": "/usr/local/bin/devcontainer-package-install", diff --git a/src/package-auto-install/install.sh b/src/package-auto-install/install.sh index 91b8a96..23c194d 100644 --- a/src/package-auto-install/install.sh +++ b/src/package-auto-install/install.sh @@ -1,27 +1,35 @@ #!/usr/bin/env bash - -# Package Auto-Install DevContainer Feature +# This file is part of helpers4. # Copyright (C) 2025 baxyz -# Licensed under LGPL-3.0 - see LICENSE file for details -# -# Automatically detects and installs npm/yarn/pnpm packages +# SPDX-License-Identifier: LGPL-3.0-or-later -set -e +set -euo pipefail echo "๐Ÿ”ง Setting up package-auto-install devcontainer feature..." +# Ensure jq is available โ€” required for reliable .code-workspace parsing +if ! command -v jq >/dev/null 2>&1; then + if apt-get update -y -q 2>/dev/null \ + && apt-get install -y -q --no-install-recommends jq 2>/dev/null; then + rm -rf /var/lib/apt/lists/* + else + echo "โš ๏ธ Could not install jq; autoDiscover will use grep fallback" + fi +fi + # Get options COMMAND="${COMMAND:-auto}" PACKAGE_MANAGER="${PACKAGEMANAGER:-auto}" WORKING_DIR="${WORKINGDIRECTORY:-/workspaces}" SKIP_IF_EXISTS="${SKIPIFNODEMODULESEXISTS:-false}" ADDITIONAL_ARGS="${ADDITIONALARGS:-}" +DIRECTORIES="${DIRECTORIES:-}" +AUTO_DISCOVER="${AUTODISCOVER:-false}" # Create the installation script cat > /usr/local/bin/devcontainer-package-install << 'EOFSCRIPT' #!/usr/bin/env bash - -set -e +set -euo pipefail # Get configuration from environment or use defaults COMMAND="${COMMAND:-auto}" @@ -29,140 +37,223 @@ PACKAGE_MANAGER="${PACKAGEMANAGER:-auto}" WORKING_DIR="${WORKINGDIRECTORY:-/workspaces}" SKIP_IF_EXISTS="${SKIPIFNODEMODULESEXISTS:-false}" ADDITIONAL_ARGS="${ADDITIONALARGS:-}" +DIRECTORIES="${DIRECTORIES:-}" +AUTO_DISCOVER="${AUTODISCOVER:-false}" echo "๐Ÿ“ฆ Starting automatic package installation..." -echo " Working directory: ${WORKING_DIR}" - -# Find the actual workspace directory -if [ ! -d "${WORKING_DIR}" ]; then - # Try to find workspace - if [ -d "/workspaces" ] && [ "$(ls -A /workspaces 2>/dev/null)" ]; then - WORKING_DIR="/workspaces/$(ls /workspaces | head -n1)" - echo " Detected workspace: ${WORKING_DIR}" + +# โ”€โ”€ Directory discovery โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +# Parse a VS Code / Cursor .code-workspace file; print one resolved path per line +_parse_code_workspace() { + local wsfile="$1" + local wsdir folder_path resolved + wsdir="$(cd "$(dirname "$wsfile")" && pwd)" + if command -v jq >/dev/null 2>&1; then + while IFS= read -r folder_path; do + [ -n "$folder_path" ] || continue + case "$folder_path" in + /*) echo "$folder_path" ;; + *) resolved="$(realpath -m "$wsdir/$folder_path" 2>/dev/null)" \ + && echo "$resolved" || echo "$wsdir/$folder_path" ;; + esac + done < <(jq -r '.folders[]?.path // empty' "$wsfile" 2>/dev/null) else - echo "โŒ Working directory not found: ${WORKING_DIR}" - exit 0 + while IFS= read -r folder_path; do + [ -n "$folder_path" ] || continue + case "$folder_path" in + /*) echo "$folder_path" ;; + *) echo "$wsdir/$folder_path" ;; + esac + done < <( + grep -o '"path"[[:space:]]*:[[:space:]]*"[^"]*"' "$wsfile" 2>/dev/null \ + | sed 's/.*"path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/' + ) fi -fi +} -cd "${WORKING_DIR}" || { - echo "โŒ Cannot access directory: ${WORKING_DIR}" - exit 0 +# Parse an IntelliJ modules.xml; print one module root directory per line +_parse_intellij_modules() { + local modules_xml="$1" + local project_dir iml_filepath module_dir + project_dir="$(dirname "$(dirname "$modules_xml")")" # parent of .idea/ + while IFS= read -r iml_filepath; do + iml_filepath="${iml_filepath//\$PROJECT_DIR\$/$project_dir}" + module_dir="$(dirname "$iml_filepath")" + [ -d "$module_dir" ] && echo "$module_dir" + done < <( + grep -o 'filepath="[^"]*"' "$modules_xml" 2>/dev/null \ + | sed 's/filepath="\([^"]*\)"/\1/' + ) } -# Check if package.json exists -if [ ! -f "package.json" ]; then - echo "โ„น๏ธ No package.json found, skipping installation" - exit 0 -fi +# Print all directories to process, one per line. +# DIRECTORIES branch preserves user-specified order; autoDiscover branch deduplicates via sort -u. +_discover_dirs() { + # 1. Explicit comma-separated list (highest priority) + if [ -n "$DIRECTORIES" ]; then + echo "$DIRECTORIES" | tr ',' '\n' \ + | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' \ + | grep -v '^$' || true + return + fi -# Check if node_modules exists and skip if requested -if [ "$SKIP_IF_EXISTS" = "true" ] && [ -d "node_modules" ]; then - echo "โœ… node_modules already exists, skipping installation" - exit 0 -fi + # 2. Auto-discover from IDE workspace / project files + if [ "$AUTO_DISCOVER" = "true" ]; then + local discovered + discovered="$( + { + # VS Code / Cursor: *.code-workspace + while IFS= read -r wsfile; do + _parse_code_workspace "$wsfile" + done < <(find /workspaces -maxdepth 3 -name "*.code-workspace" 2>/dev/null) -# Extract packageManager field from package.json -get_package_manager_from_json() { - if command -v jq >/dev/null 2>&1; then - jq -r '.packageManager // empty' package.json 2>/dev/null | cut -d'@' -f1 - else - grep -o '"packageManager"[[:space:]]*:[[:space:]]*"[^"]*"' package.json 2>/dev/null | \ - sed 's/.*"\([^@"]*\)[@"].*/\1/' + # IntelliJ IDEA: .idea/modules.xml + while IFS= read -r modules_xml; do + _parse_intellij_modules "$modules_xml" + done < <(find /workspaces -maxdepth 4 -name "modules.xml" -path "*/.idea/*" 2>/dev/null) + } | sort -u | grep -v '^$' || true + )" + if [ -n "$discovered" ]; then + echo "$discovered" + return + fi + echo " โš ๏ธ autoDiscover: no workspace files found, falling back to workingDirectory" >&2 fi -} -# Setup corepack if packageManager field exists -setup_corepack() { - local pkg_manager_field=$(get_package_manager_from_json) - - if [ -n "$pkg_manager_field" ]; then - echo " Found packageManager: $pkg_manager_field" - - if ! command -v corepack >/dev/null 2>&1; then - echo " Installing corepack..." - npm install -g corepack 2>/dev/null || echo " โš ๏ธ corepack install failed" - fi - - if command -v corepack >/dev/null 2>&1; then - corepack enable 2>/dev/null && echo " โœ… corepack enabled" + # 3. Fallback: single workingDirectory (original behaviour) + if [ ! -d "${WORKING_DIR}" ]; then + if [ -d "/workspaces" ] && [ "$(ls -A /workspaces 2>/dev/null)" ]; then + WORKING_DIR="/workspaces/$(ls /workspaces | head -n1)" + echo " Detected workspace: ${WORKING_DIR}" >&2 + else + echo "โŒ Working directory not found: ${WORKING_DIR}" >&2 + return 1 fi - - echo "$pkg_manager_field" fi + echo "$WORKING_DIR" } -# Detect package manager -detect_package_manager() { - # Priority 1: packageManager field in package.json - local from_json=$(setup_corepack) - if [ -n "$from_json" ]; then - echo "$from_json" - return +# โ”€โ”€ Package manager helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_get_pm_from_json() { + if command -v jq >/dev/null 2>&1; then + jq -r '.packageManager // empty' package.json 2>/dev/null | cut -d'@' -f1 || true + else + grep -o '"packageManager"[[:space:]]*:[[:space:]]*"[^"]*"' package.json 2>/dev/null \ + | sed 's/.*"\([^@"]*\)[@"].*/\1/' || true fi - - # Priority 2: lockfiles - [ -f "pnpm-lock.yaml" ] && echo "pnpm" && return - [ -f "yarn.lock" ] && echo "yarn" && return - [ -f "package-lock.json" ] && echo "npm" && return - - # Default - echo "npm" } -# Auto-detect package manager if needed -if [ "$PACKAGE_MANAGER" = "auto" ]; then - PACKAGE_MANAGER=$(detect_package_manager) - echo " Package manager: ${PACKAGE_MANAGER}" -fi +_setup_corepack() { + local pm_field + pm_field="$(_get_pm_from_json)" + [ -n "$pm_field" ] || return 0 + echo " Found packageManager: $pm_field" >&2 + if ! command -v corepack >/dev/null 2>&1; then + echo " Installing corepack..." >&2 + npm install -g corepack 2>/dev/null || echo " โš ๏ธ corepack install failed" >&2 + fi + command -v corepack >/dev/null 2>&1 \ + && corepack enable 2>/dev/null && echo " โœ… corepack enabled" >&2 + echo "$pm_field" +} -# Check if package manager is available -if ! command -v "${PACKAGE_MANAGER}" >/dev/null 2>&1; then - echo "โŒ Package manager '${PACKAGE_MANAGER}' not found" - exit 1 -fi +_detect_pm() { + local from_json + from_json="$(_setup_corepack)" + [ -n "$from_json" ] && echo "$from_json" && return + [ -f "pnpm-lock.yaml" ] && echo "pnpm" && return + [ -f "yarn.lock" ] && echo "yarn" && return + [ -f "package-lock.json" ] && echo "npm" && return + echo "npm" +} -# Detect install command if auto -get_install_command() { - case "$PACKAGE_MANAGER" in - npm) - [ -f "package-lock.json" ] && echo "ci" || echo "install" - ;; - pnpm) - [ -f "pnpm-lock.yaml" ] && echo "install --frozen-lockfile" || echo "install" - ;; +_get_install_cmd() { + local pm="$1" + case "$pm" in + npm) [ -f "package-lock.json" ] && echo "ci" || echo "install" ;; + pnpm) [ -f "pnpm-lock.yaml" ] && echo "install --frozen-lockfile" || echo "install" ;; yarn) - local yarn_version=$(yarn --version 2>/dev/null | cut -d. -f1) - if [ "$yarn_version" -ge 2 ] 2>/dev/null; then + local v + v="$(yarn --version 2>/dev/null | cut -d. -f1)" + if [ "${v:-0}" -ge 2 ] 2>/dev/null; then [ -f "yarn.lock" ] && echo "install --immutable" || echo "install" else [ -f "yarn.lock" ] && echo "install --frozen-lockfile" || echo "install" fi ;; - *) - echo "install" - ;; + *) echo "install" ;; esac } -if [ "$COMMAND" = "auto" ]; then - COMMAND=$(get_install_command) -fi +# โ”€โ”€ Install in a single directory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_install_in_dir() { + local dir="$1" + local pm cmd + if [ ! -d "$dir" ]; then + echo " โš ๏ธ Not found: $dir โ€” skipping" + return 0 + fi + ( + cd "$dir" || exit 1 + if [ ! -f "package.json" ]; then + echo " โ„น๏ธ No package.json in $dir โ€” skipping" + exit 0 + fi + if [ "$SKIP_IF_EXISTS" = "true" ] && [ -d "node_modules" ]; then + echo " โœ… node_modules exists in $dir โ€” skipping" + exit 0 + fi + pm="$PACKAGE_MANAGER" + [ "$pm" = "auto" ] && pm="$(_detect_pm)" + if ! command -v "$pm" >/dev/null 2>&1; then + echo " โŒ Package manager '$pm' not found in $dir" + exit 1 + fi + cmd="$COMMAND" + [ "$cmd" = "auto" ] && cmd="$(_get_install_cmd "$pm")" + echo " ๐Ÿš€ [$dir] โ†’ $pm $cmd ${ADDITIONAL_ARGS}" + # shellcheck disable=SC2086 + if $pm $cmd $ADDITIONAL_ARGS; then + echo " โœ… [$dir] done" + else + echo " โŒ [$dir] failed" + exit 1 + fi + ) +} + +# โ”€โ”€ Main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -# Ensure CI=true is set export CI=true -# Run the installation -echo "๐Ÿš€ Running: ${PACKAGE_MANAGER} ${COMMAND} ${ADDITIONAL_ARGS}" -echo "" +mapfile -t DIRS < <(_discover_dirs) + +if [ "${#DIRS[@]}" -eq 0 ]; then + echo "โŒ No directories to install in" + exit 1 +fi -if ${PACKAGE_MANAGER} ${COMMAND} ${ADDITIONAL_ARGS}; then +if [ "${#DIRS[@]}" -gt 1 ]; then + echo " Directories (${#DIRS[@]}):" + for d in "${DIRS[@]}"; do echo " โ†’ $d"; done echo "" - echo "โœ… Package installation completed successfully" - exit 0 else - echo "" - echo "โŒ Package installation failed with exit code $?" + echo " Working directory: ${DIRS[0]}" +fi + +FAILED=0 +for dir in "${DIRS[@]}"; do + _install_in_dir "$dir" || FAILED=$((FAILED + 1)) +done + +echo "" +if [ "$FAILED" -eq 0 ]; then + echo "โœ… Package installation complete" +else + echo "โŒ Package installation complete with $FAILED failure(s)" exit 1 fi EOFSCRIPT @@ -174,9 +265,11 @@ chmod +x /usr/local/bin/devcontainer-package-install cat >> /etc/environment << EOF COMMAND=${COMMAND} PACKAGEMANAGER=${PACKAGE_MANAGER} -WORKINGDIRECTORY=${WORKING_DIR} +WORKINGDIRECTORY="${WORKING_DIR}" SKIPIFNODEMODULESEXISTS=${SKIP_IF_EXISTS} -ADDITIONALARGS=${ADDITIONAL_ARGS} +ADDITIONALARGS="${ADDITIONAL_ARGS}" +DIRECTORIES="${DIRECTORIES}" +AUTODISCOVER=${AUTO_DISCOVER} EOF echo "โœ… package-auto-install feature installed" diff --git a/test/dotfiles-sync/test.sh b/test/dotfiles-sync/test.sh index 468fc22..96264da 100755 --- a/test/dotfiles-sync/test.sh +++ b/test/dotfiles-sync/test.sh @@ -92,13 +92,13 @@ else exit 1 fi -# Test 5d: hosts.yml is staged in /tmp/dotfiles-sync (always โ€” the mount is +# Test 5d: hosts.yml is staged in /mnt/h4dotfiles (always โ€” the mount is # unconditional because Feature `mounts` cannot be gated on options). The # security boundary is the *copy* step in sync-files.sh, which only writes to # $HOME/.config/gh/hosts.yml when DOTFILES_SYNC_GH_AUTH=true and the env is # not a cloud platform. The staging path is internal to the feature and never # read otherwise. -GH_STAGE="/tmp/dotfiles-sync/.config/gh" +GH_STAGE="/mnt/h4dotfiles/.config/gh" if [ -d "${GH_STAGE}" ] && [ ! -L "${GH_STAGE}" ]; then for f in "${GH_STAGE}"/*; do [ -e "$f" ] || continue @@ -112,7 +112,7 @@ if [ -d "${GH_STAGE}" ] && [ ! -L "${GH_STAGE}" ]; then esac done fi -echo "PASS: only config.yml and hosts.yml may be staged under /tmp/dotfiles-sync/.config/gh" +echo "PASS: only config.yml and hosts.yml may be staged under /mnt/h4dotfiles/.config/gh" # Test 5e: opt-in directories created at build time for dir in ".aws" ".kube" ".docker" ".cargo" ".config/pip" ".config/pnpm" ".config/gh" ".config/git"; do diff --git a/test/package-auto-install/test.sh b/test/package-auto-install/test.sh index 4023b42..5c7d7e6 100755 --- a/test/package-auto-install/test.sh +++ b/test/package-auto-install/test.sh @@ -49,4 +49,81 @@ fi cd / rm -rf /tmp/test-package +# โ”€โ”€ directories option โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +mkdir -p /tmp/test-dirs-a /tmp/test-dirs-b + +cat > /tmp/test-dirs-a/package.json << 'EOF' +{ "name": "dirs-a", "version": "1.0.0" } +EOF +cat > /tmp/test-dirs-b/package.json << 'EOF' +{ "name": "dirs-b", "version": "1.0.0" } +EOF + +# Run installer once; capture both output and exit code so the checks can assert +# that the installer succeeded AND that each directory was actually processed. +_dirs_rc=0 +_dirs_out="$(DIRECTORIES=/tmp/test-dirs-a,/tmp/test-dirs-b /usr/local/bin/devcontainer-package-install 2>&1)" \ + || _dirs_rc=$? +export _dirs_out _dirs_rc + +check "directories option processes first dir" \ + bash -c 'printf "%s\n" "$_dirs_out" | grep -qF "[/tmp/test-dirs-a]"' + +check "directories option processes second dir" \ + bash -c 'printf "%s\n" "$_dirs_out" | grep -qF "[/tmp/test-dirs-b]"' + +check "directories option installer exits successfully" \ + bash -c '[ "$_dirs_rc" -eq 0 ]' + +unset _dirs_out _dirs_rc + +check "directories overrides workingDirectory" bash -c \ + 'out=$(DIRECTORIES=/tmp/test-dirs-a WORKINGDIRECTORY=/nonexistent /usr/local/bin/devcontainer-package-install 2>&1) && printf "%s\n" "$out" | grep -qF "[/tmp/test-dirs-a]"' + +rm -rf /tmp/test-dirs-a /tmp/test-dirs-b + +# โ”€โ”€ autoDiscover option โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +# Use absolute paths in the .code-workspace to avoid resolution edge cases. +# Guard mkdir with a conditional so set -e does not abort the whole test +# if /workspaces is not writable (non-standard base images). +if mkdir -p /workspaces/test-autodiscover/frontend \ + /workspaces/test-autodiscover/backend 2>/dev/null; then + + cat > /workspaces/test-autodiscover/test.code-workspace << 'EOF' +{ + "folders": [ + { "path": "/workspaces/test-autodiscover/frontend" }, + { "path": "/workspaces/test-autodiscover/backend" } + ] +} +EOF + cat > /workspaces/test-autodiscover/frontend/package.json << 'EOF' +{ "name": "ws-frontend", "version": "1.0.0" } +EOF + cat > /workspaces/test-autodiscover/backend/package.json << 'EOF' +{ "name": "ws-backend", "version": "1.0.0" } +EOF + + # Capture output and exit code once so both directory checks and the success + # check all use the same run, and a failing installer cannot hide behind grep. + _auto_rc=0 + _auto_out="$(AUTODISCOVER=true /usr/local/bin/devcontainer-package-install 2>&1)" \ + || _auto_rc=$? + export _auto_out _auto_rc + + check "autoDiscover finds frontend folder" \ + bash -c '[ "$_auto_rc" -eq 0 ] && printf "%s\n" "$_auto_out" | grep -q "test-autodiscover/frontend"' + + check "autoDiscover finds backend folder" \ + bash -c '[ "$_auto_rc" -eq 0 ] && printf "%s\n" "$_auto_out" | grep -q "test-autodiscover/backend"' + + unset _auto_out _auto_rc + + rm -rf /workspaces/test-autodiscover +else + echo "โš ๏ธ /workspaces not writable โ€” skipping autoDiscover tests" +fi + reportResults