nugit-cli 0.0.1

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.
@@ -0,0 +1,366 @@
1
+ import React from "react";
2
+ import { render } from "ink";
3
+ import chalk from "chalk";
4
+ import {
5
+ githubListAssignableUsers,
6
+ githubPostIssueComment,
7
+ githubPostPullReviewCommentReply,
8
+ githubPostRequestedReviewers
9
+ } from "../github-pr-social.js";
10
+ import { findGitRoot, parseRepoFullName, readStackFile } from "../nugit-stack.js";
11
+ import { getRepoFullNameFromGitRoot } from "../git-info.js";
12
+ import { discoverStacksInRepo } from "../stack-discover.js";
13
+ import { getStackDiscoveryOpts, effectiveMaxOpenPrs } from "../stack-discovery-config.js";
14
+ import { tryLoadStackIndex, writeStackIndex } from "../stack-graph.js";
15
+ import { formatStacksListHuman } from "../cli-output.js";
16
+ import { fetchStackPrDetails } from "./fetch-pr-data.js";
17
+ import { loadStackDocForView } from "./loader.js";
18
+ import { StackInkApp, createExitPayload } from "./ink-app.js";
19
+ import { renderStaticStackView } from "./static-render.js";
20
+ import { questionLine } from "./prompt-line.js";
21
+
22
+ function createSpinner(prefix) {
23
+ const frames = [chalk.cyan("⠋"), chalk.cyan("⠙"), chalk.cyan("⠹"), chalk.cyan("⠸"), chalk.cyan("⠼"), chalk.cyan("⠴")];
24
+ let i = 0;
25
+ let msg = "starting...";
26
+ let timer = null;
27
+ return {
28
+ update(nextMsg) {
29
+ msg = nextMsg || msg;
30
+ },
31
+ start() {
32
+ if (!process.stderr.isTTY) {
33
+ return;
34
+ }
35
+ timer = setInterval(() => {
36
+ const frame = frames[i % frames.length];
37
+ i += 1;
38
+ process.stderr.write(`\r${chalk.bold(prefix)} ${frame} ${chalk.dim(msg)}`);
39
+ }, 100);
40
+ },
41
+ stop(finalMsg) {
42
+ if (!process.stderr.isTTY) {
43
+ if (finalMsg) {
44
+ console.error(`${prefix} ${finalMsg}`);
45
+ }
46
+ return;
47
+ }
48
+ if (timer) {
49
+ clearInterval(timer);
50
+ }
51
+ const done = finalMsg ? `${chalk.bold(prefix)} ${chalk.green(finalMsg)}` : "";
52
+ process.stderr.write(`\r${done}${" ".repeat(30)}\n`);
53
+ }
54
+ };
55
+ }
56
+
57
+ /**
58
+ * @param {{ tip_pr_number: number, tip_head_branch: string, pr_count: number, created_by: string }[]} stacks
59
+ * @returns {Promise<number>}
60
+ */
61
+ async function pickDiscoveredStackIndex(stacks) {
62
+ const stackTree = (s) => {
63
+ const prs = Array.isArray(s.prs) ? s.prs : [];
64
+ const lines = [chalk.dim(" main")];
65
+ for (let i = 0; i < prs.length; i++) {
66
+ const pr = prs[i];
67
+ const bend = i === prs.length - 1 ? "└─" : "├─";
68
+ lines.push(
69
+ chalk.dim(` ${bend} `) +
70
+ chalk.bold(`#${pr.pr_number}`) +
71
+ (pr.title ? chalk.dim(` ${String(pr.title).slice(0, 48)}`) : "")
72
+ );
73
+ }
74
+ return lines.join("\n");
75
+ };
76
+ for (;;) {
77
+ console.error(chalk.bold.cyan("Multiple stacks found. Pick one:"));
78
+ for (let i = 0; i < stacks.length; i++) {
79
+ const s = stacks[i];
80
+ console.error(
81
+ ` ${chalk.yellow("[" + (i + 1) + "]")} ` +
82
+ `${chalk.bold("tip #" + s.tip_pr_number)} · ${s.pr_count} PR(s) · ` +
83
+ `${chalk.magenta("branch " + s.tip_head_branch)} · by ${chalk.dim(s.created_by)}`
84
+ );
85
+ console.error(stackTree(s));
86
+ }
87
+ const ans = String(
88
+ await questionLine(chalk.green("Select stack number (empty=cancel): "))
89
+ ).trim();
90
+ if (!ans) {
91
+ return -1;
92
+ }
93
+ const n = Number.parseInt(ans, 10);
94
+ if (Number.isInteger(n) && n >= 1 && n <= stacks.length) {
95
+ return n - 1;
96
+ }
97
+ console.error("Invalid choice.");
98
+ }
99
+ }
100
+
101
+ /**
102
+ * @param {string} owner
103
+ * @param {string} repo
104
+ * @param {number} prNumber
105
+ * @returns {Promise<string[]>}
106
+ */
107
+ async function promptReviewers(owner, repo, prNumber) {
108
+ let candidates = [];
109
+ const spin = createSpinner("Loading reviewers");
110
+ spin.start();
111
+ try {
112
+ const users = await githubListAssignableUsers(owner, repo);
113
+ candidates = Array.isArray(users)
114
+ ? users
115
+ .map((u) => (u && typeof u === "object" ? String(u.login || "") : ""))
116
+ .filter(Boolean)
117
+ : [];
118
+ } catch {
119
+ candidates = [];
120
+ }
121
+ spin.stop(`${candidates.length} candidate(s)`);
122
+ if (candidates.length) {
123
+ console.error(chalk.bold.cyan(`Assign Reviewers for PR #${prNumber}`));
124
+ for (let i = 0; i < candidates.length; i++) {
125
+ console.error(` ${chalk.yellow("[" + (i + 1) + "]")} ${chalk.white(candidates[i])}`);
126
+ }
127
+ console.error(chalk.dim("Pick by number(s) and/or login(s): e.g. 1,3 or alice,bob"));
128
+ } else {
129
+ console.error(chalk.yellow("No assignable users listed by GitHub. You can still type logins."));
130
+ }
131
+ const raw = await questionLine(chalk.green("Assign Reviewers (empty=cancel): "));
132
+ const parts = raw
133
+ .split(",")
134
+ .map((s) => s.trim())
135
+ .filter(Boolean);
136
+ const chosen = [];
137
+ for (const p of parts) {
138
+ const n = Number.parseInt(p, 10);
139
+ if (Number.isInteger(n) && n >= 1 && n <= candidates.length) {
140
+ chosen.push(candidates[n - 1]);
141
+ } else {
142
+ chosen.push(p.replace(/^@/, ""));
143
+ }
144
+ }
145
+ return [...new Set(chosen)];
146
+ }
147
+
148
+ /**
149
+ * @param {object} opts
150
+ * @param {boolean} [opts.noTui]
151
+ * @param {string} [opts.repo]
152
+ * @param {string} [opts.ref]
153
+ * @param {string} [opts.file]
154
+ */
155
+ export async function runStackViewCommand(opts) {
156
+ const root = findGitRoot();
157
+ let repo = opts.repo;
158
+ let ref = opts.ref;
159
+ if (!opts.file && !ref) {
160
+ let repoFull = repo || null;
161
+ if (!repoFull && root) {
162
+ try {
163
+ repoFull = getRepoFullNameFromGitRoot(root);
164
+ } catch {
165
+ repoFull = null;
166
+ }
167
+ }
168
+ if (repoFull) {
169
+ const { owner, repo: repoName } = parseRepoFullName(repoFull);
170
+ const discovery = getStackDiscoveryOpts();
171
+ const full =
172
+ process.env.NUGIT_STACK_DISCOVERY_FULL === "1" || process.env.NUGIT_STACK_DISCOVERY_FULL === "true";
173
+ let discovered = null;
174
+ let usedCache = false;
175
+ if (discovery.mode === "manual" && root) {
176
+ discovered = tryLoadStackIndex(root, repoFull);
177
+ if (!discovered) {
178
+ throw new Error(
179
+ 'Stack discovery mode is "manual". Run `nugit stack index` first or pass --repo/--ref explicitly.'
180
+ );
181
+ }
182
+ usedCache = true;
183
+ } else if (discovery.mode === "lazy" && root && !full) {
184
+ const cached = tryLoadStackIndex(root, repoFull);
185
+ if (cached) {
186
+ discovered = cached;
187
+ usedCache = true;
188
+ }
189
+ }
190
+ if (!discovered) {
191
+ const spinner = createSpinner("Scanning stacks");
192
+ spinner.start();
193
+ discovered = await discoverStacksInRepo(owner, repoName, {
194
+ maxOpenPrs: effectiveMaxOpenPrs(discovery, full),
195
+ enrich: false,
196
+ fetchConcurrency: discovery.fetchConcurrency,
197
+ onProgress: (m) => spinner.update(m)
198
+ });
199
+ spinner.stop(
200
+ `found ${discovered.stacks_found} stack(s) across ${discovered.scanned_open_prs} open PR(s)`
201
+ );
202
+ if (root) {
203
+ try {
204
+ if (discovery.mode === "eager" || discovery.mode === "lazy") {
205
+ writeStackIndex(root, discovered);
206
+ }
207
+ } catch {
208
+ /* ignore index write */
209
+ }
210
+ }
211
+ } else if (usedCache) {
212
+ console.error(chalk.dim("Using .nugit/stack-index.json — set NUGIT_STACK_DISCOVERY_FULL=1 to rescan GitHub."));
213
+ }
214
+ if (discovered.stacks_found > 1) {
215
+ if (opts.noTui) {
216
+ console.log(formatStacksListHuman(discovered));
217
+ return;
218
+ }
219
+ const idx = await pickDiscoveredStackIndex(discovered.stacks);
220
+ if (idx < 0) {
221
+ console.error("Cancelled.");
222
+ return;
223
+ }
224
+ repo = repoFull;
225
+ ref = discovered.stacks[idx].tip_head_branch;
226
+ console.error(`Viewing selected stack tip: ${repo}@${ref}`);
227
+ }
228
+ if (discovered.stacks_found === 1) {
229
+ repo = repoFull;
230
+ ref = discovered.stacks[0].tip_head_branch;
231
+ console.error(`Viewing discovered stack tip: ${repo}@${ref}`);
232
+ }
233
+ }
234
+ }
235
+
236
+ let { doc } = await loadStackDocForView({
237
+ root,
238
+ repo,
239
+ ref,
240
+ file: opts.file
241
+ });
242
+
243
+ const { owner, repo: repoName } = parseRepoFullName(doc.repo_full_name);
244
+ const loadSpinner = createSpinner("Loading stack");
245
+ loadSpinner.start();
246
+ let rows = await fetchStackPrDetails(owner, repoName, doc.prs);
247
+ loadSpinner.stop(`loaded ${rows.length} PR(s)`);
248
+
249
+ if (opts.noTui) {
250
+ renderStaticStackView(rows);
251
+ return;
252
+ }
253
+
254
+ let running = true;
255
+ while (running) {
256
+ const exitPayload = createExitPayload();
257
+ const { waitUntilExit } = render(
258
+ React.createElement(StackInkApp, { rows, exitPayload })
259
+ );
260
+ await waitUntilExit();
261
+ // Give terminal mode a short moment to settle before readline prompts.
262
+ await new Promise((r) => setTimeout(r, 25));
263
+
264
+ const next = exitPayload.next;
265
+ if (!next || next.type === "quit") {
266
+ running = false;
267
+ break;
268
+ }
269
+
270
+ if (next.type === "issue_comment") {
271
+ try {
272
+ const body = await questionLine(`New issue comment on PR #${next.prNumber} (empty=cancel): `);
273
+ if (body.trim()) {
274
+ await githubPostIssueComment(
275
+ owner,
276
+ repoName,
277
+ /** @type {number} */ (next.prNumber),
278
+ body.trim()
279
+ );
280
+ }
281
+ const refresh = createSpinner("Refreshing stack");
282
+ refresh.start();
283
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs);
284
+ refresh.stop(`loaded ${rows.length} PR(s)`);
285
+ } catch (e) {
286
+ console.error(`Action failed: ${String(e?.message || e)}`);
287
+ }
288
+ continue;
289
+ }
290
+
291
+ if (next.type === "review_reply") {
292
+ try {
293
+ const body = await questionLine(`Reply in review thread (empty=cancel): `);
294
+ if (body.trim()) {
295
+ await githubPostPullReviewCommentReply(
296
+ owner,
297
+ repoName,
298
+ /** @type {number} */ (next.commentId),
299
+ body.trim()
300
+ );
301
+ }
302
+ const refresh = createSpinner("Refreshing stack");
303
+ refresh.start();
304
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs);
305
+ refresh.stop(`loaded ${rows.length} PR(s)`);
306
+ } catch (e) {
307
+ console.error(`Action failed: ${String(e?.message || e)}`);
308
+ }
309
+ continue;
310
+ }
311
+
312
+ if (next.type === "request_reviewers") {
313
+ try {
314
+ const logins = await promptReviewers(owner, repoName, /** @type {number} */ (next.prNumber));
315
+ if (logins.length) {
316
+ await githubPostRequestedReviewers(owner, repoName, /** @type {number} */ (next.prNumber), {
317
+ reviewers: logins
318
+ });
319
+ }
320
+ const refresh = createSpinner("Refreshing stack");
321
+ refresh.start();
322
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs);
323
+ refresh.stop(`loaded ${rows.length} PR(s)`);
324
+ } catch (e) {
325
+ console.error(`Action failed: ${String(e?.message || e)}`);
326
+ }
327
+ continue;
328
+ }
329
+
330
+ if (next.type === "refresh") {
331
+ const refresh = createSpinner("Refreshing stack");
332
+ refresh.start();
333
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs);
334
+ refresh.stop(`loaded ${rows.length} PR(s)`);
335
+ continue;
336
+ }
337
+
338
+ if (next.type === "split") {
339
+ try {
340
+ const r = findGitRoot();
341
+ if (!r) {
342
+ throw new Error("Not inside a git repository");
343
+ }
344
+ const { runSplitCommand } = await import("../split-view/run-split.js");
345
+ await runSplitCommand({
346
+ root: r,
347
+ owner,
348
+ repo: repoName,
349
+ prNumber: /** @type {number} */ (next.prNumber),
350
+ dryRun: false
351
+ });
352
+ const refreshed = readStackFile(r);
353
+ if (refreshed) {
354
+ doc = refreshed;
355
+ }
356
+ const reload = createSpinner("Reloading stack");
357
+ reload.start();
358
+ rows = await fetchStackPrDetails(owner, repoName, doc.prs);
359
+ reload.stop(`loaded ${rows.length} PR(s)`);
360
+ } catch (e) {
361
+ console.error(`Split failed: ${String(/** @type {{ message?: string }} */ (e)?.message || e)}`);
362
+ }
363
+ continue;
364
+ }
365
+ }
366
+ }
@@ -0,0 +1,98 @@
1
+ import boxen from "boxen";
2
+ import chalk from "chalk";
3
+
4
+ /**
5
+ * @param {string} state
6
+ * @param {boolean} draft
7
+ */
8
+ function stateLabel(state, draft) {
9
+ if (draft) {
10
+ return chalk.gray("draft");
11
+ }
12
+ if (state === "closed") {
13
+ return chalk.red("closed");
14
+ }
15
+ if (state === "merged" || state === "MERGED") {
16
+ return chalk.magenta("merged");
17
+ }
18
+ return chalk.green("open");
19
+ }
20
+
21
+ /** @param {unknown[]} rows stack rows from fetchStackPrDetails */
22
+ export function renderStaticStackView(rows) {
23
+ const lines = [];
24
+ lines.push(chalk.bold.cyan("nugit stack (bottom → top)"));
25
+ lines.push("");
26
+
27
+ for (let i = 0; i < rows.length; i++) {
28
+ const r = rows[i];
29
+ const e = r.entry;
30
+ const isLast = i === rows.length - 1;
31
+ const prefix = isLast ? "└─" : "├─";
32
+ const next = isLast ? " " : "│";
33
+
34
+ if (r.error) {
35
+ lines.push(
36
+ `${chalk.dim(next)} ${prefix} ${chalk.yellow("PR #" + e.pr_number)} ${chalk.red(r.error)}`
37
+ );
38
+ continue;
39
+ }
40
+ const p = r.pull;
41
+ const title = p?.title || "(no title)";
42
+ const head = p?.head?.ref || e.head_branch || "?";
43
+ const base = p?.base?.ref || e.base_branch || "?";
44
+ const st = stateLabel(p?.state || "open", !!p?.draft);
45
+ const ic = r.issueComments?.length ?? 0;
46
+ const rc = r.reviewComments?.length ?? 0;
47
+
48
+ lines.push(
49
+ `${chalk.dim(next)} ${prefix} ${chalk.bold(`PR #${e.pr_number}`)} ${st} ${chalk.dim(head + " ← " + base)}`
50
+ );
51
+ lines.push(`${chalk.dim(next)} ${isLast ? " " : "│"} ${title}`);
52
+ lines.push(
53
+ `${chalk.dim(next)} ${isLast ? " " : "│"} ${chalk.dim("conversation:")} ${ic} ${chalk.dim("review (line):")} ${rc}`
54
+ );
55
+ if (p?.html_url) {
56
+ lines.push(`${chalk.dim(next)} ${isLast ? " " : "│"} ${chalk.blue.underline(p.html_url)}`);
57
+ }
58
+ if (!isLast) {
59
+ lines.push(`${chalk.dim(next)} ${chalk.dim("│")}`);
60
+ }
61
+ }
62
+
63
+ console.log(
64
+ boxen(lines.join("\n"), {
65
+ padding: 1,
66
+ margin: { top: 0, right: 0, bottom: 1, left: 0 },
67
+ borderStyle: "round",
68
+ borderColor: "cyan"
69
+ })
70
+ );
71
+
72
+ for (const r of rows) {
73
+ if (r.error || !r.pull) {
74
+ continue;
75
+ }
76
+ const n = r.entry.pr_number;
77
+ const reviewWithLines = (r.reviewComments || []).filter(
78
+ (c) => c && (c.line != null || c.original_line != null)
79
+ );
80
+ if (reviewWithLines.length === 0) {
81
+ continue;
82
+ }
83
+ console.log(chalk.bold(`PR #${n} — line-linked review comments`));
84
+ for (const c of reviewWithLines.slice(0, 20)) {
85
+ const path = c.path || "?";
86
+ const line = c.line ?? c.original_line ?? "?";
87
+ const url = c.html_url || "";
88
+ const body = (c.body || "").split("\n")[0].slice(0, 72);
89
+ console.log(
90
+ ` ${chalk.dim(path + ":" + line)} ${body}${url ? " " + chalk.blue.underline(url) : ""}`
91
+ );
92
+ }
93
+ if (reviewWithLines.length > 20) {
94
+ console.log(chalk.dim(` … +${reviewWithLines.length - 20} more`));
95
+ }
96
+ console.log("");
97
+ }
98
+ }
@@ -0,0 +1,45 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import { getConfigDir } from "./user-config.js";
4
+
5
+ /** @returns {string} */
6
+ export function getGithubTokenPath() {
7
+ return path.join(getConfigDir(), "github-token");
8
+ }
9
+
10
+ /** @returns {string} */
11
+ export function readStoredGithubToken() {
12
+ const p = getGithubTokenPath();
13
+ try {
14
+ if (!fs.existsSync(p)) {
15
+ return "";
16
+ }
17
+ return fs.readFileSync(p, "utf8").trim();
18
+ } catch {
19
+ return "";
20
+ }
21
+ }
22
+
23
+ /** @param {string} token */
24
+ export function writeStoredGithubToken(token) {
25
+ const dir = getConfigDir();
26
+ fs.mkdirSync(dir, { recursive: true });
27
+ const p = getGithubTokenPath();
28
+ fs.writeFileSync(p, token, { encoding: "utf8", mode: 0o600 });
29
+ try {
30
+ fs.chmodSync(p, 0o600);
31
+ } catch {
32
+ /* Windows or FS without chmod */
33
+ }
34
+ }
35
+
36
+ export function clearStoredGithubToken() {
37
+ const p = getGithubTokenPath();
38
+ try {
39
+ if (fs.existsSync(p)) {
40
+ fs.unlinkSync(p);
41
+ }
42
+ } catch {
43
+ /* ignore */
44
+ }
45
+ }
@@ -0,0 +1,169 @@
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { fileURLToPath } from "url";
5
+
6
+ /**
7
+ * Infer monorepo root from this file: cli/src/user-config.js → …/nugit
8
+ */
9
+ export function inferMonorepoRootFromCli() {
10
+ const here = fileURLToPath(new URL(import.meta.url));
11
+ return path.dirname(path.dirname(path.dirname(here)));
12
+ }
13
+
14
+ /**
15
+ * @returns {string}
16
+ */
17
+ export function getConfigDir() {
18
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
19
+ return path.join(base, "nugit");
20
+ }
21
+
22
+ /**
23
+ * @returns {string}
24
+ */
25
+ export function getConfigPath() {
26
+ return path.join(getConfigDir(), "config.json");
27
+ }
28
+
29
+ /**
30
+ * @typedef {{
31
+ * mode?: string,
32
+ * maxOpenPrs?: number,
33
+ * fetchConcurrency?: number,
34
+ * background?: boolean,
35
+ * lazyFirstPassMaxPrs?: number
36
+ * }} StackDiscoveryConfig
37
+ *
38
+ * @typedef {{
39
+ * installRoot?: string,
40
+ * envFile?: string,
41
+ * workingDirectory?: string,
42
+ * stackDiscovery?: StackDiscoveryConfig
43
+ * }} NugitUserConfig
44
+ */
45
+
46
+ /**
47
+ * @returns {NugitUserConfig}
48
+ */
49
+ export function readUserConfig() {
50
+ const p = getConfigPath();
51
+ if (!fs.existsSync(p)) {
52
+ return {};
53
+ }
54
+ try {
55
+ const raw = fs.readFileSync(p, "utf8");
56
+ const data = JSON.parse(raw);
57
+ return typeof data === "object" && data !== null ? data : {};
58
+ } catch {
59
+ return {};
60
+ }
61
+ }
62
+
63
+ /**
64
+ * @param {NugitUserConfig} cfg
65
+ */
66
+ export function writeUserConfig(cfg) {
67
+ const dir = getConfigDir();
68
+ fs.mkdirSync(dir, { recursive: true });
69
+ fs.writeFileSync(getConfigPath(), JSON.stringify(cfg, null, 2) + "\n", "utf8");
70
+ }
71
+
72
+ /**
73
+ * Expand ~ in path segments.
74
+ * @param {string} p
75
+ */
76
+ export function expandUserPath(p) {
77
+ if (!p || typeof p !== "string") {
78
+ return p;
79
+ }
80
+ if (p === "~" || p.startsWith("~/")) {
81
+ return path.join(os.homedir(), p.slice(1).replace(/^\//, ""));
82
+ }
83
+ return path.resolve(p);
84
+ }
85
+
86
+ /**
87
+ * Minimal .env parser (KEY=VALUE, # comments, optional quotes).
88
+ * @param {string} contents
89
+ * @returns {Record<string, string>}
90
+ */
91
+ export function parseDotEnv(contents) {
92
+ const out = {};
93
+ if (!contents) {
94
+ return out;
95
+ }
96
+ for (const line of contents.split(/\r?\n/)) {
97
+ const t = line.trim();
98
+ if (!t || t.startsWith("#")) {
99
+ continue;
100
+ }
101
+ const eq = line.indexOf("=");
102
+ if (eq === -1) {
103
+ continue;
104
+ }
105
+ const key = line.slice(0, eq).trim();
106
+ if (!key || key.startsWith("#")) {
107
+ continue;
108
+ }
109
+ let val = line.slice(eq + 1).trim();
110
+ if (
111
+ (val.startsWith('"') && val.endsWith('"')) ||
112
+ (val.startsWith("'") && val.endsWith("'"))
113
+ ) {
114
+ val = val.slice(1, -1);
115
+ }
116
+ out[key] = val;
117
+ }
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * Load env vars from file into a plain object (does not mutate process.env).
123
+ * @param {string} envFilePath absolute or user-expanded path
124
+ */
125
+ export function loadEnvFile(envFilePath) {
126
+ const p = expandUserPath(envFilePath);
127
+ if (!fs.existsSync(p)) {
128
+ throw new Error(`Env file not found: ${p}`);
129
+ }
130
+ const contents = fs.readFileSync(p, "utf8");
131
+ return { vars: parseDotEnv(contents), pathUsed: p };
132
+ }
133
+
134
+ /**
135
+ * Merge nugit PATH: prepend scripts dir if installRoot set.
136
+ * @param {Record<string, string | undefined>} env
137
+ * @param {string} installRoot
138
+ */
139
+ export function mergeNugitPath(env, installRoot) {
140
+ const scripts = path.join(installRoot, "scripts");
141
+ const prev = env.PATH || process.env.PATH || "";
142
+ const parts = prev.split(path.delimiter).filter(Boolean);
143
+ if (!parts.includes(scripts)) {
144
+ return { ...env, PATH: [scripts, prev].filter(Boolean).join(path.delimiter) };
145
+ }
146
+ return { ...env };
147
+ }
148
+
149
+ /**
150
+ * Build env for child process: process.env + dotenv + optional PATH tweak + NUGIT_MONOREPO_ROOT
151
+ * @param {NugitUserConfig} cfg
152
+ */
153
+ export function buildStartEnv(cfg) {
154
+ if (!cfg.installRoot) {
155
+ throw new Error("installRoot not set; run: nugit config init");
156
+ }
157
+ if (!cfg.envFile) {
158
+ throw new Error("envFile not set; run: nugit config init");
159
+ }
160
+ const { vars, pathUsed } = loadEnvFile(cfg.envFile);
161
+ /** @type {Record<string, string>} */
162
+ const merged = { ...process.env };
163
+ for (const [k, v] of Object.entries(vars)) {
164
+ merged[k] = v;
165
+ }
166
+ merged.NUGIT_MONOREPO_ROOT = path.resolve(expandUserPath(cfg.installRoot));
167
+ merged.NUGIT_ENV_FILE = pathUsed;
168
+ return mergeNugitPath(merged, merged.NUGIT_MONOREPO_ROOT);
169
+ }