@jens_astrup/release-manager 0.1.0-alpha.2
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/README.md +95 -0
- package/dist/bin/release-manager.js +42 -0
- package/dist/src/app.js +98 -0
- package/dist/src/components/ErrorPanel.js +25 -0
- package/dist/src/components/Header.js +24 -0
- package/dist/src/components/ProgressBar.js +28 -0
- package/dist/src/components/StepList.js +28 -0
- package/dist/src/errors.js +63 -0
- package/dist/src/lib/build.js +54 -0
- package/dist/src/lib/config.js +116 -0
- package/dist/src/lib/env.js +41 -0
- package/dist/src/lib/git.js +94 -0
- package/dist/src/lib/github.js +46 -0
- package/dist/src/lib/linear.js +40 -0
- package/dist/src/lib/openai.js +100 -0
- package/dist/src/lib/version.js +69 -0
- package/dist/src/lib/workflows.js +127 -0
- package/dist/src/screens/BumpVersion.js +244 -0
- package/dist/src/screens/CreateRelease.js +109 -0
- package/dist/src/screens/MainMenu.js +22 -0
- package/dist/src/screens/Settings.js +142 -0
- package/dist/src/screens/SuggestBump.js +57 -0
- package/dist/src/types.js +1 -0
- package/package.json +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# release-manager
|
|
2
|
+
|
|
3
|
+
Interactive CLI for creating release version-bump PRs and full releases with AI-generated release notes. Built with [Ink](https://github.com/vadimdemedes/ink).
|
|
4
|
+
|
|
5
|
+
Designed to be installed once and run from inside any project directory; per-project configuration is saved to `~/.config/release-manager/projects/` so each repo remembers its own build command, issue source (Linear or GitHub Issues), branch names, and OpenAI model.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g release-manager
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or with `npx`:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx release-manager
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Required environment variables
|
|
20
|
+
|
|
21
|
+
These are read from the project's `.env.local` (if present) or from your shell:
|
|
22
|
+
|
|
23
|
+
| Variable | When required |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| `GITHUB_TOKEN` | Always — used to open PRs and (optionally) read GitHub Issues |
|
|
26
|
+
| `OPENAI_API_KEY` | When generating release notes or asking for a suggested bump |
|
|
27
|
+
| `LINEAR_API_KEY` (or `LINEAR_API_TOKEN`) | When `issueSource` is `linear` |
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
Run with no arguments inside a git project for the interactive menu:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
cd ~/code/my-app
|
|
35
|
+
release-manager
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or jump straight to an action:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
release-manager bump # version bump PR
|
|
42
|
+
release-manager release # develop → main release PR
|
|
43
|
+
release-manager suggest # AI-suggested bump
|
|
44
|
+
release-manager config # edit project settings
|
|
45
|
+
release-manager --skip-build # skip the build check during a bump
|
|
46
|
+
release-manager --cwd ~/code/my-app
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Features
|
|
50
|
+
|
|
51
|
+
1. **Version bump PR** — choose major / minor / patch / alpha / beta / specific version, or ask the AI to suggest one based on commits.
|
|
52
|
+
2. **Release PR with AI notes** — pulls eligible issues from Linear or GitHub Issues, formats them into release notes, and opens a PR from `develop` → `main`.
|
|
53
|
+
3. **Suggested bump** — analyzes commits between branches and recommends `major`, `minor`, or `patch`.
|
|
54
|
+
4. **Specific version input** — type any valid semver (including prereleases like `2.0.0-rc.1`).
|
|
55
|
+
5. **Configurable build check** — runs the configured command before bumping; can be skipped per run with `--skip-build` or by toggling in settings.
|
|
56
|
+
6. **Per-project issue source** — each project chooses Linear or GitHub Issues independently.
|
|
57
|
+
7. **Alpha / beta prereleases** — auto-increments existing prerelease tags (`1.2.4-alpha.0` → `1.2.4-alpha.1`).
|
|
58
|
+
8. **Configurable build command** — defaults to `yarn build`; change in settings to `npm run build`, `pnpm build`, etc.
|
|
59
|
+
9. **Per-project configuration** — keyed by absolute project path, stored centrally:
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
~/.config/release-manager/projects/<sha256(projectDir)>.json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Honors `XDG_CONFIG_HOME` if set.
|
|
66
|
+
|
|
67
|
+
## Progress and errors
|
|
68
|
+
|
|
69
|
+
- Long operations show a determinate progress bar where possible (build check, AI notes streaming) and a spinner everywhere else.
|
|
70
|
+
- Errors are mapped to friendly panels with a short title, a plain-language message, and a hint for common cases (missing API key, dirty git tree, network failure, build failure). Build failures include the last 80 lines of build output.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
git clone <this repo>
|
|
76
|
+
cd release-manager
|
|
77
|
+
npm install
|
|
78
|
+
npm run build
|
|
79
|
+
node dist/bin/release-manager.js
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Watch mode:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
npm run dev
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Publishing
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
npm version patch # or minor / major
|
|
92
|
+
npm publish --access public
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The `prepublishOnly` script runs `npm run build`, and only `dist/` and `README.md` ship in the package.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import React from 'react';
|
|
4
|
+
import { render } from 'ink';
|
|
5
|
+
import meow from 'meow';
|
|
6
|
+
import { App } from '../src/app.js';
|
|
7
|
+
const cli = meow(`
|
|
8
|
+
Usage
|
|
9
|
+
$ release-manager [command]
|
|
10
|
+
|
|
11
|
+
Commands
|
|
12
|
+
bump Create a version bump PR (major/minor/patch/alpha/beta/explicit)
|
|
13
|
+
release Create a release PR (develop -> main) with AI-generated notes
|
|
14
|
+
suggest Get AI-suggested version bump type
|
|
15
|
+
config View or edit per-project configuration
|
|
16
|
+
(default) Open interactive menu
|
|
17
|
+
|
|
18
|
+
Options
|
|
19
|
+
--cwd Project directory to operate on (default: current working directory)
|
|
20
|
+
--skip-build Skip the build check during version bump
|
|
21
|
+
--version Show version
|
|
22
|
+
--help Show help
|
|
23
|
+
|
|
24
|
+
Examples
|
|
25
|
+
$ release-manager
|
|
26
|
+
$ release-manager bump
|
|
27
|
+
$ release-manager release
|
|
28
|
+
$ release-manager --cwd ~/code/spades
|
|
29
|
+
`, {
|
|
30
|
+
importMeta: import.meta,
|
|
31
|
+
flags: {
|
|
32
|
+
cwd: { type: 'string' },
|
|
33
|
+
skipBuild: { type: 'boolean', default: false }
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
const projectDir = cli.flags.cwd ? resolve(cli.flags.cwd) : process.cwd();
|
|
37
|
+
const initialCommand = cli.input[0];
|
|
38
|
+
render(React.createElement(App, {
|
|
39
|
+
projectDir,
|
|
40
|
+
initialCommand,
|
|
41
|
+
skipBuildFlag: cli.flags.skipBuild
|
|
42
|
+
}));
|
package/dist/src/app.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
import { ErrorPanel } from './components/ErrorPanel.js';
|
|
5
|
+
import { Header } from './components/Header.js';
|
|
6
|
+
import { toFriendlyError } from './errors.js';
|
|
7
|
+
import { defaultConfig, loadOrInitConfig, saveConfig } from './lib/config.js';
|
|
8
|
+
import { isGitRepo, inferGithubRepo } from './lib/git.js';
|
|
9
|
+
import { BumpVersion } from './screens/BumpVersion.js';
|
|
10
|
+
import { CreateRelease } from './screens/CreateRelease.js';
|
|
11
|
+
import { MainMenu } from './screens/MainMenu.js';
|
|
12
|
+
import { Settings } from './screens/Settings.js';
|
|
13
|
+
import { SuggestBumpScreen } from './screens/SuggestBump.js';
|
|
14
|
+
export function App({ projectDir, initialCommand, skipBuildFlag }) {
|
|
15
|
+
const { exit } = useApp();
|
|
16
|
+
const [loading, setLoading] = useState(true);
|
|
17
|
+
const [config, setConfig] = useState(null);
|
|
18
|
+
const [error, setError] = useState(null);
|
|
19
|
+
const [route, setRoute] = useState(initialCommand ?? 'menu');
|
|
20
|
+
// Allow Ctrl+C globally and a handy "q" shortcut from menu
|
|
21
|
+
useInput((input, key) => {
|
|
22
|
+
if (key.ctrl && input === 'c')
|
|
23
|
+
exit();
|
|
24
|
+
});
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
let cancelled = false;
|
|
27
|
+
async function bootstrap() {
|
|
28
|
+
try {
|
|
29
|
+
if (!(await isGitRepo({ cwd: projectDir }))) {
|
|
30
|
+
throw new Error('Not a git repository');
|
|
31
|
+
}
|
|
32
|
+
let cfg = await loadOrInitConfig(projectDir);
|
|
33
|
+
// First-run convenience: try to fill in repo owner/name from git remote
|
|
34
|
+
if (!cfg.github.owner || !cfg.github.repo) {
|
|
35
|
+
const inferred = await inferGithubRepo({ cwd: projectDir });
|
|
36
|
+
if (inferred) {
|
|
37
|
+
cfg = {
|
|
38
|
+
...cfg,
|
|
39
|
+
github: {
|
|
40
|
+
...cfg.github,
|
|
41
|
+
owner: cfg.github.owner || inferred.owner,
|
|
42
|
+
repo: cfg.github.repo || inferred.repo
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
await saveConfig(cfg);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (!cancelled) {
|
|
49
|
+
setConfig(cfg);
|
|
50
|
+
setLoading(false);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
if (!cancelled) {
|
|
55
|
+
setError(toFriendlyError(e));
|
|
56
|
+
setLoading(false);
|
|
57
|
+
// Provide a placeholder config so settings can still be edited
|
|
58
|
+
setConfig(defaultConfig(projectDir));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
void bootstrap();
|
|
63
|
+
return () => {
|
|
64
|
+
cancelled = true;
|
|
65
|
+
};
|
|
66
|
+
}, [projectDir]);
|
|
67
|
+
if (loading) {
|
|
68
|
+
return (React.createElement(Box, null,
|
|
69
|
+
React.createElement(Text, { color: "cyan" },
|
|
70
|
+
React.createElement(Spinner, { type: "dots" })),
|
|
71
|
+
React.createElement(Text, null, " Loading project config\u2026")));
|
|
72
|
+
}
|
|
73
|
+
if (error && route !== 'config') {
|
|
74
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
75
|
+
React.createElement(ErrorPanel, { error: error }),
|
|
76
|
+
React.createElement(Box, { marginTop: 1 },
|
|
77
|
+
React.createElement(Text, { color: "gray" }, "Press Ctrl+C to quit, or run `release-manager config` to set up the project."))));
|
|
78
|
+
}
|
|
79
|
+
if (!config)
|
|
80
|
+
return null;
|
|
81
|
+
const handleMenu = (choice) => {
|
|
82
|
+
if (choice === 'quit') {
|
|
83
|
+
exit();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
setRoute(choice);
|
|
87
|
+
};
|
|
88
|
+
return (React.createElement(Box, { flexDirection: "column" },
|
|
89
|
+
React.createElement(Header, { config: config }),
|
|
90
|
+
route === 'menu' && React.createElement(MainMenu, { config: config, onSelect: handleMenu }),
|
|
91
|
+
route === 'bump' && (React.createElement(BumpVersion, { config: config, initialSkipBuild: skipBuildFlag, onDone: () => setRoute('menu') })),
|
|
92
|
+
route === 'release' && (React.createElement(CreateRelease, { config: config, onDone: () => setRoute('menu') })),
|
|
93
|
+
route === 'suggest' && (React.createElement(SuggestBumpScreen, { config: config, onDone: () => setRoute('menu') })),
|
|
94
|
+
route === 'config' && (React.createElement(Settings, { config: config, onSave: (c) => {
|
|
95
|
+
setConfig(c);
|
|
96
|
+
setRoute('menu');
|
|
97
|
+
}, onDone: () => setRoute('menu') }))));
|
|
98
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
/**
|
|
4
|
+
* A bordered panel that displays a user-friendly error: short title, message,
|
|
5
|
+
* an optional remediation hint, and an optional collapsed detail (last lines
|
|
6
|
+
* of build output, stack trace) shown in dim text.
|
|
7
|
+
*/
|
|
8
|
+
export function ErrorPanel({ error }) {
|
|
9
|
+
return (React.createElement(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1 },
|
|
10
|
+
React.createElement(Text, { color: "red", bold: true },
|
|
11
|
+
"\u2717 ",
|
|
12
|
+
error.title),
|
|
13
|
+
React.createElement(Text, null, error.message),
|
|
14
|
+
error.hint ? (React.createElement(Box, { marginTop: 1 },
|
|
15
|
+
React.createElement(Text, { color: "yellow" }, "Hint: "),
|
|
16
|
+
React.createElement(Text, null, error.hint))) : null,
|
|
17
|
+
error.detail ? (React.createElement(Box, { marginTop: 1, flexDirection: "column" },
|
|
18
|
+
React.createElement(Text, { color: "gray" }, "Details:"),
|
|
19
|
+
React.createElement(Text, { color: "gray" }, truncate(error.detail, 1200)))) : null));
|
|
20
|
+
}
|
|
21
|
+
function truncate(s, max) {
|
|
22
|
+
if (s.length <= max)
|
|
23
|
+
return s;
|
|
24
|
+
return s.slice(0, max) + '\n…(truncated)';
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
/**
|
|
4
|
+
* Top header: shows project name, repo, and branch targets.
|
|
5
|
+
*/
|
|
6
|
+
export function Header({ config }) {
|
|
7
|
+
const repo = config.github.owner && config.github.repo
|
|
8
|
+
? `${config.github.owner}/${config.github.repo}`
|
|
9
|
+
: '(repo not configured)';
|
|
10
|
+
return (React.createElement(Box, { flexDirection: "column", marginBottom: 1 },
|
|
11
|
+
React.createElement(Box, null,
|
|
12
|
+
React.createElement(Text, { bold: true, color: "magenta" }, "release-manager"),
|
|
13
|
+
React.createElement(Text, { color: "gray" }, " \u00B7 "),
|
|
14
|
+
React.createElement(Text, null, config.name ?? config.projectDir)),
|
|
15
|
+
React.createElement(Box, null,
|
|
16
|
+
React.createElement(Text, { color: "gray" }, "repo: "),
|
|
17
|
+
React.createElement(Text, null, repo),
|
|
18
|
+
React.createElement(Text, { color: "gray" }, " branches: "),
|
|
19
|
+
React.createElement(Text, null, config.github.developBranch),
|
|
20
|
+
React.createElement(Text, { color: "gray" }, " \u2192 "),
|
|
21
|
+
React.createElement(Text, null, config.github.mainBranch),
|
|
22
|
+
React.createElement(Text, { color: "gray" }, " issues: "),
|
|
23
|
+
React.createElement(Text, null, config.issueSource))));
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
/**
|
|
4
|
+
* A simple horizontal progress bar. When `progress` is null/undefined we render
|
|
5
|
+
* a striped indeterminate state. The bar character is drawn with full-width
|
|
6
|
+
* blocks so it feels solid in modern terminals.
|
|
7
|
+
*/
|
|
8
|
+
export function ProgressBar({ progress, width = 30, label }) {
|
|
9
|
+
const indeterminate = progress === null || progress === undefined;
|
|
10
|
+
let filled = 0;
|
|
11
|
+
if (!indeterminate && typeof progress === 'number') {
|
|
12
|
+
filled = Math.max(0, Math.min(width, Math.round(progress * width)));
|
|
13
|
+
}
|
|
14
|
+
const bar = indeterminate
|
|
15
|
+
? '─'.repeat(width)
|
|
16
|
+
: '█'.repeat(filled) + '░'.repeat(width - filled);
|
|
17
|
+
const pctText = indeterminate
|
|
18
|
+
? '...'
|
|
19
|
+
: `${Math.round((progress ?? 0) * 100).toString().padStart(3, ' ')}%`;
|
|
20
|
+
return (React.createElement(Box, null,
|
|
21
|
+
React.createElement(Text, { color: indeterminate ? 'gray' : 'cyan' }, bar),
|
|
22
|
+
React.createElement(Text, null,
|
|
23
|
+
" ",
|
|
24
|
+
pctText),
|
|
25
|
+
label ? React.createElement(Text, { color: "gray" },
|
|
26
|
+
" ",
|
|
27
|
+
label) : null));
|
|
28
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import Spinner from 'ink-spinner';
|
|
4
|
+
import { ProgressBar } from './ProgressBar.js';
|
|
5
|
+
/**
|
|
6
|
+
* Render a vertical list of steps. Running steps show a spinner; if a step
|
|
7
|
+
* exposes a numeric progress value, render a determinate progress bar beneath
|
|
8
|
+
* the label. Completed steps show a green check, failed steps show a red x,
|
|
9
|
+
* skipped steps show a gray dash.
|
|
10
|
+
*/
|
|
11
|
+
export function StepList({ steps }) {
|
|
12
|
+
return (React.createElement(Box, { flexDirection: "column" }, steps.map((step) => (React.createElement(Box, { key: step.id, flexDirection: "column", marginBottom: 0 },
|
|
13
|
+
React.createElement(Box, null,
|
|
14
|
+
React.createElement(Box, { width: 3 }, step.status === 'running' ? (React.createElement(Text, { color: "cyan" },
|
|
15
|
+
React.createElement(Spinner, { type: "dots" }))) : step.status === 'done' ? (React.createElement(Text, { color: "green" }, "\u2713")) : step.status === 'failed' ? (React.createElement(Text, { color: "red" }, "\u2717")) : step.status === 'skipped' ? (React.createElement(Text, { color: "gray" }, "\u2014")) : (React.createElement(Text, { color: "gray" }, "\u00B7"))),
|
|
16
|
+
React.createElement(Text, { color: step.status === 'failed'
|
|
17
|
+
? 'red'
|
|
18
|
+
: step.status === 'done'
|
|
19
|
+
? 'green'
|
|
20
|
+
: step.status === 'skipped'
|
|
21
|
+
? 'gray'
|
|
22
|
+
: 'white' }, step.label),
|
|
23
|
+
step.detail ? React.createElement(Text, { color: "gray" },
|
|
24
|
+
" ",
|
|
25
|
+
step.detail) : null),
|
|
26
|
+
step.status === 'running' && step.progress !== undefined ? (React.createElement(Box, { marginLeft: 3 },
|
|
27
|
+
React.createElement(ProgressBar, { progress: step.progress, width: 28 }))) : null)))));
|
|
28
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert an unknown error into a user-friendly error object,
|
|
3
|
+
* adding hints for common cases (missing env vars, dirty git tree, network).
|
|
4
|
+
*/
|
|
5
|
+
export function toFriendlyError(error) {
|
|
6
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
7
|
+
if (/working directory is not clean/i.test(raw)) {
|
|
8
|
+
return {
|
|
9
|
+
title: 'Uncommitted changes',
|
|
10
|
+
message: 'Your git working directory has uncommitted changes.',
|
|
11
|
+
hint: 'Commit or stash your changes, then try again.'
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
if (/LINEAR_API_(KEY|TOKEN)/i.test(raw)) {
|
|
15
|
+
return {
|
|
16
|
+
title: 'Linear API token missing',
|
|
17
|
+
message: 'No LINEAR_API_KEY or LINEAR_API_TOKEN is set.',
|
|
18
|
+
hint: 'Add it to your shell or to an .env.local file in the project directory, then re-run.'
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
if (/OPENAI_API_KEY/i.test(raw)) {
|
|
22
|
+
return {
|
|
23
|
+
title: 'OpenAI API key missing',
|
|
24
|
+
message: 'OPENAI_API_KEY is not set.',
|
|
25
|
+
hint: 'Add OPENAI_API_KEY to your shell or .env.local in the project directory.'
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (/GITHUB_TOKEN/i.test(raw)) {
|
|
29
|
+
return {
|
|
30
|
+
title: 'GitHub token missing',
|
|
31
|
+
message: 'GITHUB_TOKEN is not set.',
|
|
32
|
+
hint: 'Generate a personal access token with repo scope and add it to your shell or .env.local.'
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (/ENOTFOUND|ECONNREFUSED|getaddrinfo/i.test(raw)) {
|
|
36
|
+
return {
|
|
37
|
+
title: 'Network error',
|
|
38
|
+
message: 'Could not reach a remote service.',
|
|
39
|
+
hint: 'Check your internet connection and try again.',
|
|
40
|
+
detail: raw
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (/Build Failed|build failed/i.test(raw)) {
|
|
44
|
+
return {
|
|
45
|
+
title: 'Build failed',
|
|
46
|
+
message: 'The configured build command exited with an error.',
|
|
47
|
+
hint: 'Run the build manually to debug, or skip the build check during version bump.',
|
|
48
|
+
detail: raw
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (/not a git repository/i.test(raw)) {
|
|
52
|
+
return {
|
|
53
|
+
title: 'Not a git repository',
|
|
54
|
+
message: 'The project directory does not contain a git repository.',
|
|
55
|
+
hint: 'Run `git init` or change to a project that is under git source control.'
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
title: 'Something went wrong',
|
|
60
|
+
message: raw,
|
|
61
|
+
detail: error instanceof Error && error.stack ? error.stack : undefined
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { execa } from 'execa';
|
|
2
|
+
/**
|
|
3
|
+
* Run the configured build command, streaming progress updates by tracking
|
|
4
|
+
* the cumulative output line count against an exponential expectation curve.
|
|
5
|
+
* Throws an Error with `.detail` set to the last 80 lines on failure.
|
|
6
|
+
*/
|
|
7
|
+
export async function runBuildCheck(command, cwd, onProgress, signal) {
|
|
8
|
+
const [bin, ...args] = command.trim().split(/\s+/);
|
|
9
|
+
if (!bin) {
|
|
10
|
+
throw new Error('Build command is empty');
|
|
11
|
+
}
|
|
12
|
+
// Doppler-generated .env.local sets NODE_ENV=development, which can leak
|
|
13
|
+
// into Next.js builds. Force production for the build subprocess.
|
|
14
|
+
const { NODE_ENV: _ignored, ...envWithoutNodeEnv } = process.env;
|
|
15
|
+
const subprocess = execa(bin, args, {
|
|
16
|
+
cwd,
|
|
17
|
+
all: true,
|
|
18
|
+
env: { ...envWithoutNodeEnv, NODE_ENV: 'production' },
|
|
19
|
+
cancelSignal: signal
|
|
20
|
+
});
|
|
21
|
+
// Approximate progress: typical webpack/next builds emit ~150-400 lines.
|
|
22
|
+
// We map line count to progress with diminishing returns so the bar moves
|
|
23
|
+
// quickly at first and slows as we approach 95%, never hitting 100% until done.
|
|
24
|
+
const EXPECTED_LINES = 250;
|
|
25
|
+
let lineCount = 0;
|
|
26
|
+
let lastEmitted = -1;
|
|
27
|
+
const handleChunk = (chunk) => {
|
|
28
|
+
const text = typeof chunk === 'string' ? chunk : chunk.toString('utf-8');
|
|
29
|
+
const lines = text.split(/\r?\n/).filter(Boolean);
|
|
30
|
+
if (lines.length === 0)
|
|
31
|
+
return;
|
|
32
|
+
lineCount += lines.length;
|
|
33
|
+
const ratio = 1 - Math.exp(-lineCount / EXPECTED_LINES);
|
|
34
|
+
const progress = Math.min(0.95, ratio);
|
|
35
|
+
// Throttle to whole-percent changes
|
|
36
|
+
const pct = Math.floor(progress * 100);
|
|
37
|
+
if (pct !== lastEmitted) {
|
|
38
|
+
lastEmitted = pct;
|
|
39
|
+
onProgress({ progress, line: lines[lines.length - 1] ?? '' });
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
subprocess.all?.on('data', handleChunk);
|
|
43
|
+
try {
|
|
44
|
+
await subprocess;
|
|
45
|
+
onProgress({ progress: 1, line: 'Build complete' });
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
const all = error.all ?? '';
|
|
49
|
+
const tail = all.split('\n').slice(-80).join('\n');
|
|
50
|
+
const friendly = new Error('Build Failed, release process aborted');
|
|
51
|
+
friendly.detail = tail;
|
|
52
|
+
throw friendly;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, writeFile, readdir } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
/**
|
|
7
|
+
* Returns the root config directory for release-manager.
|
|
8
|
+
* Honors $XDG_CONFIG_HOME if set, otherwise uses ~/.config/release-manager.
|
|
9
|
+
*/
|
|
10
|
+
export function getConfigRoot() {
|
|
11
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
12
|
+
const base = xdg && xdg.length > 0 ? xdg : join(homedir(), '.config');
|
|
13
|
+
return join(base, 'release-manager');
|
|
14
|
+
}
|
|
15
|
+
function getProjectsDir() {
|
|
16
|
+
return join(getConfigRoot(), 'projects');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Compute a stable filename-safe id for a given absolute project directory.
|
|
20
|
+
* We use sha256 of the absolute path so different paths on different machines
|
|
21
|
+
* never collide, and so renamed paths get fresh configs.
|
|
22
|
+
*/
|
|
23
|
+
export function getProjectId(projectDir) {
|
|
24
|
+
return createHash('sha256').update(projectDir).digest('hex').slice(0, 16);
|
|
25
|
+
}
|
|
26
|
+
function getProjectConfigPath(projectDir) {
|
|
27
|
+
return join(getProjectsDir(), `${getProjectId(projectDir)}.json`);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build a sensible default config for a new project.
|
|
31
|
+
* Repo owner/name are best-effort guesses; the user can override in the Settings screen.
|
|
32
|
+
*/
|
|
33
|
+
export function defaultConfig(projectDir) {
|
|
34
|
+
const now = new Date().toISOString();
|
|
35
|
+
return {
|
|
36
|
+
projectDir,
|
|
37
|
+
name: basename(projectDir),
|
|
38
|
+
buildCommand: 'yarn build',
|
|
39
|
+
buildCheckEnabled: true,
|
|
40
|
+
issueSource: 'linear',
|
|
41
|
+
github: {
|
|
42
|
+
owner: process.env.GITHUB_REPO_OWNER ?? '',
|
|
43
|
+
repo: process.env.GITHUB_REPO_NAME ?? basename(projectDir),
|
|
44
|
+
developBranch: 'develop',
|
|
45
|
+
mainBranch: 'main'
|
|
46
|
+
},
|
|
47
|
+
linear: {
|
|
48
|
+
readyForDeployStateId: '',
|
|
49
|
+
teamKey: ''
|
|
50
|
+
},
|
|
51
|
+
openaiModel: 'gpt-4o-mini',
|
|
52
|
+
createdAt: now,
|
|
53
|
+
updatedAt: now
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Load the config for a project directory, or null if not yet configured.
|
|
58
|
+
*/
|
|
59
|
+
export async function loadConfig(projectDir) {
|
|
60
|
+
const path = getProjectConfigPath(projectDir);
|
|
61
|
+
if (!existsSync(path))
|
|
62
|
+
return null;
|
|
63
|
+
try {
|
|
64
|
+
const raw = await readFile(path, 'utf-8');
|
|
65
|
+
const parsed = JSON.parse(raw);
|
|
66
|
+
// If the saved config drifted (older version), fill in defaults.
|
|
67
|
+
return { ...defaultConfig(projectDir), ...parsed, projectDir };
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Persist a project config to disk. Creates the projects directory if needed.
|
|
75
|
+
*/
|
|
76
|
+
export async function saveConfig(config) {
|
|
77
|
+
const dir = getProjectsDir();
|
|
78
|
+
if (!existsSync(dir)) {
|
|
79
|
+
await mkdir(dir, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
const next = { ...config, updatedAt: new Date().toISOString() };
|
|
82
|
+
await writeFile(getProjectConfigPath(config.projectDir), JSON.stringify(next, null, 2), 'utf-8');
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Load existing config or initialize and persist a default one.
|
|
86
|
+
*/
|
|
87
|
+
export async function loadOrInitConfig(projectDir) {
|
|
88
|
+
const existing = await loadConfig(projectDir);
|
|
89
|
+
if (existing)
|
|
90
|
+
return existing;
|
|
91
|
+
const fresh = defaultConfig(projectDir);
|
|
92
|
+
await saveConfig(fresh);
|
|
93
|
+
return fresh;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* List all known project configs (used by potential future "list projects" UI).
|
|
97
|
+
*/
|
|
98
|
+
export async function listProjectConfigs() {
|
|
99
|
+
const dir = getProjectsDir();
|
|
100
|
+
if (!existsSync(dir))
|
|
101
|
+
return [];
|
|
102
|
+
const files = await readdir(dir);
|
|
103
|
+
const results = [];
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
if (!file.endsWith('.json'))
|
|
106
|
+
continue;
|
|
107
|
+
try {
|
|
108
|
+
const raw = await readFile(join(dir, file), 'utf-8');
|
|
109
|
+
results.push(JSON.parse(raw));
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
// ignore corrupt entries
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return results;
|
|
116
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Best-effort .env.local loader. We avoid pulling in `dotenv` to keep the
|
|
5
|
+
* dependency surface tight; we only need a tiny KEY=VALUE parser.
|
|
6
|
+
*
|
|
7
|
+
* Values that are already set on process.env are NOT overridden.
|
|
8
|
+
*/
|
|
9
|
+
export function loadProjectEnv(projectDir) {
|
|
10
|
+
const envPath = join(projectDir, '.env.local');
|
|
11
|
+
if (!existsSync(envPath))
|
|
12
|
+
return;
|
|
13
|
+
const raw = readFileSync(envPath, 'utf-8');
|
|
14
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
15
|
+
const line = rawLine.trim();
|
|
16
|
+
if (!line || line.startsWith('#'))
|
|
17
|
+
continue;
|
|
18
|
+
const eq = line.indexOf('=');
|
|
19
|
+
if (eq === -1)
|
|
20
|
+
continue;
|
|
21
|
+
const key = line.slice(0, eq).trim();
|
|
22
|
+
let value = line.slice(eq + 1).trim();
|
|
23
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
24
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
25
|
+
value = value.slice(1, -1);
|
|
26
|
+
}
|
|
27
|
+
if (!(key in process.env)) {
|
|
28
|
+
process.env[key] = value;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Throw a descriptive error if a required env var is missing.
|
|
34
|
+
*/
|
|
35
|
+
export function requireEnv(name) {
|
|
36
|
+
const value = process.env[name];
|
|
37
|
+
if (!value || value.length === 0) {
|
|
38
|
+
throw new Error(`${name} environment variable is not set`);
|
|
39
|
+
}
|
|
40
|
+
return value;
|
|
41
|
+
}
|