/** * Session #51 — command-template flag-value portability. * * Dogfooding `knowledge-refresh` surfaced a defect that only exists at the * seam between the command template and the shell that runs it: * * STALE_AFTER="--stale-after 30" * node …-cli.mjs --reference-date "$TODAY" $STALE_AFTER --output-file … * * The unquoted `$STALE_AFTER` is meant to split into TWO argv entries. Under * **bash** it does. Under **zsh** — the macOS default since Catalina, and the * shell the Bash tool actually runs on this machine — unquoted parameter * expansions are NOT word-split, so the CLI receives ONE argv entry with the * literal text `--stale-after 30`, matches no known flag, and (because the CLI * silently ignored unknown flags — see cli-unknown-flag-rejection.test.mjs) * falls back to the 90-day default while reporting success. Measured: * * $ STALE_AFTER="--stale-after 30"; set -- $STALE_AFTER; echo $# * 1 # zsh (bash prints 2) * → payload staleAfterDays: 90, exit 0, "✓ All 14 entries fresh" * * The user-facing knob was silently dead. Note the asymmetry that makes this * survivable elsewhere: an EMPTY unquoted expansion yields ZERO argv entries in * both shells, so the `FLAG=""` idiom used by ~25 other sites is portable. Only * a variable that can hold a flag AND its value is affected. * * The invariant asserted here is therefore about VALUE-carrying flags, not * about quoting in general: a command template must never depend on the shell * splitting one variable into a flag plus its argument. Pass the value through * its own quoted variable instead. */ import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFile, readdir } from 'node:fs/promises'; import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const COMMANDS_DIR = resolve(__dirname, '..', '..', 'commands'); async function commandFiles() { const entries = await readdir(COMMANDS_DIR); return entries.filter((e) => e.endsWith('.md')).sort(); } /** * Assignments whose right-hand side contains a flag followed by a value — * i.e. the value only reaches argv if the shell word-splits. Matches both * `X="--flag value"` and `X="--flag $(cmd)"`. */ const MULTIWORD_FLAG_ASSIGN = /^\s*([A-Z_][A-Z0-9_]*)=(["'])(--[a-z0-9-]+)[ \t]+\S.*\2\s*$/; test('no command template builds a flag AND its value into one shell variable', async () => { const offenders = []; for (const file of await commandFiles()) { const content = await readFile(resolve(COMMANDS_DIR, file), 'utf-8'); content.split('\n').forEach((line, i) => { const m = line.match(MULTIWORD_FLAG_ASSIGN); if (m) offenders.push(`${file}:${i + 1} ${m[1]}=${m[2]}${m[3]} …${m[2]}`); }); } assert.deepEqual( offenders, [], 'A variable holding "--flag value" only reaches argv correctly if the shell\n' + 'word-splits an unquoted expansion. zsh does not. Pass the value in its own\n' + 'quoted variable instead:\n' + ' N=$(… extract …); [ -n "$N" ] && node cli.mjs --flag "$N"\n' + 'Offending assignments:\n ' + offenders.join('\n '), ); }); test('knowledge-refresh.md passes --stale-after with a quoted value', async () => { const content = await readFile(resolve(COMMANDS_DIR, 'knowledge-refresh.md'), 'utf-8'); assert.ok( !/\$STALE_AFTER\b(?!")/.test(content.replace(/"\$STALE_AFTER"/g, '')), 'knowledge-refresh.md still expands a flag-carrying variable unquoted; under zsh the\n' + 'threshold silently reverts to the 90-day default while the command reports success.', ); assert.match( content, /--stale-after "\$[A-Z_]+"/, 'knowledge-refresh.md must pass the extracted threshold as its own quoted argument\n' + '(`--stale-after "$STALE_AFTER_DAYS"`), so no word-splitting is required.', ); });