@tiens.nguyen/gonext-cli 1.0.350
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/README.md +52 -0
- package/coding-key-prompt.mjs +47 -0
- package/daemon-control.mjs +232 -0
- package/device-login.mjs +141 -0
- package/gonext-cli.mjs +3216 -0
- package/gonext-repl.mjs +2648 -0
- package/gonext_agent_chat.py +7576 -0
- package/gonext_mlx_embed.py +155 -0
- package/gonext_probe_agent.py +93 -0
- package/gonext_transcribe.py +130 -0
- package/model-doctor.mjs +571 -0
- package/package.json +53 -0
- package/thinking_words.txt +1003 -0
package/README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @tiens.nguyen/gonext-cli
|
|
2
|
+
Run:
|
|
3
|
+
GONEXT_API_BASE=... GONEXT_WORKER_KEY=... npx -y --package @tiens.nguyen/gonext-cli gonext-cli
|
|
4
|
+
|
|
5
|
+
## Agent chat mode
|
|
6
|
+
|
|
7
|
+
Select **Agent** (instead of Chat) under the composer in the web app. Your
|
|
8
|
+
free-form prompt is handed to a [smolagents](https://github.com/huggingface/smolagents)
|
|
9
|
+
agent running on your local MLX/Ollama model. The agent can call tools (v1:
|
|
10
|
+
`http_request`) and streams its thinking steps + final answer directly into the
|
|
11
|
+
chat thread.
|
|
12
|
+
|
|
13
|
+
Requires smolagents in the worker's Python environment:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pip install smolagents certifi
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- `certifi` supplies a trusted CA bundle so the agent's `http_request` tool can
|
|
20
|
+
verify HTTPS certificates on macOS (where Python's default bundle may be
|
|
21
|
+
missing). The worker falls back to the system bundle if certifi is absent.
|
|
22
|
+
- Agent mode is blocked for cloud models (the API returns 400). Select a local
|
|
23
|
+
MLX or Ollama model first.
|
|
24
|
+
- Tool steps appear in the collapsible reasoning (`<think>`) area; the final
|
|
25
|
+
answer is the message body — no new UI needed.
|
|
26
|
+
|
|
27
|
+
The agent script is `gonext_agent_chat.py` (reads `{messages, agentBaseURL,
|
|
28
|
+
agentApiKey, agentModelId, tools, maxSteps}` on stdin; emits NDJSON
|
|
29
|
+
`{"type":"step"/"final","text":"..."}` lines on stdout).
|
|
30
|
+
|
|
31
|
+
## API Check / HTTP probe (Tools & Agents modes)
|
|
32
|
+
|
|
33
|
+
The worker can run Postman-style HTTP probes queued from the web app
|
|
34
|
+
("Tools" = `tool_only`, "Agents" = `agentic`). The worker always performs the
|
|
35
|
+
actual HTTP request itself (Node `fetch`, so TLS works), measures the
|
|
36
|
+
status/latency/headers/body, and classifies it (2xx/3xx/4xx/5xx/timeout/
|
|
37
|
+
network_error).
|
|
38
|
+
|
|
39
|
+
- **Tools (`tool_only`)** — no extra setup. The selected local model writes a
|
|
40
|
+
one-line health summary of the measured result.
|
|
41
|
+
- **Agents (`agentic`)** — a smolagents agent (running on the selected local
|
|
42
|
+
model) produces the summary. Requires `pip install smolagents`.
|
|
43
|
+
|
|
44
|
+
The agent talks to your local MLX OpenAI-compatible server (no cloud calls).
|
|
45
|
+
The agent only summarizes; the worker's measurement stays the source of truth,
|
|
46
|
+
so if smolagents or the model is unavailable the probe still returns the
|
|
47
|
+
measured result with a note.
|
|
48
|
+
|
|
49
|
+
### Env vars
|
|
50
|
+
|
|
51
|
+
GONEXT_PROBE_PYTHON Python executable for smolagents scripts
|
|
52
|
+
(default: GONEXT_MLX_LM_PYTHON or python3)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// The startup Kimi-K3 (cloud coding model) API-key prompt (task #127). Extracted from the
|
|
2
|
+
// REPL so it's importable + testable with a mock `ask`/`save` — the REPL bin runs main() on
|
|
3
|
+
// import, so the logic can't live there and be tested directly.
|
|
4
|
+
//
|
|
5
|
+
// When the resolved agent CODE model is an OpenAI-compatible cloud coder with NO key stored,
|
|
6
|
+
// the API payload carries codingKeyMissing (+ codingKeyModel/codingKeyUrl). Here we ask the
|
|
7
|
+
// user for the key (masked, via the injected askSecret) and save it. If they skip, the state
|
|
8
|
+
// stays "waiting" SERVER-SIDE (coder configured, no key), so this fires again on the next
|
|
9
|
+
// `gonext` start until the key is set. Until then the agent falls back to the chat model for
|
|
10
|
+
// coding (the coder is omitted from the job payload), so the terminal still works.
|
|
11
|
+
|
|
12
|
+
const identity = (s) => s;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param payload the /agent-payload `payload` object (reads codingKeyMissing/codingKeyModel).
|
|
16
|
+
* @param deps.askSecret async (prompt) => string — masked input; "" means the user skipped.
|
|
17
|
+
* @param deps.saveCodingKey async (key) => boolean — persist the key (POST /coding-key).
|
|
18
|
+
* @param deps.log (line) => void — printer.
|
|
19
|
+
* @param deps.colors optional { dim, cyan, green, red } — identity in tests.
|
|
20
|
+
* @returns { asked, saved } — asked=false when there was no missing key.
|
|
21
|
+
*/
|
|
22
|
+
export async function promptCodingKeyIfMissing(payload, { askSecret, saveCodingKey, log, colors = {} }) {
|
|
23
|
+
const dim = colors.dim ?? identity;
|
|
24
|
+
const cyan = colors.cyan ?? identity;
|
|
25
|
+
const green = colors.green ?? identity;
|
|
26
|
+
const red = colors.red ?? identity;
|
|
27
|
+
|
|
28
|
+
if (!payload?.codingKeyMissing) return { asked: false, saved: false };
|
|
29
|
+
|
|
30
|
+
const model = String(payload.codingKeyModel || "the coding model").trim();
|
|
31
|
+
log(dim("\ngonext: your agent coding model (") + cyan(model) + dim(") needs an API key."));
|
|
32
|
+
log(dim(" Until it's set, coding falls back to your local model. Enter the key now,"));
|
|
33
|
+
log(dim(" or press Enter to skip (I'll ask again next time).\n"));
|
|
34
|
+
|
|
35
|
+
const key = await askSecret(` ${model} API key: `);
|
|
36
|
+
if (!key) {
|
|
37
|
+
log(dim(" ⏳ waiting for your ") + cyan(model) + dim(" API key — I'll ask again next time.\n"));
|
|
38
|
+
return { asked: true, saved: false };
|
|
39
|
+
}
|
|
40
|
+
const ok = await saveCodingKey(key);
|
|
41
|
+
log(
|
|
42
|
+
ok
|
|
43
|
+
? green(` ✓ saved — ${model} is now your coding model.\n`)
|
|
44
|
+
: red(" couldn't save the key — try again next start, or set it in web Settings → Agent.\n")
|
|
45
|
+
);
|
|
46
|
+
return { asked: true, saved: ok };
|
|
47
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Worker-daemon lifecycle for the gonext CLI (task #127, Phase 2). Lets a plain terminal
|
|
2
|
+
// keep the polling worker running WITHOUT the GoTerminal desktop app: `gonext-cli
|
|
3
|
+
// start|stop|status|logs`, and `gonext` (the REPL) auto-ensures it on startup.
|
|
4
|
+
//
|
|
5
|
+
// BC4 (task #127): NEVER double-spawn. A second polling daemon would race the first for
|
|
6
|
+
// jobs. We detect an already-running daemon two ways — our own pidfile AND a `ps` scan that
|
|
7
|
+
// also catches a daemon started by GoTerminal or by hand (which never wrote our pidfile) —
|
|
8
|
+
// and refuse to start when either says one is alive.
|
|
9
|
+
import { spawn, execFile } from "node:child_process";
|
|
10
|
+
import { readFile, writeFile, unlink, mkdir, open, stat } from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const DIR = join(homedir(), ".gonext");
|
|
16
|
+
export const PID_FILE = join(DIR, "worker.pid");
|
|
17
|
+
export const LOG_FILE = join(DIR, "worker.log");
|
|
18
|
+
const LOCK_FILE = join(DIR, "worker.start.lock");
|
|
19
|
+
const LOCK_STALE_MS = 30_000; // a lock older than this is from a crashed start → steal it
|
|
20
|
+
const DAEMON_SCRIPT = fileURLToPath(new URL("./gonext-cli.mjs", import.meta.url));
|
|
21
|
+
// The daemon script was named gonext-local-worker.mjs before the CLI was renamed to
|
|
22
|
+
// gonext-cli. Match BOTH so BC4 (never double-spawn) still detects a daemon started by an
|
|
23
|
+
// OLDER install across the upgrade window.
|
|
24
|
+
const DAEMON_FILE_RE = /gonext-(cli|local-worker)\.mjs(\s|$)/;
|
|
25
|
+
|
|
26
|
+
// Any cmdline containing one of these is a SUBCOMMAND invocation (login, start, …), not the
|
|
27
|
+
// polling daemon — so it must not be mistaken for a running worker in the ps scan.
|
|
28
|
+
const SUBCOMMANDS = [
|
|
29
|
+
"login", "set", "start", "stop", "status", "logs", "restart",
|
|
30
|
+
"ws-ping-test", "simulate-chat", "embed", "workspace",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function pidAlive(pid) {
|
|
34
|
+
if (!pid || !Number.isFinite(pid)) return false;
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
return true;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
// EPERM = the process exists but we can't signal it (still "alive" for our purposes).
|
|
40
|
+
return e && e.code === "EPERM";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function psList() {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
execFile(
|
|
47
|
+
"ps",
|
|
48
|
+
["-A", "-o", "pid=,command="],
|
|
49
|
+
{ maxBuffer: 8 * 1024 * 1024 },
|
|
50
|
+
(err, stdout) => {
|
|
51
|
+
if (err) return resolve([]);
|
|
52
|
+
const rows = [];
|
|
53
|
+
for (const line of String(stdout).split("\n")) {
|
|
54
|
+
const m = line.trim().match(/^(\d+)\s+(.*)$/);
|
|
55
|
+
if (m) rows.push({ pid: Number(m[1]), cmd: m[2] });
|
|
56
|
+
}
|
|
57
|
+
resolve(rows);
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* PIDs of polling worker daemons currently running (excluding THIS process and any
|
|
65
|
+
* subcommand invocation). Catches a daemon started by GoTerminal / by hand, not just ours.
|
|
66
|
+
*/
|
|
67
|
+
export async function findRunningDaemonPids() {
|
|
68
|
+
const rows = await psList();
|
|
69
|
+
const self = process.pid;
|
|
70
|
+
return rows
|
|
71
|
+
.filter((r) => r.pid !== self)
|
|
72
|
+
.filter((r) => DAEMON_FILE_RE.test(r.cmd))
|
|
73
|
+
.filter((r) => !SUBCOMMANDS.some((sc) => new RegExp(`(\\s|/)${sc}(\\s|$)`).test(r.cmd)))
|
|
74
|
+
.map((r) => r.pid);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* True when `pid` is actually a gonext polling daemon (not an unrelated process that reused
|
|
79
|
+
* the PID). Reads that one pid's command line — cheap and exact (review M1).
|
|
80
|
+
*/
|
|
81
|
+
function pidIsGonextDaemon(pid) {
|
|
82
|
+
if (!pid || !Number.isFinite(pid)) return Promise.resolve(false);
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
execFile("ps", ["-p", String(pid), "-o", "command="], (err, stdout) => {
|
|
85
|
+
const cmd = String(stdout || "").trim();
|
|
86
|
+
resolve(
|
|
87
|
+
!err &&
|
|
88
|
+
DAEMON_FILE_RE.test(cmd) &&
|
|
89
|
+
!SUBCOMMANDS.some((sc) => new RegExp(`(\\s|/)${sc}(\\s|$)`).test(cmd))
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function readPidFile() {
|
|
96
|
+
try {
|
|
97
|
+
const n = Number(String(await readFile(PID_FILE, "utf8")).trim());
|
|
98
|
+
return Number.isFinite(n) ? n : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* { running, pids, pidFilePid }. The `ps` scan is the primary signal. The pidfile is trusted
|
|
106
|
+
* ONLY after VERIFYING that its PID is genuinely a gonext daemon (review M1) — this both
|
|
107
|
+
* rescues the case where the full-table scan missed it and rejects a stale pidfile whose PID
|
|
108
|
+
* was reused by an unrelated process (which would otherwise wrongly suppress auto-start).
|
|
109
|
+
*/
|
|
110
|
+
export async function daemonStatus() {
|
|
111
|
+
const [pids, pidFilePid] = await Promise.all([findRunningDaemonPids(), readPidFile()]);
|
|
112
|
+
const pidFileIsDaemon =
|
|
113
|
+
pidFilePid != null && (pids.includes(pidFilePid) || (await pidIsGonextDaemon(pidFilePid)));
|
|
114
|
+
const allPids = pidFileIsDaemon && !pids.includes(pidFilePid) ? [...pids, pidFilePid] : pids;
|
|
115
|
+
return {
|
|
116
|
+
running: allPids.length > 0,
|
|
117
|
+
pids: allPids,
|
|
118
|
+
pidFilePid: pidFileIsDaemon ? pidFilePid : null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Acquire an exclusive single-flight lock over the check→spawn window (review L2). O_EXCL
|
|
126
|
+
* (`wx`) is atomic, so of two concurrent starts exactly one wins. A lock left by a crashed
|
|
127
|
+
* start is stolen once it's older than LOCK_STALE_MS so it can never wedge startup.
|
|
128
|
+
* Returns a handle whose .release() removes the lock, or null if another start holds it.
|
|
129
|
+
*/
|
|
130
|
+
async function acquireStartLock() {
|
|
131
|
+
await mkdir(DIR, { recursive: true });
|
|
132
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
133
|
+
try {
|
|
134
|
+
const fh = await open(LOCK_FILE, "wx");
|
|
135
|
+
await fh.writeFile(String(process.pid), "utf8");
|
|
136
|
+
return {
|
|
137
|
+
release: async () => {
|
|
138
|
+
await fh.close().catch(() => {});
|
|
139
|
+
await unlink(LOCK_FILE).catch(() => {});
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (!e || e.code !== "EEXIST") throw e;
|
|
144
|
+
// Held by someone else — steal it only if it's stale (a crashed start), then retry.
|
|
145
|
+
let age = 0;
|
|
146
|
+
try {
|
|
147
|
+
age = Date.now() - (await stat(LOCK_FILE)).mtimeMs;
|
|
148
|
+
} catch {
|
|
149
|
+
age = LOCK_STALE_MS + 1; // vanished between EEXIST and stat → treat as free
|
|
150
|
+
}
|
|
151
|
+
if (age > LOCK_STALE_MS) {
|
|
152
|
+
await unlink(LOCK_FILE).catch(() => {});
|
|
153
|
+
continue; // retry the atomic create
|
|
154
|
+
}
|
|
155
|
+
return null; // a fresh lock → another start is genuinely in progress
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Start the polling daemon DETACHED (survives the launching shell, logs to ~/.gonext/
|
|
163
|
+
* worker.log). No-op when one is already running (BC4). Concurrency-safe (L2): a lock
|
|
164
|
+
* serializes the check→spawn window so two simultaneous starts can't both spawn.
|
|
165
|
+
* Returns { started, pid?, pids }.
|
|
166
|
+
*/
|
|
167
|
+
export async function startDaemon() {
|
|
168
|
+
// Fast path: already running → no lock needed.
|
|
169
|
+
const pre = await daemonStatus();
|
|
170
|
+
if (pre.running) return { started: false, ...pre };
|
|
171
|
+
const lock = await acquireStartLock();
|
|
172
|
+
if (!lock) {
|
|
173
|
+
// A concurrent start holds the lock — let it win, then report the resulting state.
|
|
174
|
+
await sleep(700);
|
|
175
|
+
const s = await daemonStatus();
|
|
176
|
+
return { started: false, ...s };
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
// Re-check UNDER the lock — the winner of a race must see the loser's daemon if it
|
|
180
|
+
// spawned in the gap (and vice-versa this is where the second caller no-ops).
|
|
181
|
+
const status = await daemonStatus();
|
|
182
|
+
if (status.running) return { started: false, ...status };
|
|
183
|
+
const out = await open(LOG_FILE, "a");
|
|
184
|
+
try {
|
|
185
|
+
const child = spawn(process.execPath, [DAEMON_SCRIPT], {
|
|
186
|
+
detached: true,
|
|
187
|
+
stdio: ["ignore", out.fd, out.fd],
|
|
188
|
+
env: process.env,
|
|
189
|
+
});
|
|
190
|
+
await writeFile(PID_FILE, String(child.pid ?? ""), "utf8");
|
|
191
|
+
child.unref();
|
|
192
|
+
return { started: true, pid: child.pid, pids: [] };
|
|
193
|
+
} finally {
|
|
194
|
+
await out.close();
|
|
195
|
+
}
|
|
196
|
+
} finally {
|
|
197
|
+
await lock.release();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Stop every running daemon (ours + any ps-found) and clear the pidfile. */
|
|
202
|
+
export async function stopDaemon() {
|
|
203
|
+
const status = await daemonStatus();
|
|
204
|
+
const targets = new Set(status.pids);
|
|
205
|
+
if (status.pidFilePid) targets.add(status.pidFilePid);
|
|
206
|
+
let killed = 0;
|
|
207
|
+
for (const pid of targets) {
|
|
208
|
+
try {
|
|
209
|
+
process.kill(pid, "SIGTERM");
|
|
210
|
+
killed++;
|
|
211
|
+
} catch {
|
|
212
|
+
/* already gone */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
await unlink(PID_FILE);
|
|
217
|
+
} catch {
|
|
218
|
+
/* no pidfile */
|
|
219
|
+
}
|
|
220
|
+
return { killed };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Last `n` lines of the daemon log (empty string when none yet). */
|
|
224
|
+
export async function tailLog(n = 40) {
|
|
225
|
+
try {
|
|
226
|
+
const txt = await readFile(LOG_FILE, "utf8");
|
|
227
|
+
const lines = txt.split("\n");
|
|
228
|
+
return lines.slice(Math.max(0, lines.length - n)).join("\n");
|
|
229
|
+
} catch {
|
|
230
|
+
return "";
|
|
231
|
+
}
|
|
232
|
+
}
|
package/device-login.mjs
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Device-pairing login for the gonext CLI (task #127, Phase 1). Shared by both bins
|
|
2
|
+
// (gonext-cli.mjs and gonext-repl.mjs) so `gonext login` and the first-run
|
|
3
|
+
// auto-prompt use the exact same flow. No secrets live here — the CLI obtains a worker key
|
|
4
|
+
// only after an authenticated human approves in the browser.
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
|
7
|
+
import { homedir, platform } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import dotenv from "dotenv";
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_API_BASE = "https://chat-api-v2.gomarsic.cc";
|
|
12
|
+
const ENV_FILE = join(homedir(), ".gonext", "worker.env");
|
|
13
|
+
const POLL_INTERVAL_MS = 2500;
|
|
14
|
+
const POLL_TIMEOUT_MS = 10 * 60_000; // matches the server's pairing TTL
|
|
15
|
+
|
|
16
|
+
/** True when worker.env already has BOTH a key and an API base (task #127 BC1a). */
|
|
17
|
+
export async function hasWorkerConfig() {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = dotenv.parse(await readFile(ENV_FILE, "utf8"));
|
|
20
|
+
return Boolean(
|
|
21
|
+
String(parsed.GONEXT_WORKER_KEY ?? "").trim() &&
|
|
22
|
+
String(parsed.GONEXT_API_BASE ?? "").trim()
|
|
23
|
+
);
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Write ~/.gonext/worker.env, preserving any existing GONEXT_POLL_MS. */
|
|
30
|
+
async function writeWorkerEnv(apiBase, workerKey) {
|
|
31
|
+
let pollMs = "500";
|
|
32
|
+
try {
|
|
33
|
+
const prev = dotenv.parse(await readFile(ENV_FILE, "utf8"));
|
|
34
|
+
if (String(prev.GONEXT_POLL_MS ?? "").trim()) pollMs = String(prev.GONEXT_POLL_MS).trim();
|
|
35
|
+
} catch {
|
|
36
|
+
/* first-time write */
|
|
37
|
+
}
|
|
38
|
+
await mkdir(join(homedir(), ".gonext"), { recursive: true });
|
|
39
|
+
await writeFile(
|
|
40
|
+
ENV_FILE,
|
|
41
|
+
`GONEXT_API_BASE=${apiBase.replace(/\/+$/, "")}\nGONEXT_WORKER_KEY=${workerKey}\nGONEXT_POLL_MS=${pollMs}\n`,
|
|
42
|
+
{ encoding: "utf8", mode: 0o600 }
|
|
43
|
+
);
|
|
44
|
+
return ENV_FILE;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function openBrowser(url) {
|
|
48
|
+
const cmd =
|
|
49
|
+
platform() === "darwin" ? "open" : platform() === "win32" ? "start" : "xdg-open";
|
|
50
|
+
try {
|
|
51
|
+
execFile(cmd, [url], () => {}); // best-effort; the URL is also printed
|
|
52
|
+
} catch {
|
|
53
|
+
/* the user can open it manually */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Run the browser device-pairing flow and write worker.env on success.
|
|
61
|
+
*
|
|
62
|
+
* Returns { apiBase, workerKey, envFile } on success.
|
|
63
|
+
* Throws Error with .code === "PAIRING_UNSUPPORTED" when the API has no /pair routes
|
|
64
|
+
* (404), so callers can fall back to the manual `set` path (task #127 BC7).
|
|
65
|
+
* Throws Error with .code === "PAIRING_TIMEOUT" / "PAIRING_EXPIRED" otherwise.
|
|
66
|
+
*
|
|
67
|
+
* `log` is an injectable printer (defaults to console.log) so the REPL can style it.
|
|
68
|
+
*/
|
|
69
|
+
export async function deviceLogin({ apiBase = DEFAULT_API_BASE, log = console.log } = {}) {
|
|
70
|
+
const base = String(apiBase || DEFAULT_API_BASE).trim().replace(/\/+$/, "");
|
|
71
|
+
let start;
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(`${base}/api/pair/start`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { "Content-Type": "application/json" },
|
|
76
|
+
body: JSON.stringify({ apiBase: base }),
|
|
77
|
+
});
|
|
78
|
+
if (res.status === 404) {
|
|
79
|
+
const err = new Error("This API does not support CLI login (device pairing).");
|
|
80
|
+
err.code = "PAIRING_UNSUPPORTED";
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
if (!res.ok) {
|
|
84
|
+
throw new Error(`pairing start failed (HTTP ${res.status})`);
|
|
85
|
+
}
|
|
86
|
+
start = await res.json();
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (e && e.code === "PAIRING_UNSUPPORTED") throw e;
|
|
89
|
+
// A network failure to a base that has no /pair yet also can't pair — surface as
|
|
90
|
+
// unsupported so the caller offers manual setup rather than looping.
|
|
91
|
+
const err = new Error(
|
|
92
|
+
`Could not reach ${base} to start login: ${e instanceof Error ? e.message : e}`
|
|
93
|
+
);
|
|
94
|
+
err.code = "PAIRING_UNSUPPORTED";
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const { pairId, pairCode, verificationUrlBase } = start;
|
|
99
|
+
const url = `${verificationUrlBase || `${base}/link`}?code=${encodeURIComponent(pairCode)}`;
|
|
100
|
+
log("");
|
|
101
|
+
log(" To connect this machine, sign in and approve this code in your browser:");
|
|
102
|
+
log("");
|
|
103
|
+
log(` code: ${pairCode}`);
|
|
104
|
+
log(` url: ${url}`);
|
|
105
|
+
log("");
|
|
106
|
+
log(" (opening your browser… if it doesn't, paste the url above)");
|
|
107
|
+
log("");
|
|
108
|
+
openBrowser(url);
|
|
109
|
+
|
|
110
|
+
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
111
|
+
while (Date.now() < deadline) {
|
|
112
|
+
await sleep(POLL_INTERVAL_MS);
|
|
113
|
+
let poll;
|
|
114
|
+
try {
|
|
115
|
+
const res = await fetch(`${base}/api/pair/poll`, {
|
|
116
|
+
method: "POST",
|
|
117
|
+
headers: { "Content-Type": "application/json" },
|
|
118
|
+
body: JSON.stringify({ pairId }),
|
|
119
|
+
});
|
|
120
|
+
if (!res.ok) continue; // transient — keep polling
|
|
121
|
+
poll = await res.json();
|
|
122
|
+
} catch {
|
|
123
|
+
continue; // network blip — keep polling
|
|
124
|
+
}
|
|
125
|
+
if (poll.status === "approved" && poll.workerApiKey) {
|
|
126
|
+
const finalBase = String(poll.apiBase || base).trim().replace(/\/+$/, "");
|
|
127
|
+
const envFile = await writeWorkerEnv(finalBase, poll.workerApiKey);
|
|
128
|
+
log(` ✓ Linked. Saved ${envFile}`);
|
|
129
|
+
return { apiBase: finalBase, workerKey: poll.workerApiKey, envFile };
|
|
130
|
+
}
|
|
131
|
+
if (poll.status === "expired") {
|
|
132
|
+
const err = new Error("Login code expired before it was approved. Run `gonext login` again.");
|
|
133
|
+
err.code = "PAIRING_EXPIRED";
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
// "pending" / "consumed" → keep waiting (consumed by a racing poll is unlikely).
|
|
137
|
+
}
|
|
138
|
+
const err = new Error("Login timed out waiting for browser approval.");
|
|
139
|
+
err.code = "PAIRING_TIMEOUT";
|
|
140
|
+
throw err;
|
|
141
|
+
}
|