@meza/adr-tools 1.0.12 → 2.0.0

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.
Files changed (111) hide show
  1. package/.github/renovate.json +2 -1
  2. package/.github/workflows/ci-pr.yml +2 -25
  3. package/.github/workflows/ci.yml +12 -46
  4. package/.releaserc.json +11 -6
  5. package/AGENTS.engineer.md +236 -0
  6. package/AGENTS.md +11 -0
  7. package/AGENTS.reviewer.md +115 -0
  8. package/CONTRIBUTING.md +102 -0
  9. package/README.md +16 -4
  10. package/biome.json +18 -139
  11. package/dist/index.js +164 -81
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.test.js +189 -0
  14. package/dist/index.test.js.map +1 -0
  15. package/dist/inject-version.test.js +27 -0
  16. package/dist/inject-version.test.js.map +1 -0
  17. package/dist/lib/adr.js +132 -27
  18. package/dist/lib/adr.js.map +1 -1
  19. package/dist/lib/adr.test.js +308 -0
  20. package/dist/lib/adr.test.js.map +1 -0
  21. package/dist/lib/config.js +3 -2
  22. package/dist/lib/config.js.map +1 -1
  23. package/dist/lib/config.test.js +60 -0
  24. package/dist/lib/config.test.js.map +1 -0
  25. package/dist/lib/links.test.js +5 -4
  26. package/dist/lib/links.test.js.map +1 -1
  27. package/dist/lib/manipulator-errors.test.js +21 -0
  28. package/dist/lib/manipulator-errors.test.js.map +1 -0
  29. package/dist/lib/manipulator.test.js +21 -1
  30. package/dist/lib/manipulator.test.js.map +1 -1
  31. package/dist/lib/numbering.js +1 -1
  32. package/dist/lib/numbering.js.map +1 -1
  33. package/dist/lib/numbering.test.js +7 -0
  34. package/dist/lib/numbering.test.js.map +1 -1
  35. package/dist/lib/opening.test.js +81 -0
  36. package/dist/lib/opening.test.js.map +1 -0
  37. package/dist/lib/prompt.js +1 -1
  38. package/dist/lib/prompt.js.map +1 -1
  39. package/dist/lib/template.test.js +62 -0
  40. package/dist/lib/template.test.js.map +1 -0
  41. package/dist/types/index.d.ts +25 -0
  42. package/dist/types/index.d.ts.map +1 -1
  43. package/dist/types/index.test.d.ts +2 -0
  44. package/dist/types/index.test.d.ts.map +1 -0
  45. package/dist/types/inject-version.test.d.ts +2 -0
  46. package/dist/types/inject-version.test.d.ts.map +1 -0
  47. package/dist/types/lib/adr.d.ts +15 -0
  48. package/dist/types/lib/adr.d.ts.map +1 -1
  49. package/dist/types/lib/adr.test.d.ts +2 -0
  50. package/dist/types/lib/adr.test.d.ts.map +1 -0
  51. package/dist/types/lib/config.d.ts.map +1 -1
  52. package/dist/types/lib/config.test.d.ts +2 -0
  53. package/dist/types/lib/config.test.d.ts.map +1 -0
  54. package/dist/types/lib/manipulator-errors.test.d.ts +2 -0
  55. package/dist/types/lib/manipulator-errors.test.d.ts.map +1 -0
  56. package/dist/types/lib/opening.test.d.ts +2 -0
  57. package/dist/types/lib/opening.test.d.ts.map +1 -0
  58. package/dist/types/lib/prompt.d.ts.map +1 -1
  59. package/dist/types/lib/template.test.d.ts +2 -0
  60. package/dist/types/lib/template.test.d.ts.map +1 -0
  61. package/dist/types/version.d.ts +1 -1
  62. package/dist/types/version.d.ts.map +1 -1
  63. package/dist/version.js +1 -1
  64. package/dist/version.js.map +1 -1
  65. package/doc/adr/.adr-sequence.lock +1 -1
  66. package/doc/adr/0001-record-architecture-decisions.md +21 -0
  67. package/doc/adr/0002-using-heavy-e2e-tests.md +20 -0
  68. package/doc/adr/0003-esm.md +34 -0
  69. package/doc/adr/0004-gate-editor-opening-behind---open-and---open-with.md +55 -0
  70. package/doc/adr/decisions.md +4 -1
  71. package/lefthook.yml +14 -0
  72. package/package.json +24 -26
  73. package/scripts/inject-version.mjs +34 -0
  74. package/src/index.test.ts +212 -0
  75. package/src/index.ts +229 -108
  76. package/src/inject-version.test.ts +31 -0
  77. package/src/lib/adr.test.ts +376 -0
  78. package/src/lib/adr.ts +173 -27
  79. package/src/lib/config.test.ts +69 -0
  80. package/src/lib/config.ts +3 -2
  81. package/src/lib/links.test.ts +8 -4
  82. package/src/lib/manipulator-errors.test.ts +22 -0
  83. package/src/lib/manipulator.test.ts +25 -1
  84. package/src/lib/numbering.test.ts +8 -0
  85. package/src/lib/numbering.ts +1 -1
  86. package/src/lib/opening.test.ts +96 -0
  87. package/src/lib/prompt.ts +1 -1
  88. package/src/lib/template.test.ts +74 -0
  89. package/src/version.ts +1 -2
  90. package/tests/edit-on-create.e2e.test.ts +47 -16
  91. package/tests/fake-editor.cmd +2 -0
  92. package/tests/fake-visual.cmd +2 -0
  93. package/tests/funny-characters.e2e.test.ts +12 -11
  94. package/tests/generate-graph.e2e.test.ts +13 -17
  95. package/tests/helpers/adr-cli.ts +24 -0
  96. package/tests/init-adr-repository.e2e.test.ts +7 -6
  97. package/tests/linking-records.e2e.test.ts +14 -13
  98. package/tests/list-adrs.e2e.test.ts +26 -19
  99. package/tests/new-adr.e2e.test.ts +10 -9
  100. package/tests/open-with.e2e.test.ts +53 -0
  101. package/tests/superseding-records.e2e.test.ts +11 -10
  102. package/tests/toc-prefixing.e2e.test.ts +10 -9
  103. package/tests/use-template-override.e2e.test.ts +9 -8
  104. package/tests/work-form-other-directories.e2e.test.ts +10 -8
  105. package/vitest.config.e2e.ts +8 -1
  106. package/vitest.config.ts +20 -4
  107. package/.github/workflows/sync-deps-to-main.yml +0 -25
  108. package/.github/workflows/sync-to-deps.yml +0 -26
  109. package/.husky/commit-msg +0 -4
  110. package/CHANGELOG.md +0 -121
  111. package/scripts/inject-version.sh +0 -4
@@ -0,0 +1,212 @@
1
+ import path from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import chalk from 'chalk';
4
+ import type { Command } from 'commander';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { buildProgram, defaultOnError, isDirectRun, maybeRun, run } from './index.js';
7
+
8
+ const createDeps = () => {
9
+ const deps = {
10
+ generateToc: vi.fn(),
11
+ init: vi.fn(),
12
+ link: vi.fn(),
13
+ listAdrs: vi.fn(),
14
+ newAdr: vi.fn(),
15
+ workingDir: vi.fn(),
16
+ readFile: vi.fn(),
17
+ getLinksFrom: vi.fn(),
18
+ getTitleFrom: vi.fn(),
19
+ version: '1.2.3',
20
+ onError: vi.fn(),
21
+ log: vi.fn(),
22
+ warn: vi.fn()
23
+ };
24
+
25
+ return deps;
26
+ };
27
+
28
+ describe('adr CLI', () => {
29
+ it('wires program metadata', () => {
30
+ const deps = createDeps();
31
+ const program = buildProgram(deps);
32
+ expect(program.name()).toEqual('adr');
33
+ expect(program.version()).toEqual('1.2.3');
34
+ });
35
+
36
+ it('runs the new command with options', async () => {
37
+ const deps = createDeps();
38
+ await run(['node', 'adr', 'new', 'My', 'ADR', '--open'], deps);
39
+ expect(deps.newAdr).toHaveBeenCalledWith('My ADR', {
40
+ supersedes: [],
41
+ date: process.env.ADR_DATE,
42
+ suppressPrompts: false,
43
+ links: [],
44
+ open: true,
45
+ openWith: undefined
46
+ });
47
+ });
48
+
49
+ it('invokes error handler when newAdr fails', async () => {
50
+ const deps = createDeps();
51
+ deps.newAdr.mockRejectedValueOnce(new Error('boom'));
52
+ await run(['node', 'adr', 'new', 'My', 'ADR'], deps);
53
+ expect(deps.onError).toHaveBeenCalled();
54
+ });
55
+
56
+ it('routes generate toc', async () => {
57
+ const deps = createDeps();
58
+ await run(['node', 'adr', 'generate', 'toc', '-p', '/prefix/'], deps);
59
+ expect(deps.generateToc).toHaveBeenCalledWith({ prefix: '/prefix/' });
60
+ });
61
+
62
+ it('generates graph output', async () => {
63
+ const deps = createDeps();
64
+ deps.listAdrs.mockResolvedValue(['/repo/doc/adr/0001-one.md', '/repo/doc/adr/0002-two.md']);
65
+ deps.readFile.mockResolvedValue('# Title');
66
+ deps.getTitleFrom.mockReturnValue('ADR Title');
67
+ deps.getLinksFrom.mockReturnValue([
68
+ { label: 'Relates to', targetNumber: '0002' },
69
+ { label: 'Superseded by', targetNumber: '0003' }
70
+ ]);
71
+ await run(['node', 'adr', 'generate', 'graph', '-e', '.md', '-p', '/p/'], deps);
72
+ const output = deps.log.mock.calls[0][0] as string;
73
+ expect(output).toContain('digraph');
74
+ expect(output).toContain('_1 -> _2 [style="dotted"');
75
+ expect(output).toContain('label="Relates to"');
76
+ expect(output).not.toContain('Superseded by');
77
+ });
78
+
79
+ it('generates graph output with default prefix', async () => {
80
+ const deps = createDeps();
81
+ deps.listAdrs.mockResolvedValue(['/repo/doc/adr/0001-one.md']);
82
+ deps.readFile.mockResolvedValue('# Title');
83
+ deps.getTitleFrom.mockReturnValue('ADR Title');
84
+ deps.getLinksFrom.mockReturnValue([]);
85
+ await run(['node', 'adr', 'generate', 'graph'], deps);
86
+ const output = deps.log.mock.calls[0][0] as string;
87
+ expect(output).toContain('0001-one.html');
88
+ });
89
+
90
+ it('links graph nodes by adr number', async () => {
91
+ const deps = createDeps();
92
+ deps.listAdrs.mockResolvedValue(['/repo/doc/adr/0001-one.md', '/repo/doc/adr/0003-three.md']);
93
+ deps.readFile.mockResolvedValue('# Title');
94
+ deps.getTitleFrom.mockReturnValue('ADR Title');
95
+ deps.getLinksFrom.mockReturnValue([{ label: 'Relates to', targetNumber: '3' }]);
96
+ await run(['node', 'adr', 'generate', 'graph'], deps);
97
+ const output = deps.log.mock.calls[0][0] as string;
98
+ expect(output).toContain('_1 -> _3 [style="dotted"');
99
+ expect(output).toContain('_1 -> _3 [label="Relates to"');
100
+ });
101
+
102
+ it('skips graph links that target missing nodes', async () => {
103
+ const deps = createDeps();
104
+ deps.listAdrs.mockResolvedValue(['/repo/doc/adr/0001-one.md']);
105
+ deps.readFile.mockResolvedValue('# Title');
106
+ deps.getTitleFrom.mockReturnValue('ADR Title');
107
+ deps.getLinksFrom.mockReturnValue([{ label: 'Relates to', targetNumber: '99' }]);
108
+ await run(['node', 'adr', 'generate', 'graph'], deps);
109
+ const output = deps.log.mock.calls[0][0] as string;
110
+ expect(output).not.toContain('_1 -> _99');
111
+ expect(deps.warn).toHaveBeenCalledWith('Skipping graph link from ADR 1 to missing ADR 99: Relates to');
112
+ });
113
+
114
+ it('skips files without numeric prefixes', async () => {
115
+ const deps = createDeps();
116
+ deps.listAdrs.mockResolvedValue(['/repo/doc/adr/notes.md', '/repo/doc/adr/0001-one.md']);
117
+ deps.readFile.mockResolvedValue('# Title');
118
+ deps.getTitleFrom.mockReturnValue('ADR Title');
119
+ deps.getLinksFrom.mockReturnValue([]);
120
+ await run(['node', 'adr', 'generate', 'graph'], deps);
121
+ expect(deps.readFile).toHaveBeenCalledTimes(2);
122
+ expect(deps.readFile).toHaveBeenNthCalledWith(1, '/repo/doc/adr/0001-one.md', 'utf8');
123
+ expect(deps.readFile).toHaveBeenNthCalledWith(2, '/repo/doc/adr/0001-one.md', 'utf8');
124
+ });
125
+
126
+ it('escapes graph labels and urls', async () => {
127
+ const deps = createDeps();
128
+ deps.listAdrs.mockResolvedValue(['/repo/doc/adr/0001-one.md']);
129
+ deps.readFile.mockResolvedValue('# Title');
130
+ deps.getTitleFrom.mockReturnValue('ADR "Title" \\\\ path\nline');
131
+ deps.getLinksFrom.mockReturnValue([]);
132
+ await run(['node', 'adr', 'generate', 'graph', '-e', '.md', '-p', '/p/"weird"/'], deps);
133
+ const output = deps.log.mock.calls[0][0] as string;
134
+ const match = output.match(/label="([\s\S]*?)"; URL="([^"]+)"/);
135
+ expect(match?.[1]).toContain(String.raw`ADR \"Title\" \\\\ path\\nline`);
136
+ expect(match?.[1]).not.toContain('\n');
137
+ expect(output).toContain(String.raw`URL="/p/\"weird\"/0001-one.md"`);
138
+ });
139
+
140
+ it('routes link command', async () => {
141
+ const deps = createDeps();
142
+ await run(['node', 'adr', 'link', '1', 'Amends', '2', 'Amended by'], deps);
143
+ const call = deps.link.mock.calls[0];
144
+ expect(call.slice(0, 4)).toEqual(['1', 'Amends', '2', 'Amended by']);
145
+ });
146
+
147
+ it('routes init command', async () => {
148
+ const deps = createDeps();
149
+ await run(['node', 'adr', 'init', 'doc/adr'], deps);
150
+ expect(deps.init).toHaveBeenCalledWith('doc/adr');
151
+ });
152
+
153
+ it('lists adrs relative to working dir', async () => {
154
+ const deps = createDeps();
155
+ deps.listAdrs.mockResolvedValue(['/repo/doc/adr/0001-one.md']);
156
+ deps.workingDir.mockReturnValue('/repo');
157
+ await run(['node', 'adr', 'list'], deps);
158
+ expect(deps.log).toHaveBeenCalledWith(path.join('doc', 'adr', '0001-one.md'));
159
+ });
160
+
161
+ it('collects repeated options for links and supersedes', async () => {
162
+ const deps = createDeps();
163
+ await run(
164
+ [
165
+ 'node',
166
+ 'adr',
167
+ 'new',
168
+ 'My',
169
+ 'ADR',
170
+ '-l',
171
+ '1:Links:Linked by',
172
+ '-l',
173
+ '2:Relates:Related by',
174
+ '-s',
175
+ '1',
176
+ '-s',
177
+ '2'
178
+ ],
179
+ deps
180
+ );
181
+ const [, options] = deps.newAdr.mock.calls[0];
182
+ expect(options.links).toEqual(['1:Links:Linked by', '2:Relates:Related by']);
183
+ expect(options.supersedes).toEqual(['1', '2']);
184
+ });
185
+
186
+ it('detects direct runs based on argv', () => {
187
+ const indexPath = path.resolve(process.cwd(), 'src/index.ts');
188
+ const moduleUrl = pathToFileURL(indexPath).href;
189
+ expect(isDirectRun(['node', indexPath], moduleUrl)).toBe(true);
190
+ expect(isDirectRun(['node'], moduleUrl)).toBe(false);
191
+ });
192
+
193
+ it('returns false when maybeRun is not direct', () => {
194
+ const deps = createDeps();
195
+ expect(maybeRun(['node', '/other/entry'], deps)).toBe(false);
196
+ });
197
+
198
+ it('runs when maybeRun detects direct execution', async () => {
199
+ const deps = createDeps();
200
+ deps.listAdrs.mockResolvedValue([]);
201
+ const indexPath = path.resolve(process.cwd(), 'src/index.ts');
202
+ expect(maybeRun(['node', indexPath, 'list'], deps)).toBe(true);
203
+ expect(deps.listAdrs).toHaveBeenCalled();
204
+ });
205
+
206
+ it('formats errors with the default handler', () => {
207
+ const errorHandler = vi.fn();
208
+ const program = { error: errorHandler } as unknown as Command;
209
+ defaultOnError(program, new Error('boom'));
210
+ expect(errorHandler).toHaveBeenCalledWith(chalk.red('boom'), { exitCode: 1 });
211
+ });
212
+ });
package/src/index.ts CHANGED
@@ -1,16 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import * as path from 'path';
3
+ import fs from 'node:fs/promises';
4
+ import * as path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
4
6
  import chalk from 'chalk';
5
7
  import { Command } from 'commander';
6
- import fs from 'fs/promises';
7
8
  import { generateToc, init, link, listAdrs, newAdr } from './lib/adr.js';
8
9
  import { workingDir } from './lib/config.js';
9
10
  import { getLinksFrom, getTitleFrom } from './lib/manipulator.js';
10
11
  import { LIB_VERSION } from './version.js';
11
12
 
12
- const program = new Command();
13
-
14
13
  const collectLinks = (val: string, memo: string[]) => {
15
14
  memo.push(val);
16
15
  return memo;
@@ -21,120 +20,242 @@ const collectSupersedes = (val: string, memo: string[]) => {
21
20
  return memo;
22
21
  };
23
22
 
24
- const generateGraph = async (options?: { prefix: string; extension: string }) => {
25
- let text = 'digraph {\n';
26
- text += ' node [shape=plaintext];\n';
27
- text += ' subgraph {\n';
28
-
29
- const adrs = await listAdrs();
30
- for (let i = 0; i < adrs.length; i++) {
31
- const n = i + 1;
32
- const adrPath = adrs[i];
33
- const contents = await fs.readFile(adrPath, 'utf8');
34
- const title = getTitleFrom(contents);
35
- text += ` _${n} [label="${title}"; URL="${options?.prefix || ''}${path.basename(adrPath, '.md')}${options?.extension}"];\n`;
36
- if (n > 1) {
37
- text += ` _${n - 1} -> _${n} [style="dotted", weight=1];\n`;
23
+ type ProgramDeps = {
24
+ generateToc: typeof generateToc;
25
+ init: typeof init;
26
+ link: typeof link;
27
+ listAdrs: typeof listAdrs;
28
+ newAdr: typeof newAdr;
29
+ workingDir: typeof workingDir;
30
+ readFile: typeof fs.readFile;
31
+ getLinksFrom: typeof getLinksFrom;
32
+ getTitleFrom: typeof getTitleFrom;
33
+ version: string;
34
+ onError: (program: Command, error: Error) => void;
35
+ log: (message: string) => void;
36
+ warn: (message: string) => void;
37
+ };
38
+
39
+ export const defaultOnError = (program: Command, error: Error) => {
40
+ program.error(chalk.red(error.message), { exitCode: 1 });
41
+ };
42
+
43
+ const defaultDeps: ProgramDeps = {
44
+ generateToc,
45
+ init,
46
+ link,
47
+ listAdrs,
48
+ newAdr,
49
+ workingDir,
50
+ readFile: fs.readFile,
51
+ getLinksFrom,
52
+ getTitleFrom,
53
+ version: LIB_VERSION,
54
+ onError: defaultOnError,
55
+ log: console.log,
56
+ warn: console.warn
57
+ };
58
+
59
+ const escapeDotString = (value: string) =>
60
+ value
61
+ .replace(/\\/g, '\\\\')
62
+ .replace(/"/g, '\\"')
63
+ .replace(/\r\n|\n|\r/g, '\\\\n');
64
+
65
+ const getAdrNumberFromPath = (adrPath: string): number | undefined => {
66
+ const baseName = path.basename(adrPath, '.md');
67
+ const match = baseName.match(/^0*(\d+)-/);
68
+ if (!match) {
69
+ return undefined;
70
+ }
71
+ return Number.parseInt(match[1], 10);
72
+ };
73
+
74
+ type GraphNode = { adrPath: string; number: number };
75
+
76
+ const resolveGraphNodes = (adrs: string[]): GraphNode[] =>
77
+ adrs
78
+ .map((adrPath) => ({ adrPath, number: getAdrNumberFromPath(adrPath) }))
79
+ .filter((node): node is GraphNode => node.number !== undefined);
80
+
81
+ const appendGraphNodes = async (
82
+ deps: ProgramDeps,
83
+ nodes: GraphNode[],
84
+ options: { prefix?: string; extension?: string } | undefined,
85
+ lines: string[]
86
+ ) => {
87
+ for (let i = 0; i < nodes.length; i++) {
88
+ const n = nodes[i].number;
89
+ const adrPath = nodes[i].adrPath;
90
+ const contents = await deps.readFile(adrPath, 'utf8');
91
+ const title = deps.getTitleFrom(contents);
92
+ const url = `${options?.prefix || ''}${path.basename(adrPath, '.md')}${options?.extension}`;
93
+ lines.push(` _${n} [label="${escapeDotString(title)}"; URL="${escapeDotString(url)}"];`);
94
+ const previous = nodes[i - 1]?.number;
95
+ if (previous !== undefined) {
96
+ lines.push(` _${previous} -> _${n} [style="dotted", weight=1];`);
38
97
  }
39
98
  }
40
- text += ' }\n';
41
- for (let i = 0; i < adrs.length; i++) {
42
- const n = i + 1;
43
- const adrPath = adrs[i];
44
- const contents = await fs.readFile(adrPath, 'utf8');
45
- const linksInADR = getLinksFrom(contents);
46
-
47
- for (let j = 0; j < linksInADR.length; j++) {
48
- if (!linksInADR[j].label.endsWith('by')) {
49
- text += ` _${n} -> _${linksInADR[j].targetNumber} [label="${linksInADR[j].label}", weight=0]\n`;
99
+ };
100
+
101
+ const resolveTargetNodeId = (nodeIds: Set<number>, targetNumber: string): number | undefined => {
102
+ const number = Number.parseInt(targetNumber, 10);
103
+ if (!Number.isFinite(number) || !nodeIds.has(number)) {
104
+ return undefined;
105
+ }
106
+ return number;
107
+ };
108
+
109
+ const appendGraphEdges = async (deps: ProgramDeps, nodes: GraphNode[], lines: string[]) => {
110
+ const nodeIds = new Set(nodes.map((node) => node.number));
111
+ for (const node of nodes) {
112
+ const contents = await deps.readFile(node.adrPath, 'utf8');
113
+ const linksInADR = deps.getLinksFrom(contents);
114
+ for (const link of linksInADR) {
115
+ if (link.label.endsWith('by')) {
116
+ continue;
117
+ }
118
+ const target = resolveTargetNodeId(nodeIds, link.targetNumber);
119
+ if (target !== undefined) {
120
+ lines.push(` _${node.number} -> _${target} [label="${link.label}", weight=0]`);
121
+ } else {
122
+ deps.warn(`Skipping graph link from ADR ${node.number} to missing ADR ${link.targetNumber}: ${link.label}`);
50
123
  }
51
124
  }
52
125
  }
126
+ };
127
+
128
+ const generateGraph = async (deps: ProgramDeps, options?: { prefix: string; extension: string }) => {
129
+ const lines: string[] = [];
130
+ lines.push('digraph {');
131
+ lines.push(' node [shape=plaintext];');
132
+ lines.push(' subgraph {');
53
133
 
54
- text += '}\n';
55
- console.log(text);
134
+ const adrs = await deps.listAdrs();
135
+ const nodes = resolveGraphNodes(adrs);
136
+ await appendGraphNodes(deps, nodes, options, lines);
137
+
138
+ lines.push(' }');
139
+ await appendGraphEdges(deps, nodes, lines);
140
+ lines.push('}');
141
+
142
+ deps.log(`${lines.join('\n')}\n`);
56
143
  };
57
144
 
58
- program.name('adr').version(LIB_VERSION).description('Manage Architecture Decision Logs');
59
-
60
- program
61
- .command('new')
62
- .argument('<title...>', 'The title of the decision')
63
- .option(
64
- '-q, --quiet',
65
- 'Do not ask for clarification. If multiple files match the search pattern, an error will be thrown.'
66
- )
67
- .option(
68
- '-s, --supersede <SUPERSEDE>',
69
- 'A reference (number or partial filename) of a previous decision that the new decision supercedes.\n' +
70
- 'A Markdown link to the superceded ADR is inserted into the Status section.\n' +
71
- 'The status of the superceded ADR is changed to record that it has been superceded by the new ADR.',
72
- collectSupersedes,
73
- []
74
- )
75
- .option(
76
- '-l, --link "<TARGET:LINK:REVERSE-LINK>"',
77
- 'Links the new ADR to a previous ADR.\n' +
78
- `${chalk.bold('TARGET')} is a reference (number or partial filename) of a previous decision.\n` +
79
- `${chalk.bold('LINK')} is the description of the link created in the new ADR.\n` +
80
- `${chalk.bold('REVERSE-LINK')} is the description of the link created in the existing ADR that will refer to the new ADR`,
81
- collectLinks,
82
- []
83
- )
84
- .action(async (title: string[], options) => {
85
- try {
86
- await newAdr(title.join(' '), {
87
- supersedes: options.supersede,
88
- date: process.env.ADR_DATE,
89
- suppressPrompts: options.quiet || false,
90
- links: options.link
91
- });
92
- } catch (e) {
93
- program.error(chalk.red((e as Error).message), { exitCode: 1 });
94
- }
95
- });
145
+ export const buildProgram = (deps: ProgramDeps = defaultDeps) => {
146
+ const program = new Command();
147
+ program.name('adr').version(deps.version).description('Manage Architecture Decision Logs');
96
148
 
97
- const generate = program.command('generate');
98
-
99
- generate
100
- .command('toc')
101
- .option('-p, --prefix <PREFIX>', 'The prefix to use for each file link in the generated TOC.')
102
- .action((options) => generateToc(options));
103
-
104
- generate
105
- .command('graph')
106
- .option('-p, --prefix <PREFIX>', 'Prefix each decision file link with PREFIX.')
107
- .option(
108
- '-e, --extension <EXTENSION>',
109
- 'the file extension of the documents to which generated links refer. Defaults to .html',
110
- '.html'
111
- )
112
- .action(async (options) => {
113
- await generateGraph(options);
114
- });
149
+ program
150
+ .command('new')
151
+ .argument('<title...>', 'The title of the decision')
152
+ .option(
153
+ '-q, --quiet',
154
+ 'Do not ask for clarification. If multiple files match the search pattern, an error will be thrown.'
155
+ )
156
+ .option('--open', 'Open the created ADR after writing it (use OS default or `--open-with`).')
157
+ .option(
158
+ '--open-with <COMMAND>',
159
+ 'Open the created ADR with a specific command (optionally with args); implies `--open`.'
160
+ )
161
+ .option(
162
+ '-s, --supersede <SUPERSEDE>',
163
+ 'A reference (number or partial filename) of a previous decision that the new decision supercedes.\n' +
164
+ 'A Markdown link to the superceded ADR is inserted into the Status section.\n' +
165
+ 'The status of the superceded ADR is changed to record that it has been superceded by the new ADR.',
166
+ collectSupersedes,
167
+ []
168
+ )
169
+ .option(
170
+ '-l, --link "<TARGET:LINK:REVERSE-LINK>"',
171
+ 'Links the new ADR to a previous ADR.\n' +
172
+ `${chalk.bold('TARGET')} is a reference (number or partial filename) of a previous decision.\n` +
173
+ `${chalk.bold('LINK')} is the description of the link created in the new ADR.\n` +
174
+ `${chalk.bold('REVERSE-LINK')} is the description of the link created in the existing ADR that will refer to the new ADR`,
175
+ collectLinks,
176
+ []
177
+ )
178
+ .action(async (title: string[], options) => {
179
+ try {
180
+ await deps.newAdr(title.join(' '), {
181
+ supersedes: options.supersede,
182
+ date: process.env.ADR_DATE,
183
+ suppressPrompts: options.quiet || false,
184
+ links: options.link,
185
+ open: options.open || Boolean(options.openWith),
186
+ openWith: options.openWith
187
+ });
188
+ } catch (e) {
189
+ deps.onError(program, e as Error);
190
+ }
191
+ });
192
+
193
+ const generate = program.command('generate');
194
+
195
+ generate
196
+ .command('toc')
197
+ .option('-p, --prefix <PREFIX>', 'The prefix to use for each file link in the generated TOC.')
198
+ .action((options) => deps.generateToc(options));
199
+
200
+ generate
201
+ .command('graph')
202
+ .option('-p, --prefix <PREFIX>', 'Prefix each decision file link with PREFIX.')
203
+ .option(
204
+ '-e, --extension <EXTENSION>',
205
+ 'the file extension of the documents to which generated links refer. Defaults to .html',
206
+ '.html'
207
+ )
208
+ .action(async (options) => {
209
+ await generateGraph(deps, options);
210
+ });
211
+
212
+ program
213
+ .command('link')
214
+ .argument('<SOURCE>', 'Full or Partial reference number to an ADR')
215
+ .argument('<LINK>', 'The description of the link created in the SOURCE')
216
+ .argument('<TARGET>', 'Full or Partial reference number to an ADR')
217
+ .argument('<REVERSE-LINK>', 'The description of the link created in the TARGET')
218
+ .option(
219
+ '-q, --quiet',
220
+ 'Do not ask for clarification. If multiple files match the search pattern, an error will be thrown.'
221
+ )
222
+ .action(deps.link);
115
223
 
116
- program
117
- .command('link')
118
- .argument('<SOURCE>', 'Full or Partial reference number to an ADR')
119
- .argument('<LINK>', 'The description of the link created in the SOURCE')
120
- .argument('<TARGET>', 'Full or Partial reference number to an ADR')
121
- .argument('<REVERSE-LINK>', 'The description of the link created in the TARGET')
122
- .option(
123
- '-q, --quiet',
124
- 'Do not ask for clarification. If multiple files match the search pattern, an error will be thrown.'
125
- )
126
- .action(link);
127
-
128
- program
129
- .command('init')
130
- .argument('[directory]', 'Initialize a new ADR directory')
131
- .action(async (directory?: string) => {
132
- await init(directory);
224
+ program
225
+ .command('init')
226
+ .argument('[directory]', 'Initialize a new ADR directory')
227
+ .action(async (directory?: string) => {
228
+ await deps.init(directory);
229
+ });
230
+
231
+ program.command('list').action(async () => {
232
+ const adrs = await deps.listAdrs();
233
+ deps.log(adrs.map((adr) => path.relative(deps.workingDir(), adr)).join('\n'));
133
234
  });
134
235
 
135
- program.command('list').action(async () => {
136
- const adrs = await listAdrs();
137
- console.log(adrs.map((adr) => path.relative(workingDir(), adr)).join('\n'));
138
- });
236
+ return program;
237
+ };
238
+
239
+ export const run = async (argv = process.argv, deps: ProgramDeps = defaultDeps) => {
240
+ const program = buildProgram(deps);
241
+ await program.parseAsync(argv);
242
+ return program;
243
+ };
244
+
245
+ export const isDirectRun = (argv = process.argv, moduleUrl = import.meta.url) => {
246
+ if (!argv[1]) {
247
+ return false;
248
+ }
249
+ return path.resolve(argv[1]) === fileURLToPath(moduleUrl);
250
+ };
251
+
252
+ export const maybeRun = (argv = process.argv, deps?: ProgramDeps) => {
253
+ if (!isDirectRun(argv)) {
254
+ return false;
255
+ }
256
+ const runPromise = run(argv, deps);
257
+ runPromise.catch(() => undefined);
258
+ return true;
259
+ };
139
260
 
140
- program.parse();
261
+ maybeRun();
@@ -0,0 +1,31 @@
1
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { describe, expect, it } from 'vitest';
5
+ // @ts-expect-error no module types for script import
6
+ import { buildVersionSource, resolveTargetPath, writeVersionFile } from '../scripts/inject-version.mjs';
7
+
8
+ describe('inject-version script helpers', () => {
9
+ it('builds the version export', () => {
10
+ expect(buildVersionSource('1.2.3')).toBe("export const LIB_VERSION = '1.2.3';\n");
11
+ });
12
+
13
+ it('requires an output path', () => {
14
+ expect(() => resolveTargetPath(['node', 'scripts/inject-version.mjs'])).toThrow(
15
+ 'Usage: node scripts/inject-version.mjs <output-file>'
16
+ );
17
+ });
18
+
19
+ it('writes the version file', () => {
20
+ const tmpDir = mkdtempSync(path.join(os.tmpdir(), 'adr-version-'));
21
+ const targetPath = path.join(tmpDir, 'version.ts');
22
+
23
+ try {
24
+ writeVersionFile(targetPath, '9.9.9');
25
+ const contents = readFileSync(targetPath, 'utf8');
26
+ expect(contents).toBe("export const LIB_VERSION = '9.9.9';\n");
27
+ } finally {
28
+ rmSync(tmpDir, { recursive: true, force: true });
29
+ }
30
+ });
31
+ });