diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b14fb8f..839e343 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,6 @@ name: publish -# This workflow owns both paths onto npm: +# This workflow owns every path onto npm: # # * A published GitHub Release tagged `vX.Y.Z` ships that version to the # `latest` dist-tag. The tag is the source of truth for the version — @@ -8,6 +8,9 @@ name: publish # * A merge into main ships a prerelease to the `next` dist-tag, versioned # from the current `latest` plus the commit sha. `latest` is never touched # by this path, so ordinary `npx plain-forge` users never see a prerelease. +# * A published GitHub Release tagged `regenerative-software-vX.Y.Z` ships +# the @plainlang/regenerative-software wrapper (packages/regenerative-software) +# at that version. It runs whichever plain-forge version its package.json pins. # # Auth is OIDC (npm trusted publishing) — no NPM_TOKEN secret. The trusted # publisher is configured on npmjs.com against this repo and this exact @@ -61,7 +64,7 @@ jobs: publish: name: Publish to npm needs: test - if: ${{ github.event_name == 'release' }} + if: ${{ github.event_name == 'release' && !startsWith(github.event.release.tag_name, 'regenerative-software-v') }} runs-on: ubuntu-latest # Records the release under Deployments → npm, the same way scripts/deploy.sh # does when publishing from a workstation. @@ -295,6 +298,120 @@ jobs: -H 'Content-type: application/json' \ --data "{\"text\":\"*plain-forge*: prerelease of \`${GITHUB_SHA:0:7}\` FAILED :x:. \`latest\` is unaffected. See <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|the run log>.\"}" + # --------------------------------------------------------------------------- + # Release tagged `regenerative-software-vX.Y.Z` -> @plainlang/regenerative-software. + # + # The wrapper is versioned independently of plain-forge: the tag sets the + # wrapper's version, and packages/regenerative-software/package.json pins the + # plain-forge version it runs. Lives in this file for the same trusted-publisher + # reason as the prerelease job. + publish-regenerative-software: + name: Publish @plainlang/regenerative-software to npm + needs: test + if: ${{ github.event_name == 'release' && startsWith(github.event.release.tag_name, 'regenerative-software-v') }} + runs-on: ubuntu-latest + environment: + name: npm + url: https://www.npmjs.com/package/@plainlang/regenerative-software + permissions: + contents: read + id-token: write # OIDC token for trusted publishing + + env: + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + PKG: "@plainlang/regenerative-software" + + defaults: + run: + working-directory: packages/regenerative-software + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.release.tag_name }} + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 # trusted publishing needs >= 22.14 + + - name: Use an npm that supports trusted publishing + run: | + npm install -g npm@latest + npm --version + + - name: Read the version from the release tag + id: pkg + env: + TAG: ${{ github.event.release.tag_name }} + run: | + VERSION="${TAG#regenerative-software-v}" + if ! echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::tag '$TAG' is not a semver release tag (expected regenerative-software-vX.Y.Z)" + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "forge=$(jq -r '.dependencies["plain-forge"]' package.json)" >> "$GITHUB_OUTPUT" + + - name: Refuse if this version is already on npm + run: | + if npm view "$PKG@${{ steps.pkg.outputs.version }}" version >/dev/null 2>&1; then + echo "::error::$PKG@${{ steps.pkg.outputs.version }} is already published — tag a new version" + exit 1 + fi + + - name: Refuse if the pinned plain-forge is not on npm + run: | + npm view "plain-forge@${{ steps.pkg.outputs.forge }}" version >/dev/null 2>&1 \ + || { echo "::error::package.json pins plain-forge@${{ steps.pkg.outputs.forge }}, which is not published"; exit 1; } + + - run: npm ci + + - name: Set the package version to match the tag + run: npm version "${{ steps.pkg.outputs.version }}" --no-git-tag-version --allow-same-version + + # Source-directory runs miss packaging mistakes, so install the packed + # tarball into a clean project and run the real installer through it. + - name: Smoke-test the packed package + run: | + smoke="$RUNNER_TEMP/smoke project" + mkdir -p "$smoke" + npm pack --pack-destination "$RUNNER_TEMP" + cd "$smoke" + npm init -y >/dev/null + npm install --no-audit --no-fund "$RUNNER_TEMP"/plainlang-regenerative-software-*.tgz + npx --no "$PKG" install --agent claude --scope project --skip-plain-parser + test -f .claude/skills/forge-plain/SKILL.md + test -f .claude/.plain-forge/manifest.json + + - name: Notify Slack (start) + if: ${{ env.SLACK_WEBHOOK_URL != '' }} + run: | + curl -s -X POST "$SLACK_WEBHOOK_URL" \ + -H 'Content-type: application/json' \ + --data "{\"text\":\"*$PKG*: release \`v${{ steps.pkg.outputs.version }}\` published by *${{ github.actor }}* — shipping to npm...\"}" + + - name: Publish + run: | + if [ "${{ github.event.release.prerelease }}" = "true" ]; then + npm publish --access public --tag next + else + npm publish --access public + fi + + - name: Notify Slack (success) + if: ${{ success() && env.SLACK_WEBHOOK_URL != '' }} + run: | + curl -s -X POST "$SLACK_WEBHOOK_URL" \ + -H 'Content-type: application/json' \ + --data "{\"text\":\"*$PKG*: \`v${{ steps.pkg.outputs.version }}\` published to npm :white_check_mark: (runs plain-forge \`${{ steps.pkg.outputs.forge }}\`). Try it with \`npx $PKG@latest install\`.\"}" + + - name: Notify Slack (failure) + if: ${{ failure() && env.SLACK_WEBHOOK_URL != '' }} + run: | + curl -s -X POST "$SLACK_WEBHOOK_URL" \ + -H 'Content-type: application/json' \ + --data "{\"text\":\"*$PKG*: publishing \`${{ github.event.release.tag_name }}\` to npm FAILED :x:. See <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|the run log>.\"}" + # Release pipelines to be turned on only after the rest of the infrastructure # is ready. # diff --git a/RELEASING.md b/RELEASING.md index bcf331f..10e7ca5 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -111,6 +111,55 @@ Slack gets a line either way: the version published and how to try it, or the fa `latest` confirmed unaffected. A re-run that had nothing to publish says so rather than claiming something shipped. The run summary records the same thing. +## Releasing @plainlang/regenerative-software + +[`packages/regenerative-software`](packages/regenerative-software) is a thin wrapper that +lets users run `npx @plainlang/regenerative-software install`. It has its own version and +its own tags, and runs the exact `plain-forge` version pinned in its `package.json`. + +1. To ship a newer `plain-forge`, re-pin it on a branch and merge: + + ```bash + cd packages/regenerative-software + npm install --save-exact plain-forge@latest # updates package.json + package-lock.json + ``` + +2. Create a release tagged `regenerative-software-vX.Y.Z` and keep **plain-forge** marked + as the latest GitHub release: + + ```bash + gh release create regenerative-software-v1.0.1 --target main \ + --title "regenerative-software v1.0.1" --latest=false --notes-file notes.md + ``` + +The `publish-regenerative-software` job in `publish.yml` handles these tags (the +plain-forge `publish` job skips them). It runs the tests and checks the tag is semver. +It refuses a version already on npm or a pinned `plain-forge` that isn't published. Then it +installs the packed tarball into a clean project, runs the real installer through it, and +publishes. A pre-release goes to `next`, as with plain-forge. Like plain-forge, +`package.json`'s `version` is stale by design and the tag decides. + +`--latest=false` matters: `scripts/deploy.sh` publishes "the latest GitHub release" by +default, and it refuses a `regenerative-software-v*` tag as non-semver. + +**One-time setup, before the first automated release.** The `@plainlang` scope must exist as an npm organization ([create it](https://www.npmjs.com/org/create); unlimited public packages are free), and you must be a member. npm only lets you configure a +trusted publisher on a package that already exists. Publish `1.0.0` once by hand, with +an account that can publish to the `@plainlang` scope: + +```bash +npm login +./scripts/deploy-regenerative-software.sh --dry-run # test + smoke test, publish nothing +./scripts/deploy-regenerative-software.sh # publish package.json's version +./scripts/deploy-regenerative-software.sh --version 1.0.1 --otp 123456 +``` + +The script publishes `packages/regenerative-software` as it is in your working tree. It +copies the package to a temp dir, runs the wrapper tests and checks the version isn't on +npm yet. It installs the packed tarball into a clean project, runs the real installer +through it, and publishes that same tarball. Then add a trusted publisher on npmjs.com: +`@plainlang/regenerative-software` → Settings → Trusted Publisher → GitHub Actions, +`plainlang/plain-forge`, workflow `publish.yml`, environment `npm`. + ## Release notes Generated notes (`--generate-notes`, or the button on the form) list merged PRs and are @@ -161,6 +210,7 @@ at the repo root — gitignored, and the only place a webhook should live locall | what | where | note | |---|---|---| | Trusted publisher | npmjs.com → `plain-forge` → Settings → Trusted Publisher | GitHub Actions, `plainlang/plain-forge`, workflow `publish.yml` | +| Trusted publisher | npmjs.com → `@plainlang/regenerative-software` → Settings → Trusted Publisher | same as above; **not yet configured**, see [Releasing @plainlang/regenerative-software](#releasing-plainlangregenerative-software) | | `SLACK_WEBHOOK_URL` | repo secret | notifications are skipped without it | | `LINEAR_ACCESS_KEY` | repo secret | records the release in Linear; the job is currently commented out in `publish.yml` | | Branch protection | `main` | `Test (node 22 / 24)` required, no force pushes, no deletion | diff --git a/packages/regenerative-software/README.md b/packages/regenerative-software/README.md new file mode 100644 index 0000000..f3275ec --- /dev/null +++ b/packages/regenerative-software/README.md @@ -0,0 +1,32 @@ +# @plainlang/regenerative-software + +Runs [plain-forge](https://github.com/plainlang/plain-forge) under the `@plainlang` name: + +```bash +npx @plainlang/regenerative-software install +npx @plainlang/regenerative-software install --agent codex --scope project +npx @plainlang/regenerative-software update +npx @plainlang/regenerative-software uninstall +``` + +Every argument is forwarded to `plain-forge`, so prompts, output and exit codes are the +same as `npx plain-forge ...`. Requires Node.js 22 or newer. + +## How it works + +`plain-forge` is an exact-pinned dependency. `cli.cjs` resolves its `bin/cli.mjs` and +runs it with the same Node binary, no shell, inherited stdio, and SIGINT/SIGTERM +forwarded to the child. Each wrapper release runs exactly one `plain-forge` version. +`@latest` picks the newest wrapper, which then runs the version it pins. + +## Releasing + +Releases are automated. Re-pin `plain-forge` with +`npm install --save-exact plain-forge@latest`, merge, then publish a GitHub Release +tagged `regenerative-software-vX.Y.Z`. CI tests and smoke-tests the packed tarball, +then publishes. See +[RELEASING.md](../../RELEASING.md#releasing-plainlangregenerative-software). + +If `plain-forge` ever moves `bin/cli.mjs` or adds an `exports` map, update the +`require.resolve` path in `cli.cjs`. Never add a `postinstall` script that runs the +installer; it must run only when the user invokes the command. diff --git a/packages/regenerative-software/cli.cjs b/packages/regenerative-software/cli.cjs new file mode 100755 index 0000000..ac71514 --- /dev/null +++ b/packages/regenerative-software/cli.cjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +"use strict"; + +// Runs the pinned plain-forge dependency's CLI with every argument forwarded, so +// `npx @plainlang/regenerative-software install` behaves like `npx plain-forge install`. + +const { spawn } = require("node:child_process"); + +if (Number(process.versions.node.split(".")[0]) < 22) { + console.error("regenerative-software requires Node.js 22 or newer."); + process.exit(1); +} + +let cliPath; + +try { + cliPath = require.resolve("plain-forge/bin/cli.mjs"); +} catch (error) { + console.error(`Unable to locate the plain-forge CLI: ${error.message}`); + process.exit(1); +} + +// Launch Node directly (no shell) so arguments reach plain-forge unquoted and +// Windows .cmd shims are never involved. +const child = spawn(process.execPath, [cliPath, ...process.argv.slice(2)], { + stdio: "inherit", + shell: false, +}); + +let processError = false; +const handlers = new Map(); + +// Forward termination requests sent only to the wrapper (CI cancellation, process +// supervisors); otherwise the wrapper would exit and leave the installer running. +for (const signal of ["SIGINT", "SIGTERM"]) { + const handler = () => { + if (child.exitCode === null && child.signalCode === null) { + child.kill(signal); + } + }; + + handlers.set(signal, handler); + process.on(signal, handler); +} + +child.on("error", (error) => { + processError = true; + console.error(`Unable to run plain-forge: ${error.message}`); +}); + +child.once("close", (code, signal) => { + for (const [name, handler] of handlers) { + process.removeListener(name, handler); + } + + if (processError) { + process.exitCode = 1; + return; + } + + if (signal) { + process.exitCode = signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1; + + // Re-raise so our parent sees a signal death, as it would for plain-forge itself. + if (process.platform !== "win32") { + process.kill(process.pid, signal); + } + + return; + } + + process.exitCode = code ?? 1; +}); diff --git a/packages/regenerative-software/package-lock.json b/packages/regenerative-software/package-lock.json new file mode 100644 index 0000000..44ac56a --- /dev/null +++ b/packages/regenerative-software/package-lock.json @@ -0,0 +1,34 @@ +{ + "name": "@plainlang/regenerative-software", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@plainlang/regenerative-software", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "plain-forge": "1.0.23" + }, + "bin": { + "regenerative-software": "cli.cjs" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/plain-forge": { + "version": "1.0.23", + "resolved": "https://registry.npmjs.org/plain-forge/-/plain-forge-1.0.23.tgz", + "integrity": "sha512-eanjnjoOpMUHW5/0n5JM9A8s6tIMYVrdTqCK7AHYiIRsaxG+udXie73lvyZuiuzjaVfmf71eo5Lw7SIgcqeTaw==", + "license": "MIT", + "bin": { + "plain-forge": "bin/cli.mjs" + }, + "engines": { + "node": ">=22" + } + } + } +} diff --git a/packages/regenerative-software/package.json b/packages/regenerative-software/package.json new file mode 100644 index 0000000..922d543 --- /dev/null +++ b/packages/regenerative-software/package.json @@ -0,0 +1,31 @@ +{ + "name": "@plainlang/regenerative-software", + "version": "1.0.0", + "description": "Run plain-forge through @plainlang/regenerative-software", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/plainlang/plain-forge.git", + "directory": "packages/regenerative-software" + }, + "homepage": "https://github.com/plainlang/plain-forge", + "bin": { + "regenerative-software": "./cli.cjs" + }, + "files": [ + "cli.cjs" + ], + "engines": { + "node": ">=22" + }, + "scripts": { + "check": "node --check cli.cjs", + "prepack": "npm run check" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "plain-forge": "1.0.23" + } +} diff --git a/scripts/deploy-regenerative-software.sh b/scripts/deploy-regenerative-software.sh new file mode 100755 index 0000000..dee329f --- /dev/null +++ b/scripts/deploy-regenerative-software.sh @@ -0,0 +1,194 @@ +#!/usr/bin/env bash +# +# Publish @plainlang/regenerative-software to npm from a workstation. +# +# ./scripts/deploy-regenerative-software.sh # version from package.json +# ./scripts/deploy-regenerative-software.sh --version 1.0.1 # a specific version +# ./scripts/deploy-regenerative-software.sh --dry-run # test + smoke test, publish nothing +# ./scripts/deploy-regenerative-software.sh --otp 123456 # npm account with 2FA on publish +# +# Publishes packages/regenerative-software exactly as it is in your working tree, +# which is what the very first release needs: npm only allows a trusted publisher +# on a package that already exists, so 1.0.0 has to go out by hand. After that, +# prefer the `regenerative-software-vX.Y.Z` GitHub Release (see RELEASING.md). +# +# The package is copied to a temp dir, tested, packed, and the tarball is +# installed into a clean project and run before that same tarball is published. +# Nothing in the repo is modified. Version suffixes (1.1.0-rc.1) go to `next`. +# +# Slack notifications are sent when SLACK_WEBHOOK_URL is set, in the environment +# or in a local .env.release file (gitignored). + +set -euo pipefail + +PKG="@plainlang/regenerative-software" +PKG_DIR="packages/regenerative-software" + +VERSION="" +DRY_RUN=false +OTP="" +ASSUME_YES=false +WORKDIR="" +PUBLISHING=false + +# ---------------------------------------------------------------- utilities + +log() { printf '\033[0;36m==>\033[0m %s\n' "$*"; } +warn() { printf '\033[0;33mwarning:\033[0m %s\n' "$*" >&2; } +die() { printf '\033[0;31merror:\033[0m %s\n' "$*" >&2; exit 1; } + +usage() { + sed -n '3,20p' "$0" | sed 's/^# \{0,1\}//' + exit "${1:-0}" +} + +slack() { + [ -n "${SLACK_WEBHOOK_URL:-}" ] || return 0 + curl -sf -X POST "$SLACK_WEBHOOK_URL" \ + -H 'Content-type: application/json' \ + --data "$(jq -n --arg t "$1" '{text: $t}')" >/dev/null || warn "slack notification failed" +} + +cleanup() { + local code=$? + if [ -n "$WORKDIR" ] && [ -d "$WORKDIR" ]; then + rm -rf "$WORKDIR" + fi + if [ "$code" -ne 0 ] && [ "$PUBLISHING" = true ]; then + slack "*${PKG}*: publishing \`v${VERSION}\` to npm FAILED :x: (local deploy by *$(whoami)*)." + fi + exit "$code" +} +trap cleanup EXIT + +# ---------------------------------------------------------------- arguments + +while [ $# -gt 0 ]; do + case "$1" in + --version) VERSION="${2:-}"; shift 2 ;; + --otp) OTP="${2:-}"; shift 2 ;; + --dry-run) DRY_RUN=true; shift ;; + -y|--yes) ASSUME_YES=true; shift ;; + -h|--help) usage 0 ;; + *) die "unknown argument: $1 (try --help)" ;; + esac +done + +# ---------------------------------------------------------------- preflight + +cd "$(dirname "$0")/.." + +for bin in git npm node jq; do + command -v "$bin" >/dev/null || die "$bin is required but not installed" +done + +if [ -f .env.release ]; then + set -a + # shellcheck source=/dev/null + . ./.env.release + set +a +fi + +NODE_MAJOR=$(node -p "process.versions.node.split('.')[0]") +[ "$NODE_MAJOR" -ge 22 ] || die "node >= 22 required (found $(node --version))" + +if [ "$DRY_RUN" = false ]; then + npm whoami >/dev/null 2>&1 || die "not logged in to npm — run: npm login" + # npm only reports a missing scope at the final PUT, so check it up front. + npm org ls plainlang >/dev/null 2>&1 \ + || die "the @plainlang npm org does not exist or $(npm whoami) is not a member — create it at https://www.npmjs.com/org/create" +fi + +[ -n "$VERSION" ] || VERSION=$(jq -r .version "$PKG_DIR/package.json") +echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' \ + || die "version '$VERSION' is not semver (expected X.Y.Z)" + +if npm view "$PKG@$VERSION" version >/dev/null 2>&1; then + die "$PKG@$VERSION is already published — pass a new --version" +fi + +FORGE=$(jq -r '.dependencies["plain-forge"]' "$PKG_DIR/package.json") +npm view "plain-forge@$FORGE" version >/dev/null 2>&1 \ + || die "$PKG_DIR/package.json pins plain-forge@$FORGE, which is not on npm" + +NPM_TAG_ARGS=() +case "$VERSION" in *-*) NPM_TAG_ARGS=(--tag next) ;; esac + +if [ -n "$(git status --porcelain -- "$PKG_DIR")" ]; then + warn "$PKG_DIR has uncommitted changes — they will be published as they are" +fi + +echo +log "package $PKG@$VERSION" +log "runs plain-forge@$FORGE" +log "source $PKG_DIR @ $(git rev-parse --short HEAD) ($(git branch --show-current))" +log "dist-tag $([ ${#NPM_TAG_ARGS[@]} -gt 0 ] && echo 'next (pre-release)' || echo latest)" +if [ "$DRY_RUN" = true ]; then + log "publishing (dry run — nothing is published)" +else + log "publishing as $(npm whoami)" +fi +log "slack $([ -n "${SLACK_WEBHOOK_URL:-}" ] && echo configured || echo 'not configured — notifications skipped')" +echo + +if [ "$ASSUME_YES" = false ] && [ "$DRY_RUN" = false ]; then + read -r -p "publish $PKG@$VERSION to npm? [y/N] " reply + case "$reply" in [yY]*) ;; *) die "aborted" ;; esac +fi + +# ------------------------------------------------------- build and smoke test + +log "running wrapper tests" +node --test test/regenerative-software.test.mjs + +WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/regenerative-software-release.XXXXXX") +mkdir "$WORKDIR/pkg" "$WORKDIR/smoke project" +cp "$PKG_DIR/package.json" "$PKG_DIR/package-lock.json" "$PKG_DIR/cli.cjs" "$PKG_DIR/README.md" "$WORKDIR/pkg/" + +cd "$WORKDIR/pkg" +log "installing dependencies" +npm ci --silent --no-audit --no-fund + +log "setting package version to $VERSION" +npm version "$VERSION" --no-git-tag-version --allow-same-version >/dev/null + +log "packing" +TARBALL="$WORKDIR/$(npm pack --pack-destination "$WORKDIR" --silent | tail -1)" +tar -tzf "$TARBALL" | sed 's/^/ /' + +# Install the packed tarball into a clean project (in a path with a space) and +# run the real installer through it — the same check CI makes. +log "smoke-testing the packed package" +cd "$WORKDIR/smoke project" +npm init -y >/dev/null +npm install --silent --no-audit --no-fund "$TARBALL" +npx --no "$PKG" install --agent claude --scope project --skip-plain-parser >/dev/null +[ -f .claude/skills/forge-plain/SKILL.md ] || die "smoke test: skills were not installed" +[ -f .claude/.plain-forge/manifest.json ] || die "smoke test: no install manifest written" +npx --no "$PKG" uninstall --agent claude --scope project >/dev/null +[ ! -e .claude/.plain-forge/manifest.json ] || die "smoke test: uninstall left the manifest behind" +log "smoke test passed" + +if [ "$DRY_RUN" = true ]; then + echo + log "dry run complete: $PKG@$VERSION would be published to '$([ ${#NPM_TAG_ARGS[@]} -gt 0 ] && echo next || echo latest)'" + exit 0 +fi + +# ------------------------------------------------------------------ publish + +PUBLISHING=true +slack "*${PKG}*: \`v${VERSION}\` shipping to npm (local deploy by *$(whoami)*)..." + +log "publishing to npm" +OTP_ARGS=() +if [ -n "$OTP" ]; then OTP_ARGS=(--otp "$OTP"); fi +# Publish the exact tarball that passed the smoke test. +npm publish "$TARBALL" --access public ${NPM_TAG_ARGS[@]+"${NPM_TAG_ARGS[@]}"} ${OTP_ARGS[@]+"${OTP_ARGS[@]}"} +PUBLISHING=false + +slack "*${PKG}*: \`v${VERSION}\` published to npm :white_check_mark: (runs plain-forge \`${FORGE}\`). Try it with \`npx ${PKG}@latest install\`." + +echo +log "published $PKG@$VERSION → https://www.npmjs.com/package/$PKG/v/$VERSION" +log "users run: npx $PKG install" diff --git a/test/regenerative-software.test.mjs b/test/regenerative-software.test.mjs new file mode 100644 index 0000000..7f4c7e6 --- /dev/null +++ b/test/regenerative-software.test.mjs @@ -0,0 +1,94 @@ +// Black-box tests for packages/regenerative-software/cli.cjs, the +// `npx @plainlang/regenerative-software` wrapper. Each test copies the wrapper next to +// a stand-in plain-forge CLI, so they check forwarding rather than plain-forge itself. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const WRAPPER = fileURLToPath(new URL('../packages/regenerative-software/cli.cjs', import.meta.url)); + +const STAND_IN = `import fs from 'node:fs'; +if (process.argv[2] === 'wait') { + process.on('SIGTERM', () => { fs.writeFileSync(process.argv[3] + '.stopped', 'yes'); process.exit(0); }); + fs.writeFileSync(process.argv[3], String(process.pid)); + setInterval(() => {}, 1000); +} else { + console.log(JSON.stringify({ + args: process.argv.slice(2), + cwd: process.cwd(), + marker: process.env.WRAPPER_MARKER, + input: fs.readFileSync(0, 'utf8'), + })); + console.error('stderr preserved'); + process.exit(Number(process.env.WRAPPER_EXIT || 0)); +} +`; + +function makeWrapper() { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'regen-sw-'))); + const pkg = path.join(root, 'wrapper'); + const forgeBin = path.join(pkg, 'node_modules', 'plain-forge', 'bin'); + fs.mkdirSync(forgeBin, { recursive: true }); + fs.writeFileSync(path.join(forgeBin, '..', 'package.json'), '{"name":"plain-forge","type":"module"}'); + fs.writeFileSync(path.join(forgeBin, 'cli.mjs'), STAND_IN); + fs.copyFileSync(WRAPPER, path.join(pkg, 'cli.cjs')); + const project = path.join(root, 'project with spaces'); + fs.mkdirSync(project); + return { root, cli: path.join(pkg, 'cli.cjs'), project }; +} + +for (const expected of [0, 7]) { + test(`forwards args, cwd, env and stdio; exits ${expected}`, () => { + const { root, cli, project } = makeWrapper(); + try { + const args = ['install', '--label', 'space and $literal; "text"']; + const r = spawnSync(process.execPath, [cli, ...args], { + cwd: project, + env: { ...process.env, WRAPPER_MARKER: 'present', WRAPPER_EXIT: String(expected) }, + input: 'stdin preserved', + encoding: 'utf8', + }); + assert.equal(r.status, expected, r.stderr); + assert.deepEqual(JSON.parse(r.stdout), { args, cwd: project, marker: 'present', input: 'stdin preserved' }); + assert.equal(r.stderr, 'stderr preserved\n'); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} + +test('reports a missing plain-forge dependency and exits 1', () => { + const { root, cli } = makeWrapper(); + try { + fs.rmSync(path.join(path.dirname(cli), 'node_modules'), { recursive: true }); + const r = spawnSync(process.execPath, [cli, 'install'], { encoding: 'utf8' }); + assert.equal(r.status, 1); + assert.match(r.stderr, /Unable to locate the plain-forge CLI/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('SIGTERM sent only to the wrapper reaches plain-forge', { skip: process.platform === 'win32' }, async () => { + const { root, cli } = makeWrapper(); + const pidFile = path.join(root, 'child.pid'); + const wrapper = spawn(process.execPath, [cli, 'wait', pidFile], { stdio: 'ignore' }); + try { + for (let i = 0; i < 250 && !fs.existsSync(pidFile); i++) { + await new Promise((r) => setTimeout(r, 20)); + } + assert.ok(fs.existsSync(pidFile), 'stand-in never started'); + const exited = new Promise((resolve) => wrapper.once('exit', (code) => resolve(code))); + wrapper.kill('SIGTERM'); + assert.equal(await exited, 0); + assert.ok(fs.existsSync(`${pidFile}.stopped`), 'plain-forge did not receive SIGTERM'); + } finally { + wrapper.kill('SIGKILL'); + fs.rmSync(root, { recursive: true, force: true }); + } +});