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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Carlo Onofrio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# pi-git-auth
|
|
2
|
+
|
|
3
|
+
A pi (coding-agent) extension that gives the agent **git forges
|
|
4
|
+
authentication** for **GitHub and GitLab**: login tokens are stored in the
|
|
5
|
+
OS keyring, the active account is selected in the TUI, and every `git`
|
|
6
|
+
command the agent runs is transparently authenticated with that account's
|
|
7
|
+
token for its host. It also manages accounts and repositories through each
|
|
8
|
+
service's REST API.
|
|
9
|
+
|
|
10
|
+
No npm dependencies — only pi's bundled packages and Node built-ins.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
As a pi package:
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
pi install npm:pi-git-auth
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Or from git:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
pi install git:github.com/comicrocharly/pi-git-auth
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Or manually: copy the `.ts` files into
|
|
27
|
+
`~/.pi/agent/extensions/pi-git-auth/`. No build step — pi loads the
|
|
28
|
+
TypeScript directly.
|
|
29
|
+
|
|
30
|
+
## How it works
|
|
31
|
+
|
|
32
|
+
- **Login** stores a token per (service, login) pair.
|
|
33
|
+
- One account is **active** at a time. Every action — git auth, repos,
|
|
34
|
+
create, the LLM tool — uses **the REST API of the active account's
|
|
35
|
+
service**, so switching accounts also switches the backend.
|
|
36
|
+
- A `tool_call` hook watches every `bash` invocation. When an active
|
|
37
|
+
account exists and the command runs `git`, the command is rewritten to
|
|
38
|
+
authenticate that account's host (see [git auth](#git-auth)).
|
|
39
|
+
|
|
40
|
+
## Layout
|
|
41
|
+
|
|
42
|
+
```
|
|
43
|
+
index.ts extension entry: git-gate hook, /auth command, `auth` LLM tool
|
|
44
|
+
auth.ts login/logout/switch flows (per service), status text
|
|
45
|
+
commands.ts /auth subcommands + interactive TUI menu
|
|
46
|
+
store.ts multi-account credential persistence (keyring or file)
|
|
47
|
+
keyring.ts OS keyring backend (freedesktop Secret Service) + python D-Bus client
|
|
48
|
+
forge.ts service abstraction: normalized types + GitHub/GitLab registry
|
|
49
|
+
github.ts GitHub REST client
|
|
50
|
+
gitlab.ts GitLab REST client
|
|
51
|
+
details.ts read-only repo details overlay (tree + commits + metadata)
|
|
52
|
+
git-gate.ts command rewriting that injects the token for a host
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### /auth
|
|
58
|
+
With no argument an interactive TUI menu is shown (status / login /
|
|
59
|
+
logout / switch account / repos / create); each action prompts for
|
|
60
|
+
anything missing.
|
|
61
|
+
|
|
62
|
+
Multiple accounts are supported, across GitHub **and** GitLab. One
|
|
63
|
+
account is **active** at a time.
|
|
64
|
+
|
|
65
|
+
### /auth status
|
|
66
|
+
List all stored accounts (service + active marker), the active storage
|
|
67
|
+
backend, plus the active account's details (masked token, scopes).
|
|
68
|
+
|
|
69
|
+
### /auth login
|
|
70
|
+
Pick a service (GitHub or GitLab), then paste a token straight into the
|
|
71
|
+
TUI — the token-creation page is opened in the browser.
|
|
72
|
+
|
|
73
|
+
- GitHub: classic PAT `ghp_…` (scopes: `repo`, `user:email`) or a
|
|
74
|
+
fine-grained `github_pat_…` token.
|
|
75
|
+
- GitLab: personal access token `glpat-…` (scope: `api`).
|
|
76
|
+
|
|
77
|
+
Verified against the service (`GET /user` / `GET /api/v4/user`) before
|
|
78
|
+
storing. Accounts are keyed by service+login: logging in again with the
|
|
79
|
+
same pair replaces its token. The logged-in account becomes the active
|
|
80
|
+
one.
|
|
81
|
+
|
|
82
|
+
### /auth logout
|
|
83
|
+
Picks an account to remove. If it was the active one, another remaining
|
|
84
|
+
account becomes active (or none, if it was the last).
|
|
85
|
+
|
|
86
|
+
### /auth switch
|
|
87
|
+
Picks a stored account and makes it the active one.
|
|
88
|
+
|
|
89
|
+
### /auth repos [org]
|
|
90
|
+
Lists your repos (or an org's/group's) **on the active account's service**.
|
|
91
|
+
Picking one opens a details overlay: description, branch/size/stars/forks/
|
|
92
|
+
last-push, a two-level file tree, and the five latest commits. Close with
|
|
93
|
+
esc/enter/q.
|
|
94
|
+
|
|
95
|
+
### /auth create [org/]name
|
|
96
|
+
Creates a repo on the active account's service (prompts for visibility
|
|
97
|
+
and description).
|
|
98
|
+
|
|
99
|
+
### LLM tool: `auth`
|
|
100
|
+
The model can call `auth` with actions `status | switch | list | create`
|
|
101
|
+
(plus an `account` parameter for `switch`; `"platform:login"`
|
|
102
|
+
disambiguates a login that exists on both services). These are REST API
|
|
103
|
+
operations run against the active account's service — the git gate can't
|
|
104
|
+
do them. For clone/push the model just runs `git` via bash: the harness
|
|
105
|
+
authenticates the active account's host automatically (the git gate), so
|
|
106
|
+
no tool action is needed for git.
|
|
107
|
+
|
|
108
|
+
Repository deletion is intentionally not exposed (safety).
|
|
109
|
+
|
|
110
|
+
## git auth
|
|
111
|
+
|
|
112
|
+
A `tool_call` hook watches every `bash` tool invocation. If an active
|
|
113
|
+
account is stored and the command runs `git`, it rewrites the command to
|
|
114
|
+
use that account's token for the account's host (`github.com` or
|
|
115
|
+
`gitlab.com`):
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
export GIT_TERMINAL_PROMPT=0 \
|
|
119
|
+
GIT_CONFIG_COUNT=1 \
|
|
120
|
+
GIT_CONFIG_KEY_0="url.https://x-access-token:<token>@<host>/.insteadOf" \
|
|
121
|
+
GIT_CONFIG_VALUE_0="https://<host>/" && <command>
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Forging hosts' git-over-HTTPS endpoints ignore `Authorization` headers
|
|
125
|
+
and only accept URL-embedded credentials, hence the `insteadOf` rewrite.
|
|
126
|
+
Only the active host is touched; other remotes are untouched.
|
|
127
|
+
|
|
128
|
+
## Token storage
|
|
129
|
+
|
|
130
|
+
Tokens are **never stored plaintext on disk**. Two backends are
|
|
131
|
+
available; the extension picks one at load time.
|
|
132
|
+
|
|
133
|
+
### OS keyring (preferred)
|
|
134
|
+
The token lives in the session keyring, addressed by the
|
|
135
|
+
[freedesktop Secret Service API](https://specifications.freedesktop.org/secret-service/)
|
|
136
|
+
(`org.freedesktop.secrets` over D-Bus). This covers KWallet (via
|
|
137
|
+
`ksecretd`), GNOME Keyring, KeePassXC, and any other Secret Service
|
|
138
|
+
provider.
|
|
139
|
+
|
|
140
|
+
- The embedded python3 client (a small script that `keyring.ts` keeps
|
|
141
|
+
in sync in the state dir) opens a D-Bus session and talks
|
|
142
|
+
**JSON over stdin/stdout**. The token therefore never appears in a
|
|
143
|
+
process argument list — only in the parent's memory.
|
|
144
|
+
- The client **auto-detects the Secret Service API generation** by
|
|
145
|
+
introspecting the service: modern 0.0.1
|
|
146
|
+
(`Store`/`GetSecret`) for gnome-keyring / `kwallet --secretservice`,
|
|
147
|
+
or legacy 0.0.0 (`Collection.CreateItem`/`GetSecrets`) for KDE
|
|
148
|
+
`ksecretd`. Both are implemented.
|
|
149
|
+
- `credentials.json` keeps only a `wallet:v1:<accountKey>` marker per
|
|
150
|
+
account; the token itself is in the keyring.
|
|
151
|
+
|
|
152
|
+
### Encrypted file (fallback)
|
|
153
|
+
When python3 / D-Bus / a keyring are unavailable (headless server, no
|
|
154
|
+
session bus), the extension transparently falls back to an on-disk
|
|
155
|
+
AES-256-GCM envelope:
|
|
156
|
+
|
|
157
|
+
- `~/.pi/agent/pi-git-auth/credentials.json` (0600) holds each token as
|
|
158
|
+
an `enc:v1:<base64>` envelope.
|
|
159
|
+
- The key lives in a separate 0600 `key` file (0700 dir).
|
|
160
|
+
|
|
161
|
+
### Migration
|
|
162
|
+
Moving from the old plaintext/`enc:v1:` layout to the keyring is
|
|
163
|
+
transparent. On first load, any legacy token is read, written to the
|
|
164
|
+
keyring, and the file is rewritten with `wallet:v1:` markers. A `.bak`
|
|
165
|
+
copy of the pre-migration file is kept (ciphertext/markers only, no
|
|
166
|
+
plaintext).
|
|
167
|
+
|
|
168
|
+
### Override
|
|
169
|
+
`PI_GIT_AUTH_STORE` forces the backend: `auto` (default — keyring when
|
|
170
|
+
reachable, else file), `wallet`, or `file`.
|
|
171
|
+
|
|
172
|
+
## Performance
|
|
173
|
+
|
|
174
|
+
- The git gate adds one regex pass per `git` command (same as any string
|
|
175
|
+
rewrite) and no network calls; login adds one TUI prompt.
|
|
176
|
+
- The keyring round-trip is one D-Bus exchange per account, only at load
|
|
177
|
+
and write time.
|
|
178
|
+
- GitHub's details view uses a single bounded set of REST calls
|
|
179
|
+
(repo meta + 5 commits + 1 recursive tree). GitLab's tree is top-level
|
|
180
|
+
only (its API does not recurse), bounded to 100 entries.
|
|
181
|
+
|
|
182
|
+
## Limits
|
|
183
|
+
|
|
184
|
+
- GitHub: `github.com` only (no GitHub Enterprise).
|
|
185
|
+
- GitLab: `gitlab.com` only (no self-hosted instances).
|
|
186
|
+
- GitLab does not expose project size or per-file sizes via this API;
|
|
187
|
+
the details overlay simply omits them.
|
|
188
|
+
- GitLab's repository tree is top-level only (the API ignores `recursive`),
|
|
189
|
+
so nested directories are not listed in the details overlay.
|
|
190
|
+
- GitLab project list for an org tries the group first, then a user.
|
|
191
|
+
- Keyring storage requires a D-Bus session bus and a Secret Service
|
|
192
|
+
provider; otherwise the encrypted-file fallback is used.
|
package/auth.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { loadStore, saveStore, maskToken, activeAccount, accountKey, purgeAccountStorage, storeBackend, type StoreData } from "./store";
|
|
3
|
+
import { SERVICES, type Platform, type Service } from "./forge";
|
|
4
|
+
|
|
5
|
+
/** The subset of ctx.ui the auth flows need. */
|
|
6
|
+
export interface UiLike {
|
|
7
|
+
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
8
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fail(ui: UiLike, e: unknown): void {
|
|
12
|
+
ui.notify(`Login failed: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function openBrowser(url: string): void {
|
|
16
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
|
|
17
|
+
try {
|
|
18
|
+
const p = spawn(cmd, [url], { stdio: "ignore", detached: true });
|
|
19
|
+
p.on("error", () => {});
|
|
20
|
+
p.unref();
|
|
21
|
+
} catch {
|
|
22
|
+
/* headless: the URL is shown in the notify message anyway */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Display name for an account key: the login part, without platform. */
|
|
27
|
+
export function accountName(data: StoreData, key: string): string {
|
|
28
|
+
return data.accounts[key]?.user ?? key.slice(key.indexOf(":") + 1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Login for a specific service (GitHub or GitLab). The account is keyed
|
|
33
|
+
* by platform+login: logging in again with the same pair replaces its
|
|
34
|
+
* token; either way the account becomes the active one.
|
|
35
|
+
*/
|
|
36
|
+
export async function loginWithPastedToken(ui: UiLike, service: Service): Promise<void> {
|
|
37
|
+
openBrowser(service.tokenUrl);
|
|
38
|
+
ui.notify(`Create a ${service.label} token here: ${service.tokenUrl} (scopes: ${service.scopesHint})`, "info");
|
|
39
|
+
const raw = (await ui.input(`Paste ${service.label} token (page opened in browser)`, service.tokenPlaceholder))?.trim();
|
|
40
|
+
if (!raw) {
|
|
41
|
+
ui.notify("Login cancelled", "info");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
ui.notify("Verifying token…", "info");
|
|
45
|
+
let user;
|
|
46
|
+
try {
|
|
47
|
+
user = await service.verify(raw);
|
|
48
|
+
} catch (e) {
|
|
49
|
+
fail(ui, e);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const data = loadStore();
|
|
53
|
+
const key = accountKey(service.id, user.login);
|
|
54
|
+
const isExisting = !!data.accounts[key];
|
|
55
|
+
data.accounts[key] = {
|
|
56
|
+
type: "pat",
|
|
57
|
+
platform: service.id,
|
|
58
|
+
accessToken: raw,
|
|
59
|
+
user: user.login,
|
|
60
|
+
scopes: user.scopes,
|
|
61
|
+
savedAt: new Date().toISOString(),
|
|
62
|
+
};
|
|
63
|
+
data.activeLogin = key;
|
|
64
|
+
saveStore(data);
|
|
65
|
+
ui.notify(
|
|
66
|
+
isExisting
|
|
67
|
+
? `Token updated for @${user.login} (${service.id}) — now active`
|
|
68
|
+
: `Logged in as @${user.login} (${service.id}) — now active`,
|
|
69
|
+
"info",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Remove one stored account. If it was active, another remaining account
|
|
75
|
+
* becomes active (or none when it was the last).
|
|
76
|
+
*/
|
|
77
|
+
export function removeAccount(key: string): void {
|
|
78
|
+
const data = loadStore();
|
|
79
|
+
const rec = data.accounts[key];
|
|
80
|
+
delete data.accounts[key];
|
|
81
|
+
purgeAccountStorage(key, rec); // drop the keyring item, if any
|
|
82
|
+
if (data.activeLogin === key) {
|
|
83
|
+
const rest = Object.keys(data.accounts);
|
|
84
|
+
data.activeLogin = rest.length > 0 ? rest[0] : undefined;
|
|
85
|
+
}
|
|
86
|
+
saveStore(data);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Make an account the active one. Returns false for unknown keys. */
|
|
90
|
+
export function setActiveAccount(key: string): boolean {
|
|
91
|
+
const data = loadStore();
|
|
92
|
+
if (!data.accounts[key]) return false;
|
|
93
|
+
if (data.activeLogin === key) return true;
|
|
94
|
+
data.activeLogin = key;
|
|
95
|
+
saveStore(data);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Resolve an account query ("login" or "platform:login") to stored keys.
|
|
101
|
+
* [] = unknown; >1 = ambiguous (same login on several platforms).
|
|
102
|
+
*/
|
|
103
|
+
export function findAccounts(query: string): string[] {
|
|
104
|
+
const q = query.trim().toLowerCase();
|
|
105
|
+
const keys = Object.keys(loadStore().accounts);
|
|
106
|
+
if (q.includes(":")) {
|
|
107
|
+
const key = accountKey(q.slice(0, q.indexOf(":")) as Platform, q.slice(q.indexOf(":") + 1));
|
|
108
|
+
return keys.includes(key) ? [key] : [];
|
|
109
|
+
}
|
|
110
|
+
return keys.filter((k) => k.endsWith(`:${q}`));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Service (REST API) of the active account, or undefined. */
|
|
114
|
+
export function activeService(data: StoreData): Service | undefined {
|
|
115
|
+
const acc = activeAccount(data);
|
|
116
|
+
return acc ? SERVICES[acc.platform] : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Human-readable status block: all accounts, details for the active one. */
|
|
120
|
+
export function statusDetail(data: StoreData): string {
|
|
121
|
+
const keys = Object.keys(data.accounts);
|
|
122
|
+
if (keys.length === 0) return "No git accounts. Run /auth login.";
|
|
123
|
+
const lines = ["Git accounts:"];
|
|
124
|
+
for (const k of keys) {
|
|
125
|
+
const a = data.accounts[k];
|
|
126
|
+
const star = data.activeLogin === k ? "*" : " ";
|
|
127
|
+
lines.push(
|
|
128
|
+
` ${star} @${a?.user ?? k.slice(k.indexOf(":") + 1)} (${a?.platform ?? "github"})${
|
|
129
|
+
data.activeLogin === k ? " (active)" : ""
|
|
130
|
+
}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
const activeKey = data.activeLogin;
|
|
134
|
+
if (activeKey) lines.push("");
|
|
135
|
+
lines.push(`Store: ${storeBackend() === "keyring" ? "OS keyring (Secret Service)" : "encrypted file"}`);
|
|
136
|
+
if (activeKey) lines.push("");
|
|
137
|
+
const active = activeKey ? data.accounts[activeKey] : undefined;
|
|
138
|
+
if (active && activeKey) {
|
|
139
|
+
lines.push("");
|
|
140
|
+
lines.push(`Active: @${active.user ?? activeKey.slice(activeKey.indexOf(":") + 1)} (${active.platform})`);
|
|
141
|
+
lines.push(`Token: ${maskToken(active.accessToken)}`);
|
|
142
|
+
if (active.scopes) lines.push(`Scopes: ${active.scopes}`);
|
|
143
|
+
lines.push(`Saved: ${active.savedAt}`);
|
|
144
|
+
}
|
|
145
|
+
return lines.join("\n");
|
|
146
|
+
}
|
package/commands.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { loadStore, activeAccount, type StoreData } from "./store";
|
|
3
|
+
import { SERVICES, type Service, type TreeEntry } from "./forge";
|
|
4
|
+
import { buildDetailsText, RepoDetailsPanel } from "./details";
|
|
5
|
+
import { activeService, accountName, loginWithPastedToken, removeAccount, setActiveAccount, statusDetail } from "./auth";
|
|
6
|
+
|
|
7
|
+
type Ctx = ExtensionCommandContext;
|
|
8
|
+
|
|
9
|
+
async function showStatus(ctx: Ctx): Promise<void> {
|
|
10
|
+
const data = loadStore();
|
|
11
|
+
if (Object.keys(data.accounts).length === 0) {
|
|
12
|
+
ctx.ui.notify("git auth: not connected — run /auth login", "info");
|
|
13
|
+
} else {
|
|
14
|
+
ctx.ui.notify(statusDetail(data), "info");
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function doLogin(ctx: Ctx): Promise<void> {
|
|
19
|
+
const pick = await ctx.ui.select("git auth — which service?", [SERVICES.github.label, SERVICES.gitlab.label]);
|
|
20
|
+
const service: Service = pick === SERVICES.gitlab.label ? SERVICES.gitlab : SERVICES.github;
|
|
21
|
+
await loginWithPastedToken(ctx.ui, service);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function accountLabels(data: StoreData): string[] {
|
|
25
|
+
return Object.keys(data.accounts).map(
|
|
26
|
+
(k) =>
|
|
27
|
+
`@${data.accounts[k]?.user ?? k.slice(k.indexOf(":") + 1)} (${data.accounts[k]?.platform ?? "github"})${
|
|
28
|
+
data.activeLogin === k ? " (current)" : ""
|
|
29
|
+
}`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function doSwitch(ctx: Ctx): Promise<void> {
|
|
34
|
+
const data = loadStore();
|
|
35
|
+
if (Object.keys(data.accounts).length === 0) {
|
|
36
|
+
ctx.ui.notify("No git accounts — run /auth login", "info");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const keys = Object.keys(data.accounts);
|
|
40
|
+
const labels = accountLabels(data);
|
|
41
|
+
const picked = await ctx.ui.select("git auth — set active account", labels);
|
|
42
|
+
const idx = labels.indexOf(picked ?? "");
|
|
43
|
+
if (idx < 0) return;
|
|
44
|
+
const key = keys[idx];
|
|
45
|
+
if (setActiveAccount(key)) {
|
|
46
|
+
ctx.ui.notify(
|
|
47
|
+
`Active account: @${accountName(data, key)} (${data.accounts[key]?.platform ?? "github"})`,
|
|
48
|
+
"info",
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function doLogout(ctx: Ctx): Promise<void> {
|
|
54
|
+
const data = loadStore();
|
|
55
|
+
if (Object.keys(data.accounts).length === 0) {
|
|
56
|
+
ctx.ui.notify("No git accounts logged in", "info");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const keys = Object.keys(data.accounts);
|
|
60
|
+
const labels = accountLabels(data);
|
|
61
|
+
const picked = await ctx.ui.select("git auth — pick an account to remove", labels);
|
|
62
|
+
const idx = labels.indexOf(picked ?? "");
|
|
63
|
+
if (idx < 0) return;
|
|
64
|
+
const key = keys[idx];
|
|
65
|
+
const ok = await ctx.ui.confirm("git auth logout", `Remove @${accountName(data, key)} (${data.accounts[key]?.platform})?`);
|
|
66
|
+
if (!ok) return;
|
|
67
|
+
removeAccount(key);
|
|
68
|
+
ctx.ui.notify(`Removed @${accountName(data, key)}`, "info");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Active account + its service, or a "not connected" notice. */
|
|
72
|
+
function requireActive(ctx: Ctx): { token: string; service: Service } | undefined {
|
|
73
|
+
const data = loadStore();
|
|
74
|
+
const acc = activeAccount(data);
|
|
75
|
+
const service = activeService(data);
|
|
76
|
+
if (!acc?.accessToken || !service) {
|
|
77
|
+
ctx.ui.notify("git auth: not connected — run /auth login", "warning");
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
return { token: acc.accessToken, service };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function doRepos(ctx: Ctx, restArg: string): Promise<void> {
|
|
84
|
+
const active = requireActive(ctx);
|
|
85
|
+
if (!active) return;
|
|
86
|
+
const org = restArg || undefined;
|
|
87
|
+
try {
|
|
88
|
+
const repos = await active.service.listRepos(active.token, org);
|
|
89
|
+
if (repos.length === 0) {
|
|
90
|
+
ctx.ui.notify(org ? `No repos in org ${org}` : "No repositories found", "info");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const labels = repos.map((r) => `${r.fullName} [${r.private ? "private" : "public"}]`);
|
|
94
|
+
const picked = await ctx.ui.select(
|
|
95
|
+
`Repositories${org ? ` (${org})` : " (owned)"} — pick one:`,
|
|
96
|
+
labels,
|
|
97
|
+
);
|
|
98
|
+
const idx = labels.indexOf(picked ?? "");
|
|
99
|
+
if (idx >= 0) {
|
|
100
|
+
await showRepoDetails(ctx, active.service, active.token, repos[idx].fullName);
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
ctx.ui.notify(`${active.service.label} error: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Fetch repo metadata, latest commits and tree, then show a scrollable details overlay. */
|
|
108
|
+
async function showRepoDetails(ctx: Ctx, service: Service, token: string, fullName: string): Promise<void> {
|
|
109
|
+
try {
|
|
110
|
+
const [repo, commits] = await Promise.all([service.meta(token, fullName), service.commits(token, fullName, 5)]);
|
|
111
|
+
let tree: TreeEntry[] = [];
|
|
112
|
+
if (commits.length > 0) {
|
|
113
|
+
try {
|
|
114
|
+
tree = await service.tree(token, fullName, repo.defaultBranch);
|
|
115
|
+
} catch {
|
|
116
|
+
tree = []; // non-fatal: details still shown without the tree
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const { title, lines } = buildDetailsText(repo, commits, tree);
|
|
120
|
+
await ctx.ui.custom<void>(
|
|
121
|
+
(tui, theme, _keybindings, done) =>
|
|
122
|
+
new RepoDetailsPanel(
|
|
123
|
+
title,
|
|
124
|
+
lines,
|
|
125
|
+
theme,
|
|
126
|
+
() => tui.terminal.rows,
|
|
127
|
+
() => done(undefined),
|
|
128
|
+
() => tui.requestRender(),
|
|
129
|
+
),
|
|
130
|
+
{
|
|
131
|
+
overlay: true,
|
|
132
|
+
overlayOptions: { width: "65%", minWidth: 64, maxHeight: "80%", margin: 1 },
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
} catch (e) {
|
|
136
|
+
ctx.ui.notify(`${service.label} error: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function doCreate(ctx: Ctx, restArg: string): Promise<void> {
|
|
141
|
+
const active = requireActive(ctx);
|
|
142
|
+
if (!active) return;
|
|
143
|
+
let fullName = restArg.trim();
|
|
144
|
+
if (!fullName) {
|
|
145
|
+
fullName = (await ctx.ui.input("New repository name", "name or owner/name"))?.trim() ?? "";
|
|
146
|
+
}
|
|
147
|
+
if (!fullName) return;
|
|
148
|
+
const sep = fullName.indexOf("/");
|
|
149
|
+
const org = sep > 0 ? fullName.slice(0, sep) : undefined;
|
|
150
|
+
const name = sep > 0 ? fullName.slice(sep + 1) : fullName;
|
|
151
|
+
if (!name) return;
|
|
152
|
+
|
|
153
|
+
const visibility = (await ctx.ui.select(`Create ${org ? `${org}/` : ""}${name}`, ["private", "public"])) ?? "private";
|
|
154
|
+
const description = await ctx.ui.input("Description (optional, Enter to skip)", "");
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const repo = await active.service.createRepo(active.token, name, {
|
|
158
|
+
org,
|
|
159
|
+
private: visibility === "private",
|
|
160
|
+
description: description?.trim() || undefined,
|
|
161
|
+
});
|
|
162
|
+
ctx.ui.notify(`Created ${repo.fullName} → ${repo.htmlUrl}`, "info");
|
|
163
|
+
} catch (e) {
|
|
164
|
+
ctx.ui.notify(`${active.service.label} error: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const MENU: { label: string; key: string }[] = [
|
|
169
|
+
{ label: "status", key: "status" },
|
|
170
|
+
{ label: "login", key: "login" },
|
|
171
|
+
{ label: "logout", key: "logout" },
|
|
172
|
+
{ label: "switch account", key: "switch" },
|
|
173
|
+
{ label: "repos [org]", key: "repos" },
|
|
174
|
+
{ label: "create [org/]name", key: "create" },
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* /auth [status|login|logout|switch|repos [org]|create [org/]name]
|
|
179
|
+
*
|
|
180
|
+
* Without arguments (or with an unknown one) an interactive TUI menu is
|
|
181
|
+
* shown; each option runs the matching action, which prompts for any
|
|
182
|
+
* missing arguments itself. `login` offers GitHub and GitLab; every other
|
|
183
|
+
* action uses the service of the active account.
|
|
184
|
+
*/
|
|
185
|
+
export async function handleAuthCommand(args: string, ctx: Ctx): Promise<void> {
|
|
186
|
+
const actions: Record<string, () => Promise<void>> = {
|
|
187
|
+
status: () => showStatus(ctx),
|
|
188
|
+
login: () => doLogin(ctx),
|
|
189
|
+
logout: () => doLogout(ctx),
|
|
190
|
+
switch: () => doSwitch(ctx),
|
|
191
|
+
repos: () => doRepos(ctx, restArg),
|
|
192
|
+
create: () => doCreate(ctx, restArg),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const [sub, ...rest] = (args ?? "").trim().split(/\s+/);
|
|
196
|
+
const arg = (sub ?? "").toLowerCase();
|
|
197
|
+
const restArg = rest.join(" ");
|
|
198
|
+
|
|
199
|
+
let chosen = arg && actions[arg] ? arg : undefined;
|
|
200
|
+
if (!chosen) {
|
|
201
|
+
const pick = await ctx.ui.select("git auth — pick an action", MENU.map((m) => m.label));
|
|
202
|
+
chosen = MENU.find((m) => m.label === pick)?.key;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (chosen) {
|
|
206
|
+
await actions[chosen]();
|
|
207
|
+
}
|
|
208
|
+
}
|