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 CHANGED
@@ -1,9 +1,23 @@
1
1
  import { requestDeviceCode, pollForToken } from "../providers/copilot/auth.js";
2
2
  import { writeGhToken } from "../shared/creds.js";
3
- export async function runDeviceLogin(dir, fetchFn = fetch, log = console.log) {
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
- const token = await pollForToken(code.device_code, code.interval * 1000, fetchFn);
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
- const tokenStore = new CopilotTokenStore(readGhToken(dataDir()));
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.0";
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.0",
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",