diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 847aeb6..8b6e08d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -40,3 +40,36 @@ jobs: dist/ !dist/*.txt retention-days: 30 + + npm-publish: + needs: goreleaser + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # npm --provenance (Sigstore via GitHub OIDC) + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + registry-url: https://registry.npmjs.org + + - name: Download goreleaser dist + uses: actions/download-artifact@v4 + with: + name: release-artifacts + path: dist + + - name: Assemble npm packages + run: node npm/scripts/prepare.mjs --version "${GITHUB_REF_NAME#v}" --dist dist --out dist/npm + + - name: Smoke test staged linux-x64 binary + run: dist/npm/cli-linux-x64/bin/vf version + + - name: Publish (idempotent) + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: node npm/scripts/publish.mjs --version "${GITHUB_REF_NAME#v}" --out dist/npm diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..4066209 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,80 @@ +# npm distribution pipeline + +This directory publishes the CLI to npm as `@voiceflow/cli` plus six platform +binary packages, so `npx @voiceflow/cli` works cold with no postinstall +scripts and no compilation. It is entirely hand-written — nothing here is +Speakeasy-generated. + +## How it works + +- `cli/` is the wrapper package, checked in verbatim at version `0.0.0`. + Its `bin/vf.js` shim resolves `@voiceflow/cli--` (installed via + `optionalDependencies` with `os`/`cpu` fields, so npm downloads only the + matching platform) and execs the Go binary. +- `scripts/prepare.mjs` stages all seven packages into `dist/npm/` from + goreleaser's `dist/artifacts.json`, stamping the release version everywhere + and embedding the repo's LICENSE in each package. +- `scripts/publish.mjs` publishes idempotently: platform packages first, a + registry-visibility gate, wrapper last. Re-running after a partial failure + skips what already went out. +- Both scripts are dependency-free ESM run by bare `node`. That is deliberate: + the publish step holds `NPM_TOKEN`, and a TypeScript runner would have npm + resolve its transitive dependency tree from the registry at run time — with + no lockfile — inside the token-bearing step. Keep them on node builtins. +- The `npm-publish` job in `.github/workflows/release.yaml` runs both after + goreleaser on every `v*` tag. Versions are in lockstep with git tags by + construction (`GITHUB_REF_NAME`). + +## Invariants (breaking any of these breaks users) + +1. **Every `bin` entry in `cli/package.json` must point at the same file.** + npx only resolves `npx @voiceflow/cli` when all bin values are identical. + `prepare.ts` enforces this at staging time. +2. **Platform binaries must be `chmod 755` at staging.** The Actions artifact + zip round-trip drops the executable bit, and npm records file modes from + disk into the tarball. `prepare.ts` does this; the workflow smoke-tests a + staged binary to catch regressions. +3. **Wrapper pins platform packages with exact versions** — never ranges. +4. **Platform packages carry no `bin`, no `exports`, no `main`.** An exports + map would block the shim's `require.resolve` of the `/bin/vf` subpath. +5. **One linux package per arch, no musl split.** The Go binaries are + CGO-free static builds; the same package works on glibc and Alpine. +6. **Never republish or unpublish a version.** Recovery is always: fix, bump + patch, tag again. A bad release gets `npm deprecate`, not `npm unpublish`. +7. **Prerelease versions (containing `-`) publish under the `next` dist-tag** + so `latest` never resolves an rc. +8. **`latest` only moves forward.** Before tagging, `publish.mjs` reads the + registry's current `latest`; a version that is not newer publishes under + the `previous` tag instead. Without this, a recovery re-run of an older + version would silently downgrade what `npx @voiceflow/cli` installs. +9. **A publish without a LICENSE file is impossible.** Every package.json + declares Apache-2.0, so `prepare.mjs` exits 1 when the repo root has no + LICENSE rather than shipping a license claim with no license text — a + mislabeled version cannot be taken back. goreleaser marks them prereleases too. + +## Failure recovery + +- **Partial publish** (some packages live, job died): re-run the + `npm-publish` job from the Actions UI. Existence checks make it a no-op + for published packages; the wrapper only goes out after all six platforms + are visible. Safe even if a newer version shipped in the meantime — + invariant 8 keeps the recovered older version off the `latest` tag. +- **Artifact expired** (>30 days): do not rebuild-and-republish the same + version — rebuilt binaries would not match the GitHub release. Bump patch, + tag again. +- **Speakeasy regeneration**: `release.yaml` is generation-tracked with + persistent edits enabled; the appended `npm-publish` job is expected to + survive (precedent: the goreleaser naming fix did). If a regeneration ever + drops it, move the job unchanged into its own `workflow_run`-triggered + workflow file (needs `actions: read` and `run-id` on download-artifact). + +## First-time setup (human steps) + +1. npm org owner creates a granular access token: scope `@voiceflow`, + read+write, allowed to create new packages, bypass-2FA if the org + enforces publish-2FA. Add it as the `NPM_TOKEN` Actions secret. +2. Dry-run with a prerelease tag (e.g. `v0.NNN.0-rc.1`) — publishes under + `next`. Verify `npx @voiceflow/cli@next version` on macOS/Linux/Windows. +3. Cut the real tag. +4. After first publish, configure npm trusted publishing (tokenless OIDC) + for all seven packages on npmjs.com and retire `NPM_TOKEN`. diff --git a/npm/cli/README.md b/npm/cli/README.md new file mode 100644 index 0000000..1b1b8e0 --- /dev/null +++ b/npm/cli/README.md @@ -0,0 +1,49 @@ +# Voiceflow CLI + +`vf` is the command line for [Voiceflow](https://www.voiceflow.com), the AI agent platform for customer experience automation. Manage agents, knowledge bases, workflows, tests, and transcripts from your terminal or CI. + +## Install + +```bash +# Run without installing +npx -y @voiceflow/cli --help + +# Or install globally (provides both `vf` and `voiceflow`) +npm install -g @voiceflow/cli +``` + +This package installs a prebuilt binary for your platform via an optional dependency (`@voiceflow/cli--`). No postinstall scripts, no compilation. + +## Authenticate + +Create a personal access token in Voiceflow under **Settings → Access tokens** (tokens start with `vfp_`), then: + +```bash +export VF_TOKEN=vfp_... +``` + +Every command also accepts `--token`. + +## First conversation in four commands + +```bash +vf workspace list --output-format json # -> .workspaces[].id +vf project create --name "My Agent" --type webchat \ + --workspace-id $WORKSPACE_ID --output-format json # -> .project.id +vf conversation send --user-id demo-user --project-id $PROJECT_ID \ + --environment-alias main --version-param draft \ + --action '{"type":"launch"}' --output-format json +vf conversation send --user-id demo-user --project-id $PROJECT_ID \ + --environment-alias main --version-param draft \ + --action '{"type":"text","payload":"What can you do?"}' --output-format json +``` + +## Docs + +- CLI documentation: https://www.voiceflow.com/docs/cli/overview +- Authentication: https://www.voiceflow.com/docs/api-reference/authentication +- Source and prebuilt binaries: https://github.com/voiceflow/cli + +## Supported platforms + +macOS (arm64, x64), Linux (arm64, x64 — glibc and musl), Windows (arm64, x64). Other platforms can use the binaries on the [releases page](https://github.com/voiceflow/cli/releases) or `go install github.com/voiceflow/cli/cmd/vf@latest`. diff --git a/npm/cli/bin/vf.js b/npm/cli/bin/vf.js new file mode 100644 index 0000000..579e1e2 --- /dev/null +++ b/npm/cli/bin/vf.js @@ -0,0 +1,76 @@ +#!/usr/bin/env node +'use strict'; +// Launcher for the Voiceflow CLI (vf). +// +// The real CLI is a Go binary shipped in a platform-specific package +// (@voiceflow/cli--) installed as an optionalDependency of +// @voiceflow/cli. This shim resolves the right binary and hands over. +// +// INVARIANT: every entry in this package's "bin" map must point at this +// same file — npx only resolves `npx @voiceflow/cli` when all bin values +// are identical (npm/libnpmexec checks that before the name match). + +const { spawn } = require('node:child_process'); + +const platformKey = `${process.platform}-${process.arch}`; +const SUPPORTED_PLATFORMS = [ + 'darwin-arm64', + 'darwin-x64', + 'linux-arm64', + 'linux-x64', + 'win32-arm64', + 'win32-x64', +]; + +function fail(lines) { + console.error(lines.join('\n')); + process.exit(1); +} + +if (!SUPPORTED_PLATFORMS.includes(platformKey)) { + fail([ + `The Voiceflow CLI does not ship a prebuilt binary for your platform (${platformKey}).`, + `Supported platforms: ${SUPPORTED_PLATFORMS.join(', ')}.`, + 'Binaries for other platforms may be available at:', + ' https://github.com/voiceflow/cli/releases', + ]); +} + +const executableName = process.platform === 'win32' ? 'vf.exe' : 'vf'; +let binaryPath; +try { + binaryPath = require.resolve(`@voiceflow/cli-${platformKey}/bin/${executableName}`); +} catch { + fail([ + `Could not find the Voiceflow CLI binary package "@voiceflow/cli-${platformKey}".`, + 'It is installed automatically as an optionalDependency of @voiceflow/cli.', + 'This usually means optional dependencies were skipped (npm --omit=optional,', + 'yarn --ignore-optional) or the lockfile was created on a different platform.', + 'Reinstall without those flags, or download a binary directly:', + ' https://github.com/voiceflow/cli/releases', + ]); +} + +const child = spawn(binaryPath, process.argv.slice(2), { stdio: 'inherit' }); + +// Terminal-generated SIGINT reaches the child directly through the shared +// process group, so the shim only ignores it and waits. SIGTERM/SIGHUP are +// delivered to the shim alone, so those are forwarded. +process.on('SIGINT', () => {}); +for (const signal of ['SIGTERM', 'SIGHUP']) { + process.on(signal, () => child.kill(signal)); +} + +child.on('error', (error) => { + fail([`Failed to start ${binaryPath}: ${error.message}`]); +}); + +child.on('close', (code, signal) => { + if (signal) { + // Die by the same signal so callers observe the real termination reason. + process.removeAllListeners(signal); + process.kill(process.pid, signal); + } else { + process.exit(code ?? 1); + } +}); diff --git a/npm/cli/package.json b/npm/cli/package.json new file mode 100644 index 0000000..817362e --- /dev/null +++ b/npm/cli/package.json @@ -0,0 +1,46 @@ +{ + "name": "@voiceflow/cli", + "version": "0.0.0", + "description": "Voiceflow CLI (vf) — command line for Voiceflow, the AI agent platform for customer experience automation. Manage agents, knowledge bases, workflows, tests, and transcripts from your terminal or CI.", + "keywords": [ + "voiceflow", + "vf", + "cli", + "ai", + "ai-agents", + "agents", + "chatbot", + "voice", + "conversational-ai", + "customer-experience", + "automation", + "llm" + ], + "license": "Apache-2.0", + "homepage": "https://www.voiceflow.com/docs/cli/overview", + "repository": { + "type": "git", + "url": "git+https://github.com/voiceflow/cli.git" + }, + "bugs": { + "url": "https://github.com/voiceflow/cli/issues" + }, + "bin": { + "vf": "bin/vf.js", + "voiceflow": "bin/vf.js" + }, + "files": [ + "bin/" + ], + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@voiceflow/cli-darwin-arm64": "0.0.0", + "@voiceflow/cli-darwin-x64": "0.0.0", + "@voiceflow/cli-linux-arm64": "0.0.0", + "@voiceflow/cli-linux-x64": "0.0.0", + "@voiceflow/cli-win32-arm64": "0.0.0", + "@voiceflow/cli-win32-x64": "0.0.0" + } +} diff --git a/npm/scripts/prepare.mjs b/npm/scripts/prepare.mjs new file mode 100644 index 0000000..68ca707 --- /dev/null +++ b/npm/scripts/prepare.mjs @@ -0,0 +1,159 @@ +// Stages the npm packages for a release into an output directory. +// +// Reads goreleaser's dist/artifacts.json to locate the six built binaries +// (never guesses goreleaser's per-target directory names), then writes: +// +// /cli--/ one package per platform, binary at bin/vf[.exe] +// /cli/ the wrapper package, copied from npm/cli/ +// +// with every package.json stamped to the release version. Publishing is a +// separate, side-effectful step: npm/scripts/publish.mjs. +// +// Dependency-free ESM run by bare `node` — no tsx, no transitive packages, so +// nothing from the registry executes in the release job. Node >= 18. +// +// Usage: node npm/scripts/prepare.mjs --version 0.229.0 --dist dist --out dist/npm + +import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import * as path from 'node:path'; +import * as process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +const PLATFORM_TARGETS = [ + { goos: 'darwin', goarch: 'arm64', npmOs: 'darwin', npmCpu: 'arm64', executableName: 'vf' }, + { goos: 'darwin', goarch: 'amd64', npmOs: 'darwin', npmCpu: 'x64', executableName: 'vf' }, + { goos: 'linux', goarch: 'arm64', npmOs: 'linux', npmCpu: 'arm64', executableName: 'vf' }, + { goos: 'linux', goarch: 'amd64', npmOs: 'linux', npmCpu: 'x64', executableName: 'vf' }, + { goos: 'windows', goarch: 'arm64', npmOs: 'win32', npmCpu: 'arm64', executableName: 'vf.exe' }, + { goos: 'windows', goarch: 'amd64', npmOs: 'win32', npmCpu: 'x64', executableName: 'vf.exe' }, +]; + +const { values: args } = parseArgs({ + options: { + version: { type: 'string' }, + dist: { type: 'string', default: 'dist' }, + out: { type: 'string', default: 'dist/npm' }, + }, +}); + +const version = args.version; +if (!version || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) { + console.error(`Invalid or missing --version (got: ${JSON.stringify(version)}). Expected e.g. 0.229.0 or 0.229.0-rc.1.`); + process.exit(1); +} +const distDir = path.resolve(args.dist); +const outDir = path.resolve(args.out); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +const wrapperSourceDir = path.join(repoRoot, 'npm', 'cli'); +const licensePath = path.join(repoRoot, 'LICENSE'); + +const artifactsPath = path.join(distDir, 'artifacts.json'); +if (!existsSync(artifactsPath)) { + console.error(`Missing ${artifactsPath} — run goreleaser first (or pass --dist).`); + process.exit(1); +} +const artifacts = JSON.parse(readFileSync(artifactsPath, 'utf8')); +const binaries = artifacts.filter((artifact) => artifact.type === 'Binary'); + +function findBinary(target) { + const match = binaries.find((binary) => binary.goos === target.goos && binary.goarch === target.goarch); + if (!match) { + console.error(`No built binary found for ${target.goos}/${target.goarch} in ${artifactsPath}.`); + console.error(`Binaries present: ${binaries.map((binary) => `${binary.goos}/${binary.goarch}`).join(', ') || 'none'}`); + process.exit(1); + } + // goreleaser records paths relative to the working directory (dist/...); + // resolve against cwd first, then against distDir's parent as a fallback. + const candidates = [path.resolve(match.path), path.join(distDir, '..', match.path)]; + const found = candidates.find((candidate) => existsSync(candidate)); + if (!found) { + console.error(`Binary listed in artifacts.json does not exist on disk: ${match.path}`); + process.exit(1); + } + return found; +} + +function requireLicense() { + // A public publish whose package.json claims Apache-2.0 must ship the grant + // text. Fail hard rather than warn — a mislabeled version cannot be unpublished. + if (!existsSync(licensePath)) { + console.error('No LICENSE file at repo root, but every package.json declares "license": "Apache-2.0".'); + console.error('Refusing to stage packages that would publish a license claim without the license text.'); + console.error('Merge the Apache-2.0 LICENSE PR (braden/add-apache-2-license/COR-0) first.'); + process.exit(1); + } +} + +function writePlatformPackage(target) { + const packageName = `@voiceflow/cli-${target.npmOs}-${target.npmCpu}`; + const packageDir = path.join(outDir, `cli-${target.npmOs}-${target.npmCpu}`); + const binDir = path.join(packageDir, 'bin'); + mkdirSync(binDir, { recursive: true }); + + const sourceBinary = findBinary(target); + const stagedBinary = path.join(binDir, target.executableName); + copyFileSync(sourceBinary, stagedBinary); + // Load-bearing: the Actions artifact zip round-trip drops the executable + // bit, and npm records file modes from disk into the published tarball. + chmodSync(stagedBinary, 0o755); + + writeFileSync( + path.join(packageDir, 'package.json'), + `${JSON.stringify( + { + name: packageName, + version, + description: `Voiceflow CLI binary for ${target.npmOs} ${target.npmCpu}. Install @voiceflow/cli instead of this package.`, + license: 'Apache-2.0', + repository: { type: 'git', url: 'git+https://github.com/voiceflow/cli.git' }, + os: [target.npmOs], + cpu: [target.npmCpu], + files: ['bin/'], + preferUnplugged: true, + }, + null, + 2, + )}\n`, + ); + writeFileSync( + path.join(packageDir, 'README.md'), + `# ${packageName}\n\nPrebuilt Voiceflow CLI binary for ${target.npmOs} ${target.npmCpu}. Install [@voiceflow/cli](https://www.npmjs.com/package/@voiceflow/cli) instead of depending on this package directly.\n`, + ); + copyFileSync(licensePath, path.join(packageDir, 'LICENSE')); + return packageDir; +} + +function writeWrapperPackage() { + const packageDir = path.join(outDir, 'cli'); + cpSync(wrapperSourceDir, packageDir, { recursive: true }); + + const packageJsonPath = path.join(packageDir, 'package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + packageJson.version = version; + for (const dependencyName of Object.keys(packageJson.optionalDependencies)) { + packageJson.optionalDependencies[dependencyName] = version; + } + + // npx resolves `npx @voiceflow/cli` only while every bin value is the same + // file (npm/libnpmexec checks this before the package-name match). + const binTargets = new Set(Object.values(packageJson.bin)); + if (binTargets.size !== 1) { + console.error('Wrapper "bin" entries must all point at the same file — npx resolution depends on it.'); + process.exit(1); + } + + writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); + copyFileSync(licensePath, path.join(packageDir, 'LICENSE')); + return packageDir; +} + +requireLicense(); +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +const stagedDirs = PLATFORM_TARGETS.map((target) => writePlatformPackage(target)); +stagedDirs.push(writeWrapperPackage()); + +console.log(`Staged ${stagedDirs.length} packages at version ${version}:`); +for (const dir of stagedDirs) console.log(` ${path.relative(process.cwd(), dir)}`); diff --git a/npm/scripts/publish.mjs b/npm/scripts/publish.mjs new file mode 100644 index 0000000..4947e00 --- /dev/null +++ b/npm/scripts/publish.mjs @@ -0,0 +1,125 @@ +// Publishes the staged npm packages for a release. Idempotent: every publish +// is preceded by an existence check, so re-running after a partial failure +// skips what already went out and finishes the rest. +// +// Ordering is load-bearing: the six platform packages publish first, then a +// visibility poll confirms the registry serves all of them, and only then the +// wrapper publishes — so no user can ever install a wrapper whose exact-pinned +// optionalDependencies do not resolve. +// +// The `latest` dist-tag is only moved forward: a re-run recovering an older +// version never demotes `latest` below what the registry already serves. +// Prerelease versions (containing "-") always publish under "next". +// +// Dependency-free ESM run by bare `node` — no tsx, no transitive packages, so +// nothing from the registry executes in this token-bearing step. Node >= 18. +// +// Usage: node npm/scripts/publish.mjs --version 0.229.0 --out dist/npm [--dry-run] + +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; +import * as process from 'node:process'; +import { parseArgs } from 'node:util'; + +const { values: args } = parseArgs({ + options: { + version: { type: 'string' }, + out: { type: 'string', default: 'dist/npm' }, + 'dry-run': { type: 'boolean', default: false }, + }, +}); + +const version = args.version; +if (!version || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) { + console.error(`Invalid or missing --version (got: ${JSON.stringify(version)}).`); + process.exit(1); +} +const outDir = path.resolve(args.out); +const isDryRun = args['dry-run']; +const isPrerelease = version.includes('-'); + +const PLATFORM_SUFFIXES = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-arm64', 'win32-x64']; +const platformPackages = PLATFORM_SUFFIXES.map((suffix) => ({ + name: `@voiceflow/cli-${suffix}`, + dir: path.join(outDir, `cli-${suffix}`), +})); +const wrapperPackage = { name: '@voiceflow/cli', dir: path.join(outDir, 'cli') }; + +for (const pkg of [...platformPackages, wrapperPackage]) { + if (!existsSync(path.join(pkg.dir, 'package.json'))) { + console.error(`Missing staged package at ${pkg.dir} — run npm/scripts/prepare.mjs first.`); + process.exit(1); + } +} + +function npmView(specifier, field) { + try { + return execFileSync('npm', ['view', specifier, field], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }) + .toString() + .trim(); + } catch { + return null; // not published / no packument / field absent + } +} + +function isPublished(name) { + return npmView(`${name}@${version}`, 'version') === version; +} + +// Compare two semver core versions (ignoring prerelease/build): is `a` strictly +// greater than `b`? Used only to decide whether `latest` may move to `version`. +function coreIsGreater(a, b) { + const core = (v) => v.split('-')[0].split('.').map(Number); + const [a1, a2, a3] = core(a); + const [b1, b2, b3] = core(b); + if (a1 !== b1) return a1 > b1; + if (a2 !== b2) return a2 > b2; + return a3 > b3; +} + +// A stable release tags `latest` only when it is newer than the registry's +// current latest; otherwise it publishes under a non-floating "previous" tag so +// a recovery re-run of an older version never demotes what users install. +function distTagFor(pkgName) { + if (isPrerelease) return 'next'; + const currentLatest = npmView(`${pkgName}@latest`, 'version'); + if (currentLatest && !coreIsGreater(version, currentLatest)) return 'previous'; + return 'latest'; +} + +function publish(pkg) { + if (isPublished(pkg.name)) { + console.log(`${pkg.name}@${version} already published — skipping.`); + return; + } + const distTag = distTagFor(pkg.name); + const publishArgs = ['publish', pkg.dir, '--access', 'public', '--provenance', '--tag', distTag]; + if (isDryRun) { + console.log(`[dry-run] npm ${publishArgs.join(' ')}`); + return; + } + console.log(`Publishing ${pkg.name}@${version} (tag: ${distTag})...`); + execFileSync('npm', publishArgs, { stdio: 'inherit', timeout: 300_000 }); +} + +async function waitUntilVisible(name) { + const deadline = Date.now() + 90_000; + while (Date.now() < deadline) { + if (isPublished(name)) return; + await new Promise((resolve) => setTimeout(resolve, 5_000)); + } + console.error(`${name}@${version} still not visible on the registry after 90s — aborting before the wrapper publish.`); + console.error('Re-run this job once the registry catches up; already-published packages are skipped.'); + process.exit(1); +} + +for (const pkg of platformPackages) publish(pkg); + +if (!isDryRun) { + for (const pkg of platformPackages) await waitUntilVisible(pkg.name); +} + +publish(wrapperPackage); + +console.log(isDryRun ? 'Dry run complete.' : `Published @voiceflow/cli@${version} and 6 platform packages.`); diff --git a/test/npm-distribution.test.ts b/test/npm-distribution.test.ts new file mode 100644 index 0000000..51fa9f2 --- /dev/null +++ b/test/npm-distribution.test.ts @@ -0,0 +1,286 @@ +// Hermetic tests for the npm distribution layer (npm/): the prepare staging +// script, the publish ordering/dist-tag logic, and the wrapper bin shim. +// +// No network and no live registry: goreleaser output is simulated with fixture +// binaries, and `npm` is stubbed on PATH so publish decisions are driven by +// fixture state rather than whatever the real registry happens to serve. + +import { execa } from 'execa'; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const REPO_ROOT = path.resolve(__dirname, '..'); +const PREPARE = path.join(REPO_ROOT, 'npm', 'scripts', 'prepare.mjs'); +const PUBLISH = path.join(REPO_ROOT, 'npm', 'scripts', 'publish.mjs'); + +const GO_TARGETS = [ + ['darwin', 'arm64'], + ['darwin', 'amd64'], + ['linux', 'arm64'], + ['linux', 'amd64'], + ['windows', 'arm64'], + ['windows', 'amd64'], +] as const; + +const NPM_SUFFIXES = ['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'win32-arm64', 'win32-x64']; + +let workDir: string; +let stubBinDir: string; + +/** + * Simulates a goreleaser dist dir: 6 fake binaries + artifacts.json. + * Paths are written cwd-relative and `dist/`-prefixed, exactly as real + * goreleaser emits them — that is the resolution branch CI depends on. + */ +function writeFixtureDist(cwd: string, { omit }: { omit?: readonly [string, string] } = {}): string { + const distDir = path.join(cwd, 'dist'); + mkdirSync(distDir, { recursive: true }); + const artifacts: object[] = [{ name: 'checksums.txt', path: 'dist/checksums.txt', type: 'Checksum' }]; + for (const [goos, goarch] of GO_TARGETS) { + if (omit && goos === omit[0] && goarch === omit[1]) continue; + const relativeDir = path.join('dist', `vf_${goos}_${goarch}_v8.0`); + mkdirSync(path.join(cwd, relativeDir), { recursive: true }); + const executable = goos === 'windows' ? 'vf.exe' : 'vf'; + const relativeBinary = path.join(relativeDir, executable); + writeFileSync(path.join(cwd, relativeBinary), `#!/bin/sh\necho "fake-vf ${goos}/${goarch} args:$@"\nexit 0\n`); + // Deliberately strip the exec bit, mimicking the Actions artifact zip + // round-trip — prepare.mjs must restore it. + chmodSync(path.join(cwd, relativeBinary), 0o644); + artifacts.push({ name: 'vf', path: relativeBinary, type: 'Binary', goos, goarch }); + } + writeFileSync(path.join(distDir, 'artifacts.json'), JSON.stringify(artifacts)); + return distDir; +} + +/** A scratch repo root: fixture dist + a LICENSE, so prepare has what it needs. */ +function makeStagingRoot(name: string, options: { license?: boolean; omit?: readonly [string, string] } = {}): string { + const root = mkdtempSync(path.join(workDir, `${name}-`)); + mkdirSync(path.join(root, 'npm'), { recursive: true }); + cpSync(path.join(REPO_ROOT, 'npm', 'cli'), path.join(root, 'npm', 'cli'), { recursive: true }); + mkdirSync(path.join(root, 'npm', 'scripts'), { recursive: true }); + cpSync(PREPARE, path.join(root, 'npm', 'scripts', 'prepare.mjs')); + cpSync(PUBLISH, path.join(root, 'npm', 'scripts', 'publish.mjs')); + if (options.license !== false) writeFileSync(path.join(root, 'LICENSE'), 'Apache License, Version 2.0\n'); + writeFixtureDist(root, { omit: options.omit }); + return root; +} + +async function runPrepare(root: string, version = '0.229.0') { + return execa({ reject: false, cwd: root })('node', [ + path.join(root, 'npm', 'scripts', 'prepare.mjs'), + '--version', version, '--dist', 'dist', '--out', 'dist/npm', + ]); +} + +/** + * Runs publish.mjs with a stub `npm` first on PATH. + * `published` lists exact name@version specs the fake registry already serves; + * `latest` maps package name -> its current latest version (absent = unpublished). + */ +async function runPublish( + root: string, + version: string, + registry: { published?: string[]; latest?: Record } = {}, +) { + return execa({ + reject: false, + cwd: root, + env: { + PATH: `${stubBinDir}:${process.env.PATH}`, + FAKE_NPM_PUBLISHED: (registry.published ?? []).join(','), + FAKE_NPM_LATEST: JSON.stringify(registry.latest ?? {}), + }, + })('node', [path.join(root, 'npm', 'scripts', 'publish.mjs'), '--version', version, '--out', 'dist/npm', '--dry-run']); +} + +beforeAll(() => { + workDir = mkdtempSync(path.join(tmpdir(), 'vf-npm-test-')); + + // Stub `npm` so no test ever touches the real registry. It answers only the + // two read shapes publish.mjs uses: `npm view @ version` and + // `npm view @latest version`. A real `npm publish` would be a bug here + // (the tests only run --dry-run), so the stub fails loudly on it. + stubBinDir = path.join(workDir, 'stub-bin'); + mkdirSync(stubBinDir, { recursive: true }); + const stub = path.join(stubBinDir, 'npm'); + writeFileSync( + stub, + `#!/usr/bin/env node +const [command, specifier] = process.argv.slice(2); +if (command !== 'view') { console.error('stub npm: refusing ' + process.argv.slice(2).join(' ')); process.exit(90); } +const at = specifier.lastIndexOf('@'); +const name = specifier.slice(0, at); +const range = specifier.slice(at + 1); +if (range === 'latest') { + const latest = JSON.parse(process.env.FAKE_NPM_LATEST || '{}')[name]; + if (!latest) process.exit(1); + console.log(latest); + process.exit(0); +} +const published = (process.env.FAKE_NPM_PUBLISHED || '').split(',').filter(Boolean); +if (!published.includes(name + '@' + range)) process.exit(1); +console.log(range); +`, + ); + chmodSync(stub, 0o755); +}); + +afterAll(() => { + rmSync(workDir, { recursive: true, force: true }); +}); + +describe('prepare.mjs', () => { + it('stages 7 packages with stamped versions, exec bits, and embedded license', async () => { + const root = makeStagingRoot('stage-ok'); + const result = await runPrepare(root); + expect(result.exitCode, result.stderr).toBe(0); + + const outDir = path.join(root, 'dist', 'npm'); + for (const suffix of NPM_SUFFIXES) { + const packageDir = path.join(outDir, `cli-${suffix}`); + const packageJson = JSON.parse(readFileSync(path.join(packageDir, 'package.json'), 'utf8')); + expect(packageJson.name).toBe(`@voiceflow/cli-${suffix}`); + expect(packageJson.version).toBe('0.229.0'); + expect(packageJson.license).toBe('Apache-2.0'); + expect(packageJson.bin).toBeUndefined(); + expect(packageJson.exports).toBeUndefined(); + expect(packageJson.preferUnplugged).toBe(true); + expect(existsSync(path.join(packageDir, 'LICENSE')), `${suffix} must embed the license text`).toBe(true); + + const executable = suffix.startsWith('win32') ? 'vf.exe' : 'vf'; + const mode = statSync(path.join(packageDir, 'bin', executable)).mode & 0o777; + expect(mode, `${suffix} binary must be chmod 755`).toBe(0o755); + } + + const wrapper = JSON.parse(readFileSync(path.join(outDir, 'cli', 'package.json'), 'utf8')); + expect(wrapper.name).toBe('@voiceflow/cli'); + expect(wrapper.version).toBe('0.229.0'); + expect(existsSync(path.join(outDir, 'cli', 'LICENSE'))).toBe(true); + expect(new Set(Object.values(wrapper.bin)).size).toBe(1); // npx resolution invariant + for (const pinned of Object.values(wrapper.optionalDependencies)) { + expect(pinned).toBe('0.229.0'); // exact pins, never ranges + } + }); + + it('refuses to stage when the repo has no LICENSE, rather than publishing a bare license claim', async () => { + const root = makeStagingRoot('stage-nolicense', { license: false }); + const result = await runPrepare(root); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('No LICENSE file at repo root'); + expect(existsSync(path.join(root, 'dist', 'npm', 'cli')), 'nothing may be staged').toBe(false); + }); + + it('fails loudly when a platform binary is missing', async () => { + const root = makeStagingRoot('stage-partial', { omit: ['linux', 'arm64'] }); + const result = await runPrepare(root); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('linux/arm64'); + }); + + it('rejects malformed versions', async () => { + const root = makeStagingRoot('stage-badver'); + const result = await runPrepare(root, 'v0.229.0'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid or missing --version'); + }); +}); + +describe('publish.mjs dist-tags and ordering', () => { + it('publishes platform packages before the wrapper, tagging latest on a fresh registry', async () => { + const root = makeStagingRoot('publish-fresh'); + expect((await runPrepare(root)).exitCode).toBe(0); + + const result = await runPublish(root, '0.229.0'); + expect(result.exitCode, result.stderr).toBe(0); + + const planned = result.stdout.split('\n').filter((line) => line.includes('[dry-run] npm publish')); + expect(planned).toHaveLength(7); + expect(planned[planned.length - 1]).toContain(`${path.sep}cli `); // wrapper last + expect(planned.every((line) => line.includes('--tag latest'))).toBe(true); + }); + + it('never moves latest backwards when a recovery re-run publishes an older version', async () => { + const root = makeStagingRoot('publish-recovery'); + expect((await runPrepare(root)).exitCode).toBe(0); + + // The registry already serves 0.230.0 as latest everywhere; an operator + // re-runs the failed 0.229.0 job to finish its partial publish. + const latest = Object.fromEntries([ + ['@voiceflow/cli', '0.230.0'], + ...NPM_SUFFIXES.map((suffix) => [`@voiceflow/cli-${suffix}`, '0.230.0']), + ]); + const result = await runPublish(root, '0.229.0', { latest }); + expect(result.exitCode, result.stderr).toBe(0); + + const planned = result.stdout.split('\n').filter((line) => line.includes('[dry-run] npm publish')); + expect(planned).toHaveLength(7); + expect(planned.every((line) => line.includes('--tag previous')), 'must not demote latest').toBe(true); + expect(result.stdout).not.toContain('--tag latest'); + }); + + it('skips packages already published at this version', async () => { + const root = makeStagingRoot('publish-partial'); + expect((await runPrepare(root)).exitCode).toBe(0); + + const result = await runPublish(root, '0.229.0', { + published: ['@voiceflow/cli-darwin-arm64@0.229.0', '@voiceflow/cli-linux-x64@0.229.0'], + }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('@voiceflow/cli-darwin-arm64@0.229.0 already published — skipping.'); + expect(result.stdout).toContain('@voiceflow/cli-linux-x64@0.229.0 already published — skipping.'); + expect(result.stdout.split('\n').filter((line) => line.includes('[dry-run] npm publish'))).toHaveLength(5); + }); + + it('routes prerelease versions to the next dist-tag', async () => { + const root = makeStagingRoot('publish-rc'); + expect((await runPrepare(root, '0.229.0-rc.1')).exitCode).toBe(0); + + const result = await runPublish(root, '0.229.0-rc.1', { latest: { '@voiceflow/cli': '0.228.0' } }); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('--tag next'); + expect(result.stdout).not.toContain('--tag latest'); + }); +}); + +describe('bin shim (vf.js)', () => { + function stageShimInstall(binaryContent: string): string { + const installDir = mkdtempSync(path.join(workDir, 'shim-install-')); + const shimDir = path.join(installDir, 'node_modules', '@voiceflow', 'cli'); + cpSync(path.join(REPO_ROOT, 'npm', 'cli'), shimDir, { recursive: true }); + + const platformDir = path.join(installDir, 'node_modules', '@voiceflow', `cli-${process.platform}-${process.arch}`); + mkdirSync(path.join(platformDir, 'bin'), { recursive: true }); + writeFileSync(path.join(platformDir, 'package.json'), JSON.stringify({ name: `@voiceflow/cli-${process.platform}-${process.arch}`, version: '0.0.0' })); + const binary = path.join(platformDir, 'bin', 'vf'); + writeFileSync(binary, binaryContent); + chmodSync(binary, 0o755); + return path.join(shimDir, 'bin', 'vf.js'); + } + + it('spawns the platform binary, forwards args, and propagates the exit code', async () => { + const shim = stageShimInstall('#!/bin/sh\necho "argv:$@"\nexit 42\n'); + const result = await execa({ reject: false })('node', [shim, 'workspace', 'list', '--output-format', 'json']); + expect(result.stdout).toBe('argv:workspace list --output-format json'); + expect(result.exitCode).toBe(42); + }); + + it('propagates signal death as signal death, not a plain exit code', async () => { + const shim = stageShimInstall('#!/bin/sh\nkill -TERM $$\n'); + const result = await execa({ reject: false })('node', [shim]); + expect(result.signal ?? result.exitCode).not.toBe(0); + }); + + it('prints a teaching error when the platform package is missing', async () => { + const installDir = mkdtempSync(path.join(workDir, 'shim-missing-')); + const shimDir = path.join(installDir, 'node_modules', '@voiceflow', 'cli'); + cpSync(path.join(REPO_ROOT, 'npm', 'cli'), shimDir, { recursive: true }); + + const result = await execa({ reject: false })('node', [path.join(shimDir, 'bin', 'vf.js'), 'version']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(`@voiceflow/cli-${process.platform}-${process.arch}`); + expect(result.stderr).toContain('optionalDependenc'); + expect(result.stderr).toContain('https://github.com/voiceflow/cli/releases'); + }); +});