diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fc2330..c0052a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: steps: - uses: actions/checkout@v7 - uses: jdx/mise-action@v2 - - run: bun install --frozen-lockfile + - run: bun install --frozen-lockfile --os='*' --cpu='*' - name: Derive native smoke matrix id: release-matrix shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 73ef82a..391f4a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -58,7 +58,7 @@ jobs: test "$(git rev-parse HEAD)" = "$tag_commit" test "$(bun -p "require('./package.json').version")" = "$VERSION" echo "commit=$tag_commit" >> "$GITHUB_OUTPUT" - - run: bun install --frozen-lockfile + - run: bun install --frozen-lockfile --os='*' --cpu='*' - name: Derive native smoke matrix id: release-matrix shell: bash diff --git a/AGENTS.md b/AGENTS.md index a2faf6c..785981c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,23 @@ # Repository guidance +## Learning more about Effect + +This repository uses the Effect Typescript library. + +Before writing any Effect code, first read `node_modules/effect/AGENTS.md` +**completely**, and follow the links in the file when required. + +If you need to learn more about particular Effect apis and concepts that the +guide doesn't cover, search through the source code in `node_modules/effect/src`. + ## Release contract - `scripts/release.ts` is the source of truth for target IDs, Bun targets, archive names, executable names, SHA-256 files, and release manifests. -- Every target must produce a one-executable archive, adjacent `.sha256`, an - entry in `checksums.txt`, and a native install smoke running `akua --version`, - `akua --help`, and `akua commands --limit 1`. +- Every target must produce an archive containing the `akua` executable and its + adjacent target-native package runtime, an adjacent `.sha256`, an entry in + `checksums.txt`, and a native install smoke covering both cloud commands and a + real `akua pkg init` → `check` → `render` → `inspect` flow. - Keep the tested matrix aligned across the script and workflows: macOS arm64/x64, glibc Linux arm64/x64, and Windows x64. Use baseline Bun targets for Linux x64 and Windows x64. diff --git a/README.md b/README.md index bb578c0..d723cd7 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ The canonical executable is `akua`; there is no `cnap` compatibility binary. ## Install GitHub Releases and Homebrew are the supported install channels. Every GitHub -archive contains one executable and has an adjacent SHA-256 file. A release also -publishes `checksums.txt`, a complete release manifest, and the exact Homebrew +archive contains the `akua` executable and its adjacent target-native package +runtime, and has an adjacent SHA-256 file. A release also publishes +`checksums.txt`, a complete release manifest, and the exact Homebrew artifact/checksum mapping. ### Homebrew @@ -24,6 +25,7 @@ brew install akua-dev/tap/akua akua --version akua --help akua commands --limit 1 +akua pkg version ``` Upgrade with: @@ -61,12 +63,14 @@ if command -v sha256sum >/dev/null 2>&1; then else shasum -a 256 --check "${ASSET}.sha256" fi -tar -xzf "$ASSET" -mkdir -p "$HOME/.local/bin" -install -m 0755 akua "$HOME/.local/bin/akua" +INSTALL_ROOT="$HOME/.local/libexec/akua-v${VERSION}" +mkdir -p "$INSTALL_ROOT" "$HOME/.local/bin" +tar -xzf "$ASSET" -C "$INSTALL_ROOT" +ln -sfn "$INSTALL_ROOT/akua" "$HOME/.local/bin/akua" "$HOME/.local/bin/akua" --version "$HOME/.local/bin/akua" --help "$HOME/.local/bin/akua" commands --limit 1 +"$HOME/.local/bin/akua" pkg version ``` Ensure `~/.local/bin` is on `PATH`. Manual upgrades repeat these steps with a @@ -88,11 +92,16 @@ $Expected = ((Get-Content "$Asset.sha256") -split "\s+")[0].ToLower() $Actual = (Get-FileHash $Asset -Algorithm SHA256).Hash.ToLower() if ($Actual -ne $Expected) { throw "SHA-256 mismatch for $Asset" } Expand-Archive $Asset -DestinationPath .\akua-release -Force +$InstallRoot = "$HOME\libexec\akua-v$Version" +New-Item -ItemType Directory -Force "$InstallRoot" | Out-Null +Copy-Item .\akua-release\* "$InstallRoot" -Recurse -Force New-Item -ItemType Directory -Force "$HOME\bin" | Out-Null -Copy-Item .\akua-release\akua.exe "$HOME\bin\akua.exe" -Force +Copy-Item "$InstallRoot\akua.exe" "$HOME\bin\akua.exe" -Force +Copy-Item "$InstallRoot\node_modules" "$HOME\bin\node_modules" -Recurse -Force & "$HOME\bin\akua.exe" --version & "$HOME\bin\akua.exe" --help & "$HOME\bin\akua.exe" commands --limit 1 +& "$HOME\bin\akua.exe" pkg version ``` ### Supported release artifacts @@ -108,7 +117,9 @@ Copy-Item .\akua-release\akua.exe "$HOME\bin\akua.exe" -Force Linux musl, Windows arm64, and other systems are not in the tested release contract. x64 Linux and Windows use Bun's baseline target for older CPUs. Unix archives preserve executable mode `0755`; the Windows ZIP contains `akua.exe`. -The binaries are self-contained and do not require Bun to be installed. +All archives also contain the target-native package runtime under +`node_modules/@akua-dev`. Keep that directory adjacent to the executable. Bun is +not required on the target machine. To audit a whole release, download `checksums.txt` plus the archives and run `sha256sum --check checksums.txt` on Linux or `shasum -a 256 --check diff --git a/bun.lock b/bun.lock index 7b26e90..ab3ef45 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "@akua-dev/cli", "dependencies": { + "@akua-dev/sdk": "^0.9.4", "effect": "4.0.0-beta.106", }, "devDependencies": { @@ -20,6 +21,26 @@ "@effect/openapi-generator@4.0.0-beta.106": "patches/@effect%2Fopenapi-generator@4.0.0-beta.106.patch", }, "packages": { + "@akua-dev/native": ["@akua-dev/native@0.9.4", "", { "dependencies": { "@akua-dev/native-engines": "0.9.4" }, "optionalDependencies": { "@akua-dev/native-darwin-arm64": "0.9.4", "@akua-dev/native-darwin-x64": "0.9.4", "@akua-dev/native-linux-arm64-gnu": "0.9.4", "@akua-dev/native-linux-arm64-musl": "0.9.4", "@akua-dev/native-linux-x64-gnu": "0.9.4", "@akua-dev/native-linux-x64-musl": "0.9.4", "@akua-dev/native-win32-x64-msvc": "0.9.4" } }, "sha512-hkc2yUDjhhlVl4zqiHNCC1+LrRYYH626MRmnKLYi3byBMZGKyDJ+8roeVfdgKv89Wr2ISMRINijF8eikRujUFQ=="], + + "@akua-dev/native-darwin-arm64": ["@akua-dev/native-darwin-arm64@0.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zhEEmICJ3LiGRjs+MjM7+e9M99jYwohNKfaSizP3HkLcf30EwwGofZtxdF6Km6JBt9YX9MjgSRWMouBA0jTQhw=="], + + "@akua-dev/native-darwin-x64": ["@akua-dev/native-darwin-x64@0.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CZ8983H5WmVXD6uE12Eva6Qt0isIt7fIQSoWlFIcuAG2qhnZjvAzBp0zDsODptP03+V9c80XyekgitBj2/JbDg=="], + + "@akua-dev/native-engines": ["@akua-dev/native-engines@0.9.4", "", {}, "sha512-xK4sY+NAvAXCHG+EFIZplAM1tDV4UTbHeD88nR0EKFhjT1i/3ml8YgaYYpWJarTXlvcjRuNgLRFDN9CvSBMLVA=="], + + "@akua-dev/native-linux-arm64-gnu": ["@akua-dev/native-linux-arm64-gnu@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-ba78zaHdIS8PcXPYS0x0kYiBH+d11vaXTDrWcIUNA2GClVGI/Oo1jc6t8UaSyBWnUIlb1OM05u8Gp1lRQDO7vg=="], + + "@akua-dev/native-linux-arm64-musl": ["@akua-dev/native-linux-arm64-musl@0.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-zpOf1TUsvwQI7fTzejbKzKWWswcXT9ZXwFCB6DP1bPFtyXJPfna13JgTsZPYVXvHNAlH1sGKGvIVslW4YUG8rA=="], + + "@akua-dev/native-linux-x64-gnu": ["@akua-dev/native-linux-x64-gnu@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-JhBbC3/hKThrpYr4liS5Az+3aUXFqeYoyDO6Ivu+oIeoFqn4mmG/mErvAf2ls2XMoOlI/JOcoO3eWrgAuq8MzQ=="], + + "@akua-dev/native-linux-x64-musl": ["@akua-dev/native-linux-x64-musl@0.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-LqjSJh9aqNRDBRtynUZOMU3OIex5mRmRMcccuXT4SLBBP3qKSw6Bg6TZ/WB1RXRu/W1lrrMDz6VzVOs4kCnmOQ=="], + + "@akua-dev/native-win32-x64-msvc": ["@akua-dev/native-win32-x64-msvc@0.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-7IYgTyx9Tv07SXxGTf5gkt2WuP8w6+NbZnRxqJmR6cPrpZfJzrBOJ47Ysb5sCTHgR+kUowMkCRzxAAnmREe1dw=="], + + "@akua-dev/sdk": ["@akua-dev/sdk@0.9.4", "", { "dependencies": { "@akua-dev/native": "0.9.4", "@standard-schema/spec": "^1.1.0", "ajv": "^8.18.0", "yaml": "^2.8.3" } }, "sha512-v4vm8iWxVzT9i664BKKnVFiG8f6mvmoElOVKGCH7xrvbwiZ6LRWZia6uqb1N1Rq2uUEMmArokvqBJDm2O6xzeA=="], + "@effect/openapi-generator": ["@effect/openapi-generator@4.0.0-beta.106", "", { "dependencies": { "swagger2openapi": "^7.0.8" }, "peerDependencies": { "@effect/platform-node": "^4.0.0-beta.106", "effect": "^4.0.0-beta.106" }, "bin": { "openapigen": "dist/bin.js" } }, "sha512-ae/aOOJgbF97/iyOom6QALqzV1c7GHGdGzc4cDNHt2PqtjRF0uuRJt4ptR/CiDDD0LMOsAzKyBpW/PwJECZvvQ=="], "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.106", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.106", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.106", "ioredis": ">=5.7.0 <6.0.0" } }, "sha512-Me7uorWWroWp4OuWHbFg7EqyIex45QzZ3EPwh2p8y/t3VCWVy4KSFtL9AKOKpd7QK1krUWxRK/26J/hTsqvwZg=="], @@ -50,6 +71,8 @@ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -82,8 +105,12 @@ "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "http2-client": ["http2-client@1.3.5", "", {}, "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA=="], @@ -92,6 +119,8 @@ "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], @@ -130,6 +159,8 @@ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "should": ["should@13.2.3", "", { "dependencies": { "should-equal": "^2.0.0", "should-format": "^3.0.3", "should-type": "^1.4.0", "should-type-adaptors": "^1.0.1", "should-util": "^1.0.0" } }, "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ=="], "should-equal": ["should-equal@2.0.0", "", { "dependencies": { "should-type": "^1.4.0" } }, "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA=="], @@ -170,10 +201,18 @@ "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], - "yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "yargs": ["yargs@17.7.3", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "oas-linter/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + + "oas-resolver/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + + "oas-validator/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], + + "swagger2openapi/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], } } diff --git a/docs/architecture.md b/docs/architecture.md index 91c5799..cf212f7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -266,8 +266,10 @@ published targets are: - `bun-linux-arm64` and `bun-linux-x64-baseline` (glibc); - `bun-windows-x64-baseline`. -Each Unix `.tar.gz` contains only executable `akua` with mode `0755`; the -Windows `.zip` contains only `akua.exe`. Stable names have the form +Each archive contains executable `akua` (`akua.exe` on Windows) plus the +target-native `@akua-dev/native` and `@akua-dev/native-engines` package runtime. +The package runtime must stay adjacent to the executable under `node_modules`. +Unix executables have mode `0755`. Stable names have the form `akua-v--.`. Every archive has an adjacent `.sha256`, appears in `checksums.txt`, and is described in the release manifest. The generated Homebrew manifest maps the four macOS/Linux formula selectors to @@ -277,8 +279,8 @@ exact release URLs and SHA-256 digests. release:package` cross-compiles and verifies the whole release directory, while `mise run release:smoke` extracts and executes the current host archive. CI builds the candidate once, then macOS arm64/x64, Linux arm64/x64, and Windows x64 -hosted runners execute `akua --version`, `akua --help`, and `akua commands ---limit 1` from their extracted archive. +hosted runners exercise the cloud CLI and a real `akua pkg init` → `check` → +`render` → `inspect` flow from their extracted archive. Release Please runs in manifest mode for the root Bun package. It uses `release-please-config.json` and `.release-please-manifest.json` to prepare diff --git a/package.json b/package.json index 6d83c0a..0c095c7 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "bun": ">=1.3.7" }, "dependencies": { + "@akua-dev/sdk": "^0.9.4", "effect": "4.0.0-beta.106" }, "patchedDependencies": { diff --git a/scripts/runtime/release-host-live.ts b/scripts/runtime/release-host-live.ts index 7ec113c..30a2b4b 100644 --- a/scripts/runtime/release-host-live.ts +++ b/scripts/runtime/release-host-live.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { chmodSync, + copyFileSync, lstatSync, mkdirSync, mkdtempSync, @@ -12,7 +13,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { isAbsolute, join, relative, resolve, sep } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { Effect, Layer } from "effect"; import { @@ -214,6 +215,11 @@ function packageExistingExecutables( yield* attempt("set staged executable timestamp", () => utimesSync(stagedExecutable, ARCHIVE_TIMESTAMP, ARCHIVE_TIMESTAMP), ); + const runtimeFiles = yield* stagePackageRuntime( + input.packageRoot, + stagingDir, + target, + ); if (target.archive === "tar.gz") { const metadataArguments = process.platform === "linux" @@ -244,13 +250,23 @@ function packageExistingExecutables( "-C", stagingDir, target.executable, + "node_modules", ], { COPYFILE_DISABLE: "1" }, ); } else { yield* runCommand( - ["zip", "-X", "-q", "-j", archivePath, stagedExecutable], + [ + "zip", + "-X", + "-q", + "-r", + archivePath, + target.executable, + "node_modules", + ], { COPYFILE_DISABLE: "1", TZ: "UTC" }, + stagingDir, ); } const bytes = new Uint8Array( @@ -273,6 +289,7 @@ function packageExistingExecutables( arch: target.arch, archive: target.archive, executable: target.executable, + contents: [target.executable, ...runtimeFiles], file: archive, checksum_file: checksumFile, sha256: digest, @@ -365,6 +382,8 @@ function packageRelease( version: input.version, outputDir: input.outputDir, binaries, + packageRoot: + input.packageRoot ?? join(process.cwd(), "node_modules", "@akua-dev"), }); }); yield* buildBinaries.pipe( @@ -438,6 +457,52 @@ function smokeReleaseArtifact(input: { commandsOutput.trim() !== "", `Installed ${target.id} executable returned empty command output`, ); + const packageSmokeRoot = join(installRoot, "package-smoke"); + yield* attempt("create package smoke directory", () => + mkdirSync(packageSmokeRoot, { recursive: true }), + ); + yield* runCommand( + [executable, "pkg", "version", "--json"], + {}, + packageSmokeRoot, + ); + yield* runCommand( + [executable, "pkg", "init", "demo", "--json"], + {}, + packageSmokeRoot, + ); + const packageDirectory = join(packageSmokeRoot, "demo"); + yield* runCommand( + [executable, "pkg", "check", "--json"], + {}, + packageDirectory, + ); + yield* runCommand( + [ + executable, + "pkg", + "render", + "--inputs", + "inputs.example.yaml", + "--out", + "deploy", + "--json", + ], + {}, + packageDirectory, + ); + const renderedFiles = yield* attempt("read package smoke output", () => + readdirSync(join(packageDirectory, "deploy")), + ); + yield* check( + renderedFiles.length > 0, + `Installed ${target.id} package renderer returned no manifests`, + ); + yield* runCommand( + [executable, "pkg", "inspect", "--json"], + {}, + packageDirectory, + ); }); yield* smoke.pipe( Effect.ensuring( @@ -500,7 +565,19 @@ function verifyReleaseDirectory( asset.archive === target.archive && asset.file === expectedFile && asset.checksum_file === `${expectedFile}.sha256` && - asset.executable === target.executable, + asset.executable === target.executable && + asset.contents[0] === target.executable && + asset.contents.includes( + "node_modules/@akua-dev/native/package.json", + ) && + asset.contents.includes( + "node_modules/@akua-dev/native-engines/helm-engine.wasm", + ) && + asset.contents.includes( + "node_modules/@akua-dev/native-engines/kustomize-engine.wasm", + ) && + asset.contents.filter((entry) => entry.endsWith(".node")).length === + 1, `Release manifest target mismatch for ${target.id}`, ); const bytes = new Uint8Array( @@ -530,7 +607,7 @@ function verifyReleaseDirectory( `Release asset size mismatch: ${asset.file}`, ); aggregateLines.push(expectedLine); - yield* verifyArchive(outputDir, target, asset.file); + yield* verifyArchive(outputDir, target, asset.file, asset.contents); } const aggregate = yield* attempt("read aggregate checksums", () => readFileSync(join(outputDir, manifest.checksums), "utf8"), @@ -610,6 +687,7 @@ function verifyArchive( outputDir: string, target: ReleaseTarget, file: string, + contents: readonly string[], ): Effect.Effect { return Effect.gen(function* () { const archivePath = join(outputDir, file); @@ -617,13 +695,15 @@ function verifyArchive( target.archive === "zip" ? ["unzip", "-Z1", archivePath] : ["tar", "-tzf", archivePath]; - const listed = (yield* runCommand(listCommand)) + const listedFiles = (yield* runCommand(listCommand)) .trim() .split("\n") - .filter(Boolean); + .filter((entry) => entry !== "" && !entry.endsWith("/")) + .sort(); + const expectedFiles = [...contents].sort(); yield* check( - listed.length === 1 && listed[0] === target.executable, - `Release archive ${file} must contain only ${target.executable}`, + JSON.stringify(listedFiles) === JSON.stringify(expectedFiles), + `Release archive ${file} has unexpected files: ${listedFiles.join(", ")}`, ); if (target.os === "windows") return; const extractDir = yield* attempt( @@ -654,6 +734,7 @@ function verifyArchive( function runCommand( command: string[], extraEnv: Record = {}, + cwd?: string, ): Effect.Effect { return Effect.gen(function* () { const proc = yield* attempt("run release command", () => @@ -662,6 +743,7 @@ function runCommand( stdout: "pipe", stderr: "pipe", env: { ...process.env, ...extraEnv }, + cwd, }), ); const decoder = new TextDecoder(); @@ -675,6 +757,129 @@ function runCommand( }); } +function stagePackageRuntime( + packageRoot: string, + stagingDir: string, + target: ReleaseTarget, +): Effect.Effect { + return Effect.gen(function* () { + const scopeRoot = join(stagingDir, "node_modules", "@akua-dev"); + const nativeDestination = join(scopeRoot, "native"); + const archiveFiles: string[] = []; + const runtimeDirectories = new Set(); + for (const packageName of ["native", "native-engines"]) { + const manifest = yield* readPackageManifest(packageRoot, packageName); + const declaredFiles = yield* packageManifestFiles(manifest, packageName); + for (const file of ["package.json", ...declaredFiles]) { + const destination = join(scopeRoot, packageName, file); + yield* stagePackageRuntimeFile( + join(packageRoot, packageName, file), + destination, + ); + archiveFiles.push( + `node_modules/@akua-dev/${packageName}/${file.replaceAll("\\", "/")}`, + ); + runtimeDirectories.add(dirname(destination)); + } + } + const bindingManifest = yield* readPackageManifest( + packageRoot, + target.bindingPackage, + ); + const bindingFile = yield* packageManifestMain( + bindingManifest, + target.bindingPackage, + ); + const bindingDestination = join(nativeDestination, bindingFile); + yield* stagePackageRuntimeFile( + join(packageRoot, target.bindingPackage, bindingFile), + bindingDestination, + ); + archiveFiles.push( + `node_modules/@akua-dev/native/${bindingFile.replaceAll("\\", "/")}`, + ); + runtimeDirectories.add(dirname(bindingDestination)); + runtimeDirectories.add(scopeRoot); + runtimeDirectories.add(join(stagingDir, "node_modules")); + for (const directory of runtimeDirectories) { + yield* attempt("set package runtime directory timestamp", () => + utimesSync(directory, ARCHIVE_TIMESTAMP, ARCHIVE_TIMESTAMP), + ); + } + return archiveFiles; + }); +} + +function stagePackageRuntimeFile( + source: string, + destination: string, +): Effect.Effect { + return Effect.gen(function* () { + yield* attempt("create package runtime file directory", () => + mkdirSync(dirname(destination), { recursive: true }), + ); + yield* attempt("stage package runtime file", () => + copyFileSync(source, destination), + ); + yield* attempt("set package runtime file mode", () => + chmodSync(destination, 0o644), + ); + yield* attempt("set package runtime file timestamp", () => + utimesSync(destination, ARCHIVE_TIMESTAMP, ARCHIVE_TIMESTAMP), + ); + }); +} + +function readPackageManifest( + packageRoot: string, + packageName: string, +): Effect.Effect { + return Effect.gen(function* () { + const source = yield* attempt("read package runtime manifest", () => + readFileSync(join(packageRoot, packageName, "package.json"), "utf8"), + ); + return yield* attempt("parse package runtime manifest", () => + JSON.parse(source), + ); + }); +} + +function packageManifestFiles( + value: unknown, + packageName: string, +): Effect.Effect { + if (!isRecord(value) || !Array.isArray(value.files)) { + return releaseFailure(`Package runtime files are invalid for ${packageName}`); + } + return Effect.all( + value.files.map((file) => safePackageRelativePath(file, packageName)), + ); +} + +function packageManifestMain( + value: unknown, + packageName: string, +): Effect.Effect { + return isRecord(value) + ? safePackageRelativePath(value.main, packageName) + : releaseFailure(`Package runtime main is invalid for ${packageName}`); +} + +function safePackageRelativePath( + value: unknown, + packageName: string, +): Effect.Effect { + if ( + typeof value !== "string" || + value === "" || + isAbsolute(value) || + value.split(/[\\/]/).some((segment) => segment === "" || segment === "..") + ) { + return releaseFailure(`Package runtime path is invalid for ${packageName}`); + } + return Effect.succeed(value); +} + function sha256(bytes: Uint8Array): Effect.Effect { return attempt("calculate SHA-256", () => createHash("sha256").update(bytes).digest("hex"), @@ -740,9 +945,11 @@ function parseReleaseAsset( "checksum_file", "sha256", ]; + const contents = value.contents; if ( stringFields.some((field) => typeof value[field] !== "string") || - typeof value.size !== "number" + typeof value.size !== "number" || + !isStringArray(contents) ) { return releaseFailure("Release manifest asset is invalid"); } @@ -759,6 +966,7 @@ function parseReleaseAsset( arch, archive, executable, + contents, file: yield* requiredString(value.file), checksum_file: yield* requiredString(value.checksum_file), sha256: yield* requiredString(value.sha256), @@ -823,6 +1031,10 @@ function requiredNumber(value: unknown): Effect.Effect { : releaseFailure("Release manifest asset is invalid"); } +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object" && !Array.isArray(value); } diff --git a/scripts/runtime/release-services.ts b/scripts/runtime/release-services.ts index 31f75f5..68bcd9a 100644 --- a/scripts/runtime/release-services.ts +++ b/scripts/runtime/release-services.ts @@ -19,6 +19,7 @@ export interface ReleaseTarget { arch: "arm64" | "x64"; archive: "tar.gz" | "zip"; executable: "akua" | "akua.exe"; + bindingPackage: string; runner: string; homebrew?: { os: "macos" | "linux"; @@ -34,6 +35,7 @@ export const RELEASE_TARGETS: readonly ReleaseTarget[] = [ arch: "arm64", archive: "tar.gz", executable: "akua", + bindingPackage: "native-darwin-arm64", runner: "macos-15", homebrew: { os: "macos", arch: "arm" }, }, @@ -44,6 +46,7 @@ export const RELEASE_TARGETS: readonly ReleaseTarget[] = [ arch: "x64", archive: "tar.gz", executable: "akua", + bindingPackage: "native-darwin-x64", runner: "macos-15-intel", homebrew: { os: "macos", arch: "intel" }, }, @@ -54,6 +57,7 @@ export const RELEASE_TARGETS: readonly ReleaseTarget[] = [ arch: "arm64", archive: "tar.gz", executable: "akua", + bindingPackage: "native-linux-arm64-gnu", runner: "ubuntu-24.04-arm", homebrew: { os: "linux", arch: "arm" }, }, @@ -64,6 +68,7 @@ export const RELEASE_TARGETS: readonly ReleaseTarget[] = [ arch: "x64", archive: "tar.gz", executable: "akua", + bindingPackage: "native-linux-x64-gnu", runner: "akua-x64-ci-v2", homebrew: { os: "linux", arch: "intel" }, }, @@ -74,6 +79,7 @@ export const RELEASE_TARGETS: readonly ReleaseTarget[] = [ arch: "x64", archive: "zip", executable: "akua.exe", + bindingPackage: "native-win32-x64-msvc", runner: "windows-2025", }, ]; @@ -85,6 +91,7 @@ export interface ReleaseAsset { arch: ReleaseTarget["arch"]; archive: ReleaseTarget["archive"]; executable: ReleaseTarget["executable"]; + contents: string[]; file: string; checksum_file: string; sha256: string; @@ -104,12 +111,14 @@ export interface PackageExistingExecutablesInput { version: string; outputDir: string; binaries: Record; + packageRoot: string; } export interface PackageReleaseInput { version: string; outputDir: string; entrypoint?: string; + packageRoot?: string; } export function releaseMatrix(): { diff --git a/src/bin/akua.ts b/src/bin/akua.ts index 73be29d..792a5cd 100644 --- a/src/bin/akua.ts +++ b/src/bin/akua.ts @@ -11,7 +11,10 @@ import { } from "../commands/generated"; import { buildHomeView } from "../commands/home"; import { commandRegistry } from "../generated/commands.gen"; -import { generatedCommandError } from "../runtime/errors"; +import { + generatedCommandError, + packageCommandError, +} from "../runtime/errors"; import { detectOutputMode, type OutputMode } from "../runtime/mode"; import type { RenderEnvelope } from "../runtime/render"; import { @@ -21,7 +24,7 @@ import { UsageFailure, } from "../runtime/effect-runtime"; import { CliLive } from "../runtime/services-live"; -import { Console, type CliServices } from "../runtime/services"; +import { Console, PackageCli, type CliServices } from "../runtime/services"; import { PublicApiAuthenticationFailure, PublicApiClientLive, @@ -64,29 +67,48 @@ function mainEffect( stdoutIsTTY: console.stdoutIsTTY, }).pipe( Effect.flatMap((mode) => { - if (usesStructuredRenderer(argv, mode)) { - return runCli(route(argv, env), { mode }); - } - - return Effect.gen(function* () { - const exitCode = yield* Ref.make(0); - const command = makeAkuaCommand(() => - runCli(route(argv, env), { mode }).pipe( - Effect.tap((code) => Ref.set(exitCode, code)), - Effect.asVoid, - ), - ); - yield* Command.runWith(command, { version: VERSION })( - commandInput(argv), - ).pipe( - Effect.catch(() => Ref.set(exitCode, 2)), - ); - return yield* Ref.get(exitCode); - }).pipe( - Effect.provide(NodeServices.layer), - Effect.provide( - CliOutput.layer(CliOutput.defaultFormatter({ colors: false })), - ), + return stripGlobalFlags(argv).pipe( + Effect.flatMap((stripped) => { + if (stripped[0] === "pkg") { + return Effect.gen(function* () { + const pkg = yield* PackageCli; + return yield* pkg.execute(packageCommandArgs(stripped, mode)).pipe( + Effect.catchTag("PackageCliFailure", () => + runCli( + Effect.fail( + new CommandFailure({ error: packageCommandError() }), + ), + { mode }, + ), + ), + ); + }); + } + if (usesStructuredRenderer(argv, mode)) { + return runCli(route(argv, env), { mode }); + } + + return Effect.gen(function* () { + const exitCode = yield* Ref.make(0); + const command = makeAkuaCommand(() => + runCli(route(argv, env), { mode }).pipe( + Effect.tap((code) => Ref.set(exitCode, code)), + Effect.asVoid, + ), + ); + yield* Command.runWith(command, { version: VERSION })( + commandInput(argv), + ).pipe( + Effect.catch(() => Ref.set(exitCode, 2)), + ); + return yield* Ref.get(exitCode); + }).pipe( + Effect.provide(NodeServices.layer), + Effect.provide( + CliOutput.layer(CliOutput.defaultFormatter({ colors: false })), + ), + ); + }), ); }), Effect.catch((failure) => @@ -96,6 +118,14 @@ function mainEffect( }); } +function packageCommandArgs( + argv: readonly string[], + mode: OutputMode, +): readonly string[] { + const args = argv.length === 1 ? ["--help"] : argv.slice(1); + return mode === "agent" || mode === "json" ? [...args, "--json"] : args; +} + function usesStructuredRenderer( argv: readonly string[], mode: OutputMode, @@ -213,6 +243,7 @@ function helpView(): RenderEnvelope { " akua auth status Show local authentication status", " akua auth logout Remove the saved local API token", " akua commands List generated public OpenAPI command registry", + " akua pkg --help Build, render, publish, and inspect Packages", " akua [--input -|]", " Execute a generated public API operation", " akua --help Show help", @@ -221,6 +252,7 @@ function helpView(): RenderEnvelope { next_steps: [ { command: "akua commands" }, { command: "akua commands --json" }, + { command: "akua pkg --help" }, ], }; } diff --git a/src/cli/command.ts b/src/cli/command.ts index 967c586..fc7ad03 100644 --- a/src/cli/command.ts +++ b/src/cli/command.ts @@ -82,6 +82,11 @@ export function makeAkuaCommand(handler: CommandHandler) { Command.withShortDescription("Discover API commands before executing them"), ); + const pkg = Command.make("pkg", {}, handler).pipe( + Command.withDescription("Run Akua Package commands through the embedded native toolchain"), + Command.withShortDescription("Build, render, publish, and inspect Packages"), + ); + const resources = [...resourceNames] .sort() .map((resource) => @@ -113,6 +118,6 @@ export function makeAkuaCommand(handler: CommandHandler) { "Control Akua through generated public API commands and browser/device authentication", ), Command.withSharedFlags(globalFlags), - Command.withSubcommands([auth, commands, ...resources]), + Command.withSubcommands([auth, commands, pkg, ...resources]), ); } diff --git a/src/commands/home.ts b/src/commands/home.ts index 9a7f515..4d03268 100644 --- a/src/commands/home.ts +++ b/src/commands/home.ts @@ -22,6 +22,7 @@ export function buildHomeView(): RenderEnvelope { next_steps: [ { command: "akua commands" }, { command: "akua commands --json" }, + { command: "akua pkg --help" }, { command: "akua workspaces list --input -" }, ], }; diff --git a/src/runtime/errors.ts b/src/runtime/errors.ts index c7a719c..957679e 100644 --- a/src/runtime/errors.ts +++ b/src/runtime/errors.ts @@ -74,6 +74,21 @@ export function usageError(message: string): AkuaCliError { }); } +export function packageCommandError(): AkuaCliError { + return new AkuaCliError({ + type: "runtime_error", + code: "AKUA_PACKAGE_UNAVAILABLE", + message: "The embedded package toolchain could not be loaded.", + exitCode: ExitCodes.Runtime, + nextSteps: [ + { + command: "brew reinstall akua-dev/tap/akua", + description: "Reinstall the CLI and its native package toolchain.", + }, + ], + }); +} + export function generatedCommandError( failure: GeneratedCommandFailure, ): AkuaCliError { diff --git a/src/runtime/services-live.ts b/src/runtime/services-live.ts index baf2df8..5643638 100644 --- a/src/runtime/services-live.ts +++ b/src/runtime/services-live.ts @@ -10,6 +10,7 @@ import { import { dirname, join } from "node:path"; import { Data, Duration, Effect, Layer } from "effect"; +import { execute as executePackageCommand } from "@akua-dev/sdk/execute"; import { FetchHttpClient, HttpBody, @@ -28,6 +29,8 @@ import { Http, HttpFailure, Process, + PackageCli, + PackageCliFailure, PublicInput, PublicInputFailure, SecureConfig, @@ -176,6 +179,13 @@ export const PublicInputLive = Layer.succeed(PublicInput, { }), }); +export const PackageCliLive = Layer.succeed(PackageCli, { + execute: (args) => Effect.try({ + try: () => executePackageCommand(args, { binName: "akua pkg" }), + catch: (cause) => new PackageCliFailure({ cause }), + }), +}); + export const CliLive: Layer.Layer = Layer.mergeAll( HttpLive, BrowserLive, @@ -183,6 +193,7 @@ export const CliLive: Layer.Layer = Layer.mergeAll( ConsoleLive, SecureConfigLive, PublicInputLive, + PackageCliLive, ClockLive, ); diff --git a/src/runtime/services.ts b/src/runtime/services.ts index d1d2cf0..652389f 100644 --- a/src/runtime/services.ts +++ b/src/runtime/services.ts @@ -31,6 +31,12 @@ export class PublicInputFailure extends Data.TaggedError( "PublicInputFailure", )<{}> {} +export class PackageCliFailure extends Data.TaggedError( + "PackageCliFailure", +)<{ + readonly cause: unknown; +}> {} + export class Http extends Context.Service< Http, { @@ -88,6 +94,15 @@ export class PublicInput extends Context.Service< } >()("platform/cli/PublicInput") {} +export class PackageCli extends Context.Service< + PackageCli, + { + readonly execute: ( + args: readonly string[], + ) => Effect.Effect; + } +>()("platform/cli/PackageCli") {} + export class CliClock extends Context.Service< CliClock, { @@ -103,4 +118,5 @@ export type CliServices = | Console | SecureConfig | PublicInput + | PackageCli | CliClock; diff --git a/test/cli.test.ts b/test/cli.test.ts index 4409d12..b1890ff 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -11,13 +11,97 @@ import { import { join } from "node:path"; import { Effect } from "effect"; +import { main } from "../src/bin/akua"; import { authView } from "../src/commands/auth"; import { renderSuccess, type RenderEnvelope } from "../src/runtime/render"; import { toCliError } from "../src/runtime/effect-runtime"; import { CliLive } from "../src/runtime/services-live"; +import { + Console, + PackageCli, + PackageCliFailure, +} from "../src/runtime/services"; import { runAuthView } from "./auth-test-layer"; describe("akua entrypoint", () => { + test("embeds package help under the akua pkg invocation", async () => { + const calls: Array = []; + const run = (argv: readonly string[]) => Effect.runPromise( + main(argv, {}).pipe( + Effect.provideService(PackageCli, { + execute: (args) => + Effect.sync(() => { + calls.push(args); + return 0; + }), + }), + Effect.provideService(Console, { + stdoutIsTTY: true, + writeStderr: () => Effect.void, + writeStdout: () => Effect.void, + }), + Effect.provide(CliLive), + ), + ); + + expect(await run(["pkg"])).toBe(0); + expect(await run(["pkg", "render", "--help"])).toBe(0); + expect(calls).toEqual([ + ["--help"], + ["render", "--help"], + ]); + }); + + test("normalizes root output flags before dispatching package commands", async () => { + const calls: Array = []; + const run = (argv: readonly string[]) => Effect.runPromise( + main(argv, {}).pipe( + Effect.provideService(PackageCli, { + execute: (args) => + Effect.sync(() => { + calls.push(args); + return 0; + }), + }), + Effect.provide(CliLive), + ), + ); + + expect(await run(["--json", "pkg", "version"])).toBe(0); + expect(await run(["pkg", "version", "--output", "json"])).toBe(0); + expect(calls).toEqual([ + ["version", "--json"], + ["version", "--json"], + ]); + }); + + test("renders package loader failures through the Effect error boundary", async () => { + const stdout: string[] = []; + const exitCode = await Effect.runPromise( + main(["pkg", "version"], {}).pipe( + Effect.provideService(PackageCli, { + execute: () => + Effect.fail( + new PackageCliFailure({ cause: new Error("native detail") }), + ), + }), + Effect.provideService(Console, { + stdoutIsTTY: false, + writeStderr: () => Effect.void, + writeStdout: (value) => + Effect.sync(() => { + stdout.push(value); + }), + }), + Effect.provide(CliLive), + ), + ); + + expect(exitCode).toBe(1); + expect(stdout.join("\n")).toContain("AKUA_PACKAGE_UNAVAILABLE"); + expect(stdout.join("\n")).not.toContain("native detail"); + }); + test("uses Effect CLI to describe the interactive command tree", async () => { const root = await runAkua([]); const auth = await runAkua(["auth", "--help"]); @@ -26,6 +110,7 @@ describe("akua entrypoint", () => { expect(root.stdout).toContain("SUBCOMMANDS"); expect(root.stdout).toContain("auth"); expect(root.stdout).toContain("commands"); + expect(root.stdout).toContain("pkg"); expect(root.stdout).toContain("machines"); expect(root.stdout).toContain( "agent-provider-exchanges Run generated agent-provider-exchanges API commands", @@ -59,6 +144,8 @@ describe("akua entrypoint", () => { expect(help.stdout).toContain( "akua auth login Sign in with a browser/device flow", ); + expect(home.stdout).toContain("akua pkg --help"); + expect(help.stdout).toContain("akua pkg --help"); expect(help.stdout).not.toContain("Save a local API token"); }); diff --git a/test/release.test.ts b/test/release.test.ts index 2b6f1b3..6b1113f 100644 --- a/test/release.test.ts +++ b/test/release.test.ts @@ -30,6 +30,46 @@ async function makeReleaseTempDir(): Promise { return mkdtemp(join(releaseRoot, ".tmp-akua-release-")); } +async function makePackageRuntimeFixture(root: string): Promise { + const packageRoot = join(root, "runtime-packages"); + const packages = [ + { + packageName: "native", + files: ["index.js", "loader.js", "index.d.ts", "extra-runtime.txt"], + }, + { + packageName: "native-engines", + files: ["index.js", "helm-engine.wasm", "kustomize-engine.wasm"], + }, + ]; + for (const runtimePackage of packages) { + const { packageName, files } = runtimePackage; + const directory = join(packageRoot, packageName); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "package.json"), + `${JSON.stringify({ name: `@akua-dev/${packageName}`, files })}\n`, + ); + for (const file of files) { + await writeFile(join(directory, file), `${packageName}/${file}\n`); + } + } + for (const target of RELEASE_TARGETS) { + const directory = join(packageRoot, target.bindingPackage); + const bindingFile = `akua.${target.bindingPackage.slice("native-".length)}.node`; + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, "package.json"), + `${JSON.stringify({ main: bindingFile })}\n`, + ); + await writeFile( + join(directory, bindingFile), + `${target.bindingPackage}/${bindingFile}\n`, + ); + } + return packageRoot; +} + test("release packaging has a dedicated implementation module", async () => { expect(await Bun.file("scripts/release.ts").exists()).toBe(true); }); @@ -110,6 +150,7 @@ describe("release target contract", () => { arch: "arm64", archive: "tar.gz", executable: "akua", + bindingPackage: "native-darwin-arm64", runner: "macos-15", homebrew: { os: "macos", arch: "arm" }, }, @@ -120,6 +161,7 @@ describe("release target contract", () => { arch: "x64", archive: "tar.gz", executable: "akua", + bindingPackage: "native-darwin-x64", runner: "macos-15-intel", homebrew: { os: "macos", arch: "intel" }, }, @@ -130,6 +172,7 @@ describe("release target contract", () => { arch: "arm64", archive: "tar.gz", executable: "akua", + bindingPackage: "native-linux-arm64-gnu", runner: "ubuntu-24.04-arm", homebrew: { os: "linux", arch: "arm" }, }, @@ -140,6 +183,7 @@ describe("release target contract", () => { arch: "x64", archive: "tar.gz", executable: "akua", + bindingPackage: "native-linux-x64-gnu", runner: "akua-x64-ci-v2", homebrew: { os: "linux", arch: "intel" }, }, @@ -150,6 +194,7 @@ describe("release target contract", () => { arch: "x64", archive: "zip", executable: "akua.exe", + bindingPackage: "native-win32-x64-msvc", runner: "windows-2025", }, ]); @@ -336,17 +381,21 @@ describe("release target contract", () => { } }); - test("packages executable-only archives, manifests, and matching checksums", async () => { + test("packages executables with their target-native package runtime", async () => { const release = (await import("../scripts/release")) as Record< string, unknown >; - const targets = release.RELEASE_TARGETS as Array<{ id: string }>; + const targets = release.RELEASE_TARGETS as Array<{ + id: string; + bindingPackage: string; + }>; const packageExistingExecutables = release.packageExistingExecutables as (input: { version: string; outputDir: string; binaries: Record; + packageRoot: string; }) => Effect.Effect; const verifyReleaseDirectory = release.verifyReleaseDirectory as ( outputDir: string, @@ -357,6 +406,7 @@ describe("release target contract", () => { try { const source = join(root, "akua-fixture"); const outputDir = join(root, "release"); + const packageRoot = await makePackageRuntimeFixture(root); await writeFile(source, "#!/bin/sh\necho akua fixture\n"); await chmod(source, 0o755); runRelease( @@ -366,6 +416,7 @@ describe("release target contract", () => { binaries: Object.fromEntries( targets.map((target) => [target.id, source]), ), + packageRoot, }), ); @@ -428,6 +479,33 @@ describe("release target contract", () => { expect(await readFile(join(extractDir, "akua"))).toEqual( await readFile(source), ); + expect( + await readFile( + join( + extractDir, + "node_modules/@akua-dev/native/akua.linux-x64-gnu.node", + ), + "utf8", + ), + ).toBe("native-linux-x64-gnu/akua.linux-x64-gnu.node\n"); + expect( + await readFile( + join( + extractDir, + "node_modules/@akua-dev/native-engines/helm-engine.wasm", + ), + "utf8", + ), + ).toBe("native-engines/helm-engine.wasm\n"); + expect( + await readFile( + join( + extractDir, + "node_modules/@akua-dev/native/extra-runtime.txt", + ), + "utf8", + ), + ).toBe("native/extra-runtime.txt\n"); } finally { await rm(root, { recursive: true, force: true }); } @@ -447,6 +525,7 @@ describe("release target contract", () => { version: string; outputDir: string; binaries: Record; + packageRoot: string; }) => Effect.Effect; const root = await makeReleaseTempDir(); @@ -454,6 +533,7 @@ describe("release target contract", () => { const source = join(root, "akua-fixture"); const firstOutputDir = join(root, "first"); const secondOutputDir = join(root, "second"); + const packageRoot = await makePackageRuntimeFixture(root); const binaries = Object.fromEntries( targets.map((target) => [target.id, source]), ); @@ -465,6 +545,7 @@ describe("release target contract", () => { version: "1.2.3", outputDir: firstOutputDir, binaries, + packageRoot, }), ); await Bun.sleep(2100); @@ -473,6 +554,7 @@ describe("release target contract", () => { version: "1.2.3", outputDir: secondOutputDir, binaries, + packageRoot, }), ); @@ -501,6 +583,7 @@ describe("release target contract", () => { version: string; outputDir: string; binaries: Record; + packageRoot: string; }) => Effect.Effect; const verifyReleaseDirectory = release.verifyReleaseDirectory as ( outputDir: string, @@ -511,6 +594,7 @@ describe("release target contract", () => { try { const source = join(root, "akua-fixture"); const outputDir = join(root, "release"); + const packageRoot = await makePackageRuntimeFixture(root); await writeFile(source, "#!/bin/sh\necho akua fixture\n"); await chmod(source, 0o755); runRelease( @@ -520,6 +604,7 @@ describe("release target contract", () => { binaries: Object.fromEntries( targets.map((target) => [target.id, source]), ), + packageRoot, }), ); await writeFile( @@ -602,6 +687,7 @@ describe("release target contract", () => { version: string; outputDir: string; binaries: Record; + packageRoot: string; }) => Effect.Effect; const verifyReleaseDirectory = release.verifyReleaseDirectory as ( outputDir: string, @@ -612,6 +698,7 @@ describe("release target contract", () => { try { const source = join(root, "akua-fixture"); const outputDir = join(root, "release"); + const packageRoot = await makePackageRuntimeFixture(root); await writeFile(source, "#!/bin/sh\necho akua fixture\n"); await chmod(source, 0o755); runRelease( @@ -621,6 +708,7 @@ describe("release target contract", () => { binaries: Object.fromEntries( targets.map((target) => [target.id, source]), ), + packageRoot, }), ); const manifestPath = join(outputDir, "akua-v1.2.3-homebrew.json"); @@ -711,6 +799,7 @@ describe("release target contract", () => { version: string; outputDir: string; binaries: Record; + packageRoot: string; }) => Effect.Effect; const smokeReleaseArtifact = release.smokeReleaseArtifact as (input: { version: string; @@ -722,9 +811,11 @@ describe("release target contract", () => { try { const source = join(root, "akua-fixture"); const outputDir = join(root, "release"); + const packageRoot = await makePackageRuntimeFixture(root); + const smokeLog = join(root, "smoke.log"); await writeFile( source, - '#!/bin/sh\ncase "$1" in\n --version) echo \'{"status":"ok","data":{"version":"1.2.3"}}\' ;;\n --help) echo \'Usage: akua\' ;;\n commands) echo \'commands[1]\' ;;\n *) exit 2 ;;\nesac\n', + `#!/bin/sh\nprintf '%s\\n' "$*" >> '${smokeLog}'\ncase "$1" in\n --version) echo '{"status":"ok","data":{"version":"1.2.3"}}' ;;\n --help) echo 'Usage: akua' ;;\n commands) echo 'commands[1]' ;;\n pkg)\n case "$2" in\n version) echo '{"version":"0.8.26"}' ;;\n init) mkdir -p demo; echo '{}' ;;\n check) echo '{}' ;;\n render) mkdir -p deploy; echo manifest > deploy/manifest.yaml; echo '{}' ;;\n inspect) echo '{}' ;;\n *) exit 2 ;;\n esac\n ;;\n *) exit 2 ;;\nesac\n`, ); await chmod(source, 0o755); runRelease( @@ -734,6 +825,7 @@ describe("release target contract", () => { binaries: Object.fromEntries( targets.map((target) => [target.id, source]), ), + packageRoot, }), ); @@ -746,6 +838,16 @@ describe("release target contract", () => { }), ), ).toBeUndefined(); + expect((await readFile(smokeLog, "utf8")).trim().split("\n")).toEqual([ + "--version --json", + "--help", + "commands --limit 1", + "pkg version --json", + "pkg init demo --json", + "pkg check --json", + "pkg render --inputs inputs.example.yaml --out deploy --json", + "pkg inspect --json", + ]); } finally { await rm(root, { recursive: true, force: true }); } @@ -767,6 +869,7 @@ describe("release target contract", () => { version: string; outputDir: string; binaries: Record; + packageRoot: string; }) => Effect.Effect; const smokeReleaseArtifact = release.smokeReleaseArtifact as (input: { version: string; @@ -778,6 +881,7 @@ describe("release target contract", () => { try { const source = join(root, "akua-fixture"); const outputDir = join(root, "release"); + const packageRoot = await makePackageRuntimeFixture(root); await writeFile( source, '#!/bin/sh\ncase "$1" in\n --version) echo \'{"status":"ok","data":{"version":"11.2.3"}}\' ;;\n --help) echo \'Usage: akua\' ;;\n commands) echo \'commands[1]\' ;;\n *) exit 2 ;;\nesac\n', @@ -790,6 +894,7 @@ describe("release target contract", () => { binaries: Object.fromEntries( targets.map((target) => [target.id, source]), ), + packageRoot, }), ); diff --git a/test/workflows.test.ts b/test/workflows.test.ts index ad869c0..3945234 100644 --- a/test/workflows.test.ts +++ b/test/workflows.test.ts @@ -22,6 +22,25 @@ describe("distribution workflows", () => { expect(workflow).toContain("fromJSON(needs.package.outputs.matrix)"); }); + test("release packaging installs every target-native optional dependency", async () => { + const [ciWorkflow, releaseWorkflow] = await Promise.all([ + readFile(".github/workflows/ci.yml", "utf8"), + readFile(".github/workflows/release.yml", "utf8"), + ]); + const installCommand = "bun install --frozen-lockfile --os='*' --cpu='*'"; + const ciPackageJob = ciWorkflow.slice( + ciWorkflow.indexOf(" package-release:"), + ciWorkflow.indexOf(" install-smoke:"), + ); + const releasePackageJob = releaseWorkflow.slice( + releaseWorkflow.indexOf(" package:"), + releaseWorkflow.indexOf(" install-smoke:"), + ); + + expect(ciPackageJob).toContain(installCommand); + expect(releasePackageJob).toContain(installCommand); + }); + test("CI packages once and install-smokes every runnable target natively", async () => { const workflow = await readFile(".github/workflows/ci.yml", "utf8");