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.
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "nugit-cli",
3
+ "version": "0.0.1",
4
+ "description": "CLI for stacked GitHub PRs (.nugit/stack.json)",
5
+ "type": "module",
6
+ "bin": {
7
+ "nugit": "src/nugit.js"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/jerrying123/nugit.git",
15
+ "directory": "cli"
16
+ },
17
+ "scripts": {
18
+ "test": "vitest run"
19
+ },
20
+ "dependencies": {
21
+ "boxen": "^8.0.1",
22
+ "chalk": "^5.6.2",
23
+ "commander": "^12.1.0",
24
+ "ink": "^6.8.0",
25
+ "react": "^19.2.4"
26
+ },
27
+ "devDependencies": {
28
+ "vitest": "^2.1.8"
29
+ }
30
+ }
@@ -0,0 +1,182 @@
1
+ import {
2
+ githubCreatePullRequest,
3
+ githubGetContents,
4
+ githubGetPull,
5
+ githubGetRepoMetadata,
6
+ githubGetViewer,
7
+ githubListUserRepos,
8
+ githubRestJson,
9
+ githubSearchIssues,
10
+ githubListOpenPulls
11
+ } from "./github-rest.js";
12
+ import {
13
+ githubDeviceFlowPollAccessToken,
14
+ githubDeviceFlowRequestCode
15
+ } from "./github-device-flow.js";
16
+ import { resolveGithubToken } from "./auth-token.js";
17
+
18
+ /** @returns {string} */
19
+ export function getToken() {
20
+ return resolveGithubToken();
21
+ }
22
+
23
+ export function withAuthHeaders(headers = {}) {
24
+ const token = getToken();
25
+ if (!token) {
26
+ return headers;
27
+ }
28
+ return { ...headers, Authorization: `Bearer ${token}` };
29
+ }
30
+
31
+ /**
32
+ * Start GitHub OAuth device flow (requires GITHUB_OAUTH_CLIENT_ID).
33
+ */
34
+ export async function startDeviceFlow() {
35
+ const clientId = process.env.GITHUB_OAUTH_CLIENT_ID;
36
+ if (!clientId) {
37
+ throw new Error(
38
+ "Set GITHUB_OAUTH_CLIENT_ID to your GitHub OAuth App client ID (Settings → Developer settings → OAuth Apps), or use a PAT with NUGIT_USER_TOKEN."
39
+ );
40
+ }
41
+ return githubDeviceFlowRequestCode(clientId, "repo read:user user:email");
42
+ }
43
+
44
+ /**
45
+ * Single poll for device flow. Returns token, pending, or throws.
46
+ * @param {string} deviceCode
47
+ * @param {number} [intervalSeconds] minimum wait before next poll (from GitHub or prior slow_down)
48
+ */
49
+ export async function pollDeviceFlow(deviceCode, intervalSeconds = 5) {
50
+ const clientId = process.env.GITHUB_OAUTH_CLIENT_ID;
51
+ if (!clientId) {
52
+ throw new Error("Set GITHUB_OAUTH_CLIENT_ID");
53
+ }
54
+ const payload = await githubDeviceFlowPollAccessToken(clientId, deviceCode);
55
+ if (payload.access_token) {
56
+ return { access_token: payload.access_token, token_type: payload.token_type, scope: payload.scope };
57
+ }
58
+ if (payload.error === "authorization_pending") {
59
+ return { pending: true, interval: Math.max(5, intervalSeconds) };
60
+ }
61
+ if (payload.error === "slow_down") {
62
+ return { pending: true, interval: Math.max(5, intervalSeconds) + 5 };
63
+ }
64
+ const msg = payload.error_description || payload.error || "Device flow failed";
65
+ throw new Error(typeof msg === "string" ? msg : JSON.stringify(payload));
66
+ }
67
+
68
+ /**
69
+ * Poll until token or fatal error (for `nugit auth poll`).
70
+ * @param {string} deviceCode
71
+ * @param {number} [initialInterval] from `device_code` response `interval`
72
+ */
73
+ export async function pollDeviceFlowUntilComplete(deviceCode, initialInterval = 5) {
74
+ let wait = Math.max(5, initialInterval);
75
+ for (;;) {
76
+ const result = await pollDeviceFlow(deviceCode, wait);
77
+ if (result.access_token) {
78
+ return result;
79
+ }
80
+ if (result.pending) {
81
+ wait = result.interval ?? wait;
82
+ await new Promise((r) => setTimeout(r, wait * 1000));
83
+ continue;
84
+ }
85
+ throw new Error("Unexpected device flow response");
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Validate PAT via GitHub GET /user (server does not exist; token is not stored).
91
+ * @param {string} token
92
+ */
93
+ export async function savePat(token) {
94
+ const user = await githubRestJson("GET", "/user", undefined, token);
95
+ return { access_token: token, login: user.login, id: user.id, name: user.name };
96
+ }
97
+
98
+ /**
99
+ * Open PRs authored by the current user (search API, paginated).
100
+ * @param {{ repo?: string, page?: number, perPage?: number }} [opts]
101
+ */
102
+ export async function listMyPulls(opts = {}) {
103
+ const me = await githubGetViewer();
104
+ const login = me.login;
105
+ if (!login) {
106
+ throw new Error("Could not resolve GitHub login");
107
+ }
108
+ let q = `is:pr is:open author:${login}`;
109
+ if (opts.repo) {
110
+ const parts = String(opts.repo).split("/").filter(Boolean);
111
+ if (parts.length === 2) {
112
+ q = `is:pr is:open repo:${parts[0]}/${parts[1]} author:${login}`;
113
+ }
114
+ }
115
+ const page = opts.page != null ? Math.max(1, opts.page) : 1;
116
+ const perPage = opts.perPage != null ? Math.min(100, Math.max(1, opts.perPage)) : 30;
117
+ return githubSearchIssues(q, perPage, page);
118
+ }
119
+
120
+ /**
121
+ * All open PRs in a repository (REST list, paginated). Best for picking numbers for `nugit stack add`.
122
+ * @param {string} owner
123
+ * @param {string} repo
124
+ * @param {{ page?: number, perPage?: number }} [opts]
125
+ */
126
+ export async function listOpenPullsInRepo(owner, repo, opts = {}) {
127
+ const page = opts.page != null ? Math.max(1, opts.page) : 1;
128
+ const perPage = opts.perPage != null ? Math.min(100, Math.max(1, opts.perPage)) : 30;
129
+ const pulls = await githubListOpenPulls(owner, repo, page, perPage);
130
+ const arr = Array.isArray(pulls) ? pulls : [];
131
+ return {
132
+ pulls: arr,
133
+ page,
134
+ per_page: perPage,
135
+ repo_full_name: `${owner}/${repo}`,
136
+ has_more: arr.length >= perPage
137
+ };
138
+ }
139
+
140
+ export async function authMe() {
141
+ return githubGetViewer();
142
+ }
143
+
144
+ export async function getRepoMetadata(owner, repo) {
145
+ return githubGetRepoMetadata(owner, repo);
146
+ }
147
+
148
+ export async function createPullRequest(owner, repo, fields) {
149
+ return githubCreatePullRequest(owner, repo, fields);
150
+ }
151
+
152
+ export async function getGithubContents(owner, repo, filePath, ref) {
153
+ return githubGetContents(owner, repo, filePath, ref);
154
+ }
155
+
156
+ export function decodeGithubFileContent(item) {
157
+ if (!item || item.type !== "file" || item.encoding !== "base64" || !item.content) {
158
+ return null;
159
+ }
160
+ return Buffer.from(item.content.replace(/\s/g, ""), "base64").toString("utf8");
161
+ }
162
+
163
+ export async function fetchRemoteStackJson(repoFullName, ref) {
164
+ const [owner, repo] = repoFullName.split("/");
165
+ if (!owner || !repo) {
166
+ throw new Error("repoFullName must be owner/repo");
167
+ }
168
+ const item = await getGithubContents(owner, repo, ".nugit/stack.json", ref);
169
+ const text = decodeGithubFileContent(item);
170
+ if (!text) {
171
+ throw new Error("Could not read .nugit/stack.json from GitHub");
172
+ }
173
+ return JSON.parse(text);
174
+ }
175
+
176
+ export async function getPull(owner, repo, number) {
177
+ return githubGetPull(owner, repo, number);
178
+ }
179
+
180
+ export async function listUserRepos(page = 1) {
181
+ return githubListUserRepos(page);
182
+ }
@@ -0,0 +1,14 @@
1
+ import { readStoredGithubToken } from "./token-store.js";
2
+
3
+ /**
4
+ * PAT / OAuth token for GitHub API: env first, then saved device-flow file.
5
+ * @returns {string}
6
+ */
7
+ export function resolveGithubToken() {
8
+ return (
9
+ process.env.NUGIT_USER_TOKEN ||
10
+ process.env.STACKPR_USER_TOKEN ||
11
+ readStoredGithubToken() ||
12
+ ""
13
+ );
14
+ }
@@ -0,0 +1,228 @@
1
+ import chalk from "chalk";
2
+ import boxen from "boxen";
3
+
4
+ /**
5
+ * @param {unknown} data
6
+ */
7
+ export function printJson(data) {
8
+ console.log(JSON.stringify(data, null, 2));
9
+ }
10
+
11
+ /**
12
+ * @param {unknown} data
13
+ * @param {boolean} asJson
14
+ */
15
+ export function out(data, asJson) {
16
+ if (asJson) {
17
+ printJson(data);
18
+ }
19
+ }
20
+
21
+ /** @param {Record<string, unknown>} me */
22
+ export function formatWhoamiHuman(me) {
23
+ const login = me.login ?? "?";
24
+ const name = me.name ? ` (${me.name})` : "";
25
+ const id = me.id != null ? chalk.dim(` id ${me.id}`) : "";
26
+ return `${chalk.bold(login)}${name}${id}`;
27
+ }
28
+
29
+ /**
30
+ * @param {{ total_count?: number, items?: unknown[] }} search
31
+ * @param {{ page?: number, perPage?: number }} [pag]
32
+ */
33
+ export function formatPrSearchHuman(search, pag) {
34
+ const items = Array.isArray(search.items) ? search.items : [];
35
+ const lines = [];
36
+ const page = pag?.page ?? 1;
37
+ const perPage = pag?.perPage ?? 30;
38
+ const total = search.total_count;
39
+ lines.push(
40
+ chalk.bold.cyan(
41
+ `Open PRs you authored (page ${page}, ${items.length} on this page${total != null ? ` · ${total} total` : ""})`
42
+ )
43
+ );
44
+ lines.push("");
45
+ for (const it of items) {
46
+ const o = it && typeof it === "object" ? /** @type {Record<string, unknown>} */ (it) : {};
47
+ const num = o.number;
48
+ const title = String(o.title || "");
49
+ const html = o.html_url ? String(o.html_url) : "";
50
+ const repo = o.repository_url ? String(o.repository_url).replace("https://api.github.com/repos/", "") : "";
51
+ lines.push(
52
+ ` ${chalk.bold("#" + num)} ${chalk.dim(repo)} ${title.slice(0, 72)}${title.length > 72 ? "…" : ""}`
53
+ );
54
+ if (html) {
55
+ lines.push(` ${chalk.blue.underline(html)}`);
56
+ }
57
+ }
58
+ const mayHaveMore =
59
+ total != null ? page * perPage < total : items.length >= perPage;
60
+ if (mayHaveMore && items.length > 0) {
61
+ lines.push("");
62
+ lines.push(chalk.dim(`Next page: ${chalk.bold(`nugit prs list --mine --page ${page + 1}`)}`));
63
+ }
64
+ lines.push("");
65
+ lines.push(chalk.dim("Use PR # with: nugit stack add --pr <n> [more #…] (bottom → top)"));
66
+ return lines.join("\n");
67
+ }
68
+
69
+ /**
70
+ * @param {{ pulls: unknown[], page: number, per_page: number, repo_full_name: string, has_more: boolean }} payload
71
+ */
72
+ export function formatOpenPullsHuman(payload) {
73
+ const pulls = Array.isArray(payload.pulls) ? payload.pulls : [];
74
+ const lines = [];
75
+ lines.push(
76
+ chalk.bold.cyan(
77
+ `Open PRs in ${payload.repo_full_name} (page ${payload.page}, ${pulls.length} shown, ${payload.per_page}/page)`
78
+ )
79
+ );
80
+ lines.push("");
81
+ for (const pr of pulls) {
82
+ const p = pr && typeof pr === "object" ? /** @type {Record<string, unknown>} */ (pr) : {};
83
+ const head = p.head && typeof p.head === "object" ? /** @type {{ ref?: string }} */ (p.head) : {};
84
+ const base = p.base && typeof p.base === "object" ? /** @type {{ ref?: string }} */ (p.base) : {};
85
+ const user = p.user && typeof p.user === "object" ? /** @type {{ login?: string }} */ (p.user) : {};
86
+ const num = p.number;
87
+ const title = String(p.title || "");
88
+ const branch = `${head.ref || "?"} ← ${base.ref || "?"}`;
89
+ lines.push(
90
+ ` ${chalk.bold("#" + num)} ${chalk.dim(branch)} ${chalk.dim(user.login || "")} ${title.slice(0, 56)}${title.length > 56 ? "…" : ""}`
91
+ );
92
+ if (p.html_url) {
93
+ lines.push(` ${chalk.blue.underline(String(p.html_url))}`);
94
+ }
95
+ }
96
+ if (pulls.length === 0) {
97
+ lines.push(chalk.dim(" (no open PRs on this page)"));
98
+ }
99
+ if (payload.has_more) {
100
+ lines.push("");
101
+ lines.push(chalk.dim(`Next page: ${chalk.bold(`nugit prs list --page ${payload.page + 1}`)}`));
102
+ }
103
+ lines.push("");
104
+ const nums = pulls.map((pr) => (/** @type {{ number?: number }} */ (pr).number)).filter((n) => n != null);
105
+ if (nums.length) {
106
+ lines.push(chalk.dim(`Stack (bottom→top): ${chalk.bold(`nugit stack add --pr ${nums.join(" ")}`)}`));
107
+ }
108
+ return boxen(lines.join("\n"), { padding: 1, borderStyle: "round", borderColor: "cyan" });
109
+ }
110
+
111
+ /**
112
+ * @param {Record<string, unknown>} doc
113
+ */
114
+ export function formatStackDocHuman(doc) {
115
+ const prs = Array.isArray(doc.prs) ? doc.prs : [];
116
+ const sorted = [...prs].sort(
117
+ (a, b) =>
118
+ (/** @type {{ position?: number }} */ (a).position ?? 0) -
119
+ (/** @type {{ position?: number }} */ (b).position ?? 0)
120
+ );
121
+ const lines = [];
122
+ lines.push(chalk.bold.cyan(".nugit/stack.json"));
123
+ lines.push(chalk.dim(`repo ${doc.repo_full_name} · by ${doc.created_by}`));
124
+ lines.push("");
125
+ for (let i = 0; i < sorted.length; i++) {
126
+ const p = sorted[i];
127
+ const e = p && typeof p === "object" ? /** @type {Record<string, unknown>} */ (p) : {};
128
+ lines.push(` ${chalk.bold("#" + e.pr_number)} pos ${e.position} ${e.head_branch || ""} ← ${e.base_branch || ""}`);
129
+ }
130
+ if (sorted.length === 0) {
131
+ lines.push(chalk.dim(" (no PRs — use nugit stack add)"));
132
+ }
133
+ return boxen(lines.join("\n"), { padding: 1, borderStyle: "round", borderColor: "cyan" });
134
+ }
135
+
136
+ /**
137
+ * @param {Record<string, unknown>} doc
138
+ * @param {Array<Record<string, unknown>>} enrichedPrs
139
+ */
140
+ export function formatStackEnrichHuman(doc, enrichedPrs) {
141
+ const lines = [];
142
+ lines.push(chalk.bold.cyan("Stack (with GitHub titles)"));
143
+ lines.push(chalk.dim(String(doc.repo_full_name)));
144
+ lines.push("");
145
+ for (const row of enrichedPrs) {
146
+ const err = row.error;
147
+ if (err) {
148
+ lines.push(` ${chalk.yellow("PR #" + row.pr_number)} ${chalk.red(String(err))}`);
149
+ continue;
150
+ }
151
+ const title = String(row.title || "");
152
+ const url = row.html_url ? String(row.html_url) : "";
153
+ lines.push(` ${chalk.bold("PR #" + row.pr_number)} ${title}`);
154
+ if (url) {
155
+ lines.push(` ${chalk.blue.underline(url)}`);
156
+ }
157
+ }
158
+ return boxen(lines.join("\n"), { padding: 1, borderStyle: "round", borderColor: "cyan" });
159
+ }
160
+
161
+ /**
162
+ * @param {{
163
+ * repo_full_name: string,
164
+ * scanned_open_prs: number,
165
+ * open_prs_truncated: boolean,
166
+ * stacks_found: number,
167
+ * stacks: Array<{
168
+ * tip_pr_number: number,
169
+ * created_by: string,
170
+ * pr_count: number,
171
+ * prs: Array<{ pr_number: number, position?: number, title?: string, html_url?: string }>,
172
+ * tip_head_branch: string,
173
+ * fetch_command: string,
174
+ * view_command: string
175
+ * }>
176
+ * }} payload
177
+ */
178
+ export function formatStacksListHuman(payload) {
179
+ const lines = [];
180
+ lines.push(
181
+ chalk.bold.cyan(`Stacks in ${payload.repo_full_name}`) +
182
+ chalk.dim(
183
+ ` · scanned ${payload.scanned_open_prs} open PR(s)${payload.open_prs_truncated ? " (truncated — increase --max-open-prs)" : ""}`
184
+ )
185
+ );
186
+ lines.push(chalk.dim(`Found ${payload.stacks_found} stack(s) with .nugit/stack.json on a PR head`));
187
+ lines.push("");
188
+ if (payload.stacks.length === 0) {
189
+ lines.push(chalk.dim(" (none — stacks appear after authors commit stack.json on stacked branches)"));
190
+ return lines.join("\n");
191
+ }
192
+ for (const s of payload.stacks) {
193
+ lines.push(chalk.bold(`Tip PR #${s.tip_pr_number}`) + chalk.dim(` · ${s.pr_count} PR(s) · by ${s.created_by}`));
194
+ lines.push(chalk.dim(` branch ${s.tip_head_branch}`));
195
+ for (const p of s.prs) {
196
+ const raw = p.title != null ? String(p.title) : "";
197
+ const tit = raw.length > 72 ? `${raw.slice(0, 71)}…` : raw;
198
+ lines.push(
199
+ ` ${chalk.bold("#" + p.pr_number)}${tit ? chalk.dim(" " + tit) : ""}`
200
+ );
201
+ if (p.html_url) {
202
+ lines.push(` ${chalk.blue.underline(String(p.html_url))}`);
203
+ }
204
+ }
205
+ lines.push(chalk.dim(` → ${s.view_command}`));
206
+ lines.push("");
207
+ }
208
+ return lines.join("\n").trimEnd() + "\n";
209
+ }
210
+
211
+ /** @param {Record<string, unknown>} created */
212
+ export function formatPrCreatedHuman(created) {
213
+ const num = created.number;
214
+ const url = created.html_url ? String(created.html_url) : "";
215
+ return [
216
+ chalk.green("Opened pull request"),
217
+ chalk.bold(`#${num}`),
218
+ url ? chalk.blue.underline(url) : ""
219
+ ]
220
+ .filter(Boolean)
221
+ .join(" ");
222
+ }
223
+
224
+ /** @param {Record<string, unknown>} result pat validation */
225
+ export function formatPatOkHuman(result) {
226
+ const login = result.login ?? "?";
227
+ return chalk.green("PAT OK — GitHub login ") + chalk.bold(String(login));
228
+ }
@@ -0,0 +1,60 @@
1
+ import { execSync } from "child_process";
2
+
3
+ /**
4
+ * @param {string} gitRoot
5
+ * @param {string} [remote]
6
+ * @returns {string | null}
7
+ */
8
+ export function getGitRemoteUrl(gitRoot, remote = "origin") {
9
+ try {
10
+ return execSync(`git remote get-url ${remote}`, {
11
+ cwd: gitRoot,
12
+ encoding: "utf8",
13
+ stdio: ["pipe", "pipe", "ignore"]
14
+ }).trim();
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Parse owner/repo from common GitHub remote URL shapes.
22
+ * @param {string | null | undefined} url
23
+ * @returns {string | null} "owner/repo"
24
+ */
25
+ export function parseGithubRepoFromRemote(url) {
26
+ if (!url || typeof url !== "string") {
27
+ return null;
28
+ }
29
+ const u = url.trim();
30
+ let m = u.match(/^git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/i);
31
+ if (m) {
32
+ return `${m[1]}/${m[2]}`;
33
+ }
34
+ m = u.match(/^https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?\/?$/i);
35
+ if (m) {
36
+ return `${m[1]}/${m[2]}`;
37
+ }
38
+ m = u.match(/^ssh:\/\/git@github\.com\/([^/]+)\/(.+?)(?:\.git)?\/?$/i);
39
+ if (m) {
40
+ return `${m[1]}/${m[2]}`;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * @param {string} gitRoot
47
+ * @param {string} [remote]
48
+ * @returns {string} owner/repo
49
+ */
50
+ export function getRepoFullNameFromGitRoot(gitRoot, remote = "origin") {
51
+ const url = getGitRemoteUrl(gitRoot, remote);
52
+ const full = parseGithubRepoFromRemote(url);
53
+ if (!full) {
54
+ throw new Error(
55
+ `Could not parse GitHub owner/repo from git remote "${remote}" URL: ${url || "(none)"}. ` +
56
+ `Use --repo owner/repo or set origin to a github.com remote.`
57
+ );
58
+ }
59
+ return full;
60
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * GitHub OAuth device flow (no backend). Requires GITHUB_OAUTH_CLIENT_ID.
3
+ * @see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow
4
+ */
5
+
6
+ /**
7
+ * @param {string} clientId
8
+ * @param {string} scope space-separated
9
+ */
10
+ export async function githubDeviceFlowRequestCode(clientId, scope) {
11
+ const response = await fetch("https://github.com/login/device/code", {
12
+ method: "POST",
13
+ headers: {
14
+ Accept: "application/json",
15
+ "Content-Type": "application/x-www-form-urlencoded"
16
+ },
17
+ body: new URLSearchParams({
18
+ client_id: clientId,
19
+ scope
20
+ })
21
+ });
22
+ const text = await response.text();
23
+ let payload = {};
24
+ try {
25
+ payload = text ? JSON.parse(text) : {};
26
+ } catch {
27
+ payload = { message: text };
28
+ }
29
+ if (!response.ok) {
30
+ const msg = payload.error_description || payload.error || payload.message || `HTTP ${response.status}`;
31
+ throw new Error(typeof msg === "string" ? msg : JSON.stringify(payload));
32
+ }
33
+ return payload;
34
+ }
35
+
36
+ /**
37
+ * @param {string} clientId
38
+ * @param {string} deviceCode
39
+ */
40
+ export async function githubDeviceFlowPollAccessToken(clientId, deviceCode) {
41
+ const response = await fetch("https://github.com/login/oauth/access_token", {
42
+ method: "POST",
43
+ headers: {
44
+ Accept: "application/json",
45
+ "Content-Type": "application/x-www-form-urlencoded"
46
+ },
47
+ body: new URLSearchParams({
48
+ client_id: clientId,
49
+ device_code: deviceCode,
50
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code"
51
+ })
52
+ });
53
+ const text = await response.text();
54
+ let payload = {};
55
+ try {
56
+ payload = text ? JSON.parse(text) : {};
57
+ } catch {
58
+ payload = { message: text, error: "parse_error" };
59
+ }
60
+ if (!response.ok && !payload.error) {
61
+ payload.error = `http_${response.status}`;
62
+ }
63
+ return payload;
64
+ }