Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ server-side records.
| `markpost config <get\|set\|path> [key] [value]` | View or change the stored API token and output directory |
| `markpost settings <get\|set> [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.
Expand Down
73 changes: 70 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<void>;
usage: string;
Expand Down Expand Up @@ -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 <topic>`,
// 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<void> {
// 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]);
Expand Down
125 changes: 125 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>'),
);
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 <topic>`, 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');
Expand Down