Spaces:
Runtime error
Runtime error
File size: 6,572 Bytes
a6b96c2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 | #!/usr/bin/env node
'use strict';
/**
* Scaffolds a new changeset fragment (#2975).
*
* npm run changeset -- --type Fixed --pr 1234 --body "fix the thing"
*
* Writes `.changeset/<adjective>-<noun>-<noun>.md` with frontmatter
* + body. The random three-word filename minimizes filename collision
* across concurrent PRs.
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('../lib/cli-exit.cjs');
// Small word lists — keep the function simple and dependency-free.
// Together this gives ~40 * 40 * 40 = 64,000 distinct names. The lint
// rejects any duplicate filename, so collisions are caught even when
// the random draw repeats.
const ADJECTIVES = [
'silly', 'brave', 'calm', 'eager', 'gentle', 'happy', 'jolly', 'kind',
'lively', 'merry', 'nimble', 'plucky', 'quick', 'sturdy', 'witty', 'zesty',
'bold', 'clever', 'daring', 'fierce', 'graceful', 'humble', 'lucky', 'noble',
'proud', 'rapid', 'sharp', 'tidy', 'vivid', 'wise', 'agile', 'curious',
'eager', 'gallant', 'mellow', 'patient', 'serene', 'steady', 'sturdy', 'sunny',
];
const NOUNS_A = [
'bears', 'birds', 'cats', 'dogs', 'elks', 'foxes', 'goats', 'hawks',
'ibex', 'jays', 'koalas', 'lynx', 'moles', 'newts', 'otters', 'pumas',
'quails', 'rams', 'seals', 'tigers', 'voles', 'wolves', 'yaks', 'zebras',
'badgers', 'cranes', 'deer', 'eagles', 'finches', 'geese', 'herons', 'jaguars',
'lemurs', 'mice', 'orcas', 'pandas', 'ravens', 'sloths', 'tunas', 'wasps',
];
const NOUNS_B = [
'dance', 'sing', 'leap', 'run', 'jump', 'climb', 'fly', 'swim',
'rest', 'wake', 'roam', 'greet', 'wander', 'gather', 'forage', 'travel',
'glide', 'sprint', 'tumble', 'wave', 'cheer', 'rally', 'parade', 'march',
'hop', 'frolic', 'caper', 'romp', 'zip', 'dart', 'snooze', 'munch',
'chatter', 'squeak', 'howl', 'bark', 'purr', 'roar', 'hum', 'click',
];
function pick(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function generateFragmentName() {
return `${pick(ADJECTIVES)}-${pick(NOUNS_A)}-${pick(NOUNS_B)}`;
}
// Allowed Keep-a-Changelog section types. Used by both scaffoldFragment
// (sanitization at write time) and parse.cjs (validation at consume time).
const ALLOWED_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']);
function scaffoldFragment({ repo, type, pr, body }) {
// Sanitize: reject any type value not on the allowlist BEFORE embedding it
// in frontmatter. A newline in `type` would corrupt the fragment; an
// unrecognized value would be rejected later by parse.cjs but with a
// confusing diagnostic. Catch both at the write boundary.
if (!ALLOWED_TYPES.has(type)) {
throw new Error(
`scaffoldFragment: type=${JSON.stringify(type)} is not one of [${[...ALLOWED_TYPES].join(', ')}]`,
);
}
const dir = path.join(repo, '.changeset');
fs.mkdirSync(dir, { recursive: true });
const content = `---\ntype: ${type}\npr: ${pr}\n---\n${body}\n`;
// Atomic create: writeFileSync with `flag: 'wx'` fails (EEXIST) when the
// file already exists, so concurrent invocations can't race past
// `existsSync` and overwrite each other. Re-roll the random name on
// collision; fail loudly after exhausting the retry budget.
for (let i = 0; i < 16; i++) {
const name = generateFragmentName();
const target = path.join(dir, `${name}.md`);
try {
fs.writeFileSync(target, content, { flag: 'wx' });
return target;
} catch (e) {
if (e.code !== 'EEXIST') throw e;
// collision — try another random draw
}
}
throw new Error(
'scaffoldFragment: 16 random filename draws all collided; ' +
'expand the word lists or investigate corrupted .changeset/ state',
);
}
function parseArgs(argv) {
const opts = { type: null, pr: null, body: null, repo: process.cwd() };
// Validate flag values: argv[++i] could be undefined (flag with no value)
// or another flag (silently misparsed). Match the cli.cjs convention: return
// { ok: true, opts } on success, { ok: false, error } on malformed input.
const requireValue = (flag, i) => {
const v = argv[i + 1];
if (v === undefined || v.startsWith('--')) {
return { ok: false, error: `missing value for ${flag}` };
}
return { ok: true, value: v };
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--type' || a === '--pr' || a === '--body' || a === '--repo') {
const r = requireValue(a, i);
if (!r.ok) return { ok: false, error: r.error };
if (a === '--type') opts.type = r.value;
else if (a === '--pr') {
// Accept only decimal-integer strings (digits only, no sign, no dot,
// no hex prefix, no scientific notation). Non-integer input — including
// empty string and whitespace — is normalized to NaN so the prNaN
// guard below rejects it with the usage error.
const trimmed = r.value.trim();
opts.pr = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
} else if (a === '--body') opts.body = r.value;
else if (a === '--repo') opts.repo = r.value;
i++;
continue;
}
return { ok: false, error: `unknown argument: ${a}` };
}
return { ok: true, opts };
}
function main() {
const parsed = parseArgs(process.argv.slice(2));
if (!parsed.ok) {
process.stderr.write(`${parsed.error}\n`);
process.stderr.write('usage: changeset/new.cjs --type <Fixed|Added|...> --pr NNNN --body "..."\n');
throw new ExitError(2);
}
const { opts } = parsed;
// opts.pr starts as null (missing flag) and is set by parseArgs to a Number when
// the raw value is a pure decimal-integer string (digits only), or to NaN for any
// other input (empty, whitespace, floats, hex, negatives, scientific notation, etc.).
// Accept integer 0 (the documented pr:0 placeholder); reject a missing flag (null)
// and any non-decimal-integer value (NaN). The merge/lint gate separately
// enforces pr > 0 before a fragment can land, so 0 still cannot be merged.
const prMissing = opts.pr === null;
const prNaN = typeof opts.pr === 'number' && Number.isNaN(opts.pr);
if (!opts.type || prMissing || prNaN || !opts.body) {
throw new ExitError(2, 'usage: changeset/new.cjs --type <Fixed|Added|...> --pr NNNN --body "..."');
}
const file = scaffoldFragment(opts);
process.stdout.write(`${path.relative(process.cwd(), file)}\n`);
}
if (require.main === module) runMain(main);
module.exports = { generateFragmentName, scaffoldFragment, parseArgs, ALLOWED_TYPES };
|