@gapi/gcli 1.8.236 → 1.8.238

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Changelog fragments, grouped by feature: one file per change.
4
+ *
5
+ * Every change adds its own file instead of editing one shared CHANGELOG
6
+ * (two branches prepending to the same file conflict on every rebase; two new
7
+ * files never do), and every entry names the FEATURE it belongs to, so the
8
+ * whole history of one feature - what changed, how to test it, where to look
9
+ * when it breaks - can be pulled out on demand:
10
+ *
11
+ * docs/changelog.d/YYYY-MM-DD-<feature>-<slug>.md one entry per change,
12
+ * committed WITH the change
13
+ * docs/features/<feature>.md the feature's living doc
14
+ * (current state, not history)
15
+ * docs/CHANGELOG.md the archive: compacted
16
+ * fragments, never hand-edited
17
+ *
18
+ * node scripts/changelog.mjs new <feature> "<summary>" [--type feat] [--date YYYY-MM-DD]
19
+ * create a fragment from the template and print its path
20
+ * node scripts/changelog.mjs check
21
+ * validate every fragment - run before committing
22
+ * node scripts/changelog.mjs render [--feature F] [-o FILE]
23
+ * the full log (fragments newest-first, then the archive), or only F's
24
+ * node scripts/changelog.mjs features
25
+ * every feature with its entry count, latest entry and doc status
26
+ * node scripts/changelog.mjs compact [--dry-run] [--allow-branch]
27
+ * fold all fragments into docs/CHANGELOG.md (default branch only)
28
+ *
29
+ * Plain Node (>= 18), no dependencies - runs in any Node container.
30
+ * Pattern adapted from wiener/deployment's hack/changelog.py (2026-10-07).
31
+ */
32
+ import { execFileSync } from 'node:child_process';
33
+ import {
34
+ existsSync,
35
+ mkdirSync,
36
+ readdirSync,
37
+ readFileSync,
38
+ unlinkSync,
39
+ writeFileSync,
40
+ } from 'node:fs';
41
+ import { dirname, join, relative, resolve } from 'node:path';
42
+ import { fileURLToPath } from 'node:url';
43
+
44
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
45
+ const FRAG_DIR = join(ROOT, 'docs', 'changelog.d');
46
+ const FEATURE_DIR = join(ROOT, 'docs', 'features');
47
+ const ARCHIVE = join(ROOT, 'docs', 'CHANGELOG.md');
48
+
49
+ const NAME_RE = /^(\d{4}-\d{2}-\d{2})-([a-z0-9][a-z0-9.-]*)\.md$/;
50
+ const HEAD_RE = /^## (\d{4}-\d{2}-\d{2}) - ([a-z0-9][a-z0-9-]*): (.+)$/;
51
+ const FEATURE_LINE_RE = /\*\*Feature:\*\*\s*([a-z0-9][a-z0-9-]*)/;
52
+ const FEATURE_SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
53
+ const TYPES = ['feat', 'fix', 'perf', 'refactor', 'test', 'docs', 'tooling', 'repo'];
54
+ const REQUIRED = ['**Feature:**', '**Type:**', '**Test:**', '**Debug:**', '**Verification:**'];
55
+ // Changes that aren't part of any one feature (repo setup, tooling, deps).
56
+ const FEATURE_EXEMPT = ['repo'];
57
+ const PLACEHOLDERS = ['<what is now true', '<how to test', '<where to look', '<what was run'];
58
+ const DEFAULT_BRANCHES = ['main', 'master', 'develop'];
59
+
60
+ const ARCHIVE_HEADER = `# Changelog (archive)
61
+
62
+ Compacted changelog fragments, newest first. **Never edit this file by
63
+ hand** - new entries go in \`docs/changelog.d/\` (\`node scripts/changelog.mjs new\`);
64
+ only \`node scripts/changelog.mjs compact\` writes here. See
65
+ \`docs/changelog.d/README.md\`.
66
+
67
+ ---
68
+ `;
69
+
70
+ const template = ({ date, feature, summary, type }) => `## ${date} - ${feature}: ${summary}
71
+
72
+ **Feature:** ${feature} · **Type:** ${type}
73
+
74
+ - <what is now true, and why - one bullet per logical change; name the
75
+ files/modules that own it>
76
+ - **Test:** <how to test it - the specs/commands to run and what passing
77
+ looks like; manual steps if there is no automated coverage>
78
+ - **Debug:** <where to look when it breaks - log prefixes, collections,
79
+ env flags, the likely failure modes and their symptoms>
80
+ - **Verification:** <what was run for THIS change and its result - test
81
+ counts, typecheck/lint, manual checks>
82
+ `;
83
+
84
+ const die = (msg) => {
85
+ console.error(msg);
86
+ process.exit(1);
87
+ };
88
+
89
+ const rel = (p) => relative(ROOT, p);
90
+
91
+ const slugify = (s) =>
92
+ s
93
+ .toLowerCase()
94
+ .replace(/[^a-z0-9.]+/g, '-')
95
+ .replace(/^[-.]+|[-.]+$/g, '')
96
+ .slice(0, 60)
97
+ .replace(/[-.]+$/, '');
98
+
99
+ const featureOf = (text) => text.match(FEATURE_LINE_RE)?.[1] ?? null;
100
+
101
+ const headOf = (text) => text.split('\n', 1)[0].match(HEAD_RE);
102
+
103
+ const trimEnd = (s) => s.replace(/\s+$/, '');
104
+
105
+ /** [{ path, date, text }] newest date first; filename order within a day. */
106
+ function fragments() {
107
+ if (!existsSync(FRAG_DIR)) return [];
108
+ const out = readdirSync(FRAG_DIR)
109
+ .filter((n) => n.endsWith('.md') && n !== 'README.md')
110
+ .sort()
111
+ .map((name) => {
112
+ const path = join(FRAG_DIR, name);
113
+ const date = name.match(NAME_RE)?.[1] ?? '0000-00-00';
114
+ return { path, name, date, text: trimEnd(readFileSync(path, 'utf8')) + '\n' };
115
+ });
116
+ // newest date first, stable filename order within a day
117
+ return out.sort((a, b) =>
118
+ a.date === b.date ? (a.name < b.name ? -1 : 1) : a.date < b.date ? 1 : -1,
119
+ );
120
+ }
121
+
122
+ const readArchive = () =>
123
+ existsSync(ARCHIVE) ? readFileSync(ARCHIVE, 'utf8') : ARCHIVE_HEADER;
124
+
125
+ /** [header lines incl. the first '---', remaining lines] */
126
+ function splitArchive() {
127
+ const lines = readArchive().split('\n');
128
+ const i = lines.findIndex((l) => l.trim() === '---');
129
+ if (i === -1) die(`${rel(ARCHIVE)}: no '---' header separator found`);
130
+ return [lines.slice(0, i + 1), lines.slice(i + 1)];
131
+ }
132
+
133
+ /** The archive's entries, split on '## YYYY-MM-DD' headings. */
134
+ function archiveEntries() {
135
+ const [, rest] = splitArchive();
136
+ const entries = [];
137
+ let current = [];
138
+ const flush = () => {
139
+ const text = current.join('\n').trim();
140
+ if (text) entries.push(text + '\n');
141
+ current = [];
142
+ };
143
+ for (const line of rest) {
144
+ if (HEAD_RE.test(line) && current.length) flush();
145
+ if (line.trim() === '---' && !current.length) continue;
146
+ current.push(line);
147
+ }
148
+ flush();
149
+ return entries.filter((e) => headOf(e));
150
+ }
151
+
152
+ function compose(frags) {
153
+ const [header, rest] = splitArchive();
154
+ if (!frags.length) return [...header, ...rest].join('\n');
155
+ const body = trimEnd(frags.map((f) => trimEnd(f.text) + '\n').join('\n'));
156
+ return trimEnd([...header, '', body, '', '---', ...rest].join('\n')) + '\n';
157
+ }
158
+
159
+ function problems() {
160
+ const errs = [];
161
+ for (const { path, name, text } of fragments()) {
162
+ const r = rel(path);
163
+ const m = name.match(NAME_RE);
164
+ if (!m) errs.push(`${r}: filename must be YYYY-MM-DD-<feature>-<slug>.md`);
165
+ const h = headOf(text);
166
+ if (!h) errs.push(`${r}: first line must be '## YYYY-MM-DD - <feature>: <summary>'`);
167
+ else if (m && h[1] !== m[1])
168
+ errs.push(`${r}: heading date ${h[1]} != filename date ${m[1]}`);
169
+ for (const key of REQUIRED)
170
+ if (!text.includes(key)) errs.push(`${r}: missing ${key} line`);
171
+ const feature = featureOf(text);
172
+ if (h && feature && h[2] !== feature)
173
+ errs.push(`${r}: heading feature '${h[2]}' != **Feature:** '${feature}'`);
174
+ if (feature && m && !name.slice(11).startsWith(feature + '-'))
175
+ errs.push(`${r}: filename must start with the date then the feature (${feature})`);
176
+ if (
177
+ feature &&
178
+ !FEATURE_EXEMPT.includes(feature) &&
179
+ !existsSync(join(FEATURE_DIR, `${feature}.md`))
180
+ )
181
+ errs.push(`${r}: feature doc docs/features/${feature}.md is missing`);
182
+ const type = text.match(/\*\*Type:\*\*\s*([a-z]+)/)?.[1];
183
+ if (type && !TYPES.includes(type))
184
+ errs.push(`${r}: **Type:** must be one of ${TYPES.join(', ')}`);
185
+ for (const ph of PLACEHOLDERS)
186
+ if (text.includes(ph))
187
+ errs.push(`${r}: template placeholder still present (${ph}...>)`);
188
+ if (text.includes('\n## '))
189
+ errs.push(`${r}: more than one entry in a fragment - one file per change`);
190
+ }
191
+ const arch = readArchive();
192
+ if (['<<<<<<< ', '\n=======\n', '>>>>>>> '].some((m) => arch.includes(m)))
193
+ errs.push(`${rel(ARCHIVE)}: merge conflict marker present`);
194
+ return errs;
195
+ }
196
+
197
+ function cmdNew(args, opts) {
198
+ const [feature, summary] = args;
199
+ if (!feature || !summary) die('usage: new <feature> "<summary>" [--type feat] [--date YYYY-MM-DD]');
200
+ if (!FEATURE_SLUG_RE.test(feature))
201
+ die(`feature must be kebab-case (e.g. local-ai-runners), got '${feature}'`);
202
+ const type = opts.type ?? 'feat';
203
+ if (!TYPES.includes(type)) die(`--type must be one of ${TYPES.join(', ')}`);
204
+ const date = opts.date ?? new Date().toISOString().slice(0, 10);
205
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) die(`--date must be YYYY-MM-DD, got '${date}'`);
206
+ const path = join(FRAG_DIR, `${date}-${feature}-${slugify(summary)}.md`);
207
+ if (existsSync(path)) die(`${rel(path)} already exists`);
208
+ mkdirSync(FRAG_DIR, { recursive: true });
209
+ writeFileSync(path, template({ date, feature, summary, type }));
210
+ console.log(rel(path));
211
+ const doc = join(FEATURE_DIR, `${feature}.md`);
212
+ if (!FEATURE_EXEMPT.includes(feature) && !existsSync(doc))
213
+ console.log(
214
+ `NOTE: ${rel(doc)} does not exist yet - create the feature doc ` +
215
+ '(docs/features/README.md has the outline); `check` requires it.',
216
+ );
217
+ console.log(
218
+ 'Fill in every <...> placeholder, run `node scripts/changelog.mjs check`, ' +
219
+ 'then commit it together with the change.',
220
+ );
221
+ }
222
+
223
+ function cmdCheck() {
224
+ const errs = problems();
225
+ for (const e of errs) console.error(`ERROR ${e}`);
226
+ if (errs.length) process.exit(1);
227
+ console.log(`OK: ${fragments().length} fragment(s) in ${rel(FRAG_DIR)}, archive clean`);
228
+ }
229
+
230
+ function cmdRender(_args, opts) {
231
+ let out;
232
+ if (opts.feature) {
233
+ const entries = [
234
+ ...fragments().map((f) => f.text).filter((t) => featureOf(t) === opts.feature),
235
+ ...archiveEntries().filter((e) => featureOf(e) === opts.feature),
236
+ ];
237
+ if (!entries.length) die(`no entries for feature '${opts.feature}' (see \`features\`)`);
238
+ const doc = join(FEATURE_DIR, `${opts.feature}.md`);
239
+ out =
240
+ `# ${opts.feature} - change history\n\nLiving doc: ${rel(doc)}\n\n` +
241
+ entries.map((e) => trimEnd(e) + '\n').join('\n');
242
+ } else {
243
+ out = compose(fragments());
244
+ }
245
+ if (opts.output) {
246
+ writeFileSync(opts.output, out);
247
+ console.log(opts.output);
248
+ } else {
249
+ process.stdout.write(out);
250
+ }
251
+ }
252
+
253
+ function cmdFeatures() {
254
+ const stats = new Map();
255
+ for (const text of [...fragments().map((f) => f.text), ...archiveEntries()]) {
256
+ const f = featureOf(text) ?? '?';
257
+ const s = stats.get(f) ?? { n: 0, latest: '' };
258
+ s.n += 1;
259
+ const date = headOf(text)?.[1];
260
+ if (date && date > s.latest) s.latest = date;
261
+ stats.set(f, s);
262
+ }
263
+ const docs = existsSync(FEATURE_DIR)
264
+ ? readdirSync(FEATURE_DIR)
265
+ .filter((n) => n.endsWith('.md') && n !== 'README.md')
266
+ .map((n) => n.slice(0, -3))
267
+ : [];
268
+ for (const f of [...new Set([...stats.keys(), ...docs])].sort()) {
269
+ const s = stats.get(f) ?? { n: 0, latest: '-' };
270
+ const doc = docs.includes(f) ? 'doc' : FEATURE_EXEMPT.includes(f) ? '-' : 'NO DOC';
271
+ console.log(
272
+ `${f.padEnd(32)} ${String(s.n).padStart(3)} entr${s.n === 1 ? 'y ' : 'ies'} ` +
273
+ `latest ${(s.latest || '-').padEnd(10)} ${doc}`,
274
+ );
275
+ }
276
+ }
277
+
278
+ function currentBranch() {
279
+ try {
280
+ return execFileSync('git', ['-C', ROOT, 'symbolic-ref', '--short', 'HEAD'], {
281
+ encoding: 'utf8',
282
+ stdio: ['ignore', 'pipe', 'ignore'],
283
+ }).trim();
284
+ } catch {
285
+ return '?';
286
+ }
287
+ }
288
+
289
+ function cmdCompact(_args, opts) {
290
+ const errs = problems();
291
+ if (errs.length) {
292
+ for (const e of errs) console.error(`ERROR ${e}`);
293
+ die('fix the fragments first (node scripts/changelog.mjs check)');
294
+ }
295
+ const frags = fragments();
296
+ if (!frags.length) return console.log('nothing to compact');
297
+ const branch = currentBranch();
298
+ if (!DEFAULT_BRANCHES.includes(branch) && !opts['allow-branch'])
299
+ die(
300
+ `on branch '${branch}': compact runs on ${DEFAULT_BRANCHES.join('/')} only ` +
301
+ '(single writer, own commit - that keeps CHANGELOG.md conflict-free). ' +
302
+ 'Use --allow-branch if you really mean it.',
303
+ );
304
+ console.log(`${frags.length} fragment(s) -> ${rel(ARCHIVE)}:`);
305
+ for (const f of frags) console.log(` ${rel(f.path)}`);
306
+ if (opts['dry-run']) return console.log('(dry run - nothing written)');
307
+ writeFileSync(ARCHIVE, compose(frags));
308
+ for (const f of frags) unlinkSync(f.path);
309
+ console.log(
310
+ 'Done. Commit it on its own:\n' +
311
+ ` git add -A ${rel(FRAG_DIR)} ${rel(ARCHIVE)} && ` +
312
+ `git commit -m "docs(changelog): compact ${frags.length} entries"`,
313
+ );
314
+ }
315
+
316
+ /** Tiny argv parser: positionals + --flag / --key value / -o value. */
317
+ function parseArgs(argv) {
318
+ const BOOL = new Set(['dry-run', 'allow-branch', 'help']);
319
+ const args = [];
320
+ const opts = {};
321
+ for (let i = 0; i < argv.length; i++) {
322
+ const a = argv[i];
323
+ if (a === '-h') opts.help = true;
324
+ else if (a === '-o') opts.output = argv[++i];
325
+ else if (a.startsWith('--')) {
326
+ const [k, v] = a.slice(2).split('=', 2);
327
+ opts[k] = BOOL.has(k) ? true : (v ?? argv[++i]);
328
+ } else args.push(a);
329
+ }
330
+ return { args, opts };
331
+ }
332
+
333
+ const COMMANDS = {
334
+ new: cmdNew,
335
+ check: cmdCheck,
336
+ render: cmdRender,
337
+ features: cmdFeatures,
338
+ compact: cmdCompact,
339
+ };
340
+
341
+ const [cmd, ...rest] = process.argv.slice(2);
342
+ const { args, opts } = parseArgs(rest);
343
+ if (!cmd || !COMMANDS[cmd] || opts.help) {
344
+ const doc = readFileSync(fileURLToPath(import.meta.url), 'utf8').match(/\/\*\*([\s\S]*?)\*\//)[1];
345
+ console.log(doc.replace(/^ \* ?/gm, '').trim());
346
+ process.exit(cmd && !opts.help ? 1 : 0);
347
+ }
348
+ COMMANDS[cmd](args, opts);