* feat(#498): generated package-identity seam derived from package.json Introduce a single source for GSD's published-package coordinates: scripts/generate-package-identity.cjs (pure deriveIdentity + formatManualInstall + render) emits the generated get-shit-done/bin/lib/package-identity.cjs with values baked from package.json at build time. Baking is required because the installed tree carries only a synthetic {"type":"commonjs"} package.json, so a runtime require('package.json').name resolves to undefined (#378). Reconciles Wired into npm run build; a parity test fails CI if the committed file drifts from package.json. Refs #498 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#498): repoint update worker + check-latest-version at the seam - check-latest-version.cjs sources PACKAGE_NAME from the package-identity seam instead of a re-typed literal (single source; #2992's constant guarantee is preserved since the seam bakes from package.json). - gsd-check-update-worker.js no longer does require('../package.json').name (resolved to undefined in the installed tree → background update check silently broken, #378). It now delegates the latest-version lookup to checkLatestVersion(), collapsing the duplicated npm-view call onto the single deterministic adapter and inheriting its typed {ok,version,reason} surface. - Move the PR #3102 Windows shell-gate contract test onto execNpm (where the spawn now lives) and assert the worker no longer spawns npm directly. - Rewrite the #378 contract: worker must NOT use require(package.json).name and must delegate; check-latest-version PACKAGE_NAME is single-sourced from the seam. Fixes #378-class runtime breakage. Refs #498 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#498): changeset for package-identity seam + update-check fix Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#498): drift-guard lint — value-check GSD coordinate literals against the seam scripts/lint-package-identity-drift.cjs scans the runtime/code surface (bin/, hooks/, scripts/, get-shit-done/) and asserts every GSD package name and GitHub repo slug literal equals the Package Identity seam's current value. Passes today; fails the moment a repoint isn't propagated (rename package.json, regenerate the seam, and stale literals are reported until updated). This is the second adapter that makes the seam real and a repoint mechanically safe. Enforced via tests/issue-498-identity-drift-lint.test.cjs (scanRepo === []) under npm test; also exposed as `npm run check:identity-drift`. Refs #498 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#498): update-context projection — port update.md resolution to a tested seam Add get-shit-done/bin/lib/update-context.cjs: a pure, injected-fs port of update.md's ~280-line get_installed_version bash. resolveUpdateContext() reproduces the full precedence cascade (preferred fast-path -> local probe -> global probe via env overrides then $HOME -> LOCAL-if-distinct -> scope cascade -> UNKNOWN) and returns the 4-field contract { installedVersion, scope, runtime, gsdDir }. The fs is injected so every branch is finally testable without a live multi-runtime install. Expose it as `gsd-tools update-context [--config-dir <d>] [--runtime <r>] --json`. Purely additive — update.md is unchanged in this commit; the workflow swap follows separately. Refs #498 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(#498): swap update.md resolution to the update-context projection Replace ~280 lines of inline runtime/scope/config-dir bash in update.md's get_installed_version step with a call to `gsd-tools update-context --json` (60 lines: derive PREFERRED_* from execution_context, resolve gsd-tools.cjs, parse the 4-field JSON). Behavior is unchanged — the projection reproduces the same cascade — but the logic is now tested in update-context.cjs instead of untestable bash-in-markdown. Relocate the #3608 antigravity-first-class contract onto the projection (RUNTIME_DIRS order, inferPreferredRuntime, envRuntimeDirs) plus a behavioral test; keep the execution_context path-classification assertion on update.md. Re-point install.test's custom-config-dir assertion (kilo.jsonc/KILO_CONFIG) to update-context.cjs where that detection now lives. Full root suite: 2022 pass / 0 fail. Refs #498 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(#498): record Update Context Module in CONTEXT.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#498): CI — avoid bare gsd-tools in update.md; register new CLI modules - update.md update-context invocation: resolve the PATH gsd-tools shim into a variable and call "$GSD_TOOLS" (never a bare `gsd-tools` command) — satisfies the #2851 workflow-bare-gsd-tools guard. - Register package-identity.cjs and update-context.cjs in docs/INVENTORY.md (CLI Modules 76 -> 78 + rows) and regenerate docs/INVENTORY-MANIFEST.json, fixing inventory-counts and inventory-manifest-sync. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#498): make update-context + parity tests OS-agnostic (Windows CI) Two Windows-only test failures, both test-portability (production code is fine — the real-fs CLI integration test passed on Windows): - update-context resolver tests + bug-3608 behavioral test used POSIX path-string keys in their fake fs, but the resolver builds lookups via path.join/resolve (backslash + drive letter on Windows) → keys never matched → everything resolved to UNKNOWN/claude. Normalize fake-fs keys and gsdDir comparisons through path.resolve so they match on both platforms. - package-identity parity test compared render() (LF) to the committed file, which Windows git checks out as CRLF (no .gitattributes eol rule). Normalize line endings before comparing, matching the repo convention (autonomous-decomposition, bug-3707). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#498): update.md backup must use GSD_DIR (adversarial-review finding) The get_installed_version rewrite emits GSD_DIR but dropped the probe-loop variables LOCAL_DIR/GLOBAL_DIR. The backup_custom_files step still read those, so RUNTIME_DIR went empty for every LOCAL/GLOBAL install and detect-custom-files was skipped — and since the update then runs a clean install that wipes managed dirs (commands/gsd, get-shit-done), user-added files could be deleted without the intended backup. Set RUNTIME_DIR="$GSD_DIR" directly (the resolved config dir; empty for UNKNOWN scope, which still skips the backup). Add a structural regression (tests/issue-498-update-backup-runtime-dir.test.cjs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#503): re-point Antigravity .agent detection at the #498 projection #499 moves the runtime/scope detection cascade out of update.md inline bash into get-shit-done/bin/lib/update-context.cjs. The #503 regression test asserted on the inline RUNTIME_DIRS array, which no longer exists, so it would fail against the projected update.md even though the .agent guarantee is preserved. Rewrite it to verify the surviving surfaces: - behavioral: resolveUpdateContext resolves a LOCAL ./.agent install to the antigravity runtime (the original root cause, now covered by adding ['antigravity', '.agent'] to the projection RUNTIME_DIRS table) - update.md prose classifier still maps /.agent/ -> antigravity - the post-update cache-clear for-dir loop still includes .agent Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#498): finish de-hardcoding consumers + close adversarial-review parity gaps Restore the consumer de-hardcoding that is the point of the seam, and close the parity gaps an adversarial review (codex) found in the update-context projection. De-hardcode the repo slug + install command in the changeset tooling — #516 only single-sourced the package NAME, leaving 'open-gsd/get-shit-done-redux' hardcoded in scripts/changeset/cli.cjs and github-release-notes.cjs. Route both through the seam's repoSlug/packageName so a rename is a regenerate, not a hand edit. The drift-lint real scan now reports zero divergent coordinate literals. Projection parity vs the old inline bash, as ONE consistent rule (trustedVersionAt) applied on every path: - expand a leading ~/ in preferredConfigDir before the fast path (the bash ran expand_home first; a custom --config-dir ~/foo otherwise fell to UNKNOWN) - trust a version only when BOTH VERSION and the update.md marker exist — fast path AND LOCAL/GLOBAL cascade; a partial dir falls to 0.0.0 keeping scope - apply the same same-path dedup to the 0.0.0 fallback so a partial install probed from cwd===home is not misdetected as LOCAL Adds regression tests for tilde expansion, VERSION-only (cascade + fast path), and the cwd===home partial-install dedup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
393 lines
13 KiB
JavaScript
Executable File
393 lines
13 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
/**
|
|
* CLI wrapper for the changeset-fragment workflow (#2975).
|
|
*
|
|
* Subcommands:
|
|
* render --repo <dir> --version V --date D [--json] Fold .changeset/*.md
|
|
* into CHANGELOG.md;
|
|
* delete consumed fragments.
|
|
*
|
|
* `--json` emits a structured report on stdout — the only contract tests
|
|
* assert against. Per CONTRIBUTING.md "Prohibited: Raw Text Matching on
|
|
* Test Outputs", the human formatter is operator-only.
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const { parseFragment, FRAGMENT_ERROR } = require('./parse.cjs');
|
|
const { renderChangelog } = require('./render.cjs');
|
|
const { serializeChangelog, parseChangelog } = require('./serialize.cjs');
|
|
const { renderGithubReleaseNotes } = require('./github-release-notes.cjs');
|
|
const {
|
|
compareSemverCore,
|
|
isStableTripletSemver,
|
|
} = require('../../get-shit-done/bin/lib/semver-compare.cjs');
|
|
const { packageName, repoSlug: defaultRepoSlug } = require('../../get-shit-done/bin/lib/package-identity.cjs');
|
|
|
|
function parseArgs(argv) {
|
|
const opts = {
|
|
cmd: null,
|
|
repo: process.cwd(),
|
|
version: null,
|
|
date: null,
|
|
fromRef: null,
|
|
toRef: null,
|
|
changelog: null,
|
|
output: null,
|
|
repoSlug: defaultRepoSlug,
|
|
installCommand: `npx ${packageName}@latest`,
|
|
json: false,
|
|
};
|
|
if (argv.length === 0) return { ok: true, opts };
|
|
opts.cmd = argv[0];
|
|
|
|
// Pull a value for a value-taking flag, validating that the next token
|
|
// exists and is not itself another flag (which is the silently-misparsed
|
|
// case CR called out: e.g. `--repo --json` would consume `--json` as the
|
|
// repo path).
|
|
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 = 1; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
if (a === '--json') { opts.json = true; continue; }
|
|
if (
|
|
a === '--repo' ||
|
|
a === '--version' ||
|
|
a === '--date' ||
|
|
a === '--from' ||
|
|
a === '--to' ||
|
|
a === '--changelog' ||
|
|
a === '--output' ||
|
|
a === '--repo-slug' ||
|
|
a === '--install-command'
|
|
) {
|
|
const r = requireValue(a, i);
|
|
if (!r.ok) return { ok: false, error: r.error };
|
|
if (a === '--repo') opts.repo = r.value;
|
|
else if (a === '--version') opts.version = r.value;
|
|
else if (a === '--date') opts.date = r.value;
|
|
else if (a === '--from') opts.fromRef = r.value;
|
|
else if (a === '--to') opts.toRef = r.value;
|
|
else if (a === '--changelog') opts.changelog = r.value;
|
|
else if (a === '--output') opts.output = r.value;
|
|
else if (a === '--repo-slug') opts.repoSlug = r.value;
|
|
else if (a === '--install-command') opts.installCommand = r.value;
|
|
i++;
|
|
continue;
|
|
}
|
|
return { ok: false, error: `unknown argument: ${a}` };
|
|
}
|
|
return { ok: true, opts };
|
|
}
|
|
|
|
function listFragmentFiles(changesetDir) {
|
|
if (!fs.existsSync(changesetDir)) return [];
|
|
return fs.readdirSync(changesetDir)
|
|
.filter((f) => f.endsWith('.md') && f !== 'README.md')
|
|
.map((f) => path.join(changesetDir, f));
|
|
}
|
|
|
|
function splitChangelog(text) {
|
|
// Split off the top-level "# Changelog" heading + lead matter (everything
|
|
// before the first "## [version]" block) from the rest. The rest is the
|
|
// priorChangelog passed into renderChangelog. The "## [Unreleased]" block,
|
|
// if present, is dropped (the new release replaces it).
|
|
const lines = text.split(/\r?\n/);
|
|
const firstReleaseIdx = lines.findIndex((l) => /^##\s+\[/.test(l));
|
|
if (firstReleaseIdx === -1) {
|
|
return { lead: text.replace(/\s+$/, ''), prior: '' };
|
|
}
|
|
const lead = lines.slice(0, firstReleaseIdx).join('\n').replace(/\s+$/, '');
|
|
let priorStart = firstReleaseIdx;
|
|
// Skip the [Unreleased] block if present — it's a placeholder, not a release.
|
|
if (/^##\s+\[Unreleased\]/i.test(lines[firstReleaseIdx])) {
|
|
let j = firstReleaseIdx + 1;
|
|
while (j < lines.length && !/^##\s+\[/.test(lines[j])) j++;
|
|
priorStart = j;
|
|
}
|
|
const prior = lines.slice(priorStart).join('\n').trimStart();
|
|
return { lead, prior };
|
|
}
|
|
|
|
function cmdRender(opts) {
|
|
const repo = path.resolve(opts.repo);
|
|
const changesetDir = path.join(repo, '.changeset');
|
|
const changelogPath = path.join(repo, 'CHANGELOG.md');
|
|
const fragmentFiles = listFragmentFiles(changesetDir);
|
|
|
|
const fragments = [];
|
|
const failures = [];
|
|
for (const file of fragmentFiles) {
|
|
const src = fs.readFileSync(file, 'utf8');
|
|
const r = parseFragment(src);
|
|
if (r.ok) fragments.push({ ...r.fragment, file });
|
|
else failures.push({ file: path.relative(repo, file), reason: r.reason, detail: r.detail || null });
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
return { exitCode: 1, report: { consumed: 0, failures } };
|
|
}
|
|
if (fragments.length === 0) {
|
|
return { exitCode: 0, report: { consumed: 0, failures: [] } };
|
|
}
|
|
|
|
const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
|
|
const { lead, prior } = splitChangelog(priorText);
|
|
|
|
const ir = renderChangelog({
|
|
fragments,
|
|
version: opts.version,
|
|
date: opts.date,
|
|
priorChangelog: prior || null,
|
|
});
|
|
const releaseBlock = serializeChangelog(ir);
|
|
const out = [
|
|
lead || '# Changelog',
|
|
'',
|
|
'## [Unreleased]',
|
|
'',
|
|
releaseBlock.replace(/\s+$/, ''),
|
|
'',
|
|
].join('\n');
|
|
|
|
fs.writeFileSync(changelogPath, out);
|
|
|
|
// Delete consumed fragments. If any unlink fails the changelog is written
|
|
// but the fragment is still on disk, so a re-run would double-consume it.
|
|
// Surface the partial-failure as exitCode=1 with structured detail so the
|
|
// operator can manually clean up before retrying.
|
|
const deleteFailures = [];
|
|
for (const f of fragments) {
|
|
try {
|
|
fs.unlinkSync(f.file);
|
|
} catch (e) {
|
|
deleteFailures.push({
|
|
file: path.relative(repo, f.file),
|
|
reason: 'fail_fragment_delete',
|
|
detail: e.code || e.message,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
exitCode: deleteFailures.length > 0 ? 1 : 0,
|
|
report: {
|
|
consumed: fragments.length - deleteFailures.length,
|
|
failures: deleteFailures,
|
|
release: { version: opts.version, date: opts.date },
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* extract subcommand: extracts all changelog release blocks strictly after
|
|
* `--from` (exclusive) up to and including `--to` (inclusive). Both
|
|
* arguments accept `v`-prefixed semver (e.g. `v1.5.13`).
|
|
*
|
|
* Exit codes:
|
|
* 0 — one or more releases matched, output written.
|
|
* 2 — no releases fall in the specified range (matches nothing).
|
|
* 1 — I/O error or missing required flags.
|
|
*
|
|
* Fix for #3496: provides a deterministic range-aware helper so the
|
|
* `/gsd:update` show_changes_and_confirm step no longer relies on
|
|
* vague/manual extraction that can silently skip intermediate versions.
|
|
*/
|
|
function cmdExtract(opts) {
|
|
const stripV = (v) => (typeof v === 'string' ? v.replace(/^v/, '') : v);
|
|
const from = stripV(opts.fromRef);
|
|
const to = stripV(opts.toRef);
|
|
|
|
// Validate that both bounds are strict semver (N.N.N, digits only).
|
|
// Coercing a malformed bound like "1.41.x" to "1.41.0" makes range
|
|
// selection silently wrong; reject early with a structured error.
|
|
if (!isStableTripletSemver(from)) {
|
|
return {
|
|
exitCode: 1,
|
|
report: { error: `invalid semver for --from: "${from}" (expected N.N.N)`, releases: [] },
|
|
textOutput: null,
|
|
};
|
|
}
|
|
if (!isStableTripletSemver(to)) {
|
|
return {
|
|
exitCode: 1,
|
|
report: { error: `invalid semver for --to: "${to}" (expected N.N.N)`, releases: [] },
|
|
textOutput: null,
|
|
};
|
|
}
|
|
|
|
const changelogPath = opts.changelog
|
|
? path.resolve(opts.changelog)
|
|
: path.join(path.resolve(opts.repo), 'CHANGELOG.md');
|
|
|
|
if (!fs.existsSync(changelogPath)) {
|
|
return {
|
|
exitCode: 1,
|
|
report: { error: `CHANGELOG not found: ${changelogPath}`, releases: [] },
|
|
textOutput: null,
|
|
};
|
|
}
|
|
|
|
const text = fs.readFileSync(changelogPath, 'utf8');
|
|
const { releases } = parseChangelog(text);
|
|
|
|
const matched = releases.filter((rel) => {
|
|
if (rel.version === 'Unreleased') return false;
|
|
// Extract mode intentionally operates on stable releases only.
|
|
if (!isStableTripletSemver(rel.version)) {
|
|
process.stderr.write(`[extract] skipping pre-release/non-semver entry: ${rel.version}\n`);
|
|
return false;
|
|
}
|
|
// from is exclusive: cmp > 0 means rel.version > from
|
|
const afterFrom = compareSemverCore(rel.version, from) > 0;
|
|
// to is inclusive: cmp <= 0 means rel.version <= to
|
|
const upToTo = compareSemverCore(rel.version, to) <= 0;
|
|
return afterFrom && upToTo;
|
|
});
|
|
|
|
if (matched.length === 0) {
|
|
return {
|
|
exitCode: 2,
|
|
report: { releases: [], from, to },
|
|
textOutput: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
exitCode: 0,
|
|
report: { releases: matched, from, to },
|
|
textOutput: matched
|
|
.map((rel) => {
|
|
const header = `## [${rel.version}]${rel.date ? ` - ${rel.date}` : ''}`;
|
|
const sections = (rel.sections || [])
|
|
.map((s) => {
|
|
const bullets = s.bullets
|
|
.map((b) => (b.pr !== null ? `- ${b.body} (#${b.pr})` : `- ${b.body}`))
|
|
.join('\n');
|
|
return `### ${s.type}\n\n${bullets}`;
|
|
})
|
|
.join('\n\n');
|
|
return sections ? `${header}\n\n${sections}` : header;
|
|
})
|
|
.join('\n\n'),
|
|
};
|
|
}
|
|
|
|
function cmdGithubReleaseNotes(opts) {
|
|
const repo = path.resolve(opts.repo);
|
|
const report = renderGithubReleaseNotes({
|
|
repo,
|
|
fromRef: opts.fromRef,
|
|
toRef: opts.toRef,
|
|
repoSlug: opts.repoSlug,
|
|
installCommand: opts.installCommand,
|
|
});
|
|
|
|
if (!report.ok) {
|
|
return {
|
|
exitCode: 1,
|
|
report: {
|
|
consumed: 0,
|
|
failures: report.failures,
|
|
release: { from: opts.fromRef, to: opts.toRef },
|
|
},
|
|
};
|
|
}
|
|
|
|
if (opts.output) {
|
|
fs.writeFileSync(path.resolve(opts.output), report.body);
|
|
}
|
|
|
|
return {
|
|
exitCode: 0,
|
|
report: {
|
|
consumed: report.fragments.length,
|
|
failures: [],
|
|
release: { from: opts.fromRef, to: opts.toRef },
|
|
output: opts.output || null,
|
|
body: opts.output ? null : report.body,
|
|
},
|
|
};
|
|
}
|
|
|
|
function usage() {
|
|
return [
|
|
'usage:',
|
|
' changeset/cli.cjs render --repo <dir> --version V --date D [--json]',
|
|
' changeset/cli.cjs github-release-notes --repo <dir> --from REF --to REF [--output FILE] [--repo-slug OWNER/REPO] [--install-command CMD] [--json]',
|
|
' changeset/cli.cjs extract --from VERSION --to VERSION [--changelog FILE] [--repo <dir>] [--json]',
|
|
' Extracts changelog entries strictly after --from (exclusive) and up to',
|
|
' and including --to (inclusive). Accepts v-prefixed versions.',
|
|
' Exit 2 when no releases fall in range.',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function main() {
|
|
const parsed = parseArgs(process.argv.slice(2));
|
|
if (!parsed.ok) {
|
|
process.stderr.write(`${parsed.error}\n`);
|
|
process.stderr.write(usage());
|
|
process.exit(2);
|
|
}
|
|
const { opts } = parsed;
|
|
if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract') {
|
|
process.stderr.write(usage());
|
|
process.exit(1);
|
|
}
|
|
if (opts.cmd === 'render' && (!opts.version || !opts.date)) {
|
|
process.stderr.write('--version and --date are required for render\n');
|
|
process.exit(2);
|
|
}
|
|
if (opts.cmd === 'github-release-notes' && (!opts.fromRef || !opts.toRef)) {
|
|
process.stderr.write('--from and --to are required for github-release-notes\n');
|
|
process.exit(2);
|
|
}
|
|
if (opts.cmd === 'extract' && (!opts.fromRef || !opts.toRef)) {
|
|
process.stderr.write('--from and --to are required for extract\n');
|
|
process.stderr.write(usage());
|
|
process.exit(1);
|
|
}
|
|
|
|
if (opts.cmd === 'extract') {
|
|
const { exitCode, report, textOutput } = cmdExtract(opts);
|
|
if (opts.json) {
|
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
} else if (textOutput) {
|
|
process.stdout.write(textOutput + '\n');
|
|
} else if (exitCode === 2) {
|
|
process.stderr.write(`no releases found in range (from=${report.from}, to=${report.to})\n`);
|
|
}
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts);
|
|
if (opts.json) {
|
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
} else if (opts.cmd === 'github-release-notes' && report.body) {
|
|
process.stdout.write(report.body);
|
|
} else {
|
|
process.stdout.write(`Consumed: ${report.consumed} fragment(s)\n`);
|
|
if (report.failures.length > 0) {
|
|
process.stdout.write(`Failures: ${report.failures.length}\n`);
|
|
for (const f of report.failures) {
|
|
process.stdout.write(` ${f.file}: ${f.reason}${f.detail ? ` (${f.detail})` : ''}\n`);
|
|
}
|
|
}
|
|
}
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
if (require.main === module) main();
|
|
|
|
module.exports = { cmdRender, cmdExtract, cmdGithubReleaseNotes, parseArgs, splitChangelog, listFragmentFiles, usage };
|