directionallylocal 0.4.1 → 0.6.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 +24 -2
- package/dist/laptop/commands.js +12 -33
- package/dist/laptop/credentials.js +5 -5
- package/dist/laptop/session-store.js +62 -0
- package/dist/laptop/session.js +280 -0
- package/dist/laptop/settings.js +193 -0
- package/dist/laptop/setup.js +110 -63
- package/dist/laptop/tunnel.js +8 -0
- package/dist/local/setup.js +2 -4
- package/dist/prompt.js +333 -16
- package/package.json +4 -1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "directionallylocal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://directionallylocal.com",
|
|
@@ -35,5 +35,8 @@
|
|
|
35
35
|
"@types/node": "^22.10.0",
|
|
36
36
|
"tsx": "^4.19.2",
|
|
37
37
|
"typescript": "^5.7.2"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"autopilot-ai": "^2.7.0"
|
|
38
41
|
}
|
|
39
42
|
}
|