diff --git a/bin/crawlproof.ts b/bin/crawlproof.ts index 382376c..9b07826 100755 --- a/bin/crawlproof.ts +++ b/bin/crawlproof.ts @@ -28,6 +28,7 @@ import { meetsNodeFloor, resolveRunner, vendorBin, + wantsSelfUpdate, } from '../src/crawlproof.ts'; import { spawnInherit } from '../src/codeburn.ts'; import { isMain } from '../src/is-main.ts'; @@ -41,9 +42,14 @@ import { isMain } from '../src/is-main.ts'; */ const OURS = new Set(['--self-update', '--self-where']); + async function main(argv: string[]): Promise { - const flags = new Set(argv.filter((argument) => OURS.has(argument))); - const rest = argv.filter((argument) => !OURS.has(argument)); + const wantsUpdate = wantsSelfUpdate(argv); + const given = wantsUpdate ? argv.slice(1) : argv; + + const flags = new Set(given.filter((argument) => OURS.has(argument))); + if (wantsUpdate) flags.add('--self-update'); + const rest = given.filter((argument) => !OURS.has(argument)); if (!meetsNodeFloor(process.versions.node)) { process.stderr.write( diff --git a/src/crawlproof.ts b/src/crawlproof.ts index e113e23..3f3f67f 100644 --- a/src/crawlproof.ts +++ b/src/crawlproof.ts @@ -193,6 +193,25 @@ export function hasCoinpaySession(env: NodeJS.ProcessEnv = process.env): boolean return existsSync(join(env.HOME ?? homedir(), '.coinpay.json')); } +/** + * The plain words this wrapper claims for itself. + * + * Everything else goes to the dashboard untouched. These are the exception, + * deliberately and narrowly: installing is the wrapper's job and can never come + * to mean something upstream, and `update` is the word `cli-tools update` + * already uses, so typing it here and getting the dashboard's usage back is the + * command being wrong rather than the person. + */ +export const UPDATE_WORDS = new Set(['update', 'upgrade', 'self-update']); + +/** + * Only the FIRST argument counts, so the word can never swallow some later + * subcommand's own argument (`crawlproof ads budget update` stays upstream's). + */ +export function wantsSelfUpdate(argv: readonly string[]): boolean { + return UPDATE_WORDS.has(argv[0] ?? ''); +} + /** Whether a CrawlProof API token is reachable without the caller exporting one. */ export function hasToken(env: NodeJS.ProcessEnv = process.env): boolean { if (env.CRAWLPROOF_TOKEN?.trim()) return true; diff --git a/test/crawlproof.test.ts b/test/crawlproof.test.ts index e969104..b91892d 100644 --- a/test/crawlproof.test.ts +++ b/test/crawlproof.test.ts @@ -12,6 +12,7 @@ import { resolveRunner, vendorBin, vendorRoot, + wantsSelfUpdate, } from '../src/crawlproof.ts'; import { mkdtempSync, readFileSync, rmSync, existsSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -135,6 +136,27 @@ describe('meetsNodeFloor', () => { }); }); +describe('the update verb', () => { + it('claims the words that mean "install the latest"', () => { + expect(wantsSelfUpdate(['update'])).toBe(true); + expect(wantsSelfUpdate(['upgrade'])).toBe(true); + expect(wantsSelfUpdate(['self-update'])).toBe(true); + }); + + it('leaves every other first word to the dashboard', () => { + for (const word of ['dashboard', 'stats', 'ad', 'ads', 'help', 'version', '--json']) { + expect(wantsSelfUpdate([word])).toBe(false); + } + expect(wantsSelfUpdate([])).toBe(false); + }); + + it('only claims it in first position, so a subcommand argument is safe', () => { + // `crawlproof ads budget update` must reach upstream untouched. + expect(wantsSelfUpdate(['ads', 'budget', 'update'])).toBe(false); + expect(wantsSelfUpdate(['stats', 'update'])).toBe(false); + }); +}); + describe('credential probes', () => { it('sees a token in the environment', () => { expect(hasToken({ CRAWLPROOF_TOKEN: 'crp_x' })).toBe(true);