directionallylocal 0.4.0 → 0.5.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 +202 -0
- package/README.md +4 -0
- package/dist/cli.js +20 -2
- package/dist/doctor.js +6 -3
- package/dist/laptop/commands.js +12 -2
- package/dist/laptop/credentials.js +5 -5
- package/dist/laptop/session-store.js +62 -0
- package/dist/laptop/session.js +279 -0
- package/dist/laptop/setup.js +92 -63
- package/dist/laptop/tunnel.js +8 -0
- package/dist/layout.js +4 -0
- package/dist/local/cli.js +4 -1
- package/dist/local/layout.js +3 -0
- package/dist/local/setup.js +14 -6
- package/dist/local/test-support/fake-computer.js +7 -0
- package/dist/model-prices.js +119 -0
- package/dist/prompt.js +333 -16
- package/dist/up.js +10 -1
- package/package.json +5 -1
|
@@ -9,6 +9,8 @@ export class FakeComputer {
|
|
|
9
9
|
home;
|
|
10
10
|
dryRun = false;
|
|
11
11
|
files = new Map();
|
|
12
|
+
/** Modification times (seconds) for `stat`; set on every write. */
|
|
13
|
+
mtimes = new Map();
|
|
12
14
|
dirs = new Map();
|
|
13
15
|
commands = [];
|
|
14
16
|
probes = [];
|
|
@@ -62,6 +64,7 @@ export class FakeComputer {
|
|
|
62
64
|
}
|
|
63
65
|
async writeFile(path, content, options = {}) {
|
|
64
66
|
this.files.set(path, { content, mode: options.mode ?? 0o644 });
|
|
67
|
+
this.mtimes.set(path, Math.floor(Date.now() / 1000));
|
|
65
68
|
}
|
|
66
69
|
async mkdir(path, options = {}) {
|
|
67
70
|
this.dirs.set(path, options.mode);
|
|
@@ -237,6 +240,10 @@ export class FakeComputer {
|
|
|
237
240
|
case "ls":
|
|
238
241
|
return ok([...this.dirs.keys()].filter((dir) => dir.startsWith(`${args[1]}/`) && !dir.slice(args[1].length + 1).includes("/")).map((dir) => dir.split("/").at(-1)).join("\n"));
|
|
239
242
|
case "mktemp": return ok("/tmp/tmp.build\n");
|
|
243
|
+
case "stat": {
|
|
244
|
+
const mtime = this.files.has(args.at(-1)) ? this.mtimes.get(args.at(-1)) : undefined;
|
|
245
|
+
return mtime === undefined ? fail("No such file or directory") : ok(`${mtime}\n`);
|
|
246
|
+
}
|
|
240
247
|
case "wc": return ok("0\n");
|
|
241
248
|
case "/usr/local/bin/docker": {
|
|
242
249
|
if (args[0] === "context")
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export const OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
|
|
2
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
3
|
+
/** Older than this, `dl doctor` suggests refreshing it. */
|
|
4
|
+
export const MODEL_PRICES_MAX_AGE_DAYS = 7;
|
|
5
|
+
/** USD per token as OpenRouter gives it ("0.0000001"), or undefined when it isn't a usable price. */
|
|
6
|
+
function perToken(value) {
|
|
7
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
|
|
8
|
+
// OpenRouter marks variable pricing with "-1"; genuine 0 is a free model and is kept.
|
|
9
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
|
|
10
|
+
}
|
|
11
|
+
/** Per million tokens, without float noise (0.0000001 × 1e6 is 0.09999999999999999). */
|
|
12
|
+
function perMillion(price) {
|
|
13
|
+
return Math.round(price * 1e12) / 1e6;
|
|
14
|
+
}
|
|
15
|
+
/** OpenRouter's catalog response to a price table; entries without usable prices are skipped. */
|
|
16
|
+
export function pricesFromCatalog(catalog) {
|
|
17
|
+
const data = catalog?.data;
|
|
18
|
+
if (!Array.isArray(data))
|
|
19
|
+
throw new Error("OpenRouter's model list had no models");
|
|
20
|
+
const table = {};
|
|
21
|
+
for (const entry of data) {
|
|
22
|
+
const id = typeof entry?.id === "string" ? entry.id.trim().toLowerCase() : "";
|
|
23
|
+
if (!id)
|
|
24
|
+
continue;
|
|
25
|
+
const input = perToken(entry?.pricing?.prompt);
|
|
26
|
+
const output = perToken(entry?.pricing?.completion);
|
|
27
|
+
if (input === undefined || output === undefined)
|
|
28
|
+
continue;
|
|
29
|
+
table[id] = { inputPerMTok: perMillion(input), outputPerMTok: perMillion(output) };
|
|
30
|
+
}
|
|
31
|
+
return table;
|
|
32
|
+
}
|
|
33
|
+
/** Downloads OpenRouter's public model list (no key needed) as a price table. */
|
|
34
|
+
export async function fetchModelPrices(fetchImpl = fetch) {
|
|
35
|
+
let response;
|
|
36
|
+
try {
|
|
37
|
+
response = await fetchImpl(OPENROUTER_MODELS_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { "User-Agent": "directionallylocal" } });
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
throw new Error(`couldn't reach openrouter.ai: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
if (!response.ok)
|
|
43
|
+
throw new Error(`openrouter.ai answered HTTP ${response.status}`);
|
|
44
|
+
const table = pricesFromCatalog(await response.json().catch(() => undefined));
|
|
45
|
+
const count = Object.keys(table).length;
|
|
46
|
+
if (count === 0)
|
|
47
|
+
throw new Error("OpenRouter's model list had no prices");
|
|
48
|
+
return { table, count };
|
|
49
|
+
}
|
|
50
|
+
export function renderModelPrices(table) {
|
|
51
|
+
const sorted = Object.fromEntries(Object.keys(table).sort().map((id) => [id, table[id]]));
|
|
52
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
53
|
+
}
|
|
54
|
+
/** How many models a written price file holds, or undefined when it isn't one. */
|
|
55
|
+
export function countModelPrices(text) {
|
|
56
|
+
if (text === undefined)
|
|
57
|
+
return undefined;
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(text);
|
|
60
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
61
|
+
return undefined;
|
|
62
|
+
return Object.values(parsed).filter((price) => typeof price?.inputPerMTok === "number" && typeof price?.outputPerMTok === "number").length;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Refreshes the price file. When OpenRouter can't be reached, an existing file is kept;
|
|
70
|
+
* either way it warns rather than failing the install. Returns a step detail, if any.
|
|
71
|
+
*/
|
|
72
|
+
export async function refreshModelPrices(system, input) {
|
|
73
|
+
if (system.dryRun) {
|
|
74
|
+
system.log(` would fetch: ${OPENROUTER_MODELS_URL} -> ${input.path}`);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const { table, count } = await fetchModelPrices(input.fetchImpl);
|
|
79
|
+
await system.writeFile(input.path, renderModelPrices(table), input.write);
|
|
80
|
+
return `prices for ${count} models`;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const reason = error.message;
|
|
84
|
+
const kept = countModelPrices(await system.readFile(input.path));
|
|
85
|
+
if (kept !== undefined && kept > 0) {
|
|
86
|
+
input.warn(`Couldn't refresh model prices (${reason}); kept the last list (${kept} models).`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
input.warn(`Couldn't download model prices (${reason}). Tasks will fail with "model not priced" until ${input.retry} can reach openrouter.ai.`);
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/** For `dl doctor` and `dl local status`: the file's contents and its age (mtime, seconds). */
|
|
95
|
+
export function modelPricesHealth(text, mtimeSeconds, now, refresh) {
|
|
96
|
+
if (text === undefined)
|
|
97
|
+
return { ok: false, warn: false, detail: `missing; tasks fail with "model not priced"` };
|
|
98
|
+
const count = countModelPrices(text);
|
|
99
|
+
if (count === undefined || count === 0)
|
|
100
|
+
return { ok: false, warn: false, detail: "unreadable; tasks may fail with \"model not priced\"" };
|
|
101
|
+
const models = `${count} model${count === 1 ? "" : "s"}`;
|
|
102
|
+
if (mtimeSeconds === undefined)
|
|
103
|
+
return { ok: true, warn: false, detail: models };
|
|
104
|
+
const days = Math.max(0, Math.floor((now.getTime() / 1000 - mtimeSeconds) / 86_400));
|
|
105
|
+
const age = days === 0 ? "updated today" : `${days} day${days === 1 ? "" : "s"} old`;
|
|
106
|
+
if (days > MODEL_PRICES_MAX_AGE_DAYS)
|
|
107
|
+
return { ok: true, warn: true, detail: `${models} · ${age}; ${refresh} refreshes it` };
|
|
108
|
+
return { ok: true, warn: false, detail: `${models} · ${age}` };
|
|
109
|
+
}
|
|
110
|
+
/** A file's mtime in seconds via stat (GNU on Linux, BSD on macOS); undefined when unknown. */
|
|
111
|
+
export async function fileMtime(system, path, platform = "linux") {
|
|
112
|
+
const result = await system.probe("stat", platform === "darwin" ? ["-f", "%m", path] : ["-c", "%Y", path]);
|
|
113
|
+
const seconds = Number(result.stdout.trim());
|
|
114
|
+
return result.code === 0 && result.stdout.trim() !== "" && Number.isFinite(seconds) ? seconds : undefined;
|
|
115
|
+
}
|
|
116
|
+
/** A failed run's reason when Hotcell refused the model for lack of a price. */
|
|
117
|
+
export function isModelNotPriced(text) {
|
|
118
|
+
return /model not priced/i.test(text);
|
|
119
|
+
}
|
package/dist/prompt.js
CHANGED
|
@@ -1,11 +1,52 @@
|
|
|
1
1
|
import { openSync, closeSync } from "node:fs";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
3
|
import { ReadStream, WriteStream } from "node:tty";
|
|
4
|
+
import { palette } from "./ui.js";
|
|
5
|
+
/** Thrown when someone presses Esc or Ctrl-C at a question; the terminal is already restored. */
|
|
6
|
+
export class PromptCancelled extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super("Cancelled.");
|
|
9
|
+
this.name = "PromptCancelled";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function openDevTty() {
|
|
13
|
+
let fd;
|
|
14
|
+
try {
|
|
15
|
+
fd = openSync("/dev/tty", "r+");
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error("No terminal to ask on; pass the answers as environment variables (see `dl --help`).");
|
|
19
|
+
}
|
|
20
|
+
const input = new ReadStream(fd);
|
|
21
|
+
const output = new WriteStream(fd);
|
|
22
|
+
return {
|
|
23
|
+
input,
|
|
24
|
+
output,
|
|
25
|
+
close: () => {
|
|
26
|
+
input.destroy();
|
|
27
|
+
output.destroy();
|
|
28
|
+
try {
|
|
29
|
+
closeSync(fd);
|
|
30
|
+
}
|
|
31
|
+
catch { /* already closed with the streams */ }
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const HIDE_CURSOR = "\x1b[?25l";
|
|
36
|
+
const SHOW_CURSOR = "\x1b[?25h";
|
|
4
37
|
/**
|
|
5
38
|
* Reads from /dev/tty, so prompts work under `curl … | sh` where stdin is the script.
|
|
6
39
|
* Without a terminal every question fails with the env var that answers it.
|
|
7
40
|
*/
|
|
8
41
|
export class TtyPrompter {
|
|
42
|
+
open;
|
|
43
|
+
color;
|
|
44
|
+
env;
|
|
45
|
+
constructor(options = {}) {
|
|
46
|
+
this.open = options.open ?? openDevTty;
|
|
47
|
+
this.env = options.env ?? process.env;
|
|
48
|
+
this.color = options.color ?? (!this.env.NO_COLOR && this.env.TERM !== "dumb");
|
|
49
|
+
}
|
|
9
50
|
async choose(question, options, fallback) {
|
|
10
51
|
const lines = options.map((option, index) => ` ${index + 1}. ${option.label}`).join("\n");
|
|
11
52
|
const answer = (await this.ask(`${question}\n${lines}\nChoice [1]: `, false)).trim();
|
|
@@ -14,22 +55,50 @@ export class TtyPrompter {
|
|
|
14
55
|
const index = Number(answer) - 1;
|
|
15
56
|
return options[index]?.value ?? options.find((option) => option.value === answer)?.value ?? fallback;
|
|
16
57
|
}
|
|
58
|
+
async select(question, options, initial = 0) {
|
|
59
|
+
if (options.length === 0)
|
|
60
|
+
throw new Error("select() needs at least one option");
|
|
61
|
+
const start = Math.min(Math.max(0, initial), options.length - 1);
|
|
62
|
+
const terminal = this.open();
|
|
63
|
+
const raw = typeof terminal.input.setRawMode === "function" && terminal.input.isTTY === true && this.env.TERM !== "dumb";
|
|
64
|
+
if (!raw) {
|
|
65
|
+
terminal.close();
|
|
66
|
+
return this.numbered(question, options, start);
|
|
67
|
+
}
|
|
68
|
+
return new ArrowSelect(terminal, question, options, start, this.color).run();
|
|
69
|
+
}
|
|
17
70
|
secret(question) {
|
|
18
71
|
return this.ask(question, true);
|
|
19
72
|
}
|
|
20
73
|
text(question) {
|
|
21
74
|
return this.ask(question, false);
|
|
22
75
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
76
|
+
/** The plain fallback: a numbered list, then a typed choice (Enter for the preselected one). */
|
|
77
|
+
async numbered(question, options, start) {
|
|
78
|
+
const c = palette(this.color);
|
|
79
|
+
const width = Math.max(...options.map((option) => option.label.length)) + 2;
|
|
80
|
+
const list = options.map((option, index) => ` ${c.cyan(String(index + 1))} ${option.detail ? `${option.label.padEnd(width)} ${c.dim(option.detail)}` : option.label}`).join("\n");
|
|
81
|
+
const typed = options.find((option) => option.input);
|
|
82
|
+
const range = options.length === 2 ? "1 or 2" : `1-${options.length}`;
|
|
83
|
+
const hint = `${range}${typed ? `, or ${typed.input.placeholder.split(",")[0]}` : ""} · Enter for ${start + 1}`;
|
|
84
|
+
const split = question.lastIndexOf("\n");
|
|
85
|
+
const styled = split >= 0 ? `${question.slice(0, split)} ${c.dim(hint)}${question.slice(split)}` : question;
|
|
86
|
+
for (let attempt = 1;; attempt++) {
|
|
87
|
+
const answer = (await this.ask(`${attempt === 1 ? `${list}\n\n` : ""}${styled}`, false)).trim();
|
|
88
|
+
const index = answer === "" ? start : /^\d+$/.test(answer) ? Number(answer) - 1 : -1;
|
|
89
|
+
const option = options[index];
|
|
90
|
+
if (option?.input)
|
|
91
|
+
return { value: option.value, text: (await this.ask(option.input.prompt, false)).trim() };
|
|
92
|
+
if (option)
|
|
93
|
+
return { value: option.value };
|
|
94
|
+
if (typed && answer)
|
|
95
|
+
return { value: typed.value, text: answer };
|
|
96
|
+
if (attempt >= 3)
|
|
97
|
+
throw new Error(`Pick one of ${range}`);
|
|
30
98
|
}
|
|
31
|
-
|
|
32
|
-
|
|
99
|
+
}
|
|
100
|
+
ask(question, hidden) {
|
|
101
|
+
const { input, output, close } = this.open();
|
|
33
102
|
// A styled prompt is "label line\n› ": print the label line ourselves and give readline
|
|
34
103
|
// only the last line, so its redraws (and the hidden-input filter) stay on one line.
|
|
35
104
|
const split = question.lastIndexOf("\n");
|
|
@@ -38,7 +107,7 @@ export class TtyPrompter {
|
|
|
38
107
|
if (head)
|
|
39
108
|
output.write(head);
|
|
40
109
|
return new Promise((resolve) => {
|
|
41
|
-
const rl = createInterface({ input, output, terminal: true });
|
|
110
|
+
const rl = createInterface({ input, output, terminal: input.isTTY === true });
|
|
42
111
|
if (hidden) {
|
|
43
112
|
// Echo nothing while the secret is typed.
|
|
44
113
|
rl._writeToOutput = (text) => {
|
|
@@ -50,17 +119,248 @@ export class TtyPrompter {
|
|
|
50
119
|
if (hidden)
|
|
51
120
|
output.write("\n");
|
|
52
121
|
rl.close();
|
|
53
|
-
|
|
54
|
-
output.destroy();
|
|
55
|
-
try {
|
|
56
|
-
closeSync(fd);
|
|
57
|
-
}
|
|
58
|
-
catch { /* already closed with the streams */ }
|
|
122
|
+
close();
|
|
59
123
|
resolve(answer.trim());
|
|
60
124
|
});
|
|
61
125
|
});
|
|
62
126
|
}
|
|
63
127
|
}
|
|
128
|
+
/** Splits a chunk of terminal input into keys (one chunk can hold several, e.g. a paste). */
|
|
129
|
+
export function parseKeys(chunk) {
|
|
130
|
+
const keys = [];
|
|
131
|
+
let text = "";
|
|
132
|
+
const push = (key) => {
|
|
133
|
+
if (text)
|
|
134
|
+
keys.push({ text });
|
|
135
|
+
text = "";
|
|
136
|
+
keys.push(key);
|
|
137
|
+
};
|
|
138
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
139
|
+
const ch = chunk[i];
|
|
140
|
+
if (ch === "\x1b") {
|
|
141
|
+
const next = chunk[i + 1];
|
|
142
|
+
if (next === "[" || next === "O") {
|
|
143
|
+
// CSI / SS3: skip the parameters up to the final byte.
|
|
144
|
+
let end = i + 2;
|
|
145
|
+
while (end < chunk.length && !/[A-Za-z~]/.test(chunk[end]))
|
|
146
|
+
end++;
|
|
147
|
+
const final = chunk[end];
|
|
148
|
+
if (final === "A")
|
|
149
|
+
push("up");
|
|
150
|
+
else if (final === "B")
|
|
151
|
+
push("down");
|
|
152
|
+
i = end;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
push("escape");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else if (ch === "\r" || ch === "\n") {
|
|
159
|
+
push("enter");
|
|
160
|
+
}
|
|
161
|
+
else if (ch === "\x03" || ch === "\x04") {
|
|
162
|
+
push("cancel");
|
|
163
|
+
}
|
|
164
|
+
else if (ch === "\x7f" || ch === "\b") {
|
|
165
|
+
push("backspace");
|
|
166
|
+
}
|
|
167
|
+
else if (ch === "\x15") {
|
|
168
|
+
push("clear");
|
|
169
|
+
}
|
|
170
|
+
else if (ch >= " ") {
|
|
171
|
+
text += ch;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (text)
|
|
175
|
+
keys.push({ text });
|
|
176
|
+
return keys;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The arrow-key list, drawn inline under the question in normal scrollback: ↑/↓ or j/k move
|
|
180
|
+
* the `›` cursor, a digit jumps, Enter picks, Esc or Ctrl-C cancels. It redraws only its own
|
|
181
|
+
* lines and hides the cursor meanwhile; raw mode and the cursor come back on every way out,
|
|
182
|
+
* including a crash or a SIGINT/SIGTERM from elsewhere.
|
|
183
|
+
*/
|
|
184
|
+
class ArrowSelect {
|
|
185
|
+
terminal;
|
|
186
|
+
options;
|
|
187
|
+
cursor;
|
|
188
|
+
typing = false;
|
|
189
|
+
text = "";
|
|
190
|
+
digits = "";
|
|
191
|
+
drawn = 0;
|
|
192
|
+
head;
|
|
193
|
+
marker;
|
|
194
|
+
caret;
|
|
195
|
+
c;
|
|
196
|
+
constructor(terminal, question, options, start, color) {
|
|
197
|
+
this.terminal = terminal;
|
|
198
|
+
this.options = options;
|
|
199
|
+
this.c = palette(color);
|
|
200
|
+
this.cursor = start;
|
|
201
|
+
this.caret = color ? "\x1b[7m \x1b[27m" : "▏";
|
|
202
|
+
const split = question.lastIndexOf("\n");
|
|
203
|
+
this.head = split >= 0 ? question.slice(0, split) : question.trimEnd();
|
|
204
|
+
this.marker = split >= 0 ? question.slice(split + 1) : `${this.c.cyan("›")} `;
|
|
205
|
+
}
|
|
206
|
+
run() {
|
|
207
|
+
const { input, output } = this.terminal;
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
let finished = false;
|
|
210
|
+
const restore = () => {
|
|
211
|
+
input.setRawMode?.(false);
|
|
212
|
+
output.write(SHOW_CURSOR);
|
|
213
|
+
};
|
|
214
|
+
const onExit = () => restore();
|
|
215
|
+
const onSignal = () => { restore(); process.exit(130); };
|
|
216
|
+
const end = (outcome) => {
|
|
217
|
+
if (finished)
|
|
218
|
+
return;
|
|
219
|
+
finished = true;
|
|
220
|
+
input.off("data", onData);
|
|
221
|
+
process.off("exit", onExit);
|
|
222
|
+
process.off("SIGINT", onSignal);
|
|
223
|
+
process.off("SIGTERM", onSignal);
|
|
224
|
+
restore();
|
|
225
|
+
this.terminal.close();
|
|
226
|
+
outcome();
|
|
227
|
+
};
|
|
228
|
+
const onData = (chunk) => {
|
|
229
|
+
try {
|
|
230
|
+
for (const key of parseKeys(typeof chunk === "string" ? chunk : chunk.toString("utf8"))) {
|
|
231
|
+
const result = this.handle(key);
|
|
232
|
+
if (result === "cancel") {
|
|
233
|
+
this.erase(true);
|
|
234
|
+
end(() => reject(new PromptCancelled()));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (result) {
|
|
238
|
+
// Leave what an answered text prompt leaves: the question and `› choice`.
|
|
239
|
+
this.erase(false);
|
|
240
|
+
output.write(`${this.marker}${result.text ?? this.options.find((option) => option.value === result.value).label}\n`);
|
|
241
|
+
end(() => resolve(result));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
this.draw();
|
|
246
|
+
}
|
|
247
|
+
catch (error) {
|
|
248
|
+
end(() => reject(error));
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
process.once("exit", onExit);
|
|
252
|
+
process.on("SIGINT", onSignal);
|
|
253
|
+
process.on("SIGTERM", onSignal);
|
|
254
|
+
input.setRawMode(true);
|
|
255
|
+
input.on("data", onData);
|
|
256
|
+
input.resume();
|
|
257
|
+
output.write(`${HIDE_CURSOR}${this.fit(this.head, this.c.dim(" ↑/↓ to move · Enter to pick"))}\n`);
|
|
258
|
+
this.draw();
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/** Applies a key: a result when something was picked, "cancel" to stop, else nothing (redraw). */
|
|
262
|
+
handle(key) {
|
|
263
|
+
if (key === "cancel")
|
|
264
|
+
return "cancel";
|
|
265
|
+
if (this.typing) {
|
|
266
|
+
if (key === "escape") {
|
|
267
|
+
this.typing = false;
|
|
268
|
+
this.text = "";
|
|
269
|
+
}
|
|
270
|
+
else if (key === "enter")
|
|
271
|
+
return this.text.trim() ? { value: this.options[this.cursor].value, text: this.text.trim() } : undefined;
|
|
272
|
+
else if (key === "backspace")
|
|
273
|
+
this.text = this.text.slice(0, -1);
|
|
274
|
+
else if (key === "clear")
|
|
275
|
+
this.text = "";
|
|
276
|
+
else if (typeof key === "object")
|
|
277
|
+
this.text += key.text;
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
if (key === "escape")
|
|
281
|
+
return "cancel";
|
|
282
|
+
if (key === "up")
|
|
283
|
+
this.move(-1);
|
|
284
|
+
else if (key === "down")
|
|
285
|
+
this.move(1);
|
|
286
|
+
else if (key === "enter") {
|
|
287
|
+
const option = this.options[this.cursor];
|
|
288
|
+
if (!option.input)
|
|
289
|
+
return { value: option.value };
|
|
290
|
+
this.typing = true;
|
|
291
|
+
}
|
|
292
|
+
else if (typeof key === "object") {
|
|
293
|
+
for (const ch of key.text) {
|
|
294
|
+
if (ch === "k")
|
|
295
|
+
this.move(-1);
|
|
296
|
+
else if (ch === "j")
|
|
297
|
+
this.move(1);
|
|
298
|
+
else if (/\d/.test(ch))
|
|
299
|
+
this.jump(ch);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return undefined;
|
|
303
|
+
}
|
|
304
|
+
move(by) {
|
|
305
|
+
this.digits = "";
|
|
306
|
+
this.cursor = (this.cursor + by + this.options.length) % this.options.length;
|
|
307
|
+
}
|
|
308
|
+
/** A digit jumps to that option; with 10 or more, a second digit right after makes 10+. */
|
|
309
|
+
jump(digit) {
|
|
310
|
+
const combined = Number(this.digits + digit) - 1;
|
|
311
|
+
if (this.digits && combined < this.options.length) {
|
|
312
|
+
this.cursor = combined;
|
|
313
|
+
this.digits = "";
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const single = Number(digit) - 1;
|
|
317
|
+
if (single >= 0 && single < this.options.length)
|
|
318
|
+
this.cursor = single;
|
|
319
|
+
this.digits = this.options.length >= 10 && single >= 0 ? digit : "";
|
|
320
|
+
}
|
|
321
|
+
lines() {
|
|
322
|
+
if (this.typing) {
|
|
323
|
+
const option = this.options[this.cursor];
|
|
324
|
+
const placeholder = this.text ? "" : this.c.dim(option.input.placeholder);
|
|
325
|
+
// The real cursor stays hidden (it sits below the list); draw one where typing goes.
|
|
326
|
+
return [this.c.dim(` ${option.label} · Esc to go back`), `${this.marker}${this.text}${this.caret}${placeholder}`];
|
|
327
|
+
}
|
|
328
|
+
const width = Math.max(...this.options.map((option) => option.label.length)) + 2;
|
|
329
|
+
return this.options.map((option, index) => {
|
|
330
|
+
const selected = index === this.cursor;
|
|
331
|
+
const label = option.detail ? option.label.padEnd(width) : option.label;
|
|
332
|
+
const line = ` ${selected ? this.c.cyan("›") : " "} ${selected ? this.c.bold(label) : label}`;
|
|
333
|
+
return this.fit(line, option.detail ? this.c.dim(option.detail) : "");
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/** Redraws the lines under the question in place; lines never wrap, so the count holds. */
|
|
337
|
+
draw() {
|
|
338
|
+
const lines = this.lines();
|
|
339
|
+
const rewind = this.drawn > 0 ? `\x1b[${this.drawn}F` : "";
|
|
340
|
+
// When there are fewer lines than last time (typing mode), clear what's left below.
|
|
341
|
+
const stale = this.drawn > lines.length ? "\x1b[J" : "";
|
|
342
|
+
this.terminal.output.write(`${rewind}${lines.map((line) => `\x1b[2K${line}\n`).join("")}${stale}`);
|
|
343
|
+
this.drawn = lines.length;
|
|
344
|
+
}
|
|
345
|
+
/** Erases the list (and, when cancelling, the question too). */
|
|
346
|
+
erase(all) {
|
|
347
|
+
const up = this.drawn + (all ? 1 : 0);
|
|
348
|
+
this.terminal.output.write(`${up > 0 ? `\x1b[${up}F` : ""}\x1b[J`);
|
|
349
|
+
this.drawn = 0;
|
|
350
|
+
}
|
|
351
|
+
/** `styled` plus a dim tail, the tail cut so the line fits the terminal's width. */
|
|
352
|
+
fit(styled, tail) {
|
|
353
|
+
const columns = this.terminal.output.columns || 80;
|
|
354
|
+
const visible = (text) => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
355
|
+
const room = columns - 1 - visible(styled).length;
|
|
356
|
+
const plainTail = visible(tail);
|
|
357
|
+
if (plainTail.length <= room)
|
|
358
|
+
return `${styled}${tail}`;
|
|
359
|
+
if (room <= 1)
|
|
360
|
+
return styled;
|
|
361
|
+
return `${styled}${this.c.dim(`${plainTail.slice(0, room - 1)}…`)}`;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
64
364
|
/** Answers from a map (tests); a missing answer is a test bug, so it throws. */
|
|
65
365
|
export class ScriptedPrompter {
|
|
66
366
|
answers;
|
|
@@ -71,6 +371,23 @@ export class ScriptedPrompter {
|
|
|
71
371
|
async choose(question) {
|
|
72
372
|
return this.answer(question);
|
|
73
373
|
}
|
|
374
|
+
/**
|
|
375
|
+
* An answer is a number (1-based), an option's value or label, or "" for the preselected
|
|
376
|
+
* one. Picking an option that takes input then asks its prompt; other text goes to it directly.
|
|
377
|
+
*/
|
|
378
|
+
async select(question, options, initial = 0) {
|
|
379
|
+
const answer = this.answer(question).trim();
|
|
380
|
+
const index = answer === "" ? initial : /^\d+$/.test(answer) ? Number(answer) - 1 : options.findIndex((option) => option.value === answer || option.label === answer);
|
|
381
|
+
const option = options[index];
|
|
382
|
+
if (option?.input)
|
|
383
|
+
return { value: option.value, text: this.answer(option.input.prompt).trim() };
|
|
384
|
+
if (option)
|
|
385
|
+
return { value: option.value };
|
|
386
|
+
const typed = options.find((entry) => entry.input);
|
|
387
|
+
if (typed)
|
|
388
|
+
return { value: typed.value, text: answer };
|
|
389
|
+
throw new Error(`no option "${answer}" for: ${question}`);
|
|
390
|
+
}
|
|
74
391
|
async secret(question) {
|
|
75
392
|
return this.answer(question);
|
|
76
393
|
}
|
package/dist/up.js
CHANGED
|
@@ -5,6 +5,7 @@ import { AUTOPILOT_PORT, CELL_MEMORY_MB, GITHUB_PROXY_PORT, HOTCELL_EGRESS_PORT,
|
|
|
5
5
|
import { ACCESS_MODES, readState, writeState } from "./state.js";
|
|
6
6
|
import { PlainUi } from "./ui.js";
|
|
7
7
|
import { ownerLink } from "./server-api.js";
|
|
8
|
+
import { refreshModelPrices } from "./model-prices.js";
|
|
8
9
|
const DRY_RUN_BRIDGE_IP = "172.17.0.1";
|
|
9
10
|
const DRY_RUN_URL = "https://<this-machine>.<tailnet>.ts.net";
|
|
10
11
|
/** `dl up`: install or repair Hotcell + Autopilot as services. Safe to re-run. */
|
|
@@ -103,6 +104,14 @@ export async function up(system, options) {
|
|
|
103
104
|
const hotcellValues = parseEnvFile(await system.readFile(PATHS.hotcellEnv));
|
|
104
105
|
const apiKey = hotcellValues.HOTCELL_API_KEY || randomBytes(24).toString("hex");
|
|
105
106
|
await system.mkdir(PATHS.hotcellEtc, { mode: 0o750, owner: `root:${USERS.hotcell}` });
|
|
107
|
+
// Read once at Hotcell's start, so it's in place before the restart below.
|
|
108
|
+
const prices = await refreshModelPrices(system, {
|
|
109
|
+
path: PATHS.modelPrices,
|
|
110
|
+
write: { mode: 0o644, owner: `${USERS.hotcell}:${USERS.hotcell}` },
|
|
111
|
+
fetchImpl,
|
|
112
|
+
warn: (text) => ui.warn(text),
|
|
113
|
+
retry: "dl up",
|
|
114
|
+
});
|
|
106
115
|
await system.writeFile(PATHS.hotcellEnv, hotcellEnv({ apiKey, bridgeIp }), { mode: 0o640, owner: `root:${USERS.hotcell}`, secret: true });
|
|
107
116
|
if (!(await system.readFile(PATHS.asterConfig))) {
|
|
108
117
|
await system.writeFile(PATHS.asterConfig, asterConfig(), { mode: 0o640, owner: `root:${USERS.autopilot}` });
|
|
@@ -118,7 +127,7 @@ export async function up(system, options) {
|
|
|
118
127
|
await system.run("systemctl", ["restart", SERVICES.hotcell]);
|
|
119
128
|
await waitUntil(system, sleep, "Hotcell", async () => (await system.probe("curl", ["-fsS", "-o", "/dev/null", `http://127.0.0.1:${HOTCELL_PORT}/`])).code === 0
|
|
120
129
|
|| (await system.probe("sh", ["-c", `ss -ltn | grep -q '127.0.0.1:${HOTCELL_PORT}'`])).code === 0);
|
|
121
|
-
return { value: apiKey, detail:
|
|
130
|
+
return { value: apiKey, detail: `sandboxes and keys ready${prices ? ` · ${prices}` : ""}` };
|
|
122
131
|
});
|
|
123
132
|
const url = await step("access", async () => {
|
|
124
133
|
// Moving off Tailscale: stop publishing on the tailnet, so the only way in is the new one.
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "directionallylocal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Directionally Local: background coding agents on your own server. One-line server install, and the dl command for your server and laptop.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
5
6
|
"homepage": "https://directionallylocal.com",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
@@ -34,5 +35,8 @@
|
|
|
34
35
|
"@types/node": "^22.10.0",
|
|
35
36
|
"tsx": "^4.19.2",
|
|
36
37
|
"typescript": "^5.7.2"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"autopilot-ai": "^2.7.0"
|
|
37
41
|
}
|
|
38
42
|
}
|