copilot-reverse 0.2.0 → 0.2.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/dist/cli/auth.js +17 -3
- package/dist/cli/index.js +32 -11
- package/dist/tui/app.js +18 -1
- package/dist/tui/assistant/on-chat.js +10 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/cli/auth.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { requestDeviceCode, pollForToken } from "../providers/copilot/auth.js";
|
|
2
2
|
import { writeGhToken } from "../shared/creds.js";
|
|
3
|
-
|
|
3
|
+
// Two-phase device login. `beginDeviceLogin` returns the verification code right away so a caller
|
|
4
|
+
// can surface it to the user; `complete()` then blocks on authorization and writes the token.
|
|
5
|
+
// Splitting these is what lets the TUI render the code while the poll is still pending — folding
|
|
6
|
+
// both into one call buffers the code behind the blocking poll, and the user can't authorize a
|
|
7
|
+
// code they can't see.
|
|
8
|
+
export async function beginDeviceLogin(dir, fetchFn = fetch) {
|
|
4
9
|
const code = await requestDeviceCode(fetchFn);
|
|
10
|
+
return {
|
|
11
|
+
code,
|
|
12
|
+
complete: async () => {
|
|
13
|
+
const token = await pollForToken(code.device_code, code.interval * 1000, fetchFn);
|
|
14
|
+
writeGhToken(token, dir);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export async function runDeviceLogin(dir, fetchFn = fetch, log = console.log) {
|
|
19
|
+
const { code, complete } = await beginDeviceLogin(dir, fetchFn);
|
|
5
20
|
log(`\nOpen ${code.verification_uri} and enter code: ${code.user_code}\n`);
|
|
6
|
-
|
|
7
|
-
writeGhToken(token, dir);
|
|
21
|
+
await complete();
|
|
8
22
|
log("GitHub authorization complete.");
|
|
9
23
|
}
|
package/dist/cli/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Command } from "commander";
|
|
|
5
5
|
import { App } from "../tui/app.js";
|
|
6
6
|
import { buildRegistry } from "../tui/slash/commands.js";
|
|
7
7
|
import { DaemonClient } from "../tui/daemon-client.js";
|
|
8
|
-
import { runDeviceLogin } from "./auth.js";
|
|
8
|
+
import { runDeviceLogin, beginDeviceLogin } from "./auth.js";
|
|
9
9
|
import { probeSupervisor } from "../daemon/lifecycle.js";
|
|
10
10
|
import { startSupervisor } from "../supervisor/index.js";
|
|
11
11
|
import { runAssistantTurn } from "../tui/assistant/runtime.js";
|
|
@@ -74,14 +74,6 @@ async function launchTui() {
|
|
|
74
74
|
appVersion: APP_VERSION,
|
|
75
75
|
platform: `${process.platform} node-${process.version}`,
|
|
76
76
|
resetClient,
|
|
77
|
-
// Re-run device-code login, then restart the worker so it picks up the new token.
|
|
78
|
-
login: async () => {
|
|
79
|
-
const lines = [];
|
|
80
|
-
await runDeviceLogin(dataDir(), fetch, (m) => lines.push(m));
|
|
81
|
-
await client.restart().catch(() => { });
|
|
82
|
-
lines.push("worker restarting with the new token");
|
|
83
|
-
return lines;
|
|
84
|
-
},
|
|
85
77
|
// Clear the stored token and restart the worker (it will report unauthenticated until re-login).
|
|
86
78
|
logout: async () => {
|
|
87
79
|
clearGhToken(dataDir());
|
|
@@ -89,9 +81,22 @@ async function launchTui() {
|
|
|
89
81
|
return ["signed out — GitHub token removed", "run /login to sign in again"];
|
|
90
82
|
},
|
|
91
83
|
});
|
|
84
|
+
// Two-phase /login for the TUI: surface the device code immediately, poll in the background, then
|
|
85
|
+
// restart the worker so it picks up the new token. The blocking single-call form deadlocked the
|
|
86
|
+
// Repl (the code stayed hidden behind the poll, so the user could never authorize it).
|
|
87
|
+
const doLogin = async (show) => {
|
|
88
|
+
const { code, complete } = await beginDeviceLogin(dataDir());
|
|
89
|
+
show([`Open ${code.verification_uri} and enter code: ${code.user_code}`, "waiting for authorization…"]);
|
|
90
|
+
await complete();
|
|
91
|
+
// Re-point the token store at the freshly written GitHub token; the old store still holds the
|
|
92
|
+
// expired one and would 401 once its cached Copilot token rotates, breaking the model picker.
|
|
93
|
+
tokenStore = new CopilotTokenStore(readGhToken(dataDir()));
|
|
94
|
+
await client.restart().catch(() => { });
|
|
95
|
+
return ["GitHub authorization complete — worker restarting with the new token"];
|
|
96
|
+
};
|
|
92
97
|
// Filled in below once we have a token; the assistant prefers a model's real window over the default.
|
|
93
98
|
const modelLimits = {};
|
|
94
|
-
|
|
99
|
+
let tokenStore = new CopilotTokenStore(readGhToken(dataDir()));
|
|
95
100
|
const loadModels = async () => {
|
|
96
101
|
const token = await tokenStore.get();
|
|
97
102
|
const [ids, limits] = await Promise.all([fetchCopilotModels(token), fetchModelLimits(token)]);
|
|
@@ -122,7 +127,22 @@ async function launchTui() {
|
|
|
122
127
|
maxInputTokens: DEFAULT_MAX_INPUT_TOKENS, modelLimits,
|
|
123
128
|
listModels: loadModels,
|
|
124
129
|
setupClient: async (c, s, m) => applyClient(c, s, m),
|
|
125
|
-
}, (c, p, print, abort) => runAssistantTurn(c, p, print, undefined, abort)
|
|
130
|
+
}, (c, p, print, abort) => runAssistantTurn(c, p, print, undefined, abort), undefined,
|
|
131
|
+
// Pre-flight auth gate: block a turn (with an actionable hint) when there's no GitHub token, or
|
|
132
|
+
// the stored one no longer exchanges for a Copilot token — instead of firing a request that just
|
|
133
|
+
// hangs until the turn timeout. Reuses the long-lived tokenStore so a valid login is a cached,
|
|
134
|
+
// round-trip-free check between message bursts (its get() caches with a 60s skew).
|
|
135
|
+
async () => {
|
|
136
|
+
if (!readGhToken(dataDir()))
|
|
137
|
+
return "you're signed out — run /login to sign in before chatting";
|
|
138
|
+
try {
|
|
139
|
+
await tokenStore.get();
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return "your GitHub login has expired — run /login to sign in again";
|
|
144
|
+
}
|
|
145
|
+
});
|
|
126
146
|
const persistedModel = readChatModel(dataDir());
|
|
127
147
|
app = render(React.createElement(App, {
|
|
128
148
|
registry,
|
|
@@ -143,6 +163,7 @@ async function launchTui() {
|
|
|
143
163
|
},
|
|
144
164
|
onModelChange: (m) => writeChatModel(dataDir(), m),
|
|
145
165
|
pickModelOnStart: !persistedModel,
|
|
166
|
+
login: doLogin,
|
|
146
167
|
}));
|
|
147
168
|
}
|
|
148
169
|
const program = new Command();
|
package/dist/tui/app.js
CHANGED
|
@@ -36,7 +36,7 @@ function ClientBadge({ name, status }) {
|
|
|
36
36
|
const cell = (label, on) => (_jsxs(Text, { color: on ? theme.ready : theme.muted, children: [label, ":", on ? "✓" : "○"] }));
|
|
37
37
|
return (_jsxs(Text, { color: theme.muted, children: [name, " ", cell("u", status.user), " ", cell("p", status.project)] }));
|
|
38
38
|
}
|
|
39
|
-
export function App({ registry, title, workerState = "starting", initialModel = "—", statusSource, readStatus, modelLimits, onChat, loadModels, setup, info, onModelChange, pickModelOnStart, }) {
|
|
39
|
+
export function App({ registry, title, workerState = "starting", initialModel = "—", statusSource, readStatus, modelLimits, onChat, loadModels, setup, info, onModelChange, pickModelOnStart, login, }) {
|
|
40
40
|
const cmds = registry.list().map((c) => ({ name: c.name, describe: c.describe }));
|
|
41
41
|
const [entries, setEntries] = useState([
|
|
42
42
|
{ type: "system", text: "Type a message to chat with the assistant, or /help for commands." },
|
|
@@ -47,6 +47,7 @@ export function App({ registry, title, workerState = "starting", initialModel =
|
|
|
47
47
|
const [screen, setScreen] = useState(pickModelOnStart && loadModels ? { kind: "model" } : null);
|
|
48
48
|
const [, setNow] = useState(0); // ticks the live loading line while the assistant streams
|
|
49
49
|
const abortRef = useRef(null); // current turn's interrupt handle
|
|
50
|
+
const loginInFlight = useRef(false); // guards against starting a second device-login flow
|
|
50
51
|
const add = (e) => setEntries((p) => [...p, e].slice(-100));
|
|
51
52
|
const refreshStatus = () => { if (readStatus)
|
|
52
53
|
setStatus(readStatus()); };
|
|
@@ -95,6 +96,22 @@ export function App({ registry, title, workerState = "starting", initialModel =
|
|
|
95
96
|
setScreen({ kind: "config" });
|
|
96
97
|
return;
|
|
97
98
|
}
|
|
99
|
+
if (t === "/login" && login) {
|
|
100
|
+
// Show the verification URL + code right away, then resolve a completion card once the user
|
|
101
|
+
// authorizes. Done as a special case (not a registry command) because the slash registry only
|
|
102
|
+
// renders a command's final return value — it can't surface the code mid-poll. Guarded so a
|
|
103
|
+
// double Enter doesn't start two device-code flows (polling a superseded code 401s).
|
|
104
|
+
if (loginInFlight.current) {
|
|
105
|
+
add({ type: "card", title: "/login", tone: "info", lines: ["already waiting for authorization…"] });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
loginInFlight.current = true;
|
|
109
|
+
void login((lines) => add({ type: "card", title: "/login", tone: "info", lines }))
|
|
110
|
+
.then((lines) => add({ type: "card", title: "/login", tone: "ok", lines }))
|
|
111
|
+
.catch((e) => add({ type: "card", title: "/login", tone: "error", lines: [`login failed: ${e instanceof Error ? e.message : String(e)}`] }))
|
|
112
|
+
.finally(() => { loginInFlight.current = false; });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
98
115
|
if (setup && loadModels && (t === "/setup-claude" || t === "/setup-codex")) {
|
|
99
116
|
setScreen({ kind: "setup", client: t === "/setup-claude" ? "claude" : "codex" });
|
|
100
117
|
return;
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
const DEFAULT_TURN_TIMEOUT_MS = 120_000; // 2 minutes — a turn that hasn't replied by then is given up on
|
|
2
|
-
export function makeOnChat(cfg, runner, timeoutMs = DEFAULT_TURN_TIMEOUT_MS) {
|
|
2
|
+
export function makeOnChat(cfg, runner, timeoutMs = DEFAULT_TURN_TIMEOUT_MS, precheck) {
|
|
3
3
|
return async (text, print, model, abort) => {
|
|
4
|
+
// Gate the turn on auth before firing a doomed request. Without this, a signed-out user's message
|
|
5
|
+
// hangs until the 120s timeout instead of getting an immediate, actionable hint.
|
|
6
|
+
if (precheck) {
|
|
7
|
+
const blocked = await precheck().catch(() => null); // a failed check must never wedge chat
|
|
8
|
+
if (blocked) {
|
|
9
|
+
print(blocked);
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
4
13
|
const ctrl = abort ?? new AbortController();
|
|
5
14
|
let timedOut = false;
|
|
6
15
|
// Race the turn against a hard timeout so a hung SDK/upstream can never block the UI forever.
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// AUTO-GENERATED by scripts/gen-version.mjs from package.json — do not edit.
|
|
2
|
-
export const APP_VERSION = "0.2.
|
|
2
|
+
export const APP_VERSION = "0.2.1";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "copilot-reverse",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Interactive terminal app that exposes your GitHub Copilot subscription as local OpenAI- and Anthropic-compatible endpoints, with a self-healing daemon and a built-in assistant.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|