From 85eb0f3edf2befed0698d35e813a85c2df753e2c Mon Sep 17 00:00:00 2001 From: cc-vps Date: Tue, 27 Oct 2026 17:47:50 -0700 Subject: [PATCH 1/3] fix(test): run the real test suite with bun test --isolate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bun --raw-test` is not a bun flag: it printed bun's help and exited 0, so `bun run test` — used by verify, CI, and the release preflight — never ran a test. Use `bun test` instead. `--isolate` keeps each test file in its own global object: the module mock of `fs` in tests/unit/config.config.test.ts is never restored by bun, so without it every later file that touches node:fs saw the stub. Co-authored-by: Christian Catalan --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 9f94651..c7ae8fb 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,9 @@ "dev": "bun run src/index.ts", "clean": "node -e \"for (const path of ['dist', 'tsconfig.tsbuildinfo']) require('node:fs').rmSync(path, { recursive: true, force: true })\"", "build": "bun run clean && tsc --build", - "test": "bun --raw-test", - "test:watch": "bun --raw-test --watch", - "test:coverage": "bun --raw-test --coverage", + "test": "bun test --isolate", + "test:watch": "bun test --isolate --watch", + "test:coverage": "bun test --isolate --coverage", "test:live": "RUN_LIVE_TESTS=1 bun test --preload ./scripts/live-test-preflight.ts tests/integration/live-api.test.ts tests/integration/reference-resolution.live.test.ts --timeout 60000", "typecheck": "tsc --noEmit", "lint": "oxlint src/", From 9eb12b48a60ab8cd9a617761815f9f70fff5dda2 Mon Sep 17 00:00:00 2001 From: cc-vps Date: Tue, 27 Oct 2026 17:47:53 -0700 Subject: [PATCH 2/3] chore: adopt the release standard Copy the canonical release scripts byte for byte (prep-release.sh, check-changelog.sh, release.mjs, build-info.mjs, test-package.sh) and keep this project's own smoke-test checks in scripts/test-package.local.sh, which the canonical script sources: both `tt` and `ttcli` must be installed and report the package.json version exactly. - .release-it.json: stop releasing through release-it's npm plugin (npm.publish: false) so scripts/release.mjs publishes from the released commit; run the changelog check before verify; hooks stay on bun. - package.json: release/release:dry/release:publish/release:prep now go through release.mjs and the shared prep script; the build stamps dist/build-info.json (scripts/build-info.mjs) and "files" ships it. - RELEASING.md: rewritten for the wrapped flow, bun, and the npm/git recovery steps. Co-authored-by: Christian Catalan --- .release-it.json | 7 +- RELEASING.md | 105 ++++++++++++++++------------ package.json | 10 +-- scripts/build-info.mjs | 29 ++++++++ scripts/check-changelog.sh | 19 +++++ scripts/prep-release.sh | 92 ++++++++++-------------- scripts/release.mjs | 127 ++++++++++++++++++++++++++++++++++ scripts/test-package.local.sh | 31 +++++++++ scripts/test-package.sh | 56 ++++++++------- 9 files changed, 345 insertions(+), 131 deletions(-) create mode 100755 scripts/build-info.mjs create mode 100755 scripts/check-changelog.sh create mode 100755 scripts/release.mjs create mode 100644 scripts/test-package.local.sh diff --git a/.release-it.json b/.release-it.json index 5111591..88baeb9 100644 --- a/.release-it.json +++ b/.release-it.json @@ -1,5 +1,5 @@ { - "$schema": "https://unpkg.com/release-it@20/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,11 +14,10 @@ "releaseName": "v${version}" }, "npm": { - "publish": true, - "publishArgs": ["--access", "public"] + "publish": false }, "hooks": { - "before:init": ["bun run verify"], + "before:init": ["bash scripts/check-changelog.sh", "bun run verify"], "after:bump": ["bun run test:package"] }, "plugins": { diff --git a/RELEASING.md b/RELEASING.md index 861aabb..2dc7ea0 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,91 +1,108 @@ # Releasing -This project uses [release-it](https://github.com/release-it/release-it) for manual releases from a maintainer machine. A release builds and validates the npm package, updates the changelog and version, publishes to npm, pushes a signed-off release commit and tag, and creates a GitHub Release. +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 `@crcatala/ticktick-cli` to npm from the +tagged commit. -## Prerequisites +The package is public and installs the `tt` and `ttcli` commands. -- Push access to `crcatala/ticktick-cli` -- An npm account with publish access to the `@crcatala` scope (`npm whoami`) -- A GitHub token available as `GITHUB_TOKEN` with repository **Contents: read and write** permission, so release-it can create the GitHub Release -- Node.js 22.21+ (release-it v21 requirement) or 24+, and Bun -- A clean checkout on `main` +## Prerequisites -The package is public and is published as `@crcatala/ticktick-cli`. It installs the `tt` and `ttcli` commands. +- Push access to `main`, and a GitHub token for the release (`GITHUB_TOKEN` + with repository **Contents: read and write**, or release-it opens the GitHub + release form in the browser instead). +- An npm login with publish rights to the `@crcatala` scope: `npm whoami`, + else `npm login`. +- Node.js `^22.21.0 || >=24.0.0` (release-it v21 requirement) and Bun. +- A clean checkout on `main`. ## Before releasing -1. Pull the current main branch: +1. Start from an up-to-date `main`: ```bash git checkout main git pull --ff-only ``` -2. Prepare and update the changelog. The helper lists changes since the last tag: +2. Draft the changelog: ```bash bun run release:prep ``` - Add user-facing entries beneath `## [Unreleased]` in `CHANGELOG.md`, using [Keep a Changelog](https://keepachangelog.com/) sections such as Added, Changed, Fixed, Removed, or Security. + 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. -3. Run the complete local verification suite: +3. Run the full local verification suite, which is also what the release + preflight runs: ```bash bun run verify ``` - This runs tests, linting, type checking, the JavaScript build, and an install smoke test of the exact `npm pack` tarball. + This runs the tests, linting, type checking, the JavaScript build (stamped + with the commit by `scripts/build-info.mjs`), and an install smoke test of + the exact `npm pack` tarball (`tt` and `ttcli` must answer `--help` and print + the standard `--version` line). ## Release -Preview the local release steps first: - ```bash -bun run release:dry +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 ``` -`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. - -Then release interactively: +`release:dry` runs the same preflight as `release`, so it also needs an npm +login (`npm whoami`). To preview the release-it steps without one, run +release-it directly: ```bash -export GITHUB_TOKEN=github_pat_... # if not already configured -bun run release +npx release-it --dry-run --ci --no-npm --no-github ``` -Release-it prompts for the version bump and then: +In order, `bun run release`: -1. validates the clean `main` checkout and runs `bun run verify`; -2. bumps `package.json` and moves the changelog's Unreleased entries into the new version; -3. rebuilds `dist/`, packs it, and smoke-tests the installed `tt` and `ttcli` commands; -4. commits and tags `vX.Y.Z`; -5. rebuilds immediately before publishing `@crcatala/ticktick-cli` publicly to npm; -6. pushes the commit and tag; and -7. creates a GitHub Release using the changelog notes. +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). -Useful recovery options: +A prerelease is published under a dist-tag named after its identifier, so +`latest` keeps pointing at the last full release: ```bash -# Skip npm publishing if it already succeeded in a partial release -bun run release -- --no-npm +bun run release -- --preRelease=beta # 1.3.0-beta.0, published as @crcatala/ticktick-cli@beta +bun run release -- --preRelease=beta --npm.tag=next # same version, under "next" +``` -# Skip GitHub Release creation -bun run release -- --no-github +## When a step fails -# Release a specific version without the bump prompt -bun run release -- 0.1.1 +- **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). -# Publish a prerelease -bun run release -- --preRelease=alpha -``` +Published npm versions are immutable. If a release has a defect, publish a +corrective version rather than replacing the existing one. -## Verify the release +## Verify ```bash -npm view @crcatala/ticktick-cli -npx @crcatala/ticktick-cli@latest --version +npm view @crcatala/ticktick-cli version +npx @crcatala/ticktick-cli@latest --version # tt X.Y.Z (sha7 YYYY-MM-DD) npx @crcatala/ticktick-cli@latest --help ``` - -Published npm versions are immutable. If a release has a defect, publish a corrective version rather than replacing the existing one. diff --git a/package.json b/package.json index c7ae8fb..23c6e37 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "prepublishOnly": "bun run verify", "dev": "bun run src/index.ts", "clean": "node -e \"for (const path of ['dist', 'tsconfig.tsbuildinfo']) require('node:fs').rmSync(path, { recursive: true, force: true })\"", - "build": "bun run clean && tsc --build", + "build": "bun run clean && tsc --build && node scripts/build-info.mjs", "test": "bun test --isolate", "test:watch": "bun test --isolate --watch", "test:coverage": "bun test --isolate --coverage", @@ -28,9 +28,10 @@ "schema:capture": "bun run scripts/schema-capture.ts", "test:package": "bash scripts/test-package.sh", "verify": "bun run test && bun run lint && bun run typecheck && bun run build && bun run test:package", - "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": [ "ticktick", @@ -53,6 +54,7 @@ }, "files": [ "dist/**/*.js", + "dist/build-info.json", "README.md", "LICENSE" ], 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 new file mode 100755 index 0000000..67fd452 --- /dev/null +++ b/scripts/check-changelog.sh @@ -0,0 +1,19 @@ +#!/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 + +file="${1:-CHANGELOG.md}" +if [[ ! -f "$file" ]]; then + echo "Error: $file not found." >&2 + exit 1 +fi + +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.' diff --git a/scripts/prep-release.sh b/scripts/prep-release.sh index f5274a3..b54c22f 100755 --- a/scripts/prep-release.sh +++ b/scripts/prep-release.sh @@ -1,62 +1,44 @@ -#!/bin/bash -# Usage: ./scripts/prep-release.sh [last-tag] -# Gathers commit info since last tag for changelog generation - -set -e - -LAST_TAG="${1:-$(git describe --tags --abbrev=0 2>/dev/null || echo "")}" - -echo "=== Release Prep ===" -echo "" - -if [ -z "$LAST_TAG" ]; then - echo "No previous tag found. Showing all commits." - echo "" - RANGE="HEAD" -else - echo "Changes since: $LAST_TAG" - echo "" - RANGE="${LAST_TAG}..HEAD" +#!/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 + +# 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 -echo "=== Commits ===" -echo "" -git log ${RANGE} --pretty=format:"- %s (%h)" --no-merges -echo "" -echo "" - -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 "" - -echo "=== Files changed ===" -echo "" -if [ -z "$LAST_TAG" ] || [ "$RANGE" = "HEAD" ]; then - git diff --stat HEAD~20 2>/dev/null | tail -5 || echo "(showing recent)" -else - git diff --stat ${RANGE} | tail -10 -fi -echo "" -echo "" +range='HEAD' +[[ -n "$last_tag" ]] && range="${last_tag}..HEAD" -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 (only include sections that have entries). -Be concise. Include PR/issue numbers in parentheses where mentioned. +echo '=== Release Prep ===' +if [[ -n "$last_tag" ]]; then echo "Changes since: $last_tag"; else echo 'No prior tag; showing all commits.'; fi +echo -Format example: -### Added -- New feature description ([#123](https://github.com/USER/REPO/pull/123)) +# Prompt first, so everything below it can be copied into an LLM in one go. +cat <<'EOF' +=== Changelog prompt (copy everything below this line) === -### Fixed -- Bug fix description +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. -Commits to review: +=== Full commit messages === EOF -echo "" -git log ${RANGE} --pretty=format:"- %s" --no-merges -echo "" + +# 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 + # Compare against the empty tree so a first release shows everything. + git diff --stat "$(git hash-object -t tree /dev/null)" HEAD +fi 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..34426ec --- /dev/null +++ b/scripts/test-package.local.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# release-standard: project-specific additions to scripts/test-package.sh +# +# Sourced by scripts/test-package.sh with $consumer set to the install prefix, so every +# check runs against the installed tarball, exactly as a user gets it. +set -euo pipefail + +# Both commands (`tt` and `ttcli`) must ship and report the package.json version exactly: +# the standard --version line is "tt X.Y.Z (sha7 YYYY-MM-DD)", so only the version field is +# compared here (the canonical script already checks the whole line's shape). +expected_version="$(node -p "require('./package.json').version")" + +for bin in $bins; do + if [[ ! -x "$consumer/node_modules/.bin/$bin" ]]; then + echo "Error: '$bin' is declared in package.json \"bin\" but was not installed." >&2 + exit 1 + fi + + line="$("$consumer/node_modules/.bin/$bin" --version)" + actual="${line#* }" + actual="${actual%% *}" + + if [[ "$actual" != "$expected_version" ]]; then + echo "Error: '$bin --version' reported version '$actual', expected '$expected_version' from package.json." >&2 + exit 1 + fi + + "$consumer/node_modules/.bin/$bin" --help >/dev/null +done + +echo "Local package checks passed (tt and ttcli report $expected_version)." diff --git a/scripts/test-package.sh b/scripts/test-package.sh index 1486b5c..c10986c 100755 --- a/scripts/test-package.sh +++ b/scripts/test-package.sh @@ -1,35 +1,43 @@ #!/usr/bin/env bash -# Smoke-test the exact npm tarball users install. +# 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" +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT -TMP_DIR="$(mktemp -d)" -TARBALL="" -cleanup() { - rm -rf "$TMP_DIR" - [[ -z "$TARBALL" || ! -f "$TARBALL" ]] || rm -f "$TARBALL" -} -trap cleanup 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; } -bun run build >/dev/null -# The artifact under test was built above. `prepack` separately guarantees that -# direct npm pack/publish commands rebuild immediately before packaging. -TARBALL="$(npm pack --ignore-scripts --silent | tail -n 1)" -npm install -g --prefix "$TMP_DIR/prefix" --ignore-scripts "./$TARBALL" >/dev/null +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 -EXPECTED_VERSION="$(node -p "require('./package.json').version")" -for command in tt ttcli; do - BIN="$TMP_DIR/prefix/bin/$command" - ACTUAL_VERSION="$($BIN --version)" +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; } - if [[ "$ACTUAL_VERSION" != "$EXPECTED_VERSION" ]]; then - echo "Expected $command --version to output '$EXPECTED_VERSION', got '$ACTUAL_VERSION'" >&2 +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 - - "$BIN" --help >/dev/null done -echo "Package smoke test passed" +if [[ -f scripts/test-package.local.sh ]]; then + # shellcheck source=/dev/null + source scripts/test-package.local.sh +fi + +echo 'Package smoke test passed.' From 218e5980ccfcef1581ee648b4c1ddbaed9f1566f Mon Sep 17 00:00:00 2001 From: cc-vps Date: Tue, 27 Oct 2026 17:47:54 -0700 Subject: [PATCH 3/3] feat: standard --version line, update and version commands `tt --version` / `tt -V` now prints `tt X.Y.Z (sha7 YYYY-MM-DD)`, with `-dirty` when the build came from a tree with uncommitted changes, instead of the bare version. src/cli-meta.ts reads the stamp written by scripts/build-info.mjs for builds, and git for code running from src/ in a checkout, so a stale build never hides newer commits; a build without commit information prints only the version. `tt update [--check] [--force]` updates the CLI the way it was installed: a clone is pulled with `git pull --ff-only` and rebuilt with bun, a global npm install is reinstalled from the registry, npx is told it already runs the latest. `--check` reports only: exit 0 up to date, 1 update available, 2 when it cannot tell. `tt version [--json]` shows the version, full commit, date, dirty state, install method and path. `packageManagerFor` prefers bun.lock over the pnpm lockfile this repository also keeps, so a checkout update installs and builds with bun, like CI does. Docs: the README Install section gains From source and Updating; the changelog has the Unreleased entries. Co-authored-by: Christian Catalan --- CHANGELOG.md | 15 ++ README.md | 32 ++- src/cli-meta.ts | 308 ++++++++++++++++++++++++++ src/index.ts | 23 +- tests/integration/cli-version.test.ts | 69 ++++++ tests/unit/cli-meta.test.ts | 135 +++++++++++ 6 files changed, 572 insertions(+), 10 deletions(-) create mode 100644 src/cli-meta.ts create mode 100644 tests/integration/cli-version.test.ts create mode 100644 tests/unit/cli-meta.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c13a9e..9b95ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `tt update` (with `--check` and `--force`) updates the CLI the way it was installed: a clone is pulled + with `git pull --ff-only`, then `bun install` and `bun run build`; a global npm, pnpm or bun install is + reinstalled from the registry. +- `tt version [--json]` reports the version, full commit, commit date, dirty state, install method and + install path. +- Install documentation now covers running from source and updating. + +### Changed + +- `tt --version` / `tt -V` now prints `tt X.Y.Z (sha7 YYYY-MM-DD)`, with `-dirty` for builds made from a + working tree with uncommitted changes, instead of the bare version number. The same line is printed by + a registry install and by a local build. + ## [0.1.3] - 2026-07-15 ### Added diff --git a/README.md b/README.md index 38b51dd..5feebb4 100644 --- a/README.md +++ b/README.md @@ -2,30 +2,44 @@ A command-line interface for the [TickTick](https://ticktick.com) task management app, optimized for AI agents. -## Installation +## Install -Requires Node.js 22.12+ (commander v15 is ESM-only and requires Node ≥22.12 for `require(esm)`): +Requires Node.js `^22.21.0 || >=24.0.0` (commander v15 is ESM-only and needs Node ≥22.12 for `require(esm)`): ```bash -npm install -g @crcatala/ticktick-cli +npm install -g @crcatala/ticktick-cli # or: pnpm add -g @crcatala/ticktick-cli, bun add -g @crcatala/ticktick-cli +npx @crcatala/ticktick-cli --help # run without installing ``` The package provides two equivalent commands: `tt` and `ttcli`. `tt` is used throughout this guide. -
-Build from Source +### From source -Requires Node.js 22.12+ and [Bun](https://bun.sh): +Requires Node.js `^22.21.0 || >=24.0.0` and [Bun](https://bun.sh) for the build: ```bash git clone https://github.com/crcatala/ticktick-cli.git cd ticktick-cli bun install bun run build -node dist/index.js --help +npm link # puts `tt` and `ttcli` on your PATH, pointing at this clone +tt --version # tt 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 `tt` is also +installed globally, remove that copy so the linked build is the one on `PATH` (`command -v tt` +should resolve inside the clone). + +### Updating + +```bash +tt update --check # exit 0: up to date, 1: update available, 2: could not tell +tt update # updates the same way it was installed +``` + +`tt update` follows the install method: a clone is pulled with `git pull --ff-only`, then +`bun install` and `bun run build`; a global npm, pnpm or bun install is reinstalled from the +registry. `tt version` shows which commit, install method and path are in use. ## Quick Start @@ -199,6 +213,8 @@ fi | `tt user stats` | Show usage statistics | | `tt sync` | Fetch full state snapshot | | `tt trash empty` | Permanently delete all trashed items | +| `tt update` | Update `tt` the way it was installed | +| `tt version` | Show version, commit, install method and install path | Task references accept exact IDs, unambiguous ID prefixes, and case-insensitive exact titles. Project references also accept unambiguous case-insensitive name prefixes. If multiple items match, use a full ID or add `--project PROJECT` to task lookup commands that support it. diff --git a/src/cli-meta.ts b/src/cli-meta.ts new file mode 100644 index 0000000..c7a3880 --- /dev/null +++ b/src/cli-meta.ts @@ -0,0 +1,308 @@ +// 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"; +} + +// This repository keeps a pnpm lockfile for local experiments as well as bun.lock, but bun is the +// package manager (CI installs with `bun install`, every script runs through `bun run`), so bun wins. +export function packageManagerFor(root: string): "pnpm" | "bun" | "npm" { + if (existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb"))) return "bun"; + if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm"; + 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/index.ts b/src/index.ts index 9b6ae5f..a61079b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,6 @@ * TickTick CLI - Command-line interface for TickTick */ import { Command, Option } from "commander"; -import packageJson from "../package.json" with { type: "json" }; import { createAuthCommand, createTaskCommand, @@ -17,6 +16,7 @@ import { createTrashCommand, createChecklistCommand, } from "./commands/index.js"; +import { runUpdate, versionDetails, versionLine } from "./cli-meta.js"; import type { ValidationStrategy } from "./schemas/index.js"; /** @@ -46,7 +46,7 @@ const program = new Command(); program .name("tt") .description("Command-line interface for TickTick") - .version(packageJson.version) + .version(versionLine("tt"), "-V, --version", "print the version and commit") .addOption( new Option( "--validation ", @@ -56,6 +56,25 @@ program .default("strict") ); +// Version, update and self-management commands +program + .command("update") + .description("update tt the way it was installed") + .option("--check", "report only: exit 0 up to date, 1 update available, 2 unknown") + .option("--force", "update even when already up to date") + .action((options: { check?: boolean; force?: boolean }) => { + process.exitCode = runUpdate("tt", options); + }); + +program + .command("version") + .description("print version, commit, install method and path") + .option("--json", "Output as JSON") + .action((options: { json?: boolean }) => { + const details = versionDetails(); + console.log(options.json ? JSON.stringify(details) : Object.entries(details).map(([k, v]) => `${k}: ${v}`).join("\n")); + }); + // Register all command groups program.addCommand(createAuthCommand()); program.addCommand(createTaskCommand()); diff --git a/tests/integration/cli-version.test.ts b/tests/integration/cli-version.test.ts new file mode 100644 index 0000000..36a364c --- /dev/null +++ b/tests/integration/cli-version.test.ts @@ -0,0 +1,69 @@ +/** + * Integration tests for the standard `--version` line, `update` and `version` commands. + */ +import { describe, it, expect } from "bun:test"; +import { spawn } from "bun"; +import { join } from "node:path"; + +const CLI_PATH = join(import.meta.dir, "..", "..", "src", "index.ts"); +const VERSION_LINE = /^tt \d+\.\d+\.\d+[^ ]* \([0-9a-f]{7}(-dirty)? \d{4}-\d{2}-\d{2}\)$/; + +async function run(args: string[]) { + const proc = spawn([process.execPath, "run", CLI_PATH, ...args], { + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + await proc.exited; + return { stdout, stderr, exitCode: proc.exitCode }; +} + +describe("tt --version", () => { + it("prints the standard version line for --version", async () => { + const { stdout, exitCode } = await run(["--version"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toMatch(VERSION_LINE); + }); + + it("prints the same line for -V", async () => { + const { stdout, exitCode } = await run(["-V"]); + expect(exitCode).toBe(0); + expect(stdout.trim()).toMatch(VERSION_LINE); + }); +}); + +describe("tt version", () => { + it("prints details as one JSON object with --json", async () => { + const { stdout, exitCode } = await run(["version", "--json"]); + expect(exitCode).toBe(0); + + const details = JSON.parse(stdout); + expect(details.version).toMatch(/^\d+\.\d+\.\d+/); + expect(details.commit).toMatch(/^[0-9a-f]{40}$/); + expect(details.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + // These tests run from the repository checkout. + expect(details.install).toBe("checkout"); + expect(details.path).toContain("ticktick-cli"); + }); +}); + +describe("tt update --check", () => { + it("reports without updating: exit 0, 1 or 2", async () => { + const { stdout, stderr, exitCode } = await run(["update", "--check"]); + + expect([0, 1, 2]).toContain(exitCode); + expect(stderr).not.toContain("Error"); + expect(stderr).not.toMatch(/\n\s+at /); // no stack trace + expect(`${stdout}${stderr}`).toMatch(/tt|update/i); + }); +}); + +describe("tt --help", () => { + it("lists the update and version commands", async () => { + const { stdout, exitCode } = await run(["--help"]); + expect(exitCode).toBe(0); + expect(stdout).toContain("update"); + expect(stdout).toContain("version"); + }); +}); diff --git a/tests/unit/cli-meta.test.ts b/tests/unit/cli-meta.test.ts new file mode 100644 index 0000000..154ff6c --- /dev/null +++ b/tests/unit/cli-meta.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test } from "bun:test"; +import { + buildInfo, + packageManagerFor, + packageMeta, + versionLine, + formatVersion, + compareVersions, + detectInstall, + gitCloneUrl, + gitInstallUrl, + updateSteps, +} from "../../src/cli-meta.js"; + +describe("formatVersion", () => { + test("includes short commit, dirty marker and commit date", () => { + expect( + formatVersion("tt", { version: "0.1.3", commit: "a1b2c3d4e5", date: "2026-10-27" }) + ).toBe("tt 0.1.3 (a1b2c3d 2026-10-27)"); + }); + + test("marks builds with uncommitted changes as dirty", () => { + expect( + formatVersion("tt", { version: "0.1.3", commit: "a1b2c3d4e5", date: "2026-10-27", dirty: true }) + ).toBe("tt 0.1.3 (a1b2c3d-dirty 2026-10-27)"); + }); + + test("prints only the version when there is no commit information", () => { + expect(formatVersion("tt", { version: "0.1.3" })).toBe("tt 0.1.3"); + }); +}); + +describe("compareVersions", () => { + test("orders releases", () => { + expect(compareVersions("0.1.3", "0.1.4")).toBeLessThan(0); + expect(compareVersions("0.2.0", "0.1.9")).toBeGreaterThan(0); + expect(compareVersions("1.0.0", "1.0.0")).toBe(0); + expect(compareVersions("v1.0.0", "1.0.0")).toBe(0); + }); + + test("sorts a prerelease before its release", () => { + expect(compareVersions("1.0.0-beta.1", "1.0.0")).toBeLessThan(0); + expect(compareVersions("1.0.0", "1.0.0-beta.1")).toBeGreaterThan(0); + }); +}); + +describe("packageMeta", () => { + test("finds this package's root and metadata", () => { + const meta = packageMeta(); + expect(meta.name).toBe("@crcatala/ticktick-cli"); + expect(meta.version).toMatch(/^\d+\.\d+\.\d+/); + expect(meta.isPrivate).toBe(false); + expect(meta.repositoryUrl).toContain("github.com/crcatala/ticktick-cli"); + }); +}); + +describe("packageManagerFor", () => { + test("uses bun for this repository despite the pnpm lockfile", () => { + expect(packageManagerFor(packageMeta().root)).toBe("bun"); + }); +}); + +describe("detectInstall", () => { + const root = "/usr/lib/node_modules/@crcatala/ticktick-cli"; + + test("a checkout wins over every path guess", () => { + expect(detectInstall("/home/me/src/ticktick-cli", true)).toBe("checkout"); + }); + + test("recognizes npx, mise, bun, pnpm and npm installs", () => { + expect(detectInstall("/home/me/.npm/_npx/abc123/node_modules/@crcatala/ticktick-cli", false)).toBe("npx"); + expect(detectInstall("/home/me/.local/share/mise/installs/npm-crcatala-ticktick-cli/0.1.3", false)).toBe("mise"); + expect(detectInstall("/home/me/.bun/install/global/node_modules/@crcatala/ticktick-cli", false)).toBe("bun"); + expect(detectInstall("/home/me/.local/share/pnpm/global/5/node_modules/@crcatala/ticktick-cli", false)).toBe( + "pnpm" + ); + expect(detectInstall(root, false, "/usr/lib/node_modules")).toBe("npm"); + }); + + test("falls back to unknown", () => { + expect(detectInstall("/opt/weird/place", false)).toBe("unknown"); + }); +}); + +describe("git URLs", () => { + test("normalizes the package.json repository URL", () => { + expect(gitCloneUrl("git+https://github.com/crcatala/ticktick-cli.git")).toBe( + "https://github.com/crcatala/ticktick-cli.git" + ); + expect(gitCloneUrl("github:crcatala/ticktick-cli")).toBe("https://github.com/crcatala/ticktick-cli.git"); + expect(gitCloneUrl("git@github.com:crcatala/ticktick-cli.git")).toBe( + "ssh://git@github.com/crcatala/ticktick-cli.git" + ); + expect(gitInstallUrl("github:crcatala/ticktick-cli")).toBe( + "git+https://github.com/crcatala/ticktick-cli.git" + ); + }); +}); + +describe("updateSteps", () => { + test("pulls, installs and rebuilds a source checkout with bun", () => { + const steps = updateSteps("checkout", packageMeta()); + expect(steps?.map((s) => [s.cmd, ...s.args].join(" "))).toEqual([ + "git pull --ff-only origin main", + "bun install", + "bun run build", + ]); + }); + + test("reinstalls a global npm install from the registry", () => { + const steps = updateSteps("npm", packageMeta()); + expect(steps?.map((s) => [s.cmd, ...s.args].join(" "))).toEqual([ + "npm install -g @crcatala/ticktick-cli@latest", + ]); + }); + + test("publishes nothing for an unknown install method", () => { + expect(updateSteps("unknown", packageMeta())).toBeNull(); + }); +}); + +describe("buildInfo", () => { + test("reads this checkout's git commit rather than a stale dist stamp", () => { + const meta = packageMeta(); + const info = buildInfo(meta); + expect(info.version).toBe(meta.version); + // Running from src/ inside a checkout reads git, so a commit must be present. + expect(info.commit).toMatch(/^[0-9a-f]{40}$/); + expect(info.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + test("versionLine matches the standard format", () => { + expect(versionLine("tt")).toMatch(/^tt \d+\.\d+\.\d+[^ ]* \([0-9a-f]{7}(-dirty)? \d{4}-\d{2}-\d{2}\)$/); + }); +});