From 94b8c2c5ca86f2d6d8b553d35db523180889a926 Mon Sep 17 00:00:00 2001 From: Danny Holloran Date: Sun, 6 Sep 2026 17:05:15 -0700 Subject: [PATCH 1/3] Add markpost --version / -v to print the installed CLI version Wires a top-level version flag (--version, -v, and version for symmetry with the existing help path) so a globally-installed user can confirm which @markpost/cli version they're running. Reads the version the same way libs/config.ts already imports package.json. Extra arguments after the version token are rejected (fail loud), mirroring the sync command's own unexpected-argument guard. Closes #150 --- README.md | 1 + src/index.ts | 40 ++++++++++++++++++++- tests/index.test.ts | 84 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 620f19c..c89a761 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..7bb224d 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,7 +169,7 @@ 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 @@ -170,7 +180,35 @@ function printHelp(topic: string | undefined): void { 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 { + if (args.length > 0) { + console.error(chalk.redBright(`Unexpected arguments: ${args.join(' ')}`)); + console.error(VERSION_USAGE); + 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..48e3c29 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -309,6 +309,90 @@ describe('index', () => { 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_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'); From 9759f0a33e2454dc47c724479f894c2841edc86a Mon Sep 17 00:00:00 2001 From: Grimicorn Agent Date: Mon, 7 Sep 2026 21:21:44 -0700 Subject: [PATCH 2/3] Fix version command help lookup and guard against a missing version field --- src/index.ts | 24 ++++++++++++++++++++++-- tests/index.test.ts | 18 ++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 7bb224d..a5b1639 100644 --- a/src/index.ts +++ b/src/index.ts @@ -173,9 +173,18 @@ const HELP_TEXT = [ ].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); } @@ -194,6 +203,17 @@ function runVersionCommand(args: string[]): void { 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); } diff --git a/tests/index.test.ts b/tests/index.test.ts index 48e3c29..673bb98 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -309,6 +309,24 @@ 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) => { From 0a7d9bb1f53519fa5348c82875602e7d4f0cfe10 Mon Sep 17 00:00:00 2001 From: Grimicorn Agent Date: Mon, 7 Sep 2026 21:24:11 -0700 Subject: [PATCH 3/3] Honor --help/-h as a version sub-argument like every other command --- src/index.ts | 9 +++++++++ tests/index.test.ts | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/index.ts b/src/index.ts index a5b1639..34420d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -196,6 +196,15 @@ function printHelp(topic: string | undefined): void { // 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); diff --git a/tests/index.test.ts b/tests/index.test.ts index 673bb98..d69e30a 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -380,6 +380,29 @@ describe('index', () => { }, ); + // 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