pi-git-auth 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +192 -0
- package/auth.ts +146 -0
- package/commands.ts +208 -0
- package/details.ts +219 -0
- package/forge.ts +144 -0
- package/git-gate.ts +44 -0
- package/github.ts +189 -0
- package/gitlab.ts +157 -0
- package/index.ts +119 -0
- package/keyring.ts +338 -0
- package/package.json +40 -0
- package/store.ts +302 -0
package/details.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only "repo details" overlay: metadata + 2-level tree + latest commits.
|
|
3
|
+
*
|
|
4
|
+
* `buildDetailsText` is pure (easy to test); `RepoDetailsPanel` is thin TUI
|
|
5
|
+
* glue that renders it in a scrollable, dismissible overlay.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { matchesKey, truncateToWidth, visibleWidth, type Focusable } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { CommitInfo, RepoMeta, TreeEntry } from "./forge";
|
|
11
|
+
|
|
12
|
+
const MAX_TOP_ENTRIES = 10;
|
|
13
|
+
const MAX_CHILD_ENTRIES = 8;
|
|
14
|
+
const MAX_COMMITS = 5;
|
|
15
|
+
const MAX_SUBJECT = 56;
|
|
16
|
+
/** Chrome rows of the panel: top border, title, hint row, bottom border (+1 slack). */
|
|
17
|
+
const PANEL_CHROME = 5;
|
|
18
|
+
|
|
19
|
+
function relTime(iso: string): string {
|
|
20
|
+
const then = Date.parse(iso);
|
|
21
|
+
if (Number.isNaN(then)) return "";
|
|
22
|
+
const s = Math.floor((Date.now() - then) / 1000);
|
|
23
|
+
if (s < 60) return "just now";
|
|
24
|
+
const m = Math.floor(s / 60);
|
|
25
|
+
if (m < 60) return `${m}m ago`;
|
|
26
|
+
const h = Math.floor(m / 60);
|
|
27
|
+
if (h < 24) return `${h}h ago`;
|
|
28
|
+
const d = Math.floor(h / 24);
|
|
29
|
+
if (d < 30) return `${d}d ago`;
|
|
30
|
+
const mo = Math.floor(d / 30);
|
|
31
|
+
if (mo < 12) return `${mo}mo ago`;
|
|
32
|
+
return `${Math.floor(mo / 12)}y ago`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fmtSize(kb: number): string {
|
|
36
|
+
if (!Number.isFinite(kb) || kb <= 0) return "";
|
|
37
|
+
if (kb >= 1024) return `${(kb / 1024).toFixed(1)} MB`;
|
|
38
|
+
return `${Math.round(kb)} KB`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function sortEntries(list: TreeEntry[]): TreeEntry[] {
|
|
42
|
+
return [...list].sort((a, b) => {
|
|
43
|
+
const ad = a.type === "tree" ? 0 : 1;
|
|
44
|
+
const bd = b.type === "tree" ? 0 : 1;
|
|
45
|
+
if (ad !== bd) return ad - bd;
|
|
46
|
+
return a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Flat recursive tree entries → compact 2-level listing (dirs first,
|
|
52
|
+
* capped per level, with "… +N more" markers).
|
|
53
|
+
*/
|
|
54
|
+
export function renderTree(entries: TreeEntry[]): string[] {
|
|
55
|
+
if (entries.length === 0) return [];
|
|
56
|
+
const tops: TreeEntry[] = [];
|
|
57
|
+
const children = new Map<string, TreeEntry[]>();
|
|
58
|
+
for (const e of entries) {
|
|
59
|
+
if (!e.path.includes("/")) {
|
|
60
|
+
tops.push(e);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const topDir = `${e.path.split("/")[0]!}/`;
|
|
64
|
+
const rest = e.path.slice(topDir.length);
|
|
65
|
+
if (rest.includes("/")) continue; // depth > 2: not shown
|
|
66
|
+
let list = children.get(topDir);
|
|
67
|
+
if (!list) {
|
|
68
|
+
list = [];
|
|
69
|
+
children.set(topDir, list);
|
|
70
|
+
}
|
|
71
|
+
list.push(e);
|
|
72
|
+
}
|
|
73
|
+
const out: string[] = [];
|
|
74
|
+
const shown = sortEntries(tops).slice(0, MAX_TOP_ENTRIES);
|
|
75
|
+
for (const t of shown) {
|
|
76
|
+
out.push(` ${t.path}${t.type === "tree" ? "/" : ""}`);
|
|
77
|
+
if (t.type === "tree") {
|
|
78
|
+
const kids = sortEntries(children.get(`${t.path}/`) ?? []);
|
|
79
|
+
for (const k of kids.slice(0, MAX_CHILD_ENTRIES)) {
|
|
80
|
+
out.push(` ${k.path.slice(t.path.length + 1)}${k.type === "tree" ? "/" : ""}`);
|
|
81
|
+
}
|
|
82
|
+
if (kids.length > MAX_CHILD_ENTRIES) out.push(` … +${kids.length - MAX_CHILD_ENTRIES} more`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (tops.length > MAX_TOP_ENTRIES) out.push(` … +${tops.length - MAX_TOP_ENTRIES} more`);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Compose the details view content (plain text lines, no ANSI). */
|
|
90
|
+
export function buildDetailsText(
|
|
91
|
+
repo: RepoMeta,
|
|
92
|
+
commits: CommitInfo[],
|
|
93
|
+
tree: TreeEntry[],
|
|
94
|
+
): { title: string; lines: string[] } {
|
|
95
|
+
const meta = [
|
|
96
|
+
`branch: ${repo.defaultBranch}`,
|
|
97
|
+
fmtSize(repo.sizeKb ?? 0),
|
|
98
|
+
`★ ${repo.stars}`,
|
|
99
|
+
`${repo.forks} fork${repo.forks === 1 ? "" : "s"}`,
|
|
100
|
+
repo.lastPush ? `pushed ${relTime(repo.lastPush)}` : "",
|
|
101
|
+
].filter(Boolean);
|
|
102
|
+
|
|
103
|
+
const lines: string[] = [];
|
|
104
|
+
if (repo.description) {
|
|
105
|
+
lines.push(repo.description);
|
|
106
|
+
lines.push("");
|
|
107
|
+
}
|
|
108
|
+
lines.push(meta.join(" · "));
|
|
109
|
+
lines.push(repo.htmlUrl);
|
|
110
|
+
lines.push("");
|
|
111
|
+
lines.push("Tree");
|
|
112
|
+
if (tree.length === 0) {
|
|
113
|
+
lines.push(` ${commits.length > 0 ? "(could not load)" : "(empty repository)"}`);
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(...renderTree(tree));
|
|
116
|
+
}
|
|
117
|
+
lines.push("");
|
|
118
|
+
if (commits.length === 0) {
|
|
119
|
+
lines.push("Commits — none");
|
|
120
|
+
} else {
|
|
121
|
+
lines.push(`Commits (${commits.length})`);
|
|
122
|
+
for (const c of commits.slice(0, MAX_COMMITS)) {
|
|
123
|
+
const subject =
|
|
124
|
+
c.subject.length > MAX_SUBJECT ? `${c.subject.slice(0, MAX_SUBJECT - 1)}…` : c.subject;
|
|
125
|
+
const when = c.date ? ` (${relTime(c.date)})` : "";
|
|
126
|
+
lines.push(` ${c.sha.slice(0, 7)} ${subject}${when}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { title: `${repo.fullName} [${repo.private ? "private" : "public"}]`, lines };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Border-drawn overlay panel. Scroll with up/down/j/k/pgup/pgdn/home/end;
|
|
134
|
+
* dismiss with esc/enter/q/ctrl+c.
|
|
135
|
+
*/
|
|
136
|
+
export class RepoDetailsPanel implements Focusable {
|
|
137
|
+
focused = false;
|
|
138
|
+
|
|
139
|
+
private scrollTop = 0;
|
|
140
|
+
private dismissed = false;
|
|
141
|
+
|
|
142
|
+
constructor(
|
|
143
|
+
private readonly title: string,
|
|
144
|
+
private readonly lines: string[],
|
|
145
|
+
private readonly theme: Theme,
|
|
146
|
+
private readonly getRows: () => number,
|
|
147
|
+
private readonly done: () => void,
|
|
148
|
+
private readonly requestRender: () => void,
|
|
149
|
+
) {}
|
|
150
|
+
|
|
151
|
+
handleInput(data: string): void {
|
|
152
|
+
if (
|
|
153
|
+
matchesKey(data, "escape") ||
|
|
154
|
+
matchesKey(data, "return") ||
|
|
155
|
+
matchesKey(data, "enter") ||
|
|
156
|
+
matchesKey(data, "q") ||
|
|
157
|
+
matchesKey(data, "ctrl+c")
|
|
158
|
+
) {
|
|
159
|
+
this.dismiss();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const page = Math.max(2, Math.floor(this.visibleRows() / 2));
|
|
163
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
164
|
+
this.move(-1);
|
|
165
|
+
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
166
|
+
this.move(1);
|
|
167
|
+
} else if (matchesKey(data, "pageUp")) {
|
|
168
|
+
this.move(-page);
|
|
169
|
+
} else if (matchesKey(data, "pageDown")) {
|
|
170
|
+
this.move(page);
|
|
171
|
+
} else if (matchesKey(data, "home")) {
|
|
172
|
+
this.scrollTop = 0;
|
|
173
|
+
this.requestRender();
|
|
174
|
+
} else if (matchesKey(data, "end")) {
|
|
175
|
+
this.scrollTop = Number.MAX_SAFE_INTEGER;
|
|
176
|
+
this.requestRender();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
invalidate(): void {
|
|
181
|
+
// Stateless render — nothing to clear.
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
render(width: number): string[] {
|
|
185
|
+
const innerW = Math.max(10, width - 2);
|
|
186
|
+
const maxScroll = Math.max(0, this.lines.length - this.visibleRows());
|
|
187
|
+
this.scrollTop = Math.min(Math.max(0, this.scrollTop), maxScroll);
|
|
188
|
+
const view = this.lines.slice(this.scrollTop, this.scrollTop + this.visibleRows());
|
|
189
|
+
|
|
190
|
+
const B = (s: string) => this.theme.fg("border", s);
|
|
191
|
+
const row = (content: string) => {
|
|
192
|
+
const t = truncateToWidth(content, innerW - 2);
|
|
193
|
+
return B("│") + " " + t + " ".repeat(Math.max(0, innerW - 2 - visibleWidth(t))) + " " + B("│");
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const out: string[] = [];
|
|
197
|
+
out.push(B("╭") + B("─".repeat(innerW)) + B("╮"));
|
|
198
|
+
out.push(row(this.theme.fg("accent", this.theme.bold(this.title))));
|
|
199
|
+
for (const line of view) out.push(row(line));
|
|
200
|
+
out.push(row(this.theme.fg("dim", "esc close")));
|
|
201
|
+
out.push(B("╰") + B("─".repeat(innerW)) + B("╯"));
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private visibleRows(): number {
|
|
206
|
+
return Math.max(4, Math.floor(this.getRows() * 0.8) - PANEL_CHROME);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private move(delta: number): void {
|
|
210
|
+
this.scrollTop += delta;
|
|
211
|
+
this.requestRender();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private dismiss(): void {
|
|
215
|
+
if (this.dismissed) return;
|
|
216
|
+
this.dismissed = true;
|
|
217
|
+
this.done();
|
|
218
|
+
}
|
|
219
|
+
}
|
package/forge.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service (forge) abstraction: GitHub and GitLab behind one interface.
|
|
3
|
+
*
|
|
4
|
+
* Everything the extension does (verify, list, create, details) goes
|
|
5
|
+
* through the Service of the ACTIVE account, so the rest of the code is
|
|
6
|
+
* service-agnostic. Normalized types keep details.ts / commands.ts
|
|
7
|
+
* independent of the two REST APIs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
getUser as ghGetUser,
|
|
12
|
+
listRepos as ghListRepos,
|
|
13
|
+
createRepo as ghCreateRepo,
|
|
14
|
+
getRepo as ghGetRepo,
|
|
15
|
+
listCommits as ghListCommits,
|
|
16
|
+
getTree as ghGetTree,
|
|
17
|
+
} from "./github";
|
|
18
|
+
import {
|
|
19
|
+
getUser as glGetUser,
|
|
20
|
+
listRepos as glListRepos,
|
|
21
|
+
createRepo as glCreateRepo,
|
|
22
|
+
getRepo as glGetRepo,
|
|
23
|
+
listCommits as glListCommits,
|
|
24
|
+
getTree as glGetTree,
|
|
25
|
+
} from "./gitlab";
|
|
26
|
+
|
|
27
|
+
export type Platform = "github" | "gitlab";
|
|
28
|
+
|
|
29
|
+
/** Repo reference as "owner/name" (gitlab: path_with_namespace, may nest). */
|
|
30
|
+
export interface ForgeRepo {
|
|
31
|
+
fullName: string;
|
|
32
|
+
private: boolean;
|
|
33
|
+
description?: string;
|
|
34
|
+
htmlUrl: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Normalized repo metadata for the details overlay. */
|
|
38
|
+
export interface RepoMeta {
|
|
39
|
+
fullName: string;
|
|
40
|
+
private: boolean;
|
|
41
|
+
description?: string;
|
|
42
|
+
defaultBranch: string;
|
|
43
|
+
/** KB when the service reports it (GitHub); omitted otherwise. */
|
|
44
|
+
sizeKb?: number;
|
|
45
|
+
stars: number;
|
|
46
|
+
forks: number;
|
|
47
|
+
/** ISO date when the service reports it. */
|
|
48
|
+
lastPush?: string;
|
|
49
|
+
htmlUrl: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface CommitInfo {
|
|
53
|
+
/** Full or short sha, as reported. */
|
|
54
|
+
sha: string;
|
|
55
|
+
/** First line of the commit message. */
|
|
56
|
+
subject: string;
|
|
57
|
+
/** ISO date. */
|
|
58
|
+
date: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface TreeEntry {
|
|
62
|
+
path: string;
|
|
63
|
+
type: "blob" | "tree";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface Service {
|
|
67
|
+
id: Platform;
|
|
68
|
+
label: string;
|
|
69
|
+
/** Git host, e.g. github.com / gitlab.com (used by the git gate). */
|
|
70
|
+
host: string;
|
|
71
|
+
tokenUrl: string;
|
|
72
|
+
tokenPlaceholder: string;
|
|
73
|
+
scopesHint: string;
|
|
74
|
+
verify(token: string): Promise<{ login: string; scopes?: string }>;
|
|
75
|
+
listRepos(token: string, org?: string): Promise<ForgeRepo[]>;
|
|
76
|
+
createRepo(
|
|
77
|
+
token: string,
|
|
78
|
+
name: string,
|
|
79
|
+
opts: { org?: string; private?: boolean; description?: string },
|
|
80
|
+
): Promise<ForgeRepo>;
|
|
81
|
+
meta(token: string, fullName: string): Promise<RepoMeta>;
|
|
82
|
+
commits(token: string, fullName: string, n: number): Promise<CommitInfo[]>;
|
|
83
|
+
/** Flat recursive tree (depth limited by the service). Returns [] without a ref. */
|
|
84
|
+
tree(token: string, fullName: string, ref?: string): Promise<TreeEntry[]>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const ghService: Service = {
|
|
88
|
+
id: "github",
|
|
89
|
+
label: "GitHub",
|
|
90
|
+
host: "github.com",
|
|
91
|
+
tokenUrl: "https://github.com/settings/tokens/new",
|
|
92
|
+
tokenPlaceholder: "ghp_… / github_pat_… / gho_…",
|
|
93
|
+
scopesHint: "repo, user:email",
|
|
94
|
+
verify: (token) => ghGetUser(token),
|
|
95
|
+
listRepos: (token, org) => ghListRepos(token, { org }).then((rs) =>
|
|
96
|
+
rs.map((r) => ({
|
|
97
|
+
fullName: r.full_name,
|
|
98
|
+
private: !!r.private,
|
|
99
|
+
description: r.description ?? undefined,
|
|
100
|
+
htmlUrl: r.html_url,
|
|
101
|
+
})),
|
|
102
|
+
),
|
|
103
|
+
createRepo: (token, name, opts) =>
|
|
104
|
+
ghCreateRepo(token, { name, org: opts.org, private: opts.private, description: opts.description }).then((r) => ({
|
|
105
|
+
fullName: r.full_name,
|
|
106
|
+
private: !!r.private,
|
|
107
|
+
description: r.description ?? undefined,
|
|
108
|
+
htmlUrl: r.html_url,
|
|
109
|
+
})),
|
|
110
|
+
meta: (token, fullName) =>
|
|
111
|
+
ghGetRepo(token, fullName).then((r) => ({
|
|
112
|
+
fullName: r.full_name,
|
|
113
|
+
private: !!r.private,
|
|
114
|
+
description: r.description,
|
|
115
|
+
defaultBranch: r.default_branch,
|
|
116
|
+
sizeKb: r.size,
|
|
117
|
+
stars: r.stargazers_count,
|
|
118
|
+
forks: r.forks_count,
|
|
119
|
+
lastPush: r.pushed_at || undefined,
|
|
120
|
+
htmlUrl: r.html_url,
|
|
121
|
+
})),
|
|
122
|
+
commits: (token, fullName, n) =>
|
|
123
|
+
ghListCommits(token, fullName, n).then((cs) =>
|
|
124
|
+
cs.map((c) => ({ sha: c.sha, subject: c.message, date: c.date })),
|
|
125
|
+
),
|
|
126
|
+
tree: (token, fullName, ref) => (ref ? ghGetTree(token, fullName, ref) : Promise.resolve([])),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const glService: Service = {
|
|
130
|
+
id: "gitlab",
|
|
131
|
+
label: "GitLab",
|
|
132
|
+
host: "gitlab.com",
|
|
133
|
+
tokenUrl: "https://gitlab.com/-/user_settings/personal_access_tokens",
|
|
134
|
+
tokenPlaceholder: "glpat-…",
|
|
135
|
+
scopesHint: "api",
|
|
136
|
+
verify: (token) => glGetUser(token),
|
|
137
|
+
listRepos: (token, org) => glListRepos(token, { org }),
|
|
138
|
+
createRepo: (token, name, opts) => glCreateRepo(token, name, opts),
|
|
139
|
+
meta: (token, fullName) => glGetRepo(token, fullName),
|
|
140
|
+
commits: (token, fullName, n) => glListCommits(token, fullName, n),
|
|
141
|
+
tree: (token, fullName, ref) => (ref ? glGetTree(token, fullName, ref) : Promise.resolve([])),
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const SERVICES: Record<Platform, Service> = { github: ghService, gitlab: glService };
|
package/git-gate.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic forge auth for git commands (GitHub / GitLab).
|
|
3
|
+
*
|
|
4
|
+
* When pi's bash tool is about to run a git command and a token is
|
|
5
|
+
* stored for a host, we rewrite the command so git uses the stored token
|
|
6
|
+
* for that host, regardless of git's own credential configuration:
|
|
7
|
+
*
|
|
8
|
+
* export GIT_TERMINAL_PROMPT=0 \
|
|
9
|
+
* GIT_CONFIG_COUNT=1 \
|
|
10
|
+
* GIT_CONFIG_KEY_0="url.https://x-access-token:<token>@<host>/.insteadOf" \
|
|
11
|
+
* GIT_CONFIG_VALUE_0="https://<host>/" && <command>
|
|
12
|
+
*
|
|
13
|
+
* Forging hosts' git-over-HTTPS endpoints ignore Authorization headers
|
|
14
|
+
* and only accept URL-embedded (Basic) credentials, hence the insteadOf
|
|
15
|
+
* rewrite.
|
|
16
|
+
*
|
|
17
|
+
* Guarantees:
|
|
18
|
+
* - Scoped to the given host's URLs; other remotes are untouched.
|
|
19
|
+
* - GIT_TERMINAL_PROMPT=0: a failed auth surfaces as a clean error
|
|
20
|
+
* instead of an interactive prompt hanging the TUI.
|
|
21
|
+
* - Deterministic: no credential-helper races, same behavior every run.
|
|
22
|
+
* - SSH-style URLs for the host are rewritten to HTTPS so the token applies.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export function looksLikeGit(command: string): boolean {
|
|
26
|
+
// git at the start of a simple command, or after a shell separator, with
|
|
27
|
+
// any number of leading VAR=value assignments allowed before it.
|
|
28
|
+
return /(^|[\n;&|]\s*)(?:[A-Za-z_][A-Za-z_0-9]*=\S*\s+)*git(\s|$)/.test(command);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function instrumentGit(command: string, host: string, token: string): string {
|
|
32
|
+
if (!looksLikeGit(command)) return command;
|
|
33
|
+
if (command.includes("GIT_CONFIG_COUNT=")) return command; // already instrumented
|
|
34
|
+
|
|
35
|
+
const rewritten = command
|
|
36
|
+
.replace(new RegExp(`ssh://git@${host}/`, "g"), `https://${host}/`)
|
|
37
|
+
.replace(new RegExp(`git@${host}:`, "g"), `https://${host}/`);
|
|
38
|
+
const prefix =
|
|
39
|
+
`export GIT_TERMINAL_PROMPT=0 ` +
|
|
40
|
+
`GIT_CONFIG_COUNT=1 ` +
|
|
41
|
+
`GIT_CONFIG_KEY_0="url.https://x-access-token:${token}@${host}/.insteadOf" ` +
|
|
42
|
+
`GIT_CONFIG_VALUE_0="https://${host}/" && `;
|
|
43
|
+
return prefix + rewritten;
|
|
44
|
+
}
|
package/github.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal GitHub API client (REST, no dependencies).
|
|
3
|
+
* Covers exactly what pi-git-auth needs: token verification, repo CRUD.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const API_BASE = "https://api.github.com";
|
|
7
|
+
|
|
8
|
+
export interface RepoInfo {
|
|
9
|
+
full_name: string;
|
|
10
|
+
private: boolean;
|
|
11
|
+
description?: string | null;
|
|
12
|
+
html_url: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface UserInfo {
|
|
16
|
+
login: string;
|
|
17
|
+
/** Best-effort scopes (classic OAuth/PAT tokens only). */
|
|
18
|
+
scopes?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ApiResult {
|
|
22
|
+
status: number;
|
|
23
|
+
data: any;
|
|
24
|
+
headers: Headers;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function apiFetch(path: string, token: string, init: RequestInit = {}, signal?: AbortSignal): Promise<ApiResult> {
|
|
28
|
+
const res = await fetch(`${API_BASE}${path}`, {
|
|
29
|
+
...init,
|
|
30
|
+
signal,
|
|
31
|
+
headers: {
|
|
32
|
+
accept: "application/vnd.github+json",
|
|
33
|
+
"x-github-api-version": "2022-11-28",
|
|
34
|
+
authorization: `bearer ${token}`,
|
|
35
|
+
...(init.body ? { "content-type": "application/json" } : {}),
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
let data: any = null;
|
|
39
|
+
if (res.status !== 204) {
|
|
40
|
+
try {
|
|
41
|
+
data = await res.json();
|
|
42
|
+
} catch {
|
|
43
|
+
data = null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return { status: res.status, data, headers: res.headers };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Verify a token by resolving the login user. Throws on 401/403. */
|
|
50
|
+
export async function getUser(token: string, signal?: AbortSignal): Promise<UserInfo> {
|
|
51
|
+
const { status, data, headers } = await apiFetch("/user", token, {}, signal);
|
|
52
|
+
if (status === 401) throw new Error("Token invalid or revoked (HTTP 401)");
|
|
53
|
+
if (status === 403) throw new Error(`Token lacks permissions (HTTP 403)${data?.message ? `: ${data.message}` : ""}`);
|
|
54
|
+
if (status === 404 || !data) throw new Error(`Unexpected response (HTTP ${status})`);
|
|
55
|
+
return {
|
|
56
|
+
login: data.login,
|
|
57
|
+
scopes: headers.get("x-oauth-scopes") ?? undefined,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function listRepos(
|
|
62
|
+
token: string,
|
|
63
|
+
opts: { org?: string; perPage?: number; signal?: AbortSignal },
|
|
64
|
+
): Promise<RepoInfo[]> {
|
|
65
|
+
const perPage = opts.perPage ?? 100;
|
|
66
|
+
const path = opts.org
|
|
67
|
+
? `/orgs/${encodeURIComponent(opts.org)}/repos?per_page=${perPage}&type=owner`
|
|
68
|
+
: `/user/repos?affiliation=owner&per_page=${perPage}`;
|
|
69
|
+
const { status, data } = await apiFetch(path, token, {}, opts.signal);
|
|
70
|
+
if (status === 401) throw new Error("Token invalid (HTTP 401)");
|
|
71
|
+
if (status === 403) throw new Error("Insufficient scopes to list repos (needs `repo`)");
|
|
72
|
+
if (status >= 400 || !Array.isArray(data)) throw new Error(`List repos failed (HTTP ${status})`);
|
|
73
|
+
return data.map((r: any) => ({
|
|
74
|
+
full_name: r.full_name,
|
|
75
|
+
private: !!r.private,
|
|
76
|
+
description: r.description ?? undefined,
|
|
77
|
+
html_url: r.html_url,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RepoDetail {
|
|
82
|
+
full_name: string;
|
|
83
|
+
private: boolean;
|
|
84
|
+
description?: string;
|
|
85
|
+
default_branch: string;
|
|
86
|
+
/** Size in KB (GitHub's `size` field). */
|
|
87
|
+
size: number;
|
|
88
|
+
stargazers_count: number;
|
|
89
|
+
forks_count: number;
|
|
90
|
+
pushed_at: string;
|
|
91
|
+
html_url: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CommitInfo {
|
|
95
|
+
sha: string;
|
|
96
|
+
/** First line of the commit message. */
|
|
97
|
+
message: string;
|
|
98
|
+
author: string;
|
|
99
|
+
/** ISO date. */
|
|
100
|
+
date: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface TreeEntry {
|
|
104
|
+
path: string;
|
|
105
|
+
type: "blob" | "tree";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Full repo metadata for the details view. */
|
|
109
|
+
export async function getRepo(token: string, fullName: string, signal?: AbortSignal): Promise<RepoDetail> {
|
|
110
|
+
const { status, data } = await apiFetch(`/repos/${fullName}`, token, {}, signal);
|
|
111
|
+
if (status === 401) throw new Error("Token invalid (HTTP 401)");
|
|
112
|
+
if (status === 404) throw new Error(`Repository ${fullName} not found (HTTP 404)`);
|
|
113
|
+
if (status >= 400 || !data) throw new Error(`Get repo failed (HTTP ${status})`);
|
|
114
|
+
return {
|
|
115
|
+
full_name: data.full_name,
|
|
116
|
+
private: !!data.private,
|
|
117
|
+
description: data.description ?? undefined,
|
|
118
|
+
default_branch: data.default_branch,
|
|
119
|
+
size: data.size ?? 0,
|
|
120
|
+
stargazers_count: data.stargazers_count ?? 0,
|
|
121
|
+
forks_count: data.forks_count ?? 0,
|
|
122
|
+
pushed_at: data.pushed_at ?? "",
|
|
123
|
+
html_url: data.html_url,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Latest commits, newest first. */
|
|
128
|
+
export async function listCommits(
|
|
129
|
+
token: string,
|
|
130
|
+
fullName: string,
|
|
131
|
+
perPage = 5,
|
|
132
|
+
signal?: AbortSignal,
|
|
133
|
+
): Promise<CommitInfo[]> {
|
|
134
|
+
const { status, data } = await apiFetch(`/repos/${fullName}/commits?per_page=${perPage}`, token, {}, signal);
|
|
135
|
+
if (status === 401) throw new Error("Token invalid (HTTP 401)");
|
|
136
|
+
if (status === 404) throw new Error(`Repository ${fullName} not found (HTTP 404)`);
|
|
137
|
+
if (status >= 400 || !Array.isArray(data)) throw new Error(`List commits failed (HTTP ${status})`);
|
|
138
|
+
return data.map((c: any) => ({
|
|
139
|
+
sha: c.sha,
|
|
140
|
+
message: String(c.commit?.message ?? "").split("\n")[0],
|
|
141
|
+
author: c.author?.login ?? c.commit?.author?.name ?? "unknown",
|
|
142
|
+
date: c.commit?.author?.date ?? c.commit?.committer?.date ?? "",
|
|
143
|
+
}));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Flat recursive tree (all paths). Returns [] on 404/409 (missing ref). */
|
|
147
|
+
export async function getTree(
|
|
148
|
+
token: string,
|
|
149
|
+
fullName: string,
|
|
150
|
+
ref: string,
|
|
151
|
+
signal?: AbortSignal,
|
|
152
|
+
): Promise<TreeEntry[]> {
|
|
153
|
+
const { status, data } = await apiFetch(
|
|
154
|
+
`/repos/${fullName}/git/trees/${encodeURIComponent(ref)}?recursive=1`,
|
|
155
|
+
token,
|
|
156
|
+
{},
|
|
157
|
+
signal,
|
|
158
|
+
);
|
|
159
|
+
if (status === 404 || status === 409) return [];
|
|
160
|
+
if (status >= 400 || !data) throw new Error(`Get tree failed (HTTP ${status})`);
|
|
161
|
+
return (data.tree ?? []).map((t: any) => ({
|
|
162
|
+
path: String(t.path),
|
|
163
|
+
type: t.type === "tree" ? "tree" as const : ("blob" as const),
|
|
164
|
+
}));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function createRepo(
|
|
168
|
+
token: string,
|
|
169
|
+
opts: { name: string; org?: string; private?: boolean; description?: string; signal?: AbortSignal },
|
|
170
|
+
): Promise<RepoInfo> {
|
|
171
|
+
const path = opts.org ? `/orgs/${encodeURIComponent(opts.org)}/repos` : "/user/repos";
|
|
172
|
+
const body = JSON.stringify({
|
|
173
|
+
name: opts.name,
|
|
174
|
+
description: opts.description ?? null,
|
|
175
|
+
private: !!opts.private,
|
|
176
|
+
has_issues: true,
|
|
177
|
+
});
|
|
178
|
+
const { status, data } = await apiFetch(path, token, { method: "POST", body }, opts.signal);
|
|
179
|
+
if (status === 409) throw new Error(`Repository ${opts.name} already exists`);
|
|
180
|
+
if (status >= 400) throw new Error(`Create repo failed (HTTP ${status}): ${data?.message ?? ""}`);
|
|
181
|
+
return {
|
|
182
|
+
full_name: data.full_name,
|
|
183
|
+
private: !!data.private,
|
|
184
|
+
description: data.description,
|
|
185
|
+
html_url: data.html_url,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|