From 3ae2a8af1358b8c01e0c0e1f4b2ed20eb20b840f Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:22:05 -0400 Subject: [PATCH 1/4] feat: publish the CLI to npm as @voiceflow/cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a hand-written (non-generated) npm distribution layer so 'npx @voiceflow/cli' works cold with no postinstall scripts: - npm/cli: wrapper package exposing 'vf' and 'voiceflow' bins via a zero-dependency shim that resolves the platform binary package (@voiceflow/cli--, optionalDependencies with os/cpu fields) and hands over stdio, exit codes, and signals - npm/scripts/prepare.ts: stages all 7 packages from goreleaser's artifacts.json, stamping the release version and restoring the executable bit the Actions artifact zip round-trip drops - npm/scripts/publish.ts: idempotent publish — platform packages first, registry-visibility gate, wrapper last; prereleases go to the 'next' dist-tag so latest never resolves an rc - npm/README.md: pipeline invariants and failure recovery - test/npm-distribution.test.ts: hermetic coverage of staging, ordering, version stamping, exec bits, and the shim's exec/error paths --- npm/README.md | 66 +++++++++++++ npm/cli/README.md | 49 ++++++++++ npm/cli/bin/vf.js | 76 +++++++++++++++ npm/cli/package.json | 46 ++++++++++ npm/scripts/prepare.ts | 168 ++++++++++++++++++++++++++++++++++ npm/scripts/publish.ts | 94 +++++++++++++++++++ test/npm-distribution.test.ts | 165 +++++++++++++++++++++++++++++++++ 7 files changed, 664 insertions(+) create mode 100644 npm/README.md create mode 100644 npm/cli/README.md create mode 100644 npm/cli/bin/vf.js create mode 100644 npm/cli/package.json create mode 100644 npm/scripts/prepare.ts create mode 100644 npm/scripts/publish.ts create mode 100644 test/npm-distribution.test.ts diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 0000000..6b49f27 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,66 @@ +# 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.ts` stages all seven packages into `dist/npm/` from + goreleaser's `dist/artifacts.json`, stamping the release version everywhere. +- `scripts/publish.ts` publishes idempotently: platform packages first, a + registry-visibility gate, wrapper last. Re-running after a partial failure + skips what already went out. +- 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. 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. +- **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..d674261 --- /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 @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.ts b/npm/scripts/prepare.ts new file mode 100644 index 0000000..0e1e264 --- /dev/null +++ b/npm/scripts/prepare.ts @@ -0,0 +1,168 @@ +// 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.ts. +// +// Usage: tsx npm/scripts/prepare.ts --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 { parseArgs } from 'node:util'; + +interface GoreleaserArtifact { + name: string; + path: string; + type: string; + goos?: string; + goarch?: string; +} + +interface PlatformTarget { + goos: string; + goarch: string; + npmOs: string; + npmCpu: string; + executableName: string; +} + +const PLATFORM_TARGETS: PlatformTarget[] = [ + { 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(import.meta.dirname, '..', '..'); +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: GoreleaserArtifact[] = JSON.parse(readFileSync(artifactsPath, 'utf8')); +const binaries = artifacts.filter((artifact) => artifact.type === 'Binary'); + +function findBinary(target: PlatformTarget): string { + 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); + } + const binaryPath = path.isAbsolute(match.path) ? match.path : path.join(distDir, '..', match.path); + // goreleaser records paths relative to the working directory (dist/...); + // resolve against cwd first, then distDir's parent as a fallback. + const candidates = [path.resolve(match.path), binaryPath]; + 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 writePlatformPackage(target: PlatformTarget): string { + 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`, + ); + copyLicenseInto(packageDir); + return packageDir; +} + +function writeWrapperPackage(): string { + 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`); + copyLicenseInto(packageDir); + return packageDir; +} + +function copyLicenseInto(packageDir: string): void { + if (existsSync(licensePath)) { + copyFileSync(licensePath, path.join(packageDir, 'LICENSE')); + } else { + console.warn('Warning: no LICENSE file at repo root — published packages will not embed one.'); + } +} + +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.ts b/npm/scripts/publish.ts new file mode 100644 index 0000000..aad47a3 --- /dev/null +++ b/npm/scripts/publish.ts @@ -0,0 +1,94 @@ +// 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. +// +// Prerelease versions (anything containing "-") publish under the "next" +// dist-tag so `npx @voiceflow/cli` (implicit latest) never resolves an rc. +// +// Usage: tsx npm/scripts/publish.ts --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 distTag = version.includes('-') ? 'next' : 'latest'; + +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.ts first.`); + process.exit(1); + } +} + +function isPublished(name: string): boolean { + try { + execFileSync('npm', ['view', `${name}@${version}`, 'version'], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }); + return true; + } catch { + return false; + } +} + +function publish(pkg: { name: string; dir: string }): void { + if (isPublished(pkg.name)) { + console.log(`${pkg.name}@${version} already published — skipping.`); + return; + } + 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: string): Promise { + 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 (tag: ${distTag}).`); diff --git a/test/npm-distribution.test.ts b/test/npm-distribution.test.ts new file mode 100644 index 0000000..dd25372 --- /dev/null +++ b/test/npm-distribution.test.ts @@ -0,0 +1,165 @@ +// Hermetic tests for the npm distribution layer (npm/): the prepare staging +// script and the wrapper bin shim. No network, no live API — goreleaser +// output is simulated with fixture binaries. + +import { execa } from 'execa'; +import { chmodSync, cpSync, 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.ts'); +const PUBLISH = path.join(REPO_ROOT, 'npm', 'scripts', 'publish.ts'); +const SHIM = path.join(REPO_ROOT, 'npm', 'cli', 'bin', 'vf.js'); +const TSX = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx'); + +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; + +/** Simulates a goreleaser dist dir: 6 fake binaries + artifacts.json. */ +function writeFixtureDist(distDir: string, { omit }: { omit?: readonly [string, string] } = {}): void { + const artifacts: object[] = [{ name: 'checksums.txt', path: path.join(distDir, 'checksums.txt'), type: 'Checksum' }]; + for (const [goos, goarch] of GO_TARGETS) { + if (omit && goos === omit[0] && goarch === omit[1]) continue; + const dir = path.join(distDir, `vf_${goos}_${goarch}`); + mkdirSync(dir, { recursive: true }); + const binary = path.join(dir, goos === 'windows' ? 'vf.exe' : 'vf'); + writeFileSync(binary, `#!/bin/sh\necho "fake-vf ${goos}/${goarch} args:$@"\nexit 0\n`); + // Deliberately strip the exec bit, mimicking the Actions artifact zip + // round-trip — prepare.ts must restore it. + chmodSync(binary, 0o644); + artifacts.push({ name: 'vf', path: binary, type: 'Binary', goos, goarch }); + } + writeFileSync(path.join(distDir, 'artifacts.json'), JSON.stringify(artifacts)); +} + +async function runPrepare(distDir: string, outDir: string, version = '0.229.0') { + return execa({ reject: false })(TSX, [PREPARE, '--version', version, '--dist', distDir, '--out', outDir]); +} + +beforeAll(() => { + workDir = mkdtempSync(path.join(tmpdir(), 'vf-npm-test-')); +}); + +afterAll(() => { + rmSync(workDir, { recursive: true, force: true }); +}); + +describe('prepare.ts', () => { + it('stages 7 packages with stamped versions, exec bits, and license metadata', async () => { + const distDir = path.join(workDir, 'dist-ok'); + const outDir = path.join(workDir, 'out-ok'); + writeFixtureDist(distDir); + + const result = await runPrepare(distDir, outDir); + expect(result.exitCode, result.stderr).toBe(0); + + for (const suffix of NPM_SUFFIXES) { + const packageJson = JSON.parse(readFileSync(path.join(outDir, `cli-${suffix}`, '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); + + const executable = suffix.startsWith('win32') ? 'vf.exe' : 'vf'; + const mode = statSync(path.join(outDir, `cli-${suffix}`, '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(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('fails loudly when a platform binary is missing', async () => { + const distDir = path.join(workDir, 'dist-partial'); + const outDir = path.join(workDir, 'out-partial'); + writeFixtureDist(distDir, { omit: ['linux', 'arm64'] }); + + const result = await runPrepare(distDir, outDir); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('linux/arm64'); + }); + + it('rejects malformed versions', async () => { + const distDir = path.join(workDir, 'dist-ok'); + const result = await runPrepare(distDir, path.join(workDir, 'out-badver'), 'v0.229.0'); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid or missing --version'); + }); +}); + +describe('publish.ts --dry-run', () => { + it('plans platform packages before the wrapper and never invokes a real publish', async () => { + const outDir = path.join(workDir, 'out-ok'); // staged by the first test + const result = await execa({ reject: false })(TSX, [PUBLISH, '--version', '0.229.0', '--out', outDir, '--dry-run']); + 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(result.stdout).toContain('--tag latest'); + }); + + it('routes prerelease versions to the next dist-tag', async () => { + const distDir = path.join(workDir, 'dist-ok'); + const outDir = path.join(workDir, 'out-rc'); + await runPrepare(distDir, outDir, '0.229.0-rc.1'); + + const result = await execa({ reject: false })(TSX, [PUBLISH, '--version', '0.229.0-rc.1', '--out', outDir, '--dry-run']); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain('--tag next'); + }); +}); + +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('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'); + }); +}); From fd5f95cc56f110d9ce81f57212b0f398d139a8b3 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:22:05 -0400 Subject: [PATCH 2/4] ci: publish npm packages after goreleaser on every release tag Appends an npm-publish job to the existing tag-triggered release workflow: downloads the goreleaser dist artifact, stages the seven npm packages, smoke-tests a staged binary, and publishes idempotently with provenance. Requires the NPM_TOKEN repo secret (human step). --- .github/workflows/release.yaml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 847aeb6..86ac3c5 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: npx -y tsx@4.23.1 npm/scripts/prepare.ts --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: npx -y tsx@4.23.1 npm/scripts/publish.ts --version "${GITHUB_REF_NAME#v}" --out dist/npm From 3ec8d8aa85729355401023af9c1e26b5f4998f35 Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Tue, 25 Aug 2026 22:40:35 -0400 Subject: [PATCH 3/4] fix: harden the npm publish pipeline against adversarial review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - prepare now FAILS when the repo has no LICENSE instead of warning. Every package.json declares Apache-2.0; staging without the license text made it possible to permanently publish a bare license claim (versions cannot be unpublished) if a tag landed before the LICENSE PR. - Both scripts converted from .ts to dependency-free .mjs run by bare node. The publish step holds NPM_TOKEN, and 'npx tsx' had npm resolve tsx's transitive tree from the registry at run time with no lockfile — arbitrary registry code executing in the token-bearing step. Both scripts only ever used node builtins, so the runner bought nothing. - latest now only moves forward: publish reads the registry's current latest and tags a non-newer version 'previous' instead. The documented recovery ('re-run the job') could otherwise demote what npx installs when a newer release had already shipped. - Each staged package embeds the LICENSE file. - Tests: goreleaser fixtures now use cwd-relative dist/-prefixed paths (the branch CI actually takes, previously untested), npm is stubbed on PATH so dist-tag decisions are hermetic instead of depending on live registry state, plus coverage for the license gate, latest-guard, partial-publish skip, and shim signal propagation. --- .github/workflows/release.yaml | 4 +- npm/README.md | 24 ++- npm/scripts/{prepare.ts => prepare.mjs} | 69 ++++---- npm/scripts/{publish.ts => publish.mjs} | 59 +++++-- test/npm-distribution.test.ts | 209 +++++++++++++++++++----- 5 files changed, 261 insertions(+), 104 deletions(-) rename npm/scripts/{prepare.ts => prepare.mjs} (77%) rename npm/scripts/{publish.ts => publish.mjs} (58%) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 86ac3c5..8b6e08d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -64,7 +64,7 @@ jobs: path: dist - name: Assemble npm packages - run: npx -y tsx@4.23.1 npm/scripts/prepare.ts --version "${GITHUB_REF_NAME#v}" --dist dist --out dist/npm + 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 @@ -72,4 +72,4 @@ jobs: - name: Publish (idempotent) env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: npx -y tsx@4.23.1 npm/scripts/publish.ts --version "${GITHUB_REF_NAME#v}" --out dist/npm + run: node npm/scripts/publish.mjs --version "${GITHUB_REF_NAME#v}" --out dist/npm diff --git a/npm/README.md b/npm/README.md index 6b49f27..4066209 100644 --- a/npm/README.md +++ b/npm/README.md @@ -11,11 +11,16 @@ Speakeasy-generated. 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.ts` stages all seven packages into `dist/npm/` from - goreleaser's `dist/artifacts.json`, stamping the release version everywhere. -- `scripts/publish.ts` publishes idempotently: platform packages first, a +- `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`). @@ -37,14 +42,23 @@ Speakeasy-generated. 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. goreleaser marks them prereleases too. + 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. + 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. diff --git a/npm/scripts/prepare.ts b/npm/scripts/prepare.mjs similarity index 77% rename from npm/scripts/prepare.ts rename to npm/scripts/prepare.mjs index 0e1e264..68ca707 100644 --- a/npm/scripts/prepare.ts +++ b/npm/scripts/prepare.mjs @@ -7,32 +7,20 @@ // /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.ts. +// separate, side-effectful step: npm/scripts/publish.mjs. // -// Usage: tsx npm/scripts/prepare.ts --version 0.229.0 --dist dist --out dist/npm +// 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'; -interface GoreleaserArtifact { - name: string; - path: string; - type: string; - goos?: string; - goarch?: string; -} - -interface PlatformTarget { - goos: string; - goarch: string; - npmOs: string; - npmCpu: string; - executableName: string; -} - -const PLATFORM_TARGETS: PlatformTarget[] = [ +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' }, @@ -54,9 +42,9 @@ 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(import.meta.dirname, '..', '..'); +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'); @@ -65,20 +53,19 @@ if (!existsSync(artifactsPath)) { console.error(`Missing ${artifactsPath} — run goreleaser first (or pass --dist).`); process.exit(1); } -const artifacts: GoreleaserArtifact[] = JSON.parse(readFileSync(artifactsPath, 'utf8')); +const artifacts = JSON.parse(readFileSync(artifactsPath, 'utf8')); const binaries = artifacts.filter((artifact) => artifact.type === 'Binary'); -function findBinary(target: PlatformTarget): string { +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); } - const binaryPath = path.isAbsolute(match.path) ? match.path : path.join(distDir, '..', match.path); // goreleaser records paths relative to the working directory (dist/...); - // resolve against cwd first, then distDir's parent as a fallback. - const candidates = [path.resolve(match.path), binaryPath]; + // 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}`); @@ -87,7 +74,18 @@ function findBinary(target: PlatformTarget): string { return found; } -function writePlatformPackage(target: PlatformTarget): string { +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'); @@ -122,11 +120,11 @@ function writePlatformPackage(target: PlatformTarget): string { 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`, ); - copyLicenseInto(packageDir); + copyFileSync(licensePath, path.join(packageDir, 'LICENSE')); return packageDir; } -function writeWrapperPackage(): string { +function writeWrapperPackage() { const packageDir = path.join(outDir, 'cli'); cpSync(wrapperSourceDir, packageDir, { recursive: true }); @@ -146,18 +144,11 @@ function writeWrapperPackage(): string { } writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); - copyLicenseInto(packageDir); + copyFileSync(licensePath, path.join(packageDir, 'LICENSE')); return packageDir; } -function copyLicenseInto(packageDir: string): void { - if (existsSync(licensePath)) { - copyFileSync(licensePath, path.join(packageDir, 'LICENSE')); - } else { - console.warn('Warning: no LICENSE file at repo root — published packages will not embed one.'); - } -} - +requireLicense(); rmSync(outDir, { recursive: true, force: true }); mkdirSync(outDir, { recursive: true }); diff --git a/npm/scripts/publish.ts b/npm/scripts/publish.mjs similarity index 58% rename from npm/scripts/publish.ts rename to npm/scripts/publish.mjs index aad47a3..4947e00 100644 --- a/npm/scripts/publish.ts +++ b/npm/scripts/publish.mjs @@ -7,10 +7,14 @@ // wrapper publishes — so no user can ever install a wrapper whose exact-pinned // optionalDependencies do not resolve. // -// Prerelease versions (anything containing "-") publish under the "next" -// dist-tag so `npx @voiceflow/cli` (implicit latest) never resolves an rc. +// 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". // -// Usage: tsx npm/scripts/publish.ts --version 0.229.0 --out dist/npm [--dry-run] +// 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'; @@ -31,9 +35,9 @@ 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 distTag = version.includes('-') ? 'next' : 'latest'; +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) => ({ @@ -44,25 +48,52 @@ 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.ts first.`); + console.error(`Missing staged package at ${pkg.dir} — run npm/scripts/prepare.mjs first.`); process.exit(1); } } -function isPublished(name: string): boolean { +function npmView(specifier, field) { try { - execFileSync('npm', ['view', `${name}@${version}`, 'version'], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }); - return true; + return execFileSync('npm', ['view', specifier, field], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000 }) + .toString() + .trim(); } catch { - return false; + return null; // not published / no packument / field absent } } -function publish(pkg: { name: string; dir: string }): void { +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(' ')}`); @@ -72,7 +103,7 @@ function publish(pkg: { name: string; dir: string }): void { execFileSync('npm', publishArgs, { stdio: 'inherit', timeout: 300_000 }); } -async function waitUntilVisible(name: string): Promise { +async function waitUntilVisible(name) { const deadline = Date.now() + 90_000; while (Date.now() < deadline) { if (isPublished(name)) return; @@ -91,4 +122,4 @@ if (!isDryRun) { publish(wrapperPackage); -console.log(isDryRun ? 'Dry run complete.' : `Published @voiceflow/cli@${version} and 6 platform packages (tag: ${distTag}).`); +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 index dd25372..51fa9f2 100644 --- a/test/npm-distribution.test.ts +++ b/test/npm-distribution.test.ts @@ -1,18 +1,19 @@ // Hermetic tests for the npm distribution layer (npm/): the prepare staging -// script and the wrapper bin shim. No network, no live API — goreleaser -// output is simulated with fixture binaries. +// 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, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +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.ts'); -const PUBLISH = path.join(REPO_ROOT, 'npm', 'scripts', 'publish.ts'); -const SHIM = path.join(REPO_ROOT, 'npm', 'cli', 'bin', 'vf.js'); -const TSX = path.join(REPO_ROOT, 'node_modules', '.bin', 'tsx'); +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'], @@ -26,106 +27,220 @@ const GO_TARGETS = [ 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. */ -function writeFixtureDist(distDir: string, { omit }: { omit?: readonly [string, string] } = {}): void { - const artifacts: object[] = [{ name: 'checksums.txt', path: path.join(distDir, 'checksums.txt'), type: 'Checksum' }]; +/** + * 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 dir = path.join(distDir, `vf_${goos}_${goarch}`); - mkdirSync(dir, { recursive: true }); - const binary = path.join(dir, goos === 'windows' ? 'vf.exe' : 'vf'); - writeFileSync(binary, `#!/bin/sh\necho "fake-vf ${goos}/${goarch} args:$@"\nexit 0\n`); + 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.ts must restore it. - chmodSync(binary, 0o644); - artifacts.push({ name: 'vf', path: binary, type: 'Binary', goos, goarch }); + // 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; } -async function runPrepare(distDir: string, outDir: string, version = '0.229.0') { - return execa({ reject: false })(TSX, [PREPARE, '--version', version, '--dist', distDir, '--out', outDir]); +/** 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.ts', () => { - it('stages 7 packages with stamped versions, exec bits, and license metadata', async () => { - const distDir = path.join(workDir, 'dist-ok'); - const outDir = path.join(workDir, 'out-ok'); - writeFixtureDist(distDir); - - const result = await runPrepare(distDir, outDir); +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 packageJson = JSON.parse(readFileSync(path.join(outDir, `cli-${suffix}`, 'package.json'), 'utf8')); + 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(outDir, `cli-${suffix}`, 'bin', executable)).mode & 0o777; + 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('fails loudly when a platform binary is missing', async () => { - const distDir = path.join(workDir, 'dist-partial'); - const outDir = path.join(workDir, 'out-partial'); - writeFixtureDist(distDir, { omit: ['linux', 'arm64'] }); + 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); + }); - const result = await runPrepare(distDir, outDir); + 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 distDir = path.join(workDir, 'dist-ok'); - const result = await runPrepare(distDir, path.join(workDir, 'out-badver'), 'v0.229.0'); + 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.ts --dry-run', () => { - it('plans platform packages before the wrapper and never invokes a real publish', async () => { - const outDir = path.join(workDir, 'out-ok'); // staged by the first test - const result = await execa({ reject: false })(TSX, [PUBLISH, '--version', '0.229.0', '--out', outDir, '--dry-run']); +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(result.stdout).toContain('--tag latest'); + 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 distDir = path.join(workDir, 'dist-ok'); - const outDir = path.join(workDir, 'out-rc'); - await runPrepare(distDir, outDir, '0.229.0-rc.1'); + const root = makeStagingRoot('publish-rc'); + expect((await runPrepare(root, '0.229.0-rc.1')).exitCode).toBe(0); - const result = await execa({ reject: false })(TSX, [PUBLISH, '--version', '0.229.0-rc.1', '--out', outDir, '--dry-run']); + 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'); }); }); @@ -151,6 +266,12 @@ describe('bin shim (vf.js)', () => { 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'); From 75a83cd711e4dc801730862eb0d3b77e0afe3c2e Mon Sep 17 00:00:00 2001 From: BR <51544548+Bradenream@users.noreply.github.com> Date: Wed, 26 Aug 2026 00:29:48 -0400 Subject: [PATCH 4/4] docs: use 'npx -y' in the package README npx prompts 'Ok to proceed?' when a TTY is present and the package is not already cached, which stalls unattended agent runs. This README is the one npm renders on the package page, so it is the copy most people and agents will paste. --- npm/cli/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/npm/cli/README.md b/npm/cli/README.md index d674261..1b1b8e0 100644 --- a/npm/cli/README.md +++ b/npm/cli/README.md @@ -6,7 +6,7 @@ ```bash # Run without installing -npx @voiceflow/cli --help +npx -y @voiceflow/cli --help # Or install globally (provides both `vf` and `voiceflow`) npm install -g @voiceflow/cli