diff --git a/README.md b/README.md index 23c5fb0..34c5223 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ server-side records. | `markpost config [key] [value]` | View or change the stored API token and output directory | | `markpost settings [key=value ...]` | View or change server-side sync settings (`autoSync`, `autoDelete`, `frontmatter`, `conflictStrategy`) | | `markpost help` | Show aggregated usage | +| `markpost --version` (or `-v` / `version`) | Print the installed CLI version | The destructive fetch/write/delete sync runs only under the explicit `markpost sync` command. diff --git a/src/index.ts b/src/index.ts index 724f7bc..34420d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,7 @@ import { runSettingsCommand, USAGE as SETTINGS_USAGE, } from '@/commands/settings.js'; +import packageJson from '../package.json' with { type: 'json' }; import yoctoSpinner from 'yocto-spinner'; import cliSpinners from 'cli-spinners'; import chalk from 'chalk'; @@ -104,6 +105,15 @@ const SYNC_USAGE = `Usage: markpost sync [--dry-run] const HELP_COMMANDS = new Set(['help', '--help', '-h']); const HELP_FLAG_ARGS = new Set(['--help', '-h']); +// Tokens in the command position that print the installed CLI version instead +// of running a command — a globally-installed user's only way to confirm +// which @markpost/cli they're on. `version` is included alongside the flags +// for the same reason `help` sits alongside `--help`/`-h` in HELP_COMMANDS. +// Checked like HELP_COMMANDS: a top-level token, never a per-command +// sub-argument. +const VERSION_COMMANDS = new Set(['version', '--version', '-v']); +const VERSION_USAGE = 'Usage: markpost --version'; + interface Command { run: (args: string[]) => Promise; usage: string; @@ -159,18 +169,75 @@ const HELP_TEXT = [ 'Commands:', ...[...COMMANDS.values()].flatMap((command) => ['', command.usage]), '', - 'Run `markpost help` (or `--help`) to see this message.', + 'Run `markpost help` (or `--help`) to see this message, or `markpost --version` (or `-v`) for the installed version.', ].join('\n'); // A top-level help request optionally targets one command: `markpost help -// sync` prints just the sync usage. An unrecognized topic falls back to the -// full help rather than erroring — a help request should stay helpful. +// sync` prints just the sync usage. `markpost help version` is handled the +// same way even though `version` isn't in COMMANDS — without this it would +// fall through to the full HELP_TEXT, which contradicts the line HELP_TEXT +// itself prints about `markpost --version`. An unrecognized topic falls back +// to the full help rather than erroring — a help request should stay +// helpful. function printHelp(topic: string | undefined): void { + if (topic !== undefined && VERSION_COMMANDS.has(topic)) { + console.log(VERSION_USAGE); + return; + } + const command = topic ? COMMANDS.get(topic) : undefined; console.log(command ? command.usage : HELP_TEXT); } +// A version request takes no further arguments — unlike `help `, +// there's no sub-argument for it to mean anything, so `markpost --version +// sync` is a genuine usage mistake (a stray extra word), not a request to +// version *and* sync. Fails loud (stderr + exit 1) like the sync command's +// own unexpected-argument guard, rather than silently printing the version +// and ignoring the rest of the line. +function runVersionCommand(args: string[]): void { + // `markpost version --help` must print usage like every other command's + // `--help`/`-h` sub-argument does (see the centralized HELP_FLAG_ARGS check + // in dispatch) — checked here, before the arity guard, because version + // bypasses that centralized check by returning early from dispatch itself. + if (args.some((arg) => HELP_FLAG_ARGS.has(arg))) { + console.log(VERSION_USAGE); + return; + } + + if (args.length > 0) { + console.error(chalk.redBright(`Unexpected arguments: ${args.join(' ')}`)); + console.error(VERSION_USAGE); + process.exitCode = 1; + return; + } + + // Fail loud rather than printing the literal string "undefined": the one + // thing this command exists to answer, it must not answer wrong while + // still exiting 0. + if (!packageJson.version) { + console.error( + chalk.redBright('Unable to determine the installed CLI version.'), + ); + process.exitCode = 1; + return; + } + + console.log(packageJson.version); +} + async function dispatch(): Promise { + // An explicit version request is a success: print to stdout, exit 0. Checked + // before help so `--version`/`-v`/`version` is dispatched the same way + // `--help`/`-h`/`help` is, rather than falling through to the unknown-command + // branch. This does mean a future `version` entry in COMMANDS would be + // permanently shadowed — the same precedence tradeoff HELP_COMMANDS already + // makes for `help`. + if (VERSION_COMMANDS.has(commandName)) { + runVersionCommand(commandArgs); + return; + } + // An explicit top-level help request is a success: print to stdout, exit 0. if (HELP_COMMANDS.has(commandName)) { printHelp(commandArgs[0]); diff --git a/tests/index.test.ts b/tests/index.test.ts index a69bc81..d69e30a 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -309,6 +309,131 @@ describe('index', () => { expect(process.exitCode).toBeUndefined(); }); + // `version` isn't a COMMANDS entry, so without a special case this would + // fall through to the full HELP_TEXT — contradicting the line HELP_TEXT + // itself prints about `markpost --version`. + it.each(['version', '--version', '-v'])( + 'prints only the version usage for "help %s" instead of the full help', + async (versionTopic) => { + process.argv = ['node', 'index.js', 'help', versionTopic]; + + await import('@/index.js'); + + expect(console.log).toHaveBeenCalledWith('Usage: markpost --version'); + expect(console.log).not.toHaveBeenCalledWith( + expect.stringContaining('Usage: markpost '), + ); + expect(process.exitCode).toBeUndefined(); + }, + ); + + it.each(['--version', '-v', 'version'])( + 'prints only the installed package version and exits 0 for "%s" without touching the sync', + async (versionFlag) => { + process.argv = ['node', 'index.js', versionFlag]; + const { fetchAllRecords, deleteRecords } = await import('@/libs/records.js'); + const { default: yoctoSpinner } = await import('yocto-spinner'); + const packageJson = await import('../package.json', { with: { type: 'json' } }); + + await import('@/index.js'); + + // Exactly one console.log call, and it's the bare version string — pins + // the version path against accidentally also dumping HELP_TEXT. + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith(packageJson.default.version); + // Also pin the shape (semver-ish), not just "whatever package.json + // currently says" — a dropped/blanked `version` field would still pass + // the exact-match assertion above but must fail here. + expect(console.log).toHaveBeenCalledWith( + expect.stringMatching(/^\d+\.\d+\.\d+/), + ); + expect(fetchAllRecords).not.toHaveBeenCalled(); + expect(deleteRecords).not.toHaveBeenCalled(); + expect(yoctoSpinner).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }, + ); + + // A version request takes no further arguments — unlike `help `, a + // trailing word after `--version` isn't meaningful, so it must be treated as + // a genuine usage mistake (fail loud) rather than silently printing the + // version and ignoring the rest of the line. + it.each(['--version', '-v', 'version'])( + 'rejects extra arguments after "%s" instead of printing the version', + async (versionToken) => { + process.argv = ['node', 'index.js', versionToken, 'sync']; + const { fetchAllRecords, deleteRecords } = await import( + '@/libs/records.js' + ); + + await import('@/index.js'); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Unexpected arguments: sync'), + ); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Usage: markpost --version'), + ); + expect(process.exitCode).toBe(1); + expect(fetchAllRecords).not.toHaveBeenCalled(); + expect(deleteRecords).not.toHaveBeenCalled(); + }, + ); + + // Version bypasses the centralized per-command HELP_FLAG_ARGS check in + // dispatch (it returns early, before that check runs), so it needs its own + // — otherwise `markpost version --help` would fall into the + // unexpected-arguments guard instead of behaving like every other + // command's `--help`. + it.each(['--help', '-h'])( + 'prints version usage for "version %s" instead of rejecting it as an unexpected argument', + async (helpFlag) => { + process.argv = ['node', 'index.js', 'version', helpFlag]; + const { fetchAllRecords, deleteRecords } = await import( + '@/libs/records.js' + ); + + await import('@/index.js'); + + expect(console.log).toHaveBeenCalledWith('Usage: markpost --version'); + expect(console.error).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + expect(fetchAllRecords).not.toHaveBeenCalled(); + expect(deleteRecords).not.toHaveBeenCalled(); + }, + ); + + // VERSION_COMMANDS is only checked against the command position — a + // per-command sub-argument that happens to collide with a version token + // must still reach the handler untouched, exactly like HELP_FLAG_ARGS + // doesn't intercept a `-h`/`--help` sub-argument outside its own check. + it('passes "-v" through to the command handler when it is a sub-argument, not the command itself', async () => { + process.argv = ['node', 'index.js', 'push', '-v']; + const { runPushCommand } = await import('@/commands/push.js'); + + await import('@/index.js'); + + expect(runPushCommand).toHaveBeenCalledWith(['-v']); + }); + + // The sync command rejects unexpected arguments outright (see + // runSyncCommand), so `--version` used as a sub-argument here must not be + // silently swallowed as a version request or as a no-op — it's a genuine + // usage mistake and must fail loud like any other unrecognized sync flag. + it('rejects "--version" as a sync sub-argument instead of printing the version', async () => { + process.argv = ['node', 'index.js', 'sync', '--version']; + const { fetchAllRecords, deleteRecords } = await import('@/libs/records.js'); + + await import('@/index.js'); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Unexpected arguments: --version'), + ); + expect(process.exitCode).toBe(1); + expect(fetchAllRecords).not.toHaveBeenCalled(); + expect(deleteRecords).not.toHaveBeenCalled(); + }); + it('prints help, fails loud, and never runs the destructive sync when invoked with no arguments', async () => { process.argv = ['node', 'index.js']; const { fetchAllRecords, deleteRecords } = await import('@/libs/records.js');