ekohacks 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ekohacks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # ekohacks
2
+
3
+ The EkoHacks CLI: our everyday operations, automated one small command at a time.
4
+
5
+ We run a distributed team shipping open source to npm with strict TDD and trunk based development. Every operation we repeat by hand gets written down first, run by hand until it is boring, and then encoded here. The first one is releasing: the manual process lives in [EkoLite's RELEASING.md](https://github.com/ekohacks/ekolite/blob/main/RELEASING.md), and `ekohacks release` is that document as a command.
6
+
7
+ ## Status
8
+
9
+ The release command is built: the four stories in [`stories/`](stories/) are implemented, each delivered red first — a failing test is committed and reviewed before any production code exists. The suite is the specification. The acceptance test for the whole is the next real EkoLite release, run with `ekohacks release <version>`.
10
+
11
+ ## How this is built
12
+
13
+ - A tested core policy behind nullable infrastructure, in the style of [James Shore's Testing Without Mocks](https://www.jamesshore.com/v2/projects/nullables): real wrappers around `git`, `gh` and `npm`, each with a `createNull()` that answers with configurable responses and records what was asked of it. No mocks, no spies.
14
+ - The CLI layer stays thin. Everything worth testing lives below it.
15
+ - TypeScript on Node 24, tests with Vitest, nothing bundled.
package/dist/cli.js ADDED
@@ -0,0 +1,99 @@
1
+ #!/usr/bin/env node
2
+ // The thin shell: read the repo's files, wire the real wrappers, print one line per
3
+ // check and per step, exit 0 only when the command ran to its end. Everything worth
4
+ // testing lives below.
5
+ import { readFileSync } from 'node:fs';
6
+ import { createInterface } from 'node:readline/promises';
7
+ import { GhWrapper } from './infrastructure/gh.js';
8
+ import { GitWrapper } from './infrastructure/git.js';
9
+ import { NpmWrapper } from './infrastructure/npm.js';
10
+ import { ProcessRunner } from './infrastructure/process.js';
11
+ import { cut } from './logic/cut.js';
12
+ import { preflight } from './logic/preflight.js';
13
+ import { release } from './logic/release.js';
14
+ import { ship } from './logic/ship.js';
15
+ const argv = process.argv.slice(2);
16
+ const yes = argv.includes('--yes');
17
+ const [command, ...rest] = argv.filter((arg) => arg !== '--yes');
18
+ const first = rest[0];
19
+ const subcommand = first === 'preflight' || first === 'cut' || first === 'ship' ? first : undefined;
20
+ const version = subcommand === undefined ? first : rest[1];
21
+ if (command !== 'release' || version === undefined) {
22
+ console.error('usage: ekohacks release [preflight|cut|ship] <version> [--yes]');
23
+ process.exit(2);
24
+ }
25
+ const read = (file) => readFileSync(file, 'utf8');
26
+ const changelog = read('CHANGELOG.md');
27
+ const pkg = JSON.parse(read('package.json')).name;
28
+ const confirm = async (question) => {
29
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
30
+ const answer = await readline.question(`${question} (y/n) `);
31
+ readline.close();
32
+ return answer.trim().toLowerCase() === 'y';
33
+ };
34
+ const narrate = (line) => console.log(` ${line}`);
35
+ if (subcommand === undefined) {
36
+ const result = await release({
37
+ version,
38
+ changelog,
39
+ lockfile: read('package-lock.json'),
40
+ pkg,
41
+ git: GitWrapper.create(),
42
+ npm: NpmWrapper.create(),
43
+ gh: GhWrapper.create(),
44
+ runner: ProcessRunner.create(),
45
+ confirm,
46
+ narrate,
47
+ yes,
48
+ });
49
+ if ('stopped' in result) {
50
+ console.error(`stopped: ${result.stopped}`);
51
+ process.exit(1);
52
+ }
53
+ process.exit(0);
54
+ }
55
+ if (subcommand === 'ship') {
56
+ const result = await ship({
57
+ version,
58
+ changelog,
59
+ pkg,
60
+ gh: GhWrapper.create(),
61
+ npm: NpmWrapper.create(),
62
+ confirm,
63
+ narrate,
64
+ });
65
+ if ('stopped' in result) {
66
+ console.error(`stopped: ${result.stopped}`);
67
+ process.exit(1);
68
+ }
69
+ process.exit(0);
70
+ }
71
+ const report = await preflight({
72
+ version,
73
+ pkg,
74
+ changelog,
75
+ lockfile: read('package-lock.json'),
76
+ npm: NpmWrapper.create(),
77
+ git: GitWrapper.create(),
78
+ runner: ProcessRunner.create(),
79
+ });
80
+ for (const check of report.checks) {
81
+ console.log(check.passed ? ` ok ${check.name}` : ` FAIL ${check.name}: ${check.reason}`);
82
+ }
83
+ if (subcommand === 'preflight') {
84
+ process.exit(report.checks.every((check) => check.passed) ? 0 : 1);
85
+ }
86
+ const result = await cut({
87
+ version,
88
+ changelog,
89
+ report,
90
+ git: GitWrapper.create(),
91
+ npm: NpmWrapper.create(),
92
+ gh: GhWrapper.create(),
93
+ narrate,
94
+ });
95
+ if ('stopped' in result) {
96
+ console.error(`stopped: ${result.stopped}`);
97
+ process.exit(1);
98
+ }
99
+ process.exit(0);
@@ -0,0 +1,234 @@
1
+ import { execFile } from 'node:child_process';
2
+ const bucketFor = (check) => !check.concluded ? 'pending' : check.passed ? 'pass' : 'fail';
3
+ // Wraps the GitHub surface the CLI needs, behind the gh cli. Real and null share every
4
+ // line above the bottom layer: create() shells out to gh, createNull() answers gh-shaped
5
+ // output from configured state and never talks to GitHub; the real side is proven by a
6
+ // real release rather than a faked GitHub. checkRounds configures successive answers to
7
+ // checks(): each call takes the next round, and the last round repeats, so a test can
8
+ // walk a PR from pending to concluded.
9
+ export class GhWrapper {
10
+ static create({ cwd = process.cwd() } = {}) {
11
+ return new GhWrapper((args) => new Promise((resolve) => {
12
+ execFile('gh', args, { cwd }, (error, stdout, stderr) => {
13
+ const exitCode = error === null ? 0 : 1;
14
+ resolve({ exitCode, stdout, stderr });
15
+ });
16
+ }));
17
+ }
18
+ static createNull({ prNumber = 1, checkRounds = [[]], waitingRunRounds = [undefined], runRounds = [{ concluded: true, passed: true, url: 'https://github.com/nulled/nulled' }], } = {}) {
19
+ let round = 0;
20
+ let runRound = 0;
21
+ let waitingRound = 0;
22
+ return new GhWrapper((args) => {
23
+ if (args[0] === 'run' && args[1] === 'view') {
24
+ const index = Math.min(runRound, runRounds.length - 1);
25
+ runRound += 1;
26
+ const state = runRounds[index] ?? { concluded: true, passed: true, url: '' };
27
+ const row = {
28
+ status: state.concluded ? 'completed' : 'in_progress',
29
+ conclusion: !state.concluded ? '' : state.passed ? 'success' : 'failure',
30
+ url: state.url,
31
+ };
32
+ return Promise.resolve({ exitCode: 0, stdout: `${JSON.stringify(row)}\n`, stderr: '' });
33
+ }
34
+ if (args[0] === 'run' && args[1] === 'list') {
35
+ const index = Math.min(waitingRound, waitingRunRounds.length - 1);
36
+ waitingRound += 1;
37
+ const waiting = waitingRunRounds[index];
38
+ const rows = waiting === undefined ? [] : [{ databaseId: waiting }];
39
+ return Promise.resolve({ exitCode: 0, stdout: `${JSON.stringify(rows)}\n`, stderr: '' });
40
+ }
41
+ if (args[0] === 'api' && args[1]?.endsWith('pending_deployments') === true) {
42
+ const rows = [{ environment: { id: 1, name: 'release' } }];
43
+ return Promise.resolve({ exitCode: 0, stdout: `${JSON.stringify(rows)}\n`, stderr: '' });
44
+ }
45
+ if (args[0] === 'pr' && args[1] === 'create') {
46
+ return Promise.resolve({
47
+ exitCode: 0,
48
+ stdout: `https://github.com/nulled/nulled/pull/${prNumber}\n`,
49
+ stderr: '',
50
+ });
51
+ }
52
+ if (args[0] === 'pr' && args[1] === 'checks') {
53
+ const index = Math.min(round, checkRounds.length - 1);
54
+ round += 1;
55
+ const rows = (checkRounds[index] ?? []).map((check) => ({
56
+ name: check.name,
57
+ bucket: bucketFor(check),
58
+ }));
59
+ if (rows.length === 0) {
60
+ return Promise.resolve({
61
+ exitCode: 1,
62
+ stdout: '',
63
+ stderr: "no checks reported on the 'release/v0.5.0' branch",
64
+ });
65
+ }
66
+ return Promise.resolve({ exitCode: 0, stdout: `${JSON.stringify(rows)}\n`, stderr: '' });
67
+ }
68
+ return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' });
69
+ });
70
+ }
71
+ runGh;
72
+ constructor(runGh) {
73
+ this.runGh = runGh;
74
+ }
75
+ openTrackers = [];
76
+ trackOpens() {
77
+ const tracker = [];
78
+ this.openTrackers.push(tracker);
79
+ return { data: tracker };
80
+ }
81
+ async openPr(options) {
82
+ const result = await this.runGh([
83
+ 'pr',
84
+ 'create',
85
+ '--title',
86
+ options.title,
87
+ '--body',
88
+ options.body,
89
+ ]);
90
+ if (result.exitCode !== 0) {
91
+ throw new Error(`gh pr create failed:\n${result.stderr}`);
92
+ }
93
+ const url = result.stdout.match(/\/pull\/(\d+)/);
94
+ if (url === null) {
95
+ throw new Error(`gh pr create answered no pr url:\n${result.stdout}`);
96
+ }
97
+ for (const tracker of this.openTrackers) {
98
+ tracker.push(options);
99
+ }
100
+ return { number: Number(url[1]) };
101
+ }
102
+ async waitingRun(workflow) {
103
+ const result = await this.runGh([
104
+ 'run',
105
+ 'list',
106
+ '--workflow',
107
+ workflow,
108
+ '--status',
109
+ 'waiting',
110
+ '--json',
111
+ 'databaseId',
112
+ '--limit',
113
+ '1',
114
+ ]);
115
+ if (result.exitCode !== 0) {
116
+ throw new Error(`gh run list failed:\n${result.stderr}`);
117
+ }
118
+ const rows = JSON.parse(result.stdout);
119
+ const first = rows[0];
120
+ return first === undefined ? undefined : { id: first.databaseId };
121
+ }
122
+ approvalTrackers = [];
123
+ trackApprovals() {
124
+ const tracker = [];
125
+ this.approvalTrackers.push(tracker);
126
+ return { data: tracker };
127
+ }
128
+ // The gate approval is the one raw API call in the flow: the pending deployments
129
+ // endpoint wants the run's waiting environment ids back, marked approved.
130
+ async approveRun(runId) {
131
+ const path = `repos/{owner}/{repo}/actions/runs/${runId}/pending_deployments`;
132
+ const pending = await this.runGh(['api', path]);
133
+ if (pending.exitCode !== 0) {
134
+ throw new Error(`gh api ${path} failed:\n${pending.stderr}`);
135
+ }
136
+ const rows = JSON.parse(pending.stdout);
137
+ const approval = await this.runGh([
138
+ 'api',
139
+ '-X',
140
+ 'POST',
141
+ path,
142
+ ...rows.flatMap((row) => ['-F', `environment_ids[]=${row.environment.id}`]),
143
+ '-f',
144
+ 'state=approved',
145
+ '-f',
146
+ 'comment=approved by ekohacks release ship',
147
+ ]);
148
+ if (approval.exitCode !== 0) {
149
+ throw new Error(`gh api -X POST ${path} failed:\n${approval.stderr}`);
150
+ }
151
+ for (const tracker of this.approvalTrackers) {
152
+ tracker.push(runId);
153
+ }
154
+ }
155
+ async run(runId) {
156
+ const result = await this.runGh([
157
+ 'run',
158
+ 'view',
159
+ String(runId),
160
+ '--json',
161
+ 'status,conclusion,url',
162
+ ]);
163
+ if (result.exitCode !== 0) {
164
+ throw new Error(`gh run view ${runId} failed:\n${result.stderr}`);
165
+ }
166
+ const row = JSON.parse(result.stdout);
167
+ return {
168
+ concluded: row.status === 'completed',
169
+ passed: row.conclusion === 'success',
170
+ url: row.url,
171
+ };
172
+ }
173
+ // gh pr checks exits non-zero while checks are pending or failing, so the answer is in
174
+ // stdout, not the exit code. Right after a PR opens it exits non-zero with "no checks
175
+ // reported" until CI registers — that is an empty round, not an error.
176
+ async checks(prNumber) {
177
+ const result = await this.runGh(['pr', 'checks', String(prNumber), '--json', 'name,bucket']);
178
+ if (result.stdout.trim() === '') {
179
+ if (result.stderr.includes('no checks reported')) {
180
+ return [];
181
+ }
182
+ if (result.exitCode !== 0) {
183
+ throw new Error(`gh pr checks ${prNumber} failed:\n${result.stderr}`);
184
+ }
185
+ return [];
186
+ }
187
+ const rows = JSON.parse(result.stdout);
188
+ return rows.map((row) => ({
189
+ name: row.name,
190
+ concluded: row.bucket !== 'pending',
191
+ passed: row.bucket === 'pass' || row.bucket === 'skipping',
192
+ }));
193
+ }
194
+ releaseTrackers = [];
195
+ trackReleases() {
196
+ const tracker = [];
197
+ this.releaseTrackers.push(tracker);
198
+ return { data: tracker };
199
+ }
200
+ async createRelease(options) {
201
+ const result = await this.runGh([
202
+ 'release',
203
+ 'create',
204
+ options.tag,
205
+ '--target',
206
+ 'main',
207
+ '--title',
208
+ options.title,
209
+ '--notes',
210
+ options.notes,
211
+ ]);
212
+ if (result.exitCode !== 0) {
213
+ throw new Error(`gh release create ${options.tag} failed:\n${result.stderr}`);
214
+ }
215
+ for (const tracker of this.releaseTrackers) {
216
+ tracker.push(options);
217
+ }
218
+ }
219
+ mergeTrackers = [];
220
+ trackMerges() {
221
+ const tracker = [];
222
+ this.mergeTrackers.push(tracker);
223
+ return { data: tracker };
224
+ }
225
+ async mergePr(prNumber) {
226
+ const result = await this.runGh(['pr', 'merge', String(prNumber), '--squash']);
227
+ if (result.exitCode !== 0) {
228
+ throw new Error(`gh pr merge ${prNumber} failed:\n${result.stderr}`);
229
+ }
230
+ for (const tracker of this.mergeTrackers) {
231
+ tracker.push(prNumber);
232
+ }
233
+ }
234
+ }
@@ -0,0 +1,70 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ const execFileAsync = promisify(execFile);
4
+ // Wraps the local git surface the CLI needs. Real and null share every line above the
5
+ // bottom layer: create() shells out to git, createNull() answers from configured output,
6
+ // so the null behaves exactly like the real thing without running git.
7
+ export class GitWrapper {
8
+ static create({ cwd = process.cwd() } = {}) {
9
+ return new GitWrapper(async (args) => {
10
+ const { stdout } = await execFileAsync('git', args, { cwd });
11
+ return stdout;
12
+ });
13
+ }
14
+ static createNull({ branch = 'main', dirty = false, behindOrigin = false, existingBranches = [], } = {}) {
15
+ const outputs = {
16
+ 'rev-parse --abbrev-ref HEAD': `${branch}\n`,
17
+ 'status --porcelain': dirty ? ' M some-file.ts\n' : '',
18
+ 'rev-parse main': behindOrigin ? 'local\n' : 'shared\n',
19
+ 'rev-parse origin/main': 'shared\n',
20
+ };
21
+ for (const existing of existingBranches) {
22
+ outputs[`branch --list ${existing}`] = ` ${existing}\n`;
23
+ }
24
+ return new GitWrapper((args) => Promise.resolve(outputs[args.join(' ')] ?? ''));
25
+ }
26
+ runGit;
27
+ constructor(runGit) {
28
+ this.runGit = runGit;
29
+ }
30
+ async currentBranch() {
31
+ return (await this.runGit(['rev-parse', '--abbrev-ref', 'HEAD'])).trim();
32
+ }
33
+ async workingTreeClean() {
34
+ return (await this.runGit(['status', '--porcelain'])).trim() === '';
35
+ }
36
+ // git branch --list exits 0 whether or not the branch exists, so this never trips the
37
+ // real runner's throw-on-nonzero; existence is in the output.
38
+ async branchExists(branch) {
39
+ return (await this.runGit(['branch', '--list', branch])).trim() !== '';
40
+ }
41
+ async mainInSyncWithOrigin() {
42
+ await this.runGit(['fetch', 'origin', 'main']);
43
+ const local = (await this.runGit(['rev-parse', 'main'])).trim();
44
+ const remote = (await this.runGit(['rev-parse', 'origin/main'])).trim();
45
+ return local === remote;
46
+ }
47
+ actionTrackers = [];
48
+ trackActions() {
49
+ const tracker = [];
50
+ this.actionTrackers.push(tracker);
51
+ return { data: tracker };
52
+ }
53
+ record(action) {
54
+ for (const tracker of this.actionTrackers) {
55
+ tracker.push(action);
56
+ }
57
+ }
58
+ async createBranch(branch) {
59
+ await this.runGit(['switch', '-c', branch]);
60
+ this.record({ action: 'createBranch', branch });
61
+ }
62
+ async commitAll(message) {
63
+ await this.runGit(['commit', '--all', '--message', message]);
64
+ this.record({ action: 'commitAll', message });
65
+ }
66
+ async push() {
67
+ await this.runGit(['push', '--set-upstream', 'origin', 'HEAD']);
68
+ this.record({ action: 'push' });
69
+ }
70
+ }
@@ -0,0 +1,60 @@
1
+ import { execFile } from 'node:child_process';
2
+ // Wraps the npm registry surface the CLI needs. Real and null share every line above the
3
+ // bottom layer: create() shells out to npm, createNull() answers from a configured map of
4
+ // package name to published version, so the null never touches the network.
5
+ export class NpmWrapper {
6
+ static create({ cwd = process.cwd() } = {}) {
7
+ return new NpmWrapper((args) => new Promise((resolve) => {
8
+ execFile('npm', args, { cwd }, (error, stdout, stderr) => {
9
+ const exitCode = error === null ? 0 : 1;
10
+ resolve({ exitCode, stdout, stderr });
11
+ });
12
+ }));
13
+ }
14
+ static createNull({ publishedVersions = {}, } = {}) {
15
+ const rounds = {};
16
+ return new NpmWrapper((args) => {
17
+ if (args[0] === 'version') {
18
+ return Promise.resolve({ exitCode: 0, stdout: `v${args[1]}\n`, stderr: '' });
19
+ }
20
+ const pkg = args[1] ?? '';
21
+ const configured = publishedVersions[pkg];
22
+ if (configured === undefined) {
23
+ return Promise.resolve({ exitCode: 1, stdout: '', stderr: `npm error code E404 ${pkg}` });
24
+ }
25
+ const list = Array.isArray(configured) ? configured : [configured];
26
+ const index = Math.min(rounds[pkg] ?? 0, list.length - 1);
27
+ rounds[pkg] = index + 1;
28
+ return Promise.resolve({ exitCode: 0, stdout: `${list[index]}\n`, stderr: '' });
29
+ });
30
+ }
31
+ runNpm;
32
+ constructor(runNpm) {
33
+ this.runNpm = runNpm;
34
+ }
35
+ bumpTrackers = [];
36
+ trackBumps() {
37
+ const tracker = [];
38
+ this.bumpTrackers.push(tracker);
39
+ return { data: tracker };
40
+ }
41
+ async bumpVersion(version) {
42
+ const result = await this.runNpm(['version', version, '--no-git-tag-version']);
43
+ if (result.exitCode !== 0) {
44
+ throw new Error(`npm version ${version} failed:\n${result.stderr}`);
45
+ }
46
+ for (const tracker of this.bumpTrackers) {
47
+ tracker.push(version);
48
+ }
49
+ }
50
+ async publishedVersion(pkg) {
51
+ const result = await this.runNpm(['view', pkg, 'version']);
52
+ if (result.exitCode === 0) {
53
+ return result.stdout.trim();
54
+ }
55
+ if (result.stderr.includes('E404')) {
56
+ return undefined;
57
+ }
58
+ throw new Error(`npm view ${pkg} failed:\n${result.stderr}`);
59
+ }
60
+ }
@@ -0,0 +1,23 @@
1
+ import { exec } from 'node:child_process';
2
+ // Wraps the child processes the CLI shells out to. create() runs the command through a
3
+ // real shell and answers its exit code; the null answers configured exit codes per
4
+ // command, exit 0 when unconfigured, and never spawns anything.
5
+ export class ProcessRunner {
6
+ static create({ cwd = process.cwd() } = {}) {
7
+ return new ProcessRunner((command) => new Promise((resolve) => {
8
+ const child = exec(command, { cwd });
9
+ child.on('error', () => resolve({ exitCode: 1 }));
10
+ child.on('exit', (code) => resolve({ exitCode: code ?? 1 }));
11
+ }));
12
+ }
13
+ static createNull({ commands = {} } = {}) {
14
+ return new ProcessRunner((command) => Promise.resolve(commands[command] ?? { exitCode: 0 }));
15
+ }
16
+ runCommand;
17
+ constructor(runCommand) {
18
+ this.runCommand = runCommand;
19
+ }
20
+ run(command) {
21
+ return this.runCommand(command);
22
+ }
23
+ }
@@ -0,0 +1,15 @@
1
+ // Finds the changelog entry for a version: the lines between its `## X.Y.Z` heading and
2
+ // the next `## ` heading, without the heading itself. The heading is dropped because the
3
+ // entry's consumers already carry the version: a GitHub Release titles itself vX.Y.Z, so
4
+ // its notes start straight at the entry body.
5
+ export const changelogEntryFor = (log, version) => {
6
+ const lines = log.split('\n');
7
+ const heading = lines.indexOf(`## ${version}`);
8
+ if (heading === -1) {
9
+ return undefined;
10
+ }
11
+ const body = lines.slice(heading + 1);
12
+ const nextHeading = body.findIndex((line) => line.startsWith('## '));
13
+ const entry = nextHeading === -1 ? body : body.slice(0, nextHeading);
14
+ return entry.join('\n').trim();
15
+ };
@@ -0,0 +1,47 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { changelogEntryFor } from './changelog.js';
3
+ import { GhWrapper } from '../infrastructure/gh.js';
4
+ import { GitWrapper } from '../infrastructure/git.js';
5
+ import { NpmWrapper } from '../infrastructure/npm.js';
6
+ // The mechanical middle of RELEASING.md as a policy: branch, bump, commit, push, open the
7
+ // release PR, wait for CI, merge on green. It refuses to start unless preflight passed,
8
+ // and every stop names the reason so a human can pick up by hand. The caller supplies the
9
+ // wrappers and hears each step through narrate.
10
+ export const cut = async ({ version, changelog, report, git, npm, gh, narrate, confirm = () => Promise.resolve(true), pollDelayMs = 15_000, }) => {
11
+ const failed = report.checks.filter((check) => !check.passed);
12
+ if (failed.length > 0) {
13
+ return { stopped: `preflight failed: ${failed.map((check) => check.name).join(', ')}` };
14
+ }
15
+ const branch = `release/v${version}`;
16
+ if (await git.branchExists(branch)) {
17
+ return { stopped: `branch ${branch} already exists from an earlier cut` };
18
+ }
19
+ await git.createBranch(branch);
20
+ narrate(`branched ${branch}`);
21
+ await npm.bumpVersion(version);
22
+ narrate(`bumped to ${version}`);
23
+ const message = `chore: release ${version}`;
24
+ await git.commitAll(message);
25
+ narrate(`committed ${message}`);
26
+ await git.push();
27
+ narrate('pushed');
28
+ const body = changelogEntryFor(changelog, version) ?? '';
29
+ const { number } = await gh.openPr({ title: message, body });
30
+ narrate(`opened pr #${number}`);
31
+ let checks = await gh.checks(number);
32
+ while (checks.length === 0 || checks.some((check) => !check.concluded)) {
33
+ narrate('waiting for checks');
34
+ await delay(pollDelayMs);
35
+ checks = await gh.checks(number);
36
+ }
37
+ const failing = checks.find((check) => !check.passed);
38
+ if (failing !== undefined) {
39
+ return { stopped: `check ${failing.name} failed` };
40
+ }
41
+ if (!(await confirm(`merge pr #${number}?`))) {
42
+ return { stopped: 'merge not approved' };
43
+ }
44
+ await gh.mergePr(number);
45
+ narrate(`merged pr #${number}`);
46
+ return { merged: number };
47
+ };
@@ -0,0 +1,64 @@
1
+ import { changelogEntryFor } from './changelog.js';
2
+ import { GitWrapper } from '../infrastructure/git.js';
3
+ import { NpmWrapper } from '../infrastructure/npm.js';
4
+ import { ProcessRunner } from '../infrastructure/process.js';
5
+ const SMOKE_COMMAND = 'npm run test:package';
6
+ // The "Before cutting" checklist from RELEASING.md as a policy: each check answers with
7
+ // its name and, when it fails, the reason a human needs to fix it. The caller supplies
8
+ // file contents and wrappers; the policy only judges.
9
+ export const preflight = async ({ version, changelog, npm, pkg, git, lockfile, runner, }) => {
10
+ const entry = changelogEntryFor(changelog, version);
11
+ const changelogCheck = entry === undefined
12
+ ? {
13
+ name: 'changelog entry',
14
+ passed: false,
15
+ reason: `no ## ${version} heading in the changelog`,
16
+ }
17
+ : { name: 'changelog entry', passed: true };
18
+ const published = await npm.publishedVersion(pkg);
19
+ const versionCheck = published === version
20
+ ? { name: 'version is new', passed: false, reason: `${version} is already published` }
21
+ : { name: 'version is new', passed: true };
22
+ const branch = await git.currentBranch();
23
+ const branchCheck = branch === 'main'
24
+ ? { name: 'on main', passed: true }
25
+ : { name: 'on main', passed: false, reason: `on ${branch}, not main` };
26
+ const inSync = await git.mainInSyncWithOrigin();
27
+ const syncCheck = inSync
28
+ ? { name: 'in sync with origin', passed: true }
29
+ : {
30
+ name: 'in sync with origin',
31
+ passed: false,
32
+ reason: 'main does not match origin/main',
33
+ };
34
+ const treeClean = await git.workingTreeClean();
35
+ const treeCheck = treeClean
36
+ ? { name: 'clean working tree', passed: true }
37
+ : {
38
+ name: 'clean working tree',
39
+ passed: false,
40
+ reason: 'uncommitted changes in the working tree',
41
+ };
42
+ const lockfileCheck = lockfile.includes('npmmirror')
43
+ ? {
44
+ name: 'lockfile registry',
45
+ passed: false,
46
+ reason: 'lockfile resolves packages outside registry.npmjs.org',
47
+ }
48
+ : { name: 'lockfile registry', passed: true };
49
+ const smoke = await runner.run(SMOKE_COMMAND);
50
+ const smokeCheck = smoke.exitCode === 0
51
+ ? { name: 'package smoke', passed: true }
52
+ : { name: 'package smoke', passed: false, reason: `${SMOKE_COMMAND} failed` };
53
+ return {
54
+ checks: [
55
+ changelogCheck,
56
+ versionCheck,
57
+ branchCheck,
58
+ syncCheck,
59
+ treeCheck,
60
+ lockfileCheck,
61
+ smokeCheck,
62
+ ],
63
+ };
64
+ };
@@ -0,0 +1,45 @@
1
+ import { cut } from './cut.js';
2
+ import { preflight } from './preflight.js';
3
+ import { ship } from './ship.js';
4
+ import { GhWrapper } from '../infrastructure/gh.js';
5
+ import { GitWrapper } from '../infrastructure/git.js';
6
+ import { NpmWrapper } from '../infrastructure/npm.js';
7
+ import { ProcessRunner } from '../infrastructure/process.js';
8
+ // The whole of RELEASING.md as one command: preflight, cut, ship in order, stopping at
9
+ // the first failure with that stage's own reason. No stage's logic lives here — this
10
+ // policy only composes the three and decides which pauses --yes may skip: the merge and
11
+ // the Release. The gate always asks.
12
+ export const release = async ({ version, changelog, lockfile, pkg, git, npm, gh, runner, confirm, narrate, yes = false, pollDelayMs, findRunAttempts, registryAttempts, }) => {
13
+ const report = await preflight({ version, changelog, npm, pkg, git, lockfile, runner });
14
+ for (const check of report.checks) {
15
+ narrate(check.passed ? `ok ${check.name}` : `FAIL ${check.name}: ${check.reason}`);
16
+ }
17
+ const skippable = yes ? () => Promise.resolve(true) : confirm;
18
+ const cutResult = await cut({
19
+ version,
20
+ changelog,
21
+ report,
22
+ git,
23
+ npm,
24
+ gh,
25
+ narrate,
26
+ confirm: skippable,
27
+ pollDelayMs,
28
+ });
29
+ if ('stopped' in cutResult) {
30
+ return cutResult;
31
+ }
32
+ return ship({
33
+ version,
34
+ changelog,
35
+ pkg,
36
+ gh,
37
+ npm,
38
+ confirm,
39
+ confirmRelease: skippable,
40
+ narrate,
41
+ pollDelayMs,
42
+ findRunAttempts,
43
+ registryAttempts,
44
+ });
45
+ };
@@ -0,0 +1,52 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { changelogEntryFor } from './changelog.js';
3
+ import { GhWrapper } from '../infrastructure/gh.js';
4
+ import { NpmWrapper } from '../infrastructure/npm.js';
5
+ // The tail of RELEASING.md as a policy: cut the GitHub Release, approve the publish
6
+ // gate, follow the run to green, and only report success once the registry serves the
7
+ // version. The one human decision stays human: the gate is never approved without the
8
+ // confirm answering yes.
9
+ export const ship = async ({ version, changelog, pkg, gh, npm, confirm, confirmRelease = () => Promise.resolve(true), narrate, pollDelayMs = 15_000, findRunAttempts = 8, registryAttempts = 20, workflow = 'publish.yml', }) => {
10
+ const tag = `v${version}`;
11
+ if (!(await confirmRelease(`cut release ${tag}?`))) {
12
+ return { stopped: 'release not approved' };
13
+ }
14
+ const notes = changelogEntryFor(changelog, version) ?? '';
15
+ await gh.createRelease({ tag, title: tag, notes });
16
+ narrate(`release ${tag} cut`);
17
+ // The run takes a few seconds to appear after the release event, so a miss here is
18
+ // usually the race, not a missing workflow: ask again briefly before giving up.
19
+ let waiting = await gh.waitingRun(workflow);
20
+ for (let attempt = 1; waiting === undefined && attempt < findRunAttempts; attempt += 1) {
21
+ narrate('waiting for the publish run to appear');
22
+ await delay(pollDelayMs);
23
+ waiting = await gh.waitingRun(workflow);
24
+ }
25
+ if (waiting === undefined) {
26
+ return { stopped: 'no waiting publish run' };
27
+ }
28
+ if (!(await confirm(`approve the release gate for run #${waiting.id}?`))) {
29
+ return { stopped: 'gate not approved' };
30
+ }
31
+ await gh.approveRun(waiting.id);
32
+ narrate(`gate approved for run #${waiting.id}`);
33
+ let state = await gh.run(waiting.id);
34
+ while (!state.concluded) {
35
+ narrate('waiting for the publish run');
36
+ await delay(pollDelayMs);
37
+ state = await gh.run(waiting.id);
38
+ }
39
+ if (!state.passed) {
40
+ return { stopped: `publish run failed: ${state.url}` };
41
+ }
42
+ narrate('publish run green');
43
+ for (let attempt = 0; attempt < registryAttempts; attempt += 1) {
44
+ if ((await npm.publishedVersion(pkg)) === version) {
45
+ narrate(`registry serves ${pkg}@${version}`);
46
+ return { shipped: version };
47
+ }
48
+ narrate('waiting for the registry');
49
+ await delay(pollDelayMs);
50
+ }
51
+ return { stopped: `registry never served ${pkg}@${version}` };
52
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "ekohacks",
3
+ "version": "0.1.0",
4
+ "description": "The EkoHacks CLI: our everyday operations, automated one small command at a time.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ekohacks/cli.git"
8
+ },
9
+ "license": "MIT",
10
+ "author": "ekohacks",
11
+ "type": "module",
12
+ "engines": {
13
+ "node": ">=24"
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.build.json",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest --watch",
22
+ "typecheck": "tsc --noEmit",
23
+ "format": "prettier --write .",
24
+ "format:check": "prettier --check .",
25
+ "prepare": "husky",
26
+ "prepack": "npm run build",
27
+ "prepublishOnly": "npm run typecheck && npm test",
28
+ "test:changed": "vitest run --changed origin/main",
29
+ "test:integration": "vitest run --config vitest.integration.config.ts",
30
+ "test:package": "scripts/test-package.sh"
31
+ },
32
+ "devDependencies": {
33
+ "@commitlint/cli": "^21.2.1",
34
+ "@commitlint/config-conventional": "^21.2.0",
35
+ "@types/node": "^26.1.1",
36
+ "husky": "^9.1.7",
37
+ "prettier": "^3.9.5",
38
+ "typescript": "^7.0.2",
39
+ "vitest": "^4.1.10"
40
+ },
41
+ "bin": {
42
+ "ekohacks": "dist/cli.js"
43
+ }
44
+ }