@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 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
+ }));
@@ -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
+ }