directionallylocal 0.2.0 → 0.3.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/dist/cli.js +20 -2
- package/dist/laptop/commands.js +54 -27
- package/dist/laptop/config.js +11 -1
- package/dist/laptop/credentials.js +129 -0
- package/dist/laptop/remote-install.js +280 -0
- package/dist/laptop/setup.js +253 -0
- package/dist/laptop/ssh.js +211 -0
- package/dist/laptop/tunnel.js +120 -0
- package/dist/local/check.js +194 -0
- package/dist/local/cli.js +43 -0
- package/dist/local/layout.js +227 -0
- package/dist/local/probes.js +22 -0
- package/dist/local/services.js +68 -0
- package/dist/local/setup.js +472 -0
- package/dist/local/state.js +15 -0
- package/dist/local/test-support/fake-computer.js +263 -0
- package/install.sh +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,10 @@ import { parseArgs } from "node:util";
|
|
|
4
4
|
import { doctor, formatChecks } from "./doctor.js";
|
|
5
5
|
import { briefStatus, connectLaptop, laptopsCommand, logsCommand, pairLaptop, serverHome, serverRunsCommand } from "./home.js";
|
|
6
6
|
import { cancel, connect, describeError, laptopHome, pull, run, runs, servers, watch } from "./laptop/commands.js";
|
|
7
|
+
import { loadRemotes } from "./laptop/config.js";
|
|
8
|
+
import { setup } from "./laptop/setup.js";
|
|
9
|
+
import { localCommand } from "./local/cli.js";
|
|
10
|
+
import { setUpOnThisComputer } from "./local/setup.js";
|
|
7
11
|
import { TtyPrompter } from "./prompt.js";
|
|
8
12
|
import { readState } from "./state.js";
|
|
9
13
|
import { DryRunSystem, RealSystem } from "./system.js";
|
|
@@ -15,7 +19,9 @@ Send a task from your laptop; it runs in its own sandbox and comes back as a pul
|
|
|
15
19
|
(dl is short for directionallylocal; both work.)
|
|
16
20
|
|
|
17
21
|
On your laptop:
|
|
18
|
-
dl
|
|
22
|
+
dl set up where agents run (the first time; afterwards, an overview)
|
|
23
|
+
dl setup set up, or repair, a server over SSH (safe to re-run)
|
|
24
|
+
dl connect URL connect to a server someone else set up (approve the code it shows)
|
|
19
25
|
dl run "TASK" run a task on the server, from inside a GitHub repository
|
|
20
26
|
(your uncommitted changes go along; Ctrl-C stops watching, not the task)
|
|
21
27
|
dl runs your tasks and their pull requests
|
|
@@ -23,6 +29,8 @@ On your laptop:
|
|
|
23
29
|
dl pull ID check out a task's branch here
|
|
24
30
|
dl servers connected servers (dl servers remove NAME)
|
|
25
31
|
run options: --server NAME, -m MODEL, --repo OWNER/NAME, --detach
|
|
32
|
+
dl local status agents running on this computer: running?, where, how many fit
|
|
33
|
+
dl local uninstall [--purge] stop and remove them (--purge also deletes keys and data)
|
|
26
34
|
|
|
27
35
|
On the server:
|
|
28
36
|
dl what this is, whether it's healthy, laptops, and runs
|
|
@@ -193,6 +201,9 @@ export async function main(argv, io = {
|
|
|
193
201
|
// A laptop connected to servers gets its own home; otherwise, what this is and how to start.
|
|
194
202
|
if (!command && await laptopHome(laptopIO))
|
|
195
203
|
return 0;
|
|
204
|
+
// A laptop with nothing set up yet: the wizard (it needs someone to answer it).
|
|
205
|
+
if (!command && terminal && !isRoot() && (await loadRemotes()).length === 0)
|
|
206
|
+
return await setup({ io: laptopIO, terminal, localSetup: (input) => setUpOnThisComputer(input) });
|
|
196
207
|
io.out(NOT_A_SERVER(colors));
|
|
197
208
|
return command ? 1 : 0;
|
|
198
209
|
}
|
|
@@ -208,6 +219,8 @@ export async function main(argv, io = {
|
|
|
208
219
|
await serverHome(system, state, io.out, colors);
|
|
209
220
|
return 0;
|
|
210
221
|
}
|
|
222
|
+
case "setup":
|
|
223
|
+
return await setup({ io: laptopIO, terminal, localSetup: (input) => setUpOnThisComputer(input) });
|
|
211
224
|
case "connect": {
|
|
212
225
|
const url = parsed.positionals[1];
|
|
213
226
|
if (!url) {
|
|
@@ -280,6 +293,8 @@ export async function main(argv, io = {
|
|
|
280
293
|
return await serverRunsCommand(system, io.out, colors);
|
|
281
294
|
return await logsCommand(system, [...rest, ...(values.follow ? ["-f"] : [])]);
|
|
282
295
|
}
|
|
296
|
+
case "local":
|
|
297
|
+
return await localCommand(parsed.positionals.slice(1), { purge: Boolean(values.purge) }, io, colors, ui);
|
|
283
298
|
case "uninstall":
|
|
284
299
|
await uninstall(system, { purge: Boolean(values.purge) });
|
|
285
300
|
return 0;
|
|
@@ -309,7 +324,10 @@ const NOT_A_SERVER = (c) => `
|
|
|
309
324
|
Background coding agents on your own server: send a task from your laptop,
|
|
310
325
|
get a pull request back.
|
|
311
326
|
|
|
312
|
-
${c.dim("Set up
|
|
327
|
+
${c.dim("Set it up from your laptop (asks a few questions):")}
|
|
328
|
+
dl setup
|
|
329
|
+
|
|
330
|
+
${c.dim("Or on a server (as root, on Ubuntu or Debian):")}
|
|
313
331
|
curl -fsSL https://directionallylocal.run | sh
|
|
314
332
|
|
|
315
333
|
${c.dim("Already have one? Connect this laptop to it:")}
|
package/dist/laptop/commands.js
CHANGED
|
@@ -6,11 +6,15 @@ import { palette } from "../ui.js";
|
|
|
6
6
|
import { RemoteApiError, RemoteClient, TERMINAL_STATUSES } from "./client.js";
|
|
7
7
|
import { defaultRemoteName, loadRemotes, normalizeServerUrl, REMOTE_NAME_RE, removeRemote, resolveRemote, saveRemote } from "./config.js";
|
|
8
8
|
import { detectGitHubRepository, snapshotWorkingTree } from "./git.js";
|
|
9
|
+
import { sshCommandLine } from "./ssh.js";
|
|
9
10
|
import { openBrowser } from "./open-browser.js";
|
|
11
|
+
import { withConnections } from "./tunnel.js";
|
|
10
12
|
/**
|
|
11
13
|
* `dl` on a laptop: connect to a server, send it tasks, follow them, and check out the
|
|
12
14
|
* pull requests. It talks to the server's API directly (the same one Autopilot's
|
|
13
15
|
* `--on` uses, and shares its list of servers), so the laptop needs nothing else.
|
|
16
|
+
* Servers set up with `dl setup` are reached through an SSH tunnel that each command
|
|
17
|
+
* opens and closes (see tunnel.ts); `open` lets tests swap the tunnel out.
|
|
14
18
|
*/
|
|
15
19
|
const execFileAsync = promisify(execFile);
|
|
16
20
|
const RUN_END_EVENTS = new Set(["completed", "failed", "cancelled", "interrupted"]);
|
|
@@ -56,9 +60,11 @@ export async function connect(input, io = processIO) {
|
|
|
56
60
|
* checkout (uncommitted changes go along). Follows it until it ends; Ctrl-C only detaches.
|
|
57
61
|
*/
|
|
58
62
|
export async function run(input, io = processIO) {
|
|
59
|
-
const c = palette(io.color === true);
|
|
60
63
|
const remote = await resolveRemote(input.server);
|
|
61
|
-
|
|
64
|
+
return withConnections(input.open, async (connections) => runOn(await connections.client(remote), remote, input, io));
|
|
65
|
+
}
|
|
66
|
+
async function runOn(client, remote, input, io) {
|
|
67
|
+
const c = palette(io.color === true);
|
|
62
68
|
const repository = input.repository ?? await detectGitHubRepository(input.cwd);
|
|
63
69
|
const snapshot = await snapshotWorkingTree(input.cwd);
|
|
64
70
|
const [workspaces, models] = await Promise.all([client.workspaces(), client.models()]);
|
|
@@ -88,14 +94,17 @@ export async function run(input, io = processIO) {
|
|
|
88
94
|
}
|
|
89
95
|
/** `dl runs`: tasks on every connected server, newest first. */
|
|
90
96
|
export async function runs(input, io = processIO) {
|
|
91
|
-
const c = palette(io.color === true);
|
|
92
97
|
const remotes = input.server ? [await resolveRemote(input.server)] : await loadRemotes();
|
|
93
98
|
if (remotes.length === 0)
|
|
94
99
|
throw new Error("This laptop isn't connected to a server yet. Run: dl connect https://your-server");
|
|
100
|
+
return withConnections(input.open, (connections) => listRuns(remotes, connections, input.json === true, io));
|
|
101
|
+
}
|
|
102
|
+
async function listRuns(remotes, connections, json, io) {
|
|
103
|
+
const c = palette(io.color === true);
|
|
95
104
|
const rows = [];
|
|
96
105
|
for (const remote of remotes) {
|
|
97
106
|
try {
|
|
98
|
-
const list = await
|
|
107
|
+
const list = await (await connections.client(remote)).listConversations();
|
|
99
108
|
rows.push(...list.filter((entry) => entry.kind === "code").map((entry) => ({ ...entry, server: remote.name })));
|
|
100
109
|
}
|
|
101
110
|
catch (error) {
|
|
@@ -103,7 +112,7 @@ export async function runs(input, io = processIO) {
|
|
|
103
112
|
}
|
|
104
113
|
}
|
|
105
114
|
rows.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
106
|
-
if (
|
|
115
|
+
if (json) {
|
|
107
116
|
io.out(`${JSON.stringify(rows, null, 2)}\n`);
|
|
108
117
|
return;
|
|
109
118
|
}
|
|
@@ -122,21 +131,26 @@ export async function runs(input, io = processIO) {
|
|
|
122
131
|
}
|
|
123
132
|
/** `dl watch <id>`: replay and follow a task's output. */
|
|
124
133
|
export async function watch(input, io = processIO) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
return withConnections(input.open, async (connections) => {
|
|
135
|
+
const { remote, run: found } = await findRun(input.id, input.server, connections);
|
|
136
|
+
const client = await connections.client(remote);
|
|
137
|
+
const finished = TERMINAL_STATUSES.has(found.status) && !found.activeRunId;
|
|
138
|
+
return follow(client, remote, found.conversationId, 0, input.signal, io, finished ? found.lastEventId : undefined);
|
|
139
|
+
});
|
|
129
140
|
}
|
|
130
141
|
export async function cancel(input, io = processIO) {
|
|
131
142
|
const c = palette(io.color === true);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
143
|
+
await withConnections(input.open, async (connections) => {
|
|
144
|
+
const { remote, run: found } = await findRun(input.id, input.server, connections);
|
|
145
|
+
await (await connections.client(remote)).cancel(found.conversationId);
|
|
146
|
+
io.out(` ${c.green("✓")} Stopped ${found.conversationId.slice(0, 8)}\n`);
|
|
147
|
+
});
|
|
135
148
|
}
|
|
136
149
|
/** `dl pull <id>`: check out a task's branch here. */
|
|
137
150
|
export async function pull(input, io = processIO) {
|
|
138
151
|
const c = palette(io.color === true);
|
|
139
|
-
|
|
152
|
+
// The server is only asked for the branch name; the code itself comes from GitHub.
|
|
153
|
+
const { run: found } = await withConnections(input.open, (connections) => findRun(input.id, input.server, connections));
|
|
140
154
|
const branch = found.branch;
|
|
141
155
|
if (!branch)
|
|
142
156
|
throw new Error("That task has no branch");
|
|
@@ -164,40 +178,44 @@ export async function servers(args, io = processIO) {
|
|
|
164
178
|
const c = palette(io.color === true);
|
|
165
179
|
if (args[0] === "remove") {
|
|
166
180
|
const name = args[1] ?? "";
|
|
167
|
-
|
|
181
|
+
const removed = (await loadRemotes()).find((remote) => remote.name === name);
|
|
182
|
+
if (!removed || !(await removeRemote(name))) {
|
|
168
183
|
io.err(` ${c.red("✗")} Not connected to "${name}".\n`);
|
|
169
184
|
return 1;
|
|
170
185
|
}
|
|
171
|
-
|
|
186
|
+
const onServer = `sudo dl laptops remove "${clientName()}"`;
|
|
187
|
+
io.out(` ${c.green("✓")} Forgot ${name}. To disconnect it on the server too: ${removed.ssh ? `${sshCommandLine(removed.ssh)} '${onServer}'` : onServer}\n`);
|
|
172
188
|
return 0;
|
|
173
189
|
}
|
|
174
190
|
const remotes = await loadRemotes();
|
|
175
191
|
if (remotes.length === 0) {
|
|
176
|
-
io.out(`\n Not connected to a server yet. ${c.bold("dl connect https://your-server")}\n\n`);
|
|
192
|
+
io.out(`\n Not connected to a server yet. ${c.bold("dl setup")} ${c.dim("or, for a server someone else set up:")} ${c.bold("dl connect https://your-server")}\n\n`);
|
|
177
193
|
return 0;
|
|
178
194
|
}
|
|
179
195
|
io.out("\n");
|
|
180
196
|
for (const remote of remotes)
|
|
181
|
-
io.out(` ${c.green("●")} ${remote.name.padEnd(16)} ${remote
|
|
197
|
+
io.out(` ${c.green("●")} ${remote.name.padEnd(16)} ${where(remote)} ${c.dim(`until ${remote.expiresAt.slice(0, 10)}`)}\n`);
|
|
182
198
|
io.out("\n");
|
|
183
199
|
return 0;
|
|
184
200
|
}
|
|
185
201
|
/** `dl` on a laptop: what this is, which servers it's connected to, what to type next. */
|
|
186
|
-
export async function laptopHome(io = processIO) {
|
|
202
|
+
export async function laptopHome(io = processIO, open) {
|
|
187
203
|
const c = palette(io.color === true);
|
|
188
204
|
const remotes = await loadRemotes();
|
|
189
205
|
if (remotes.length === 0)
|
|
190
206
|
return false;
|
|
191
207
|
let running = 0;
|
|
192
208
|
let total = 0;
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
209
|
+
await withConnections(open, async (connections) => {
|
|
210
|
+
for (const remote of remotes) {
|
|
211
|
+
const list = await connections.client(remote).then((client) => client.listConversations()).catch(() => undefined);
|
|
212
|
+
running += list?.filter((entry) => entry.activeRunId !== null).length ?? 0;
|
|
213
|
+
total += list?.length ?? 0;
|
|
214
|
+
}
|
|
215
|
+
});
|
|
198
216
|
io.out(`\n ${c.bold("Directionally Local")} ${c.dim("on this laptop")}\n\n`);
|
|
199
217
|
io.out(" Background coding agents on your own server: send a task, get a pull request back.\n\n");
|
|
200
|
-
io.out(` ${c.dim("Servers".padEnd(11))}${remotes.map((remote) => `${remote.name} ${c.dim(remote
|
|
218
|
+
io.out(` ${c.dim("Servers".padEnd(11))}${remotes.map((remote) => `${remote.name} ${c.dim(where(remote))}`).join(", ")} ${c.dim("dl servers")}\n`);
|
|
201
219
|
io.out(` ${c.dim("Tasks".padEnd(11))}${running} running · ${total} in all ${c.dim("dl runs")}\n\n`);
|
|
202
220
|
io.out(` ${c.dim("Send a task, from inside a GitHub repository:")}\n ${c.bold('dl run "fix the flaky checkout test"')}\n\n`);
|
|
203
221
|
return true;
|
|
@@ -285,20 +303,25 @@ class EventRenderer {
|
|
|
285
303
|
this.io.out(`${text}\n`);
|
|
286
304
|
}
|
|
287
305
|
}
|
|
288
|
-
async function findRun(id, server) {
|
|
306
|
+
async function findRun(id, server, connections) {
|
|
289
307
|
const needle = id.trim().toLowerCase();
|
|
290
308
|
if (!/^[0-9a-f-]{4,36}$/.test(needle))
|
|
291
309
|
throw new Error("Task IDs are the short hex IDs dl runs shows");
|
|
292
310
|
const remotes = server ? [await resolveRemote(server)] : await loadRemotes();
|
|
293
311
|
const matches = [];
|
|
312
|
+
const unreachable = [];
|
|
294
313
|
for (const remote of remotes) {
|
|
295
|
-
const list = await
|
|
314
|
+
const list = await connections.client(remote).then((client) => client.listConversations(200)).catch((error) => {
|
|
315
|
+
unreachable.push(`${remote.name}: ${describeError(error)}`);
|
|
316
|
+
return [];
|
|
317
|
+
});
|
|
296
318
|
for (const entry of list)
|
|
297
319
|
if (entry.conversationId.startsWith(needle))
|
|
298
320
|
matches.push({ remote, run: entry });
|
|
299
321
|
}
|
|
322
|
+
// Say why when a server couldn't be asked (e.g. its SSH tunnel failed), rather than "no task".
|
|
300
323
|
if (matches.length === 0)
|
|
301
|
-
throw new Error(`No task ${id} on ${server ?? "any connected server"}`);
|
|
324
|
+
throw new Error(`No task ${id} on ${server ?? "any connected server"}${unreachable.length ? `\n${unreachable.join("\n")}` : ""}`);
|
|
302
325
|
if (matches.length > 1)
|
|
303
326
|
throw new Error(`${id} matches several tasks; use more characters`);
|
|
304
327
|
return matches[0];
|
|
@@ -316,6 +339,10 @@ function pickModel(models, requested, preferred) {
|
|
|
316
339
|
throw new Error("The server offers no models");
|
|
317
340
|
return first;
|
|
318
341
|
}
|
|
342
|
+
/** Where a server is, as people know it: its URL, or the SSH destination its tunnel goes to. */
|
|
343
|
+
function where(remote) {
|
|
344
|
+
return remote.ssh ? `ssh ${remote.ssh.target}${remote.ssh.port ? `:${remote.ssh.port}` : ""}` : remote.url;
|
|
345
|
+
}
|
|
319
346
|
function clientName() {
|
|
320
347
|
const host = hostname().replace(/\.local$/, "").replace(/[^\p{L}\p{N} ._'()@-]/gu, "-").slice(0, 40) || "laptop";
|
|
321
348
|
return `${host} (dl)`;
|
package/dist/laptop/config.js
CHANGED
|
@@ -85,5 +85,15 @@ function isRemote(value) {
|
|
|
85
85
|
if (!value || typeof value !== "object")
|
|
86
86
|
return false;
|
|
87
87
|
const entry = value;
|
|
88
|
-
|
|
88
|
+
if (!["name", "url", "token", "credentialId", "expiresAt", "addedAt"].every((key) => typeof entry[key] === "string"))
|
|
89
|
+
return false;
|
|
90
|
+
return entry.ssh === undefined || isSshAccess(entry.ssh);
|
|
91
|
+
}
|
|
92
|
+
function isSshAccess(value) {
|
|
93
|
+
if (!value || typeof value !== "object")
|
|
94
|
+
return false;
|
|
95
|
+
const ssh = value;
|
|
96
|
+
return typeof ssh.target === "string" && ssh.target.length > 0 && !ssh.target.startsWith("-") &&
|
|
97
|
+
(ssh.port === undefined || (typeof ssh.port === "number" && Number.isInteger(ssh.port) && ssh.port > 0 && ssh.port < 65536)) &&
|
|
98
|
+
(ssh.identityFile === undefined || typeof ssh.identityFile === "string");
|
|
89
99
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
/** Autopilot's config, where `autopilot` keeps the user's OpenRouter key. */
|
|
3
|
+
export function autopilotConfigPath(env, home) {
|
|
4
|
+
return join(env.XDG_CONFIG_HOME?.trim() || join(home, ".config"), "kimiflare", "config.json");
|
|
5
|
+
}
|
|
6
|
+
export async function validateOpenRouterKey(key, fetchImpl) {
|
|
7
|
+
if (!/^sk-or-[A-Za-z0-9_-]{10,}$/.test(key))
|
|
8
|
+
return "That doesn't look like an OpenRouter key (sk-or-…).";
|
|
9
|
+
if (!fetchImpl)
|
|
10
|
+
return undefined;
|
|
11
|
+
try {
|
|
12
|
+
const response = await fetchImpl("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${key}` } });
|
|
13
|
+
if (response.status === 401 || response.status === 403)
|
|
14
|
+
return "OpenRouter rejected that key.";
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Offline: the format is right, and the server checks it again.
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
export async function validateGitHubToken(token, fetchImpl) {
|
|
22
|
+
if (!/^(github_pat_|ghp_|gho_|ghu_)[A-Za-z0-9_]{20,}$/.test(token))
|
|
23
|
+
return { problem: "That doesn't look like a GitHub token (github_pat_… or ghp_…)." };
|
|
24
|
+
if (!fetchImpl)
|
|
25
|
+
return {};
|
|
26
|
+
try {
|
|
27
|
+
const response = await fetchImpl("https://api.github.com/user", { headers: { Authorization: `Bearer ${token}`, "User-Agent": "directionallylocal" } });
|
|
28
|
+
if (response.status === 401)
|
|
29
|
+
return { problem: "GitHub rejected that token." };
|
|
30
|
+
if (response.ok) {
|
|
31
|
+
const user = await response.json();
|
|
32
|
+
if (typeof user.login === "string")
|
|
33
|
+
return { login: user.login };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// Offline isn't fatal.
|
|
38
|
+
}
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
/** Finds, confirms, or asks for exactly the keys in `needs`. */
|
|
42
|
+
export async function gatherKeys(needs, input) {
|
|
43
|
+
const { ui } = input;
|
|
44
|
+
const answers = {};
|
|
45
|
+
const wanted = needs.filter((need) => need === "github" || need === "openrouter");
|
|
46
|
+
if (wanted.length === 0)
|
|
47
|
+
return answers;
|
|
48
|
+
ui.questions(`${wanted.length === 1 ? "1 key" : "2 keys"}, kept on your server only; then it sets itself up.`);
|
|
49
|
+
if (wanted.includes("github"))
|
|
50
|
+
answers.githubToken = await githubToken(input);
|
|
51
|
+
if (wanted.includes("openrouter"))
|
|
52
|
+
answers.openrouterKey = await openrouterKey(input);
|
|
53
|
+
return answers;
|
|
54
|
+
}
|
|
55
|
+
async function githubToken({ ui, prompter, sources }) {
|
|
56
|
+
const fromCli = (await sources.gh().catch(() => undefined))?.trim();
|
|
57
|
+
if (fromCli) {
|
|
58
|
+
const checked = await validateGitHubToken(fromCli, sources.fetch);
|
|
59
|
+
if (checked.problem) {
|
|
60
|
+
ui.warn(`Your GitHub CLI login didn't work (${checked.problem}); asking for a token instead.`);
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
const who = checked.login ? ` (${checked.login})` : "";
|
|
64
|
+
if (await confirm(ui, prompter, `Use your GitHub CLI login${who}? It stays on your server.`)) {
|
|
65
|
+
ui.answered("GitHub", `${checked.login ? `signed in as ${checked.login} · ` : ""}from the GitHub CLI`);
|
|
66
|
+
return fromCli;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return askUntilValid(ui, "GitHub", async () => {
|
|
71
|
+
const token = await prompter.secret(ui.prompt("GitHub token", "fine-grained: github.com/settings/personal-access-tokens/new"));
|
|
72
|
+
const checked = await validateGitHubToken(token, sources.fetch);
|
|
73
|
+
return { value: token, ...(checked.problem ? { problem: checked.problem } : {}), ...(checked.login ? { detail: `signed in as ${checked.login}` } : {}) };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async function openrouterKey({ ui, prompter, sources }) {
|
|
77
|
+
const candidates = [];
|
|
78
|
+
const fromEnv = sources.env.OPENROUTER_API_KEY?.trim();
|
|
79
|
+
if (fromEnv)
|
|
80
|
+
candidates.push({ key: fromEnv, question: "Use the OpenRouter key in OPENROUTER_API_KEY?", from: "from OPENROUTER_API_KEY" });
|
|
81
|
+
const config = await sources.readFile(autopilotConfigPath(sources.env, sources.home));
|
|
82
|
+
const fromAutopilot = config ? readAutopilotKey(config) : undefined;
|
|
83
|
+
if (fromAutopilot && fromAutopilot !== fromEnv)
|
|
84
|
+
candidates.push({ key: fromAutopilot, question: "Use the OpenRouter key from Autopilot?", from: "from Autopilot" });
|
|
85
|
+
for (const candidate of candidates) {
|
|
86
|
+
const problem = await validateOpenRouterKey(candidate.key, sources.fetch);
|
|
87
|
+
if (problem) {
|
|
88
|
+
ui.warn(`The OpenRouter key ${candidate.from} didn't work (${problem})`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (await confirm(ui, prompter, `${candidate.question} It stays on your server.`)) {
|
|
92
|
+
ui.answered("OpenRouter", candidate.from);
|
|
93
|
+
return candidate.key;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return askUntilValid(ui, "OpenRouter", async () => {
|
|
97
|
+
const key = await prompter.secret(ui.prompt("OpenRouter key", "openrouter.ai/keys"));
|
|
98
|
+
const problem = await validateOpenRouterKey(key, sources.fetch);
|
|
99
|
+
return { value: key, ...(problem ? { problem } : {}) };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
function readAutopilotKey(text) {
|
|
103
|
+
try {
|
|
104
|
+
const value = JSON.parse(text).openrouterApiKey;
|
|
105
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** A Y/n question; Enter means yes. */
|
|
112
|
+
export async function confirm(ui, prompter, question) {
|
|
113
|
+
const answer = (await prompter.text(ui.prompt(question, "Y/n"))).trim().toLowerCase();
|
|
114
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
115
|
+
}
|
|
116
|
+
async function askUntilValid(ui, label, ask) {
|
|
117
|
+
for (let attempt = 1;; attempt++) {
|
|
118
|
+
const { value, problem, detail } = await ask();
|
|
119
|
+
if (!problem) {
|
|
120
|
+
ui.answered(label, detail);
|
|
121
|
+
return value.trim();
|
|
122
|
+
}
|
|
123
|
+
if (attempt >= 3) {
|
|
124
|
+
ui.rejected(problem);
|
|
125
|
+
throw new Error(problem);
|
|
126
|
+
}
|
|
127
|
+
ui.rejected(`${problem} Try again.`);
|
|
128
|
+
}
|
|
129
|
+
}
|