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 };