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/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/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 9f94651..23c6e37 100644
--- a/package.json
+++ b/package.json
@@ -18,19 +18,20 @@
"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",
- "test": "bun --raw-test",
- "test:watch": "bun --raw-test --watch",
- "test:coverage": "bun --raw-test --coverage",
+ "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",
"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/",
"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.'
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}\)$/);
+ });
+});