diff --git a/.release-it.json b/.release-it.json index 3e52e23..88baeb9 100644 --- a/.release-it.json +++ b/.release-it.json @@ -1,5 +1,5 @@ { - "$schema": "https://unpkg.com/release-it@19/schema/release-it.json", + "$schema": "https://unpkg.com/release-it@21/schema/release-it.json", "git": { "commitMessage": "chore: release v${version}", "tagName": "v${version}", @@ -14,8 +14,7 @@ "releaseName": "v${version}" }, "npm": { - "publish": true, - "publishArgs": ["--access", "public"] + "publish": false }, "hooks": { "before:init": ["bash scripts/check-changelog.sh", "bun run verify"], diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aa10ce..3fa7c9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). ## [Unreleased] +### Added +- `rd update` updates the CLI the way it was installed, with `--check` to report only (exit 0 up to date, 1 update available, 2 unknown) and `--force` to reinstall even when current. `rd update ` still updates a bookmark. + +### Changed +- `rd --version` now prints the commit as well: `rd X.Y.Z (sha7 YYYY-MM-DD)`, with `-dirty` for a build from uncommitted changes, so it is obvious which commit is installed. + ## [0.3.1] - 2026-07-15 ### Fixed diff --git a/README.md b/README.md index 022db4b..4916d77 100644 --- a/README.md +++ b/README.md @@ -23,24 +23,47 @@ Built for power users, automation workflows, and AI agent integration. ## Requirements -- Node.js 20+ +- Node.js 22+ ## Installation ```bash -npm install -g raindrop-cli +npm install -g raindrop-cli # or: pnpm add -g raindrop-cli, bun add -g raindrop-cli +npx raindrop-cli --help # run without installing ``` -Or use without installing: -```bash -npx raindrop-cli --help -``` +Requires Node.js 22 or later. This installs `rd` as the primary command. An `rdcli` alias is also available if `rd` conflicts with another tool: - **Windows**: `rd` is a built-in command (remove directory) - **oh-my-zsh**: `rd` is aliased to `rmdir` — add `unalias rd` to your `~/.zshrc` or use `rdcli` +### From source + +```bash +git clone https://github.com/crcatala/raindrop-cli.git +cd raindrop-cli +bun install +bun run build +bun link # puts `rd` on your PATH, pointing at this clone +rd --version # rd X.Y.Z (sha7 YYYY-MM-DD): the commit you built +``` + +Rebuild after pulling (`bun run build`); the link keeps pointing at the clone. If `rd` is +also installed globally, remove that copy so the linked build is the one on `PATH` +(`command -v rd` should resolve inside the clone). + +### Updating + +```bash +rd update --check # exit 0: up to date, 1: update available, 2: unknown +rd update # updates the same way it was installed +``` + +`rd update` pulls and rebuilds a source checkout (it refuses when the tree is dirty or +not on `main`), and runs `npm install -g raindrop-cli@latest` for a global npm install. + ## Quick Start ### 1. Get Your API Token @@ -351,107 +374,10 @@ src/ ## Releasing -For maintainers publishing a new version to npm. We use [release-it](https://github.com/release-it/release-it) to automate the release process. - -### Prerequisites - -- Push access to the repository -- npm account with publish rights to `raindrop-cli` (`npm whoami` to verify) -- ggshield installed and authenticated (`ggshield auth status`) -- `GITHUB_TOKEN` environment variable set (for creating GitHub releases) - -#### Getting a GitHub Token - -1. Go to [GitHub Settings → Tokens](https://github.com/settings/tokens?type=beta) (fine-grained) -2. Click **Generate new token** -3. Set expiration and select **Only select repositories** → `raindrop-cli` -4. Under **Repository permissions**, set **Contents** to **Read and write** -5. Generate and export: `export GITHUB_TOKEN=github_pat_xxxx` - -Or use a [classic token](https://github.com/settings/tokens/new) with the `repo` scope (broader access). - -### Before You Release - -Generate a changelog draft from commits since the latest release: - -```bash -bun run release:prep -``` - -The helper prints commit history, a changed-files summary, and a prompt you can use to draft changelog entries. Review the result and update `CHANGELOG.md` under the `[Unreleased]` section: - -```markdown -## [Unreleased] - -### Added -- New feature X - -### Fixed -- Bug Y -``` - -The helper does not modify the changelog. The release process will automatically move your `[Unreleased]` entries to the new version section. - -### Release Process - -```bash -# 1. Ensure you're on main with latest changes -git checkout main -git pull - -# 2. Preview the local release steps (recommended first time) -bun run release:dry - -# 3. Run the release -bun run release -``` - -`release:dry` deliberately disables the npm and GitHub integrations, so it cannot publish or open GitHub's release form. Run `release` for the full process. - -The interactive prompt will ask you to select the version bump (patch/minor/major). Then release-it will: - -1. **Verify** — runs tests, lint, typecheck, format -2. **Bump version** — updates `package.json` -3. **Update changelog** — moves "Unreleased" items to new version section with date -4. **Package smoke test** — rebuilds `dist/`, packs it, and executes the installed `rd` CLI -5. **Commit** — creates "chore: release vX.Y.Z" commit -6. **Tag** — creates `vX.Y.Z` git tag -7. **Secret scan** — ggshield scans a freshly rebuilt `dist/` for leaked secrets -8. **Publish to npm** — rebuilds immediately before packing and uploads the artifact -9. **Push** — pushes commit and tag to GitHub -10. **GitHub Release** — creates release with changelog notes - -### Manual Steps (if needed) - -If you need to skip certain steps or recover from a partial release: - -```bash -# Skip npm publish (e.g., if it succeeded but git push failed) -bun run release -- --no-npm - -# Skip GitHub release -bun run release -- --no-github - -# Specific version (skip prompt) -bun run release -- 1.2.3 - -# Pre-release -bun run release -- --preRelease=beta -``` - -### Verify After Release - -```bash -npm info raindrop-cli -npx raindrop-cli@latest --version -``` - -### Notes - -- **Immutable** — published npm versions cannot be changed, only deprecated -- **Clean working directory required** — commit or stash changes before releasing -- **Main branch only** — releases must be from the `main` branch -- **Secrets** — publish will fail if ggshield detects secrets in the bundle +For maintainers publishing a new version to npm. Releases are cut by hand from a clean +`main` with [release-it](https://github.com/release-it/release-it), wrapped by +`scripts/release.mjs`; the full process, prerequisites and recovery steps are in +[RELEASING.md](RELEASING.md). ## Contributing diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..2068f2f --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,98 @@ +# Releasing + +Releases are cut by hand from a clean `main` with +[release-it](https://github.com/release-it/release-it), wrapped by +`scripts/release.mjs`. One command bumps the version, moves the changelog's +Unreleased section into the new version, commits, tags `vX.Y.Z`, pushes, +creates the GitHub Release, and then publishes `raindrop-cli` to npm from the +tagged commit. + +## Prerequisites + +- Push access to `main`, and a GitHub token for the release (`GITHUB_TOKEN`, + or release-it opens the GitHub release form in the browser instead): + + ```bash + export GITHUB_TOKEN=github_pat_xxxx + ``` + + A fine-grained token needs **Contents: Read and write** on + `crcatala/raindrop-cli`; a classic token needs the `repo` scope. +- An npm login with publish rights to `raindrop-cli`: `npm whoami`, else `npm login`. +- `ggshield` installed and authenticated (`ggshield auth status`). `npm publish` + runs `prepublishOnly`, which rebuilds and then scans the source tree and + `dist/` for secrets, so a publish fails without it. + +## Before releasing + +1. Start from an up-to-date `main`: + + ```bash + git checkout main + git pull --ff-only + ``` + +2. Draft the changelog: + + ```bash + bun run release:prep + ``` + + Paste the output into an LLM, review the result, and put it under + `## [Unreleased]` in `CHANGELOG.md` (Added, Changed, Fixed, Removed, + Security; user-facing changes only). Commit it to `main` and push. + +## Release + +```bash +bun run release:dry # preview; changes nothing and skips the hooks +bun run release # asks for the version; or: bun run release -- patch|minor|major +``` + +In order, `bun run release`: + +1. refuses to start when not logged in to npm or when `HEAD` is already a release; +2. checks that Unreleased has entries and runs `bun run verify`; +3. bumps the version and the changelog, then runs `bun run test:package` (packs + the tarball, installs it, checks `--help` and `--version` for every command); +4. commits `chore: release vX.Y.Z`, tags `vX.Y.Z`, pushes both; +5. creates the GitHub Release; +6. publishes to npm from the tagged commit (npm may ask for a one-time password). + Its `prepublishOnly` hook rebuilds and runs the `ggshield` secret scans + before the tarball is uploaded. + +A prerelease is published under a dist-tag named after its identifier, so +`latest` keeps pointing at the last full release: + +```bash +bun run release -- --preRelease=beta # 1.3.0-beta.0, published as raindrop-cli@beta +bun run release -- --preRelease=beta --npm.tag=next # same version, under "next" +``` + +## When a step fails + +- **Before the commit** (steps 1–3): nothing was changed on the remote. Fix the + cause and run `bun run release` again; `git checkout -- .` discards a + half-made bump. +- **GitHub Release** (step 5): the tag is pushed. Create the release from the + tag with that version's CHANGELOG section as notes + (`gh release create vX.Y.Z --title vX.Y.Z --notes-file
`), then run + `bun run release:publish`. +- **npm publish** (step 6): the tag is pushed; only the publish is left. Run + `bun run release:publish` from the tagged commit (it picks the prerelease + dist-tag again; `bun run release:publish -- --tag ` chooses another). + +## Verify + +```bash +npm view raindrop-cli version +npx raindrop-cli@latest --version # rd X.Y.Z (sha7 YYYY-MM-DD) +``` + +## Notes + +- **Immutable** — published npm versions cannot be changed, only deprecated. +- **Clean working directory required** — commit or stash changes before releasing. +- **Main branch only** — releases must be from the `main` branch. +- **Secrets** — the publish fails if `ggshield` detects secrets in the source + tree or the freshly built `dist/`. diff --git a/package.json b/package.json index 808b5cf..070d795 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,13 @@ "!dist/**/*.test.d.ts", "!dist/**/*.test.d.ts.map", "!dist/test-utils", + "dist/build-info.json", "README.md", "LICENSE" ], "scripts": { "clean": "node -e \"for (const path of ['dist', 'tsconfig.tsbuildinfo']) require('node:fs').rmSync(path, { recursive: true, force: true })\"", - "build": "bun run clean && bun build src/cli.ts --outdir dist --target node --format esm && chmod +x dist/cli.js && bun run build:dts", + "build": "bun run clean && bun build src/cli.ts --outdir dist --target node --format esm && chmod +x dist/cli.js && bun run build:dts && node scripts/build-info.mjs", "build:dts": "tsc --emitDeclarationOnly --declaration --outDir dist", "dev": "bun src/index.ts", "test": "bash scripts/check.sh test", @@ -46,9 +47,10 @@ "verify:verbose": "VERBOSE=1 bun scripts/verify.ts", "prepack": "bun run build", "prepublishOnly": "bun run build && ggshield secret scan path . -r --use-gitignore -y && ggshield secret scan path dist -r -y", - "release": "release-it", - "release:dry": "release-it --dry-run --no-npm --no-github", - "release:prep": "./scripts/prep-release.sh" + "release": "node scripts/release.mjs release", + "release:dry": "node scripts/release.mjs release --dry-run", + "release:publish": "node scripts/release.mjs publish", + "release:prep": "bash scripts/prep-release.sh" }, "keywords": [ "raindrop", diff --git a/scripts/build-info.mjs b/scripts/build-info.mjs new file mode 100755 index 0000000..d452ce4 --- /dev/null +++ b/scripts/build-info.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// release-standard: scripts/build-info.mjs r1 (do not edit; see RELEASING.md) +// Usage: node scripts/build-info.mjs [output] (default: dist/build-info.json) +// Stamps the version, commit, commit date and dirty state into the build. Run it at the end of `build`. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const output = process.argv[2] ?? 'dist/build-info.json'; +const git = (...args) => { + try { + return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); + } catch { + return undefined; + } +}; + +const { version } = JSON.parse(fs.readFileSync('package.json', 'utf8')); +const info = { version }; +const commit = git('rev-parse', 'HEAD'); +if (commit) { + info.commit = commit; + info.date = git('show', '-s', '--format=%cs', 'HEAD'); + // Ignored files (the build output itself) do not count. + info.dirty = git('status', '--porcelain') !== ''; +} + +fs.mkdirSync(path.dirname(output), { recursive: true }); +fs.writeFileSync(output, `${JSON.stringify(info, null, 2)}\n`); diff --git a/scripts/check-changelog.sh b/scripts/check-changelog.sh index 39d9e9c..67fd452 100755 --- a/scripts/check-changelog.sh +++ b/scripts/check-changelog.sh @@ -1,22 +1,19 @@ -#!/bin/bash -# Validates that CHANGELOG.md has entries in the [Unreleased] section before releasing. +#!/usr/bin/env bash +# release-standard: scripts/check-changelog.sh r1 (do not edit; see RELEASING.md) +# Fail a release when CHANGELOG.md has no unreleased user-facing entries. +set -euo pipefail -set -e - -# Extract content between [Unreleased] and next version header -content=$(awk '/^## \[Unreleased\]/{found=1;next} /^## \[/{exit} found{print}' CHANGELOG.md) +file="${1:-CHANGELOG.md}" +if [[ ! -f "$file" ]]; then + echo "Error: $file not found." >&2 + exit 1 +fi -# Check if there's any actual content (### headers like Added, Fixed, etc.) -if ! echo "$content" | grep -q '^### '; then - echo "Error: No entries in [Unreleased] section of CHANGELOG.md" - echo "" - echo "Add your changes before releasing:" - echo " ### Added" - echo " - New feature description" - echo "" - echo " ### Fixed" - echo " - Bug fix description" +content=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{exit} found{print}' "$file") +if ! awk '/^### /{section=1; next} section && /^[-*+] /{found=1} END{exit !found}' <<<"$content"; then + echo "Error: $file has no unreleased user-facing entries." >&2 + echo 'Run the release prep script, then add a section under "## [Unreleased]", such as "### Added" followed by "- New feature".' >&2 exit 1 fi -echo "✓ Changelog has unreleased entries" +echo 'Changelog has unreleased entries.' diff --git a/scripts/prep-release.sh b/scripts/prep-release.sh index 4314bb5..b54c22f 100755 --- a/scripts/prep-release.sh +++ b/scripts/prep-release.sh @@ -1,73 +1,44 @@ -#!/bin/bash -# Usage: ./scripts/prep-release.sh [last-tag] -# Gathers commit information since the last release for changelog drafting. - +#!/usr/bin/env bash +# release-standard: scripts/prep-release.sh r1 (do not edit; see RELEASING.md) +# Usage: scripts/prep-release.sh [last-tag] +# Prints a changelog-drafting prompt, then the commits and files changed since the last tag. set -euo pipefail -LAST_TAG="${1:-$(git describe --tags --abbrev=0 2>/dev/null || true)}" - -if [ -n "$LAST_TAG" ] && ! git rev-parse --verify --quiet "${LAST_TAG}^{commit}" >/dev/null; then - echo "Error: '$LAST_TAG' is not a valid tag or commit." >&2 +# Only version tags (vX.Y.Z) mark releases; other tags never become the range start. +last_tag="${1:-$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)}" +if [[ -n "$last_tag" ]] && ! git rev-parse --verify --quiet "${last_tag}^{commit}" >/dev/null; then + echo "Error: '$last_tag' is not a valid tag or commit." >&2 exit 1 fi -if [ -z "$LAST_TAG" ]; then - RANGE="HEAD" -else - RANGE="${LAST_TAG}..HEAD" -fi +range='HEAD' +[[ -n "$last_tag" ]] && range="${last_tag}..HEAD" -echo "=== Release Prep ===" -echo "" +echo '=== Release Prep ===' +if [[ -n "$last_tag" ]]; then echo "Changes since: $last_tag"; else echo 'No prior tag; showing all commits.'; fi +echo -if [ -z "$LAST_TAG" ]; then - echo "No previous tag found. Showing all commits." -else - echo "Changes since: $LAST_TAG" -fi -echo "" +# Prompt first, so everything below it can be copied into an LLM in one go. +cat <<'EOF' +=== Changelog prompt (copy everything below this line) === -echo "=== Commits ===" -echo "" -git log "$RANGE" --pretty=format:"- %s (%h)" --no-merges -echo "" -echo "" +Draft Keep a Changelog entries for the Unreleased section from the commits below. +Include only user-facing changes. Group items under Added, Changed, Fixed, Removed, +or Security; omit empty groups. Exclude internal tests, CI, refactors, and routine +dependency updates unless users are affected. Keep each item to one line and keep +PR or issue numbers in parentheses where the commits mention them. -echo "=== Merged PRs (if using GitHub) ===" -echo "" -git log "$RANGE" --pretty=format:"%s" | grep -E "^Merge pull request|#[0-9]+" || echo "(none found)" -echo "" -echo "" +=== Full commit messages === +EOF -echo "=== Files changed ===" -echo "" -if [ -z "$LAST_TAG" ]; then - # Compare against the empty tree so initial releases include the complete history. - EMPTY_TREE="$(git hash-object -t tree /dev/null)" - git diff --stat "$EMPTY_TREE" HEAD | tail -5 +# Drop co-author trailers (any case). awk, not sed's GNU-only `I` flag, so this runs on macOS too. +git log "$range" --format='## %s%n%n%b%n---' --no-merges | awk 'tolower($0) !~ /^co-authored-by:/' | cat -s + +echo +echo '=== Changed files ===' +if [[ -n "$last_tag" ]]; then + git diff --stat "$range" else - git diff --stat "$RANGE" | tail -10 + # Compare against the empty tree so a first release shows everything. + git diff --stat "$(git hash-object -t tree /dev/null)" HEAD fi -echo "" -echo "" - -echo "=== AI Prompt Template ===" -echo "" -cat << 'EOF' -Review these commits and generate changelog entries in Keep a Changelog format. -Group entries by: Added, Changed, Fixed, Removed, and Security (only include sections that have entries). -Include only user-facing changes; exclude tests, CI, internal refactors, and routine dependency updates unless they affect users. -Be concise. Include PR or issue numbers in parentheses where mentioned. - -Format example: -### Added -- New feature description (#123) - -### Fixed -- Bug fix description - -Commits to review: -EOF -echo "" -git log "$RANGE" --pretty=format:"- %s" --no-merges -echo "" diff --git a/scripts/release.mjs b/scripts/release.mjs new file mode 100755 index 0000000..480aaa7 --- /dev/null +++ b/scripts/release.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// release-standard: scripts/release.mjs r2 (do not edit; see RELEASING.md) +// +// node scripts/release.mjs release [release-it args] preflight, release-it (bump, changelog, tag, push, +// GitHub Release), then publish from the tag +// node scripts/release.mjs publish [--dry-run] [--tag ] +// publish the tagged HEAD to npm (resume a release) +// +// release-it publishes to npm before it commits and tags, so its npm step is turned off in +// .release-it.json and this script publishes afterwards: the published build is stamped with the +// release commit and a clean tree. A package with "private": true is never published. +// A prerelease (1.2.0-beta.1) is published under a dist-tag (`beta`), never as `latest`; npm 11 +// refuses a prerelease without one. `--tag` (publish) or `--npm.tag` (release) chooses another. +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const root = process.cwd(); +const pkg = () => JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const fail = (msg) => { + console.error(msg); + process.exit(1); +}; + +function out(cmd, args) { + try { + return execFileSync(cmd, args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); + } catch { + return null; + } +} + +// true: on npm, false: not on npm (E404), null: could not tell (network, auth). +function publishedOnNpm(name, version) { + const r = spawnSync('npm', ['view', `${name}@${version}`, 'version'], { cwd: root, encoding: 'utf8' }); + if (r.status === 0) return r.stdout.trim() === version; + return /E404/.test(r.stderr) ? false : null; +} + +// The dist-tag for a version: none (npm's `latest`) for a release, the first prerelease identifier +// for a prerelease (`beta` for 1.2.0-beta.1), or `next` when that identifier is numeric (1.2.0-0). +function distTag(version) { + const pre = /^\d+\.\d+\.\d+-([0-9A-Za-z-]+)/.exec(version)?.[1]; + if (!pre) return undefined; + return /^\d+$/.test(pre) ? 'next' : pre; +} + +// The value of `--name ` or `--name=` in args: undefined when absent, '' when empty. +function optionValue(args, name) { + const i = args.findIndex((a) => a === name || a.startsWith(`${name}=`)); + if (i === -1) return undefined; + return args[i] === name ? (args[i + 1] ?? '') : args[i].slice(name.length + 1); +} + +function publish({ dryRun, tag: chosenTag, resumeHint }) { + const { name, version, private: isPrivate } = pkg(); + if (isPrivate) { + console.error(`${name} is private; nothing to publish.`); + return; + } + const gitTag = `v${version}`; + const distTagName = chosenTag ?? distTag(version); + const problems = []; + const tagsAtHead = (out('git', ['tag', '--points-at', 'HEAD']) ?? '').split('\n'); + if (!tagsAtHead.includes(gitTag)) problems.push(`HEAD is not tagged ${gitTag}; publish only from the release commit.`); + if (chosenTag === '') problems.push('--tag needs a value.'); + if (out('git', ['status', '--porcelain']) !== '') problems.push('the working tree is not clean.'); + const user = out('npm', ['whoami']); + if (!user) problems.push('not logged in to npm (run `npm login`).'); + const published = publishedOnNpm(name, version); + if (published === true) problems.push(`${name}@${version} is already on npm.`); + if (published === null) problems.push(`could not check whether ${name}@${version} is on npm (network?).`); + if (problems.length > 0) { + fail(`Not publishing ${name}@${version}:\n${problems.map((p) => ` - ${p}`).join('\n')}\n${resumeHint}`); + } + const tagNote = distTagName ? ` under the "${distTagName}" dist-tag` : ''; + console.error(`Publishing ${name}@${version}${tagNote} as ${user}${dryRun ? ' (dry run)' : ''}...`); + // Inherit stdio: npm asks for a one-time password or opens a browser here when the account uses 2FA. + // No --access flag: it would override package.json "publishConfig"; scoped packages set + // "publishConfig": { "access": "public" } there. + const args = ['publish', ...(distTagName ? ['--tag', distTagName] : []), ...(dryRun ? ['--dry-run'] : [])]; + const r = spawnSync('npm', args, { + cwd: root, + stdio: 'inherit', + }); + if (r.status !== 0) fail(`npm publish failed. ${resumeHint}`); + if (!dryRun) console.error(`Published. Check: npm view ${name}@${version}`); +} + +const [command, ...rest] = process.argv.slice(2); +switch (command) { + case 'release': { + const info = rest.some((a) => ['--help', '-h', '--version', '-v', '--release-version', '--changelog'].includes(a)); + const dryRun = rest.some((a) => a === '--dry-run' || a === '-d'); + const { name, version, private: isPrivate } = pkg(); + if (!info) { + const problems = []; + if ((out('git', ['tag', '--points-at', 'HEAD']) ?? '').split('\n').includes(`v${version}`)) { + problems.push(`HEAD is already the v${version} release. If only the npm publish failed, run \`npm run release:publish\`.`); + } + if (!isPrivate && !out('npm', ['whoami'])) problems.push('not logged in to npm (run `npm login`); the publish step needs it.'); + if (problems.length > 0) fail(`Not releasing ${name}:\n${problems.map((p) => ` - ${p}`).join('\n')}`); + } + const bin = path.join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'release-it.cmd' : 'release-it'); + const r = spawnSync(bin, rest, { cwd: root, stdio: 'inherit' }); + if (r.error) fail(`could not run release-it (${r.error.message}); install dev dependencies first.`); + if (r.status !== 0) process.exit(r.status ?? 1); + if (info || dryRun) break; + const npmTag = optionValue(rest, '--npm.tag'); + const resume = `npm run release:publish${npmTag ? ` -- --tag ${npmTag}` : ''}`; + publish({ + dryRun: false, + tag: npmTag, + resumeHint: `The release commit, tag and GitHub Release are done; only the npm publish is left. Fix this, then run \`${resume}\`.`, + }); + break; + } + case 'publish': + publish({ + dryRun: rest.includes('--dry-run'), + tag: optionValue(rest, '--tag'), + resumeHint: 'Fix this, then run `npm run release:publish` again.', + }); + break; + default: + fail('usage: node scripts/release.mjs release [release-it args] | publish [--dry-run] [--tag ] (see RELEASING.md)'); +} diff --git a/scripts/test-package.local.sh b/scripts/test-package.local.sh new file mode 100644 index 0000000..c9eb8d6 --- /dev/null +++ b/scripts/test-package.local.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Project-specific package checks, sourced by scripts/test-package.sh (the canonical smoke test) +# with $consumer set to the install prefix. Keep the canonical script unchanged. +# +# These checks used to live in scripts/test-package.sh before it was replaced by the release +# standard's copy; they are kept here so nothing the repository relied on was dropped. + +# dist/ was just rebuilt by `npm pack` (which runs prepack), so this inspects the artifact +# under test rather than a previous build. +SHEBANG_COUNT="$(grep -c '^#!' dist/cli.js || true)" +if [[ "$SHEBANG_COUNT" != "1" ]]; then + echo "Expected dist/cli.js to have exactly one shebang, got $SHEBANG_COUNT" >&2 + exit 1 +fi + +# The version line must carry this package's version, not just look like a version. +EXPECTED_VERSION="$(node -p "require('./package.json').version")" +ACTUAL_VERSION="$("$consumer/node_modules/.bin/rd" --version)" +case "$ACTUAL_VERSION" in + "rd $EXPECTED_VERSION" | "rd $EXPECTED_VERSION "*) ;; + *) + echo "Expected 'rd --version' to report $EXPECTED_VERSION, got '$ACTUAL_VERSION'" >&2 + exit 1 + ;; +esac + +# Check that executing a real Axios request from the project root does not emit +# Node deprecation warnings (e.g. from proxy-from-env using the legacy +# url.parse() API). Running from inside node_modules suppresses these warnings, +# so we exercise the local dist artifact directly. +DEPRECATION_OUTPUT="$(NODE_OPTIONS=--trace-deprecation RAINDROP_TOKEN=fake ./dist/cli.js collections stats 2>&1 || true)" +if echo "$DEPRECATION_OUTPUT" | grep -q "DeprecationWarning"; then + echo "Found deprecation warning in CLI output:" >&2 + echo "$DEPRECATION_OUTPUT" >&2 + exit 1 +fi diff --git a/scripts/test-package.sh b/scripts/test-package.sh index ded6656..c10986c 100755 --- a/scripts/test-package.sh +++ b/scripts/test-package.sh @@ -1,56 +1,43 @@ #!/usr/bin/env bash -# Smoke test the npm package artifact exactly as users install it. -# This catches packaging-only failures such as invalid bin paths, missing files, -# duplicate shebangs, and runtime assumptions about the installed layout. +# release-standard: scripts/test-package.sh r1 (do not edit; see RELEASING.md) +# Pack the npm tarball, install it the way a user would, and check that every command +# answers --help and prints a standard --version line. Project-specific checks go in +# scripts/test-package.local.sh, which is sourced with $consumer set to the install prefix. set -euo pipefail -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -TMP_DIR="$(mktemp -d)" -TARBALL="" -cleanup() { - rm -rf "$TMP_DIR" - if [[ -n "$TARBALL" && -f "$TARBALL" ]]; then - rm -f "$TARBALL" +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +# `npm pack` runs prepack (the build), so the tarball carries a fresh build-info stamp. +npm pack --pack-destination "$tmpdir" >/dev/null +archive=$(find "$tmpdir" -maxdepth 1 -name '*.tgz' | head -n 1) +[[ -n "$archive" ]] || { echo 'Error: npm pack produced no tarball.' >&2; exit 1; } + +consumer="$tmpdir/consumer" +# A normal install, so dependency install scripts run just as they do for users. +npm install --omit=dev --no-audit --no-fund --prefix "$consumer" "$archive" >/dev/null + +bins=$(node -e ' + const p = require(process.argv[1]); + const names = typeof p.bin === "string" ? [p.name.split("/").pop()] : Object.keys(p.bin ?? {}); + console.log(names.join(" ")); +' "$(pwd)/package.json") +[[ -n "$bins" ]] || { echo 'Error: package.json declares no "bin".' >&2; exit 1; } + +version_re='^[^ ]+ [0-9]+\.[0-9]+\.[0-9]+[^ ]* \([0-9a-f]{7}(-dirty)? [0-9]{4}-[0-9]{2}-[0-9]{2}\)$' +for bin in $bins; do + "$consumer/node_modules/.bin/$bin" --help >/dev/null + line=$("$consumer/node_modules/.bin/$bin" --version) + if ! [[ "$line" =~ $version_re ]]; then + echo "Error: '$bin --version' printed '$line'; expected ' X.Y.Z (sha7 YYYY-MM-DD)'." >&2 + echo 'Check that the build runs scripts/build-info.mjs and that package.json "files" ships dist/build-info.json.' >&2 + exit 1 fi -} -trap cleanup EXIT - -bun run build >/dev/null - -# dist was built above; do not run lifecycle scripts again while packing the -# artifact under test. `prepack` separately guarantees direct npm publish -# commands rebuild dist immediately before upload. -TARBALL="$(npm pack --ignore-scripts --silent | tail -n 1)" -npm install -g --prefix "$TMP_DIR/prefix" --ignore-scripts "./$TARBALL" >/dev/null - -SHEBANG_COUNT="$(grep -c '^#!' dist/cli.js || true)" -if [[ "$SHEBANG_COUNT" != "1" ]]; then - echo "Expected dist/cli.js to have exactly one shebang, got $SHEBANG_COUNT" >&2 - exit 1 -fi - -BIN="$TMP_DIR/prefix/bin/rd" -EXPECTED_VERSION="$(node -p "require('./package.json').version")" -ACTUAL_VERSION="$($BIN --version)" - -if [[ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]]; then - echo "Expected rdcli --version to output '$EXPECTED_VERSION', got '$ACTUAL_VERSION'" >&2 - exit 1 -fi - -"$BIN" --help >/dev/null +done -# Check that executing a real Axios request from the project root does not emit -# Node deprecation warnings (e.g. from proxy-from-env using the legacy -# url.parse() API). Running from inside node_modules suppresses these warnings, -# so we exercise the local dist artifact directly. -DEPRECATION_OUTPUT="$(NODE_OPTIONS=--trace-deprecation RAINDROP_TOKEN=fake ./dist/cli.js collections stats 2>&1 || true)" -if echo "$DEPRECATION_OUTPUT" | grep -q "DeprecationWarning"; then - echo "Found deprecation warning in CLI output:" >&2 - echo "$DEPRECATION_OUTPUT" >&2 - exit 1 +if [[ -f scripts/test-package.local.sh ]]; then + # shellcheck source=/dev/null + source scripts/test-package.local.sh fi -echo "Package smoke test passed" +echo 'Package smoke test passed.' diff --git a/src/cli-meta.test.ts b/src/cli-meta.test.ts new file mode 100644 index 0000000..0e50541 --- /dev/null +++ b/src/cli-meta.test.ts @@ -0,0 +1,91 @@ +import { describe, test, expect } from "bun:test"; +import { + compareVersions, + formatVersion, + gitCloneUrl, + gitInstallUrl, + versionLine, +} from "./cli-meta.js"; + +/** + * Unit tests for the release standard's version line and self-update helpers. + * + * These are pure functions: no git, no network, no installed package. + */ + +describe("formatVersion", () => { + test("prints the name and version alone without commit information", () => { + expect(formatVersion("rd", { version: "0.3.1" })).toBe("rd 0.3.1"); + }); + + test("prints the short commit and commit date", () => { + expect( + formatVersion("rd", { + version: "0.3.1", + commit: "c0100eba6012ddb0e8d02aeecb842fab02aadfc8", + date: "2026-10-26", + dirty: false, + }) + ).toBe("rd 0.3.1 (c0100eb 2026-10-26)"); + }); + + test("marks builds from uncommitted changes", () => { + expect( + formatVersion("rd", { + version: "0.3.1", + commit: "9f8e7d6c5b4a39281706f5e4d3c2b1a098765432", + date: "2026-10-28", + dirty: true, + }) + ).toBe("rd 0.3.1 (9f8e7d6-dirty 2026-10-28)"); + }); + + test("prints the commit without a date when the date is missing", () => { + expect(formatVersion("rd", { version: "1.0.0", commit: "abcdef1234567" })).toBe( + "rd 1.0.0 (abcdef1)" + ); + }); +}); + +describe("versionLine", () => { + test("prints the standard version line for this checkout", () => { + // Runs from src/ inside a git checkout, so the commit is read from git. + expect(versionLine("rd")).toMatch( + /^rd \d+\.\d+\.\d+( \([0-9a-f]{7}(-dirty)? \d{4}-\d{2}-\d{2}\))?$/ + ); + }); +}); + +describe("compareVersions", () => { + test("orders plain versions", () => { + expect(compareVersions("0.3.0", "0.3.1")).toBeLessThan(0); + expect(compareVersions("0.3.1", "0.3.1")).toBe(0); + expect(compareVersions("1.0.0", "0.9.9")).toBeGreaterThan(0); + }); + + test("sorts a prerelease before its release", () => { + expect(compareVersions("1.0.0-beta.0", "1.0.0")).toBeLessThan(0); + expect(compareVersions("1.0.0", "1.0.0-beta.0")).toBeGreaterThan(0); + expect(compareVersions("1.0.0-alpha", "1.0.0-beta")).toBeLessThan(0); + }); +}); + +describe("git URLs", () => { + test("turns a repository field into a clone URL", () => { + expect(gitCloneUrl("github:crcatala/raindrop-cli")).toBe( + "https://github.com/crcatala/raindrop-cli.git" + ); + expect(gitCloneUrl("git@github.com:crcatala/raindrop-cli.git")).toBe( + "ssh://git@github.com/crcatala/raindrop-cli.git" + ); + expect(gitCloneUrl("git+https://github.com/crcatala/raindrop-cli.git")).toBe( + "https://github.com/crcatala/raindrop-cli.git" + ); + }); + + test("turns a clone URL into the form npm installs from", () => { + expect(gitInstallUrl("crcatala/raindrop-cli")).toBe( + "git+https://github.com/crcatala/raindrop-cli.git" + ); + }); +}); diff --git a/src/cli-meta.ts b/src/cli-meta.ts new file mode 100644 index 0000000..17bb0e3 --- /dev/null +++ b/src/cli-meta.ts @@ -0,0 +1,371 @@ +// release-standard: src/cli-meta.ts r3 (see the release standard before editing) +// +// Version line and self-update for a Node CLI: +// program.version(versionLine("mytool"), "-V, --version", "print the version and commit"); +// program.command("update").option("--check").option("--force") +// .action((o) => { process.exitCode = runUpdate("mytool", o); }); +// +// A build stamps dist/build-info.json (scripts/build-info.mjs), which is what a run from dist/ +// reports. A run from source in a git checkout (tsx, bun) reads git instead, so it always shows the +// current commit; anything else without a stamp prints the version alone. +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, join, sep } from "node:path"; +import { fileURLToPath } from "node:url"; + +// The directory of this module. The only ESM-specific line: in a CommonJS build use `__dirname`. +const HERE = dirname(fileURLToPath(import.meta.url)); + +export interface BuildInfo { + version: string; + commit?: string; + date?: string; + dirty?: boolean; +} + +export interface PackageMeta { + root: string; + name: string; + version: string; + isPrivate: boolean; + repositoryUrl?: string; +} + +export type InstallKind = "checkout" | "npx" | "mise" | "bun" | "pnpm" | "npm" | "unknown"; + +export interface Step { + cmd: string; + args: string[]; + cwd?: string; +} + +const run = (cmd: string, args: string[], cwd?: string): string | undefined => { + try { + return execFileSync(cmd, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return undefined; + } +}; + +// ── version ───────────────────────────────────────────────────────────────── + +export function formatVersion(name: string, info: BuildInfo): string { + if (!info.commit) return `${name} ${info.version}`; + const sha = info.commit.slice(0, 7) + (info.dirty ? "-dirty" : ""); + return `${name} ${info.version} (${info.date ? `${sha} ${info.date}` : sha})`; +} + +export function packageMeta(from: string = HERE): PackageMeta { + let dir = realpathSync(from); + while (!existsSync(join(dir, "package.json"))) { + const parent = dirname(dir); + if (parent === dir) throw new Error("cli-meta: no package.json above " + from); + dir = parent; + } + const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")); + const repo = typeof pkg.repository === "string" ? pkg.repository : pkg.repository?.url; + return { + root: dir, + name: pkg.name, + version: pkg.version, + isPrivate: pkg.private === true, + repositoryUrl: repo, + }; +} + +export function isCheckout(root: string): boolean { + return existsSync(join(root, ".git")); +} + +// The build stamp in dist/, if there is a readable one. +export function readStamp(root: string): BuildInfo | undefined { + try { + return JSON.parse(readFileSync(join(root, "dist", "build-info.json"), "utf8")) as BuildInfo; + } catch { + return undefined; + } +} + +// `runningFrom` is the directory of the running module. In a checkout the stamp describes dist/ +// only; code running from src/ reads git, so a stale build cannot hide newer commits or edits. +export function buildInfo( + meta: PackageMeta = packageMeta(), + runningFrom: string = HERE +): BuildInfo { + const checkout = isCheckout(meta.root); + const fromDist = (realpathSync(runningFrom) + sep).startsWith(join(meta.root, "dist") + sep); + const stamp = readStamp(meta.root); + if (stamp && (!checkout || fromDist)) return stamp; + if (checkout) { + const commit = run("git", ["rev-parse", "HEAD"], meta.root); + if (commit) { + return { + version: meta.version, + commit, + date: run("git", ["show", "-s", "--format=%cs", "HEAD"], meta.root), + dirty: run("git", ["status", "--porcelain"], meta.root) !== "", + }; + } + } + return { version: meta.version }; +} + +export function versionLine(name: string): string { + return formatVersion(name, buildInfo()); +} + +// For ` version [--json]`. +export function versionDetails(): Record { + const meta = packageMeta(); + const info = buildInfo(meta); + const checkout = isCheckout(meta.root); + const install = detectInstall( + meta.root, + checkout, + checkout ? undefined : run("npm", ["root", "-g"]) + ); + return { ...info, install, path: meta.root }; +} + +// ── install detection ─────────────────────────────────────────────────────── + +// `root` is the real path of the installed package. `npmGlobalRoot` is `npm root -g`, when known. +export function detectInstall( + root: string, + checkout: boolean, + npmGlobalRoot?: string +): InstallKind { + if (checkout) return "checkout"; + const p = root.split(sep).join("/"); + if (p.includes("/_npx/")) return "npx"; + if (p.includes("/mise/installs/npm-")) return "mise"; + if (p.includes("/.bun/install/global/")) return "bun"; + if (p.includes("/pnpm/global/") || p.includes("/.pnpm-global/")) return "pnpm"; + if (npmGlobalRoot && root.startsWith(npmGlobalRoot + sep)) return "npm"; + return "unknown"; +} + +export function packageManagerFor(root: string): "pnpm" | "bun" | "npm" { + if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm"; + if (existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb"))) return "bun"; + return "npm"; +} + +// A package.json "repository" (`github:o/r`, `o/r`, `git@host:o/r.git`, `git+https://...`) as a URL +// git can clone. `gitInstallUrl` is the same URL in the form npm, pnpm and bun install from. +export function gitCloneUrl(repositoryUrl: string): string { + const url = repositoryUrl.trim().replace(/^git\+/, ""); + const github = /^(?:github:)?([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url); + if (github) return `https://github.com/${github[1]}.git`; + const scp = /^([\w.-]+@[\w.-]+):(.+)$/.exec(url); + if (scp) return `ssh://${scp[1]}/${scp[2]}`; + return url; +} + +export function gitInstallUrl(repositoryUrl: string): string { + return `git+${gitCloneUrl(repositoryUrl)}`; +} + +export function updateSteps( + kind: InstallKind, + meta: PackageMeta, + pm = packageManagerFor(meta.root) +): Step[] | null { + // An unpublished package is reinstalled from its repository, never looked up on the registry by name. + if (meta.isPrivate && kind !== "checkout") { + if (!meta.repositoryUrl || !["npm", "pnpm", "bun"].includes(kind)) return null; + const url = gitInstallUrl(meta.repositoryUrl); + return [{ cmd: kind, args: kind === "npm" ? ["install", "-g", url] : ["add", "-g", url] }]; + } + const spec = `${meta.name}@latest`; + switch (kind) { + case "checkout": + return [ + // The same branch checkForUpdate compares with, whatever main's upstream is. + { cmd: "git", args: ["pull", "--ff-only", "origin", "main"], cwd: meta.root }, + { cmd: pm, args: ["install"], cwd: meta.root }, + { cmd: pm, args: ["run", "build"], cwd: meta.root }, + ]; + case "mise": + return [{ cmd: "mise", args: ["upgrade", "--bump", `npm:${meta.name}`] }]; + case "bun": + return [{ cmd: "bun", args: ["add", "-g", spec] }]; + case "pnpm": + return [{ cmd: "pnpm", args: ["add", "-g", spec] }]; + case "npm": + return [{ cmd: "npm", args: ["install", "-g", spec] }]; + default: + return null; + } +} + +// ── latest version ────────────────────────────────────────────────────────── + +// Negative when a < b. Plain X.Y.Z; a prerelease sorts before its release. +export function compareVersions(a: string, b: string): number { + const parse = (v: string) => { + const [core, pre] = v.replace(/^v/, "").split("-", 2); + return { nums: (core ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0), pre }; + }; + const x = parse(a); + const y = parse(b); + for (let i = 0; i < 3; i++) { + const d = (x.nums[i] ?? 0) - (y.nums[i] ?? 0); + if (d !== 0) return d; + } + if (x.pre === y.pre) return 0; + if (x.pre === undefined) return 1; + if (y.pre === undefined) return -1; + return x.pre < y.pre ? -1 : 1; +} + +export interface UpdateStatus { + current: string; + latest?: string; + available?: boolean; // undefined: could not tell + reason?: string; // why it could not tell + note?: string; // why an update is available, when it is not a newer version +} + +function remoteMainCommit(url: string): string | undefined { + const line = run("git", ["ls-remote", gitCloneUrl(url), "refs/heads/main"]); + return line?.split(/\s/)[0] || undefined; +} + +export function checkForUpdate( + kind: InstallKind, + meta: PackageMeta, + info: BuildInfo +): UpdateStatus { + if (kind === "checkout") { + if (run("git", ["fetch", "--quiet", "origin", "main"], meta.root) === undefined) { + return { current: meta.version, reason: "could not fetch origin/main" }; + } + const head = run("git", ["rev-parse", "HEAD"], meta.root); + const remote = run("git", ["rev-parse", "origin/main"], meta.root); + if (!head || !remote) + return { current: meta.version, reason: "could not read the checkout's commits" }; + // Ahead of origin/main is not an update. + const behind = run("git", ["rev-list", "--count", "HEAD..origin/main"], meta.root); + if (Number(behind) > 0) + return { current: head.slice(0, 7), latest: remote.slice(0, 7), available: true }; + // Up to date with origin/main, but dist/ was built from another commit: an update whose pull + // succeeded and whose install or build failed, or a pull without a rebuild. Rebuilding is the update. + const stamp = readStamp(meta.root); + if (stamp?.commit && stamp.commit !== head) { + return { + current: stamp.commit.slice(0, 7), + latest: head.slice(0, 7), + available: true, + note: "the build is older than the checkout", + }; + } + // Built from uncommitted edits that are gone now: dist/ no longer matches the source. + if (stamp?.dirty && run("git", ["status", "--porcelain"], meta.root) === "") { + return { + current: `${head.slice(0, 7)}-dirty`, + latest: head.slice(0, 7), + available: true, + note: "the build has uncommitted edits the checkout no longer has", + }; + } + return { current: head.slice(0, 7), latest: remote.slice(0, 7), available: false }; + } + if (meta.isPrivate) { + const remote = meta.repositoryUrl ? remoteMainCommit(meta.repositoryUrl) : undefined; + if (!remote) + return { current: meta.version, reason: "could not read the repository's main branch" }; + if (!info.commit) + return { + current: meta.version, + latest: remote.slice(0, 7), + reason: "this build has no commit stamp", + }; + return { + current: info.commit.slice(0, 7), + latest: remote.slice(0, 7), + available: remote !== info.commit, + }; + } + const latest = run("npm", ["view", `${meta.name}@latest`, "version"]); + if (!latest) return { current: meta.version, reason: "could not reach the npm registry" }; + return { current: meta.version, latest, available: compareVersions(meta.version, latest) < 0 }; +} + +// ── update command ────────────────────────────────────────────────────────── + +export interface UpdateOptions { + check?: boolean; + force?: boolean; +} + +const show = (s: Step) => [s.cmd, ...s.args].join(" "); + +// Returns the exit code: see the release standard's update table. +export function runUpdate(name: string, opts: UpdateOptions = {}): number { + const meta = packageMeta(); + const info = buildInfo(meta); + const checkout = isCheckout(meta.root); + const kind = detectInstall( + meta.root, + checkout, + checkout ? undefined : run("npm", ["root", "-g"]) + ); + + if (kind === "npx") { + console.log( + `${name} is running through npx; \`npx ${meta.name}@latest\` always runs the latest version.` + ); + return 0; + } + + const status = checkForUpdate(kind, meta, info); + const steps = updateSteps(kind, meta); + if (status.available === undefined) { + console.error(`${name}: cannot tell whether an update is available (${status.reason}).`); + if (opts.check || !opts.force) return 2; + } else if (!status.available && !opts.force) { + console.log(`${name} is up to date (${status.current}).`); + return 0; + } else if (status.available) { + console.log( + `${name}: update available, ${status.current} → ${status.latest}${status.note ? ` (${status.note})` : ""}.` + ); + } + if (opts.check) return status.available ? 1 : 0; + + if (!steps) { + console.error( + `${name}: installed from ${meta.root}, which is not a recognized install method.` + ); + console.error( + `Update it the way it was installed, for example \`npm install -g ${meta.name}@latest\`.` + ); + return 1; + } + if (kind === "checkout") { + const branch = run("git", ["symbolic-ref", "--quiet", "--short", "HEAD"], meta.root); + const dirty = run("git", ["status", "--porcelain"], meta.root) !== ""; + if (branch !== "main" || dirty) { + console.error( + `${name}: the checkout at ${meta.root} must be clean and on main to update (it is ${dirty ? "dirty" : "clean"}, on ${branch ?? "a detached HEAD"}).` + ); + return 1; + } + } + for (const step of steps) { + console.log(`$ ${show(step)}`); + const r = spawnSync(step.cmd, step.args, { cwd: step.cwd, stdio: "inherit" }); + if (r.status !== 0) { + console.error(`${name}: \`${show(step)}\` failed${r.error ? ` (${r.error.message})` : ""}.`); + console.error(`Fix the cause, then run \`${name} update\` again.`); + return 1; + } + } + console.log(`${name} updated. Check with \`${name} --version\`.`); + return 0; +} diff --git a/src/cli.integration.test.ts b/src/cli.integration.test.ts index e16e684..52a48d2 100644 --- a/src/cli.integration.test.ts +++ b/src/cli.integration.test.ts @@ -119,19 +119,48 @@ describe("CLI integration", () => { }); describe("version command", () => { - test("--version outputs version to stdout", async () => { + // " ([-dirty] )", see the release standard. + const VERSION_LINE = /^rd \d+\.\d+\.\d+( \([0-9a-f]{7}(-dirty)? \d{4}-\d{2}-\d{2}\))?$/; + + test("--version outputs the version line to stdout", async () => { const result = await runCli(["--version"]); expect(result.exitCode).toBe(0); // Version should be in stdout, not stderr - expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); + expect(result.stdout.trim()).toMatch(VERSION_LINE); + expect(result.stderr).toBe(""); }); test("-V is alias for --version", async () => { const result = await runCli(["-V"]); expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/\d+\.\d+\.\d+/); + expect(result.stdout.trim()).toMatch(VERSION_LINE); + }); + }); + + describe("update command", () => { + test("update --check reports without changing the install", async () => { + const result = await runCli(["update", "--check"], { timeout: 30000 }); + + // 0 up to date, 1 update available, 2 cannot tell (e.g. no network to origin). + expect([0, 1, 2]).toContain(result.exitCode); + expect(result.stdout + result.stderr).not.toContain("Error:"); + }); + + test("update still updates a bookmark (shortcut kept)", async () => { + const result = await runCli(["update", "not-a-number", "--title", "x"]); + + // Reaches the bookmark command's own validation, so no API call is made. + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("Invalid bookmark ID"); + }); + + test("update --help documents the bookmark form", async () => { + const result = await runCli(["update", "--help"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Update an existing bookmark"); }); }); diff --git a/src/cli/program.ts b/src/cli/program.ts index 747b591..6c67e74 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -10,7 +10,6 @@ */ import { Command, CommanderError, Option } from "commander"; -import pkg from "../../package.json" with { type: "json" }; import type { CliContext } from "./context.js"; import { OUTPUT_FORMATS } from "../types/index.js"; import { createAuthCommand } from "../commands/auth.js"; @@ -23,6 +22,7 @@ import { createFiltersCommand } from "../commands/filters.js"; import { createTrashCommand } from "../commands/trash.js"; import { setNoColorFlag } from "../utils/tty.js"; import { setDebugEnabled, setVerboseEnabled, debug } from "../utils/debug.js"; +import { runUpdate, versionLine } from "../cli-meta.js"; import { setTimeoutSeconds, validateTimeout, DEFAULT_TIMEOUT_SECONDS } from "../utils/timeout.js"; import { configureStyledHelpRecursive } from "../utils/help-formatter.js"; import { UsageError } from "../utils/errors.js"; @@ -183,6 +183,68 @@ All bookmarks list options are supported.` return cmd; } +/** + * Create the root `update` command. + * + * Without a bookmark ID it updates the CLI itself the way it was installed (the release + * standard's self-update command). With an ID it stays a shortcut for `rd bookmarks update`. + */ +function createUpdateCommand(program: Command): Command { + return new Command("update") + .description( + "Update rd itself, or update a bookmark when given an ID (shortcut for: bookmarks update)" + ) + .allowUnknownOption() + .allowExcessArguments() + .passThroughOptions() + .helpOption(false) // pass --help through to `bookmarks update`, as the other shortcuts do + .addHelpText( + "after", + ` +Without a bookmark ID, updates rd the way it was installed: + rd update # check, then update + rd update --check # exit 0 up to date, 1 update available, 2 unknown + rd update --force # update even when already up to date + +With a bookmark ID, updates that bookmark (shortcut for: rd bookmarks update): + rd update 12345 --title "New Title"` + ) + .action(async (_opts, command: Command) => { + // Get the raw argv from the root program, as the other shortcuts do. + const rawArgs: string[] = command.parent?.args ?? []; + const cmdIndex = rawArgs.indexOf("update"); + const rest = cmdIndex === -1 ? [] : rawArgs.slice(cmdIndex + 1); + + // The bare command and its own two flags update the CLI; anything else (a bookmark ID, + // --help, an unknown option) is handed to `bookmarks update` as before. + const isSelfUpdate = rest.every((arg) => arg === "--check" || arg === "--force"); + if (isSelfUpdate) { + process.exitCode = runUpdate("rd", { + check: rest.includes("--check"), + force: rest.includes("--force"), + }); + return; + } + + try { + await program.parseAsync([...rawArgs.slice(0, cmdIndex), "bookmarks", "update", ...rest], { + from: "user", + }); + } catch (err) { + if (err instanceof CommanderError) { + const isHelpOrVersion = + err.code === "commander.help" || + err.code === "commander.helpDisplayed" || + err.code === "commander.version"; + if (isHelpOrVersion) { + return; // Let it propagate naturally + } + } + throw err; + } + }); +} + /** * Create and configure the Commander program. * @@ -195,7 +257,7 @@ export function createProgram(ctx: CliContext): Command { program .name("rd") .description("CLI for Raindrop.io, the all-in-one bookmark manager") - .version(pkg.version) + .version(versionLine("rd"), "-V, --version", "print the version and commit") .enablePositionalOptions() .addHelpText("after", "\nReport issues: https://github.com/crcatala/raindrop-cli-spike/issues") .addOption( @@ -286,14 +348,7 @@ export function createProgram(ctx: CliContext): Command { "Add a bookmark (shortcut for: bookmarks add)" ) ); - program.addCommand( - createRootBookmarkShortcut( - program, - "update", - [], - "Update a bookmark (shortcut for: bookmarks update)" - ) - ); + program.addCommand(createUpdateCommand(program)); program.addCommand( createRootBookmarkShortcut( program, diff --git a/src/release-prep.test.ts b/src/release-prep.test.ts index d3c02de..0c22a94 100644 --- a/src/release-prep.test.ts +++ b/src/release-prep.test.ts @@ -57,7 +57,8 @@ describe("release prep helper", () => { const result = runReleasePrep(directory); expect(result.exitCode).toBe(0); - expect(result.stdout).toContain("No previous tag found. Showing all commits."); + expect(result.stdout).toContain("No prior tag; showing all commits."); + expect(result.stdout).toContain("=== Changelog prompt"); expect(result.stdout).toMatch(/initial\.txt\s+\|\s+1 \+/); expect(result.stdout).toContain("1 file changed, 1 insertion(+)"); }); diff --git a/src/run.test.ts b/src/run.test.ts index 98e5a6c..5a78c2e 100644 --- a/src/run.test.ts +++ b/src/run.test.ts @@ -83,8 +83,11 @@ describe("runCli unit tests", () => { stderr: noopStream(), }); - // Version should match semver pattern - expect(getOutput()).toMatch(/\d+\.\d+\.\d+/); + // " ([-dirty] )", or just " " + // when the running code has no commit information. + expect(getOutput().trim()).toMatch( + /^rd \d+\.\d+\.\d+( \([0-9a-f]{7}(-dirty)? \d{4}-\d{2}-\d{2}\))?$/ + ); }); });