diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index e385546dc0..50df755762 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -456,19 +456,76 @@ jobs: echo "❌ Smoke test timeout for ${PKG_SPEC}" exit 1 + smoke_test_exports: + # Issue #734 regression guard: validates that @aiox-squads/core exposes + # bin/* via package exports. The npx smoke above does NOT detect this + # because npm bin shims resolve internally without triggering Node's + # package-exports gate. This job forces require() resolution from an + # external context across Node 20/22/24 — the matrix that revealed + # ERR_PACKAGE_PATH_NOT_EXPORTED affected v5.2.2/.3/.4. + needs: [build, publish, publish_legacy_aiox_core] + if: ${{ needs.publish.result == 'success' || needs.publish_legacy_aiox_core.result == 'success' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node: ['20', '22', '24'] + steps: + - name: Setup Node.js ${{ matrix.node }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node }} + registry-url: ${{ env.NPM_REGISTRY }} + + - name: Wait for npm propagation + env: + CORE_SPEC: '@aiox-squads/core@${{ needs.build.outputs.version }}' + run: | + for i in 1 2 3 4 5 6; do + if npm view "${CORE_SPEC}" version > /dev/null 2>&1; then + echo "✅ ${CORE_SPEC} available on npm" + exit 0 + fi + echo "⏳ Waiting for ${CORE_SPEC} propagation... (attempt $i/6)" + sleep 15 + done + echo "❌ ${CORE_SPEC} not visible after 90s — cannot run exports smoke" + exit 1 + + - name: Force require() resolution to trigger exports gate + env: + CORE_SPEC: '@aiox-squads/core@${{ needs.build.outputs.version }}' + run: | + set -o pipefail + SMOKE_DIR=$(mktemp -d) + cd "${SMOKE_DIR}" + npm init -y >/dev/null 2>&1 + npm install "${CORE_SPEC}" 2>&1 | tail -3 + # External require() forces Node's package-exports gate. If bin/* + # is not declared in exports, this fails with ERR_PACKAGE_PATH_NOT_EXPORTED. + # set -o pipefail above ensures node's exit status survives the head pipe. + if node -e "require('@aiox-squads/core/bin/aiox.js')" 2>&1 | head -20; then + echo "✅ require('@aiox-squads/core/bin/aiox.js') succeeds on Node ${{ matrix.node }}" + else + echo "❌ REGRESSION: require() blocked by exports field on Node ${{ matrix.node }}" + echo "❌ Issue #734 has returned — check exports field in package.json" + exit 1 + fi + rm -rf "${SMOKE_DIR}" + notify: - needs: [build, publish, publish_workspace_packages, publish_legacy_aiox_core] + needs: [build, publish, publish_workspace_packages, publish_legacy_aiox_core, smoke_test_exports] if: always() runs-on: ubuntu-latest steps: - name: Notify completion run: | - if [ "${{ needs.publish.result }}" = "failure" ] || [ "${{ needs.publish_workspace_packages.result }}" = "failure" ] || [ "${{ needs.publish_legacy_aiox_core.result }}" = "failure" ]; then + if [ "${{ needs.publish.result }}" = "failure" ] || [ "${{ needs.publish_workspace_packages.result }}" = "failure" ] || [ "${{ needs.publish_legacy_aiox_core.result }}" = "failure" ] || [ "${{ needs.smoke_test_exports.result }}" = "failure" ]; then echo "❌ Publishing failed" exit 1 fi - if [ "${{ needs.publish.result }}" = "cancelled" ] || [ "${{ needs.publish_workspace_packages.result }}" = "cancelled" ] || [ "${{ needs.publish_legacy_aiox_core.result }}" = "cancelled" ]; then + if [ "${{ needs.publish.result }}" = "cancelled" ] || [ "${{ needs.publish_workspace_packages.result }}" = "cancelled" ] || [ "${{ needs.publish_legacy_aiox_core.result }}" = "cancelled" ] || [ "${{ needs.smoke_test_exports.result }}" = "cancelled" ]; then echo "❌ Publishing was cancelled" exit 1 fi diff --git a/compat/aiox-core/package.json b/compat/aiox-core/package.json index 2a932d64c5..085cb9359a 100644 --- a/compat/aiox-core/package.json +++ b/compat/aiox-core/package.json @@ -1,6 +1,6 @@ { "name": "aiox-core", - "version": "5.2.4", + "version": "5.2.5", "description": "Compatibility wrapper for @aiox-squads/core.", "license": "MIT", "bin": { @@ -15,7 +15,7 @@ "README.md" ], "dependencies": { - "@aiox-squads/core": "5.2.4" + "@aiox-squads/core": "5.2.5" }, "engines": { "node": ">=18" diff --git a/docs/stories/epic-124-aiox-squads-scope-migration/STORY-124.13-fix-issue-734-exports-bin.md b/docs/stories/epic-124-aiox-squads-scope-migration/STORY-124.13-fix-issue-734-exports-bin.md new file mode 100644 index 0000000000..b95b31f060 --- /dev/null +++ b/docs/stories/epic-124-aiox-squads-scope-migration/STORY-124.13-fix-issue-734-exports-bin.md @@ -0,0 +1,80 @@ +# Story 124.13: Fix Issue #734 — Expose `bin/*` in Package Exports + +## Status + +Ready for Review + +## Story + +As an AIOX user installing `aiox-core@5.2.x` or `@aiox-squads/core@5.2.x` on Node.js 22+, +I want the CLI to execute without `ERR_PACKAGE_PATH_NOT_EXPORTED`, +so that every published patch release after the `@aiox-squads` scope migration remains usable on supported Node versions. + +## Acceptance Criteria + +- [x] Root `package.json` `exports` field exposes `./bin/*` so external `require('@aiox-squads/core/bin/aiox.js')` resolves under Node ≥22. +- [x] Compat wrapper (`compat/aiox-core/package.json`) bumped to 5.2.5 with matching `@aiox-squads/core` dependency. +- [x] Local smoke validates `aiox --version`, `aiox-core --version`, and `require()` direct on Node 22.16 against the locally packed tarballs. +- [x] CI workflow has a `smoke_test_exports` job (matrix Node 20/22/24) that runs after publish and forces the `require()` resolution path, catching future regressions of this exact bug. +- [ ] After publish, `npm i -g aiox-core@5.2.5 && aiox --version` works on Node 22, 24, and 25. + +## Tasks + +- [x] Add `"./bin/*": "./bin/*"` to `exports` in `package.json`. +- [x] Bump `@aiox-squads/core` 5.2.4 → 5.2.5. +- [x] Bump `compat/aiox-core` 5.2.4 → 5.2.5 (version + dependency). +- [x] Add `smoke_test_exports` job to `.github/workflows/npm-publish.yml` with Node 20/22/24 matrix, using external `require()` to force the package-exports gate. +- [x] Update `notify` job to fail if `smoke_test_exports` fails. +- [x] Local smoke validated (Node 22.16): `require('@aiox-squads/core/bin/aiox.js')` loads CLI banner instead of throwing `ERR_PACKAGE_PATH_NOT_EXPORTED`. +- [ ] Push branch via @devops + open PR linking #734 and Epic 124. +- [ ] CodeRabbit review pass. +- [ ] @qa final review. +- [ ] Merge + tag `v5.2.5`. +- [ ] @devops deprecates 5.2.2, 5.2.3, 5.2.4 on npm (both `aiox-core` and `@aiox-squads/core`) with message pointing to 5.2.5. +- [ ] @qa runs post-publish global install smoke on Node 22/24/25 and closes Issue #734. + +## File List + +- `package.json` +- `compat/aiox-core/package.json` +- `.github/workflows/npm-publish.yml` +- `docs/stories/epic-124-aiox-squads-scope-migration/STORY-124.13-fix-issue-734-exports-bin.md` + +## Dev Notes + +### Root Cause + +Story 124.8 introduced the `compat/aiox-core` wrapper that delegates to `@aiox-squads/core` via: + +```js +require(`@aiox-squads/core/bin/${targetBin}`); +``` + +The root `package.json` `exports` field (introduced earlier in Epic 124) restricts subpath access to `./resilience/*`, `./installer/*`, and `./package.json` only. Node.js 22+ enforces the package-exports spec strictly: any subpath not declared in `exports` is rejected with `ERR_PACKAGE_PATH_NOT_EXPORTED`, even if the file physically exists. + +This bug affected three consecutive published versions (5.2.2, 5.2.3, 5.2.4) because the existing CI smoke (`npx --yes aiox-core --version`) uses npm bin shims which resolve internally without triggering the exports gate. Only external `require()` (or `npm i -g` followed by direct shell invocation that re-imports the package from outside the package boundary) reveals the bug. + +### Fix Strategy + +Single-pattern entry `"./bin/*": "./bin/*"` (pass-through, no extension transformation) added before the `./installer/*` entries. The pattern is pass-through because the existing wrapper passes `.js` extensions already (e.g. `bin/aiox.js`); transforming with `.js` suffix would yield invalid `bin/aiox.js.js`. + +### CI Hardening (Regression Guard) + +New job `smoke_test_exports` runs after publish and: +1. Sets up Node from a matrix of `['20', '22', '24']`. +2. Waits for npm propagation of `@aiox-squads/core@${version}`. +3. Installs the published package in a fresh tempdir. +4. Executes `node -e "require('@aiox-squads/core/bin/aiox.js')"` — this forces external resolution that triggers the package-exports gate, catching this exact bug class. + +The existing `Smoke test legacy npx` step is preserved (it validates the bin shim path); the new job complements it by covering the external-require path that escaped detection three times. + +### Related Stories / Issues + +- Issue #734 — bug report (2026-05-14). +- Story 124.8 — introduced the wrapper that triggered the issue via internal `require()`. +- Story 124.3 — published `@aiox-squads/core` with the restrictive `exports` field. + +### Risks + +- Pattern `./bin/*` exposes any future file added under `bin/`. Current contents: 5 declared binaries + `aiox-init.js`, `aiox-ids.js` + `modules/`, `utils/` (already part of CLI runtime). No sensitive files in `bin/`. +- Deprecation of 5.2.2/.3/.4 happens post-merge via `npm deprecate` and requires @devops. Pinned consumers must read the deprecation message. diff --git a/package.json b/package.json index 116fbc3867..0b97ff9b85 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aiox-squads/core", - "version": "5.2.4", + "version": "5.2.5", "description": "Synkra AIOX: AI-Orchestrated System for Full Stack Development - Core Framework", "bin": { "aiox": "bin/aiox.js", @@ -45,6 +45,7 @@ "exports": { "./resilience": "./.aiox-core/core/resilience/index.js", "./resilience/agent-immortality": "./.aiox-core/core/resilience/agent-immortality.js", + "./bin/*": "./bin/*", "./installer/aiox-core-installer": "./packages/installer/src/installer/aiox-core-installer.js", "./installer/enterprise-detector": "./packages/installer/src/enterprise/enterprise-detector.js", "./installer/enterprise-errors": "./packages/installer/src/enterprise/enterprise-errors.js",