cawdev-cli 0.9.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/README.md +175 -0
- package/lib/ansi.mjs +224 -0
- package/lib/cawdev.mjs +104 -0
- package/lib/code-map.mjs +164 -0
- package/lib/harness-prompt.mjs +197 -0
- package/lib/roadmap-format.mjs +453 -0
- package/lib/run-plugin.mjs +119 -0
- package/lib/secrets.mjs +290 -0
- package/lib/stage-tools.mjs +384 -0
- package/lib/tool-line.mjs +92 -0
- package/lib/tool-rules.mjs +282 -0
- package/lib/transcript-batch.mjs +88 -0
- package/lib/usage-limit.mjs +80 -0
- package/lib/usage-report.mjs +142 -0
- package/lib/usage.mjs +119 -0
- package/mcp/README.md +273 -0
- package/mcp/orchestration-smoke.mjs +267 -0
- package/mcp/server.mjs +2163 -0
- package/mcp/smoke.mjs +220 -0
- package/package.json +20 -0
- package/runner/README.md +930 -0
- package/runner/attach.mjs +2397 -0
- package/runner/banner.mjs +106 -0
- package/runner/bootstrap.mjs +501 -0
- package/runner/brand.mjs +57 -0
- package/runner/cawdev.mjs +414 -0
- package/runner/control.mjs +225 -0
- package/runner/history.mjs +91 -0
- package/runner/input.mjs +355 -0
- package/runner/macbook-laptop.json +48 -0
- package/runner/runner.mjs +7445 -0
- package/runner/scrollback.mjs +165 -0
- package/runner/select.mjs +316 -0
- package/runner/session-store.mjs +78 -0
- package/runner/sign-in.mjs +210 -0
- package/runner/stub-agent.mjs +212 -0
- package/runner/token-store.mjs +107 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// The terminal's own scrollback, with a live region pinned under it — R81.
|
|
2
|
+
//
|
|
3
|
+
// **This replaces a pane manager rather than adding a framework.** The client
|
|
4
|
+
// used to take the alternate screen and repaint a viewport into it, which meant
|
|
5
|
+
// the terminal's scroll, its wheel, its search and its copy all stopped working
|
|
6
|
+
// the moment you attached, and the transcript above the fold was gone. Claude
|
|
7
|
+
// Code's CLI does not do that, and the difference is the one people notice.
|
|
8
|
+
//
|
|
9
|
+
// So: transcript lines are PRINTED. They go into the scrollback and are never
|
|
10
|
+
// touched again — which is what makes wheel, `shift+PgUp`, search and copy the
|
|
11
|
+
// terminal's job rather than ours. Only the last few lines and the footer are
|
|
12
|
+
// drawn, and they are the only thing that is ever erased.
|
|
13
|
+
//
|
|
14
|
+
// The whole mechanism is three escape sequences and one invariant:
|
|
15
|
+
//
|
|
16
|
+
// **After every write, the cursor sits at column 0 of the live region's first
|
|
17
|
+
// row, and everything below it belongs to the live region.**
|
|
18
|
+
//
|
|
19
|
+
// Which makes the update trivial: erase from the cursor down (`ESC[0J`), print
|
|
20
|
+
// whatever is being committed to the scrollback, print the live region, then
|
|
21
|
+
// walk back up by however many rows it took.
|
|
22
|
+
//
|
|
23
|
+
// The one rule that keeps that arithmetic honest: **every live line is clipped
|
|
24
|
+
// to the width.** A line that wraps occupies two rows and one row of cursor
|
|
25
|
+
// arithmetic, and the difference between those two numbers is how a footer
|
|
26
|
+
// eats a transcript. Committed lines are NOT clipped — they are the terminal's
|
|
27
|
+
// to wrap, and its to reflow when the window changes.
|
|
28
|
+
|
|
29
|
+
import { clip, colourDepth, stripAnsi } from '../lib/ansi.mjs';
|
|
30
|
+
|
|
31
|
+
const ESC = '\x1b';
|
|
32
|
+
const ERASE_DOWN = `${ESC}[0J`;
|
|
33
|
+
const HIDE_CURSOR = `${ESC}[?25l`;
|
|
34
|
+
const SHOW_CURSOR = `${ESC}[?25h`;
|
|
35
|
+
const RESET = `${ESC}[0m`;
|
|
36
|
+
|
|
37
|
+
export class Scrollback {
|
|
38
|
+
/**
|
|
39
|
+
* @param out where to write. `process.stdout` in the real thing; anything
|
|
40
|
+
* with `write` in a test, which is how the arithmetic below is checked
|
|
41
|
+
* without a terminal.
|
|
42
|
+
*/
|
|
43
|
+
constructor(out = process.stdout, {
|
|
44
|
+
colour = colourDepth(process.env, out) > 0,
|
|
45
|
+
// A dumb terminal is a TTY that cannot be repainted, and R83 needs to say
|
|
46
|
+
// so: everything drawn here assumes `ESC[0J` and `ESC[nA` mean something.
|
|
47
|
+
// Passed in rather than read here so the caller keeps one flag for "can this
|
|
48
|
+
// be drawn on at all", which is also what decides whether it reads keys.
|
|
49
|
+
tty = Boolean(out.isTTY) && process.env.TERM !== 'dumb',
|
|
50
|
+
} = {}) {
|
|
51
|
+
this.out = out;
|
|
52
|
+
this.drawn = 0;
|
|
53
|
+
this.pinned = [];
|
|
54
|
+
// A pipe, a `less`, a CI log. There is no cursor to move and no region to
|
|
55
|
+
// pin, so the live region simply stops existing and the transcript is the
|
|
56
|
+
// whole output — which is the honest degradation, not a lesser one.
|
|
57
|
+
this.tty = tty;
|
|
58
|
+
// **Two flags, because they are two questions.** `tty` is about a cursor;
|
|
59
|
+
// this is about escape codes, and `FORCE_COLOR` is somebody piping into
|
|
60
|
+
// something that does understand them. Where it is off, a committed line is
|
|
61
|
+
// stripped rather than merely un-tinted: the transcript carries the AGENT'S
|
|
62
|
+
// colour (R23), which our own painter has no say over, and a file full of
|
|
63
|
+
// `ESC[32m` is not what "legible through a pipe" means.
|
|
64
|
+
this.colour = colour;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get width() {
|
|
68
|
+
return this.out.columns ?? 80;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get height() {
|
|
72
|
+
return this.out.rows ?? 24;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Takes the cursor. The alternate screen is deliberately NOT entered. */
|
|
76
|
+
open() {
|
|
77
|
+
if (this.tty) {
|
|
78
|
+
this.out.write(HIDE_CURSOR);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Commit lines to the scrollback and replace what is pinned, in one write.
|
|
84
|
+
*
|
|
85
|
+
* **One call rather than two, and that is not tidiness.** Printing a line and
|
|
86
|
+
* then setting the footer is two erase-and-redraw cycles per line: on a
|
|
87
|
+
* session emitting a few hundred lines a second the terminal spends its time
|
|
88
|
+
* on escape codes, and — worse — the footer drawn by the first of the two is
|
|
89
|
+
* the *previous* one, so a stale prompt line flashes under everything that is
|
|
90
|
+
* printed. Both were visible in a real terminal.
|
|
91
|
+
*
|
|
92
|
+
* Committed lines are not clipped and not wrapped: a long line is the
|
|
93
|
+
* terminal's to fold, and folding it here would freeze today's width into the
|
|
94
|
+
* copy somebody takes tomorrow.
|
|
95
|
+
*/
|
|
96
|
+
update(lines = [], live = this.pinned) {
|
|
97
|
+
this.pinned = live ?? [];
|
|
98
|
+
this.#write(Array.isArray(lines) ? lines : [lines], this.pinned);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Commit lines under whatever is already pinned. */
|
|
102
|
+
print(lines) {
|
|
103
|
+
this.update(lines, this.pinned);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Replace what is pinned at the bottom. */
|
|
107
|
+
live(lines) {
|
|
108
|
+
this.update([], lines ?? []);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The window changed shape.
|
|
113
|
+
*
|
|
114
|
+
* Nothing is repaired above the cursor, and that is the point: those lines
|
|
115
|
+
* are the terminal's now, and it has already reflowed them the way it
|
|
116
|
+
* reflows every other line in the buffer. Only the live region is redrawn.
|
|
117
|
+
*/
|
|
118
|
+
resize() {
|
|
119
|
+
if (!this.tty) return;
|
|
120
|
+
this.out.write('\r');
|
|
121
|
+
this.drawn = 0;
|
|
122
|
+
this.#write([], this.pinned);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Give the terminal back, leaving the transcript where it is. */
|
|
126
|
+
close() {
|
|
127
|
+
if (!this.tty) return;
|
|
128
|
+
this.out.write(`${ERASE_DOWN}${SHOW_CURSOR}`);
|
|
129
|
+
this.drawn = 0;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A line as it should be committed: closed if coloured, plain if not. */
|
|
133
|
+
#commit(line) {
|
|
134
|
+
return this.colour ? `${line}${RESET}\n` : `${stripAnsi(line)}\n`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#write(committed, live) {
|
|
138
|
+
if (!this.tty) {
|
|
139
|
+
// No region, so nothing to erase and nothing to walk back over.
|
|
140
|
+
if (committed.length) {
|
|
141
|
+
this.out.write(committed.map((line) => this.#commit(line)).join(''));
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A live region taller than the window would scroll the transcript off the
|
|
147
|
+
// top and leave the cursor arithmetic pointing at rows that are no longer
|
|
148
|
+
// there. One row is kept for whatever is committed next.
|
|
149
|
+
const shown = live.slice(-Math.max(1, this.height - 1)).map((line) => clip(line, this.width));
|
|
150
|
+
|
|
151
|
+
let out = ERASE_DOWN;
|
|
152
|
+
for (const line of committed) {
|
|
153
|
+
out += this.#commit(line);
|
|
154
|
+
}
|
|
155
|
+
for (const line of shown) {
|
|
156
|
+
out += `${line}${RESET}\n`;
|
|
157
|
+
}
|
|
158
|
+
if (shown.length) {
|
|
159
|
+
out += `${ESC}[${shown.length}A`;
|
|
160
|
+
}
|
|
161
|
+
out += '\r';
|
|
162
|
+
this.out.write(out);
|
|
163
|
+
this.drawn = shown.length;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// One select widget, written once — R83.
|
|
2
|
+
//
|
|
3
|
+
// **The CLI was the one surface that threw the agent's options away.**
|
|
4
|
+
// `ask_user` has carried `options` since R10 — the agent has usually already
|
|
5
|
+
// worked out the two or three answers it can act on — and the console renders
|
|
6
|
+
// them as buttons. Here the question printed and you retyped one of them,
|
|
7
|
+
// spelled correctly. A permission request had the same shape and the same
|
|
8
|
+
// problem: R60's three answers were three single keys bolted to the side of a
|
|
9
|
+
// scrolling transcript rather than a thing you look at and choose from.
|
|
10
|
+
//
|
|
11
|
+
// So there is ONE of these and everything that offers a choice is it: the
|
|
12
|
+
// question picker, the permission decision, and R81's `L` run overlay, which
|
|
13
|
+
// stops being its own code. A second implementation of "a cursor, some rows and
|
|
14
|
+
// a window that walks" is a second place for the arithmetic to be wrong by one.
|
|
15
|
+
//
|
|
16
|
+
// Three rules the widget keeps:
|
|
17
|
+
//
|
|
18
|
+
// 1. **It draws into R81's live region and never rewrites the scrollback.**
|
|
19
|
+
// `lines()` returns rows; the client pins them. Nothing here writes.
|
|
20
|
+
// 2. **Getting to free text costs one key.** `ask_user` says people can still
|
|
21
|
+
// answer in prose because the options are the agent's GUESS at the shape of
|
|
22
|
+
// the decision, and the whole value of asking a person is that they can say
|
|
23
|
+
// the thing that was not on the list. So the last row opens a line editor,
|
|
24
|
+
// and Esc from there comes back to the list rather than abandoning the
|
|
25
|
+
// answer.
|
|
26
|
+
// 3. **Where there is no cursor to move it is a numbered list read from
|
|
27
|
+
// stdin** — a plain prompt, not a broken repaint. {@link plainLines} and
|
|
28
|
+
// {@link pickFromLine} are that path, and they are the same rows.
|
|
29
|
+
//
|
|
30
|
+
// Pure, and rendered from a plain object, for R62's reason: a widget built
|
|
31
|
+
// inside a draw method can only be checked by looking at it, and three of R81's
|
|
32
|
+
// five layout bugs were found by rendering at four widths and READING them.
|
|
33
|
+
|
|
34
|
+
import { clip, keyList, painter, visibleWidth } from '../lib/ansi.mjs';
|
|
35
|
+
|
|
36
|
+
const ESC = '\x1b';
|
|
37
|
+
|
|
38
|
+
/** The row that is always last on a question, and what choosing it means. */
|
|
39
|
+
export const WRITE_MY_OWN = '__write-my-own__';
|
|
40
|
+
|
|
41
|
+
// Arrows, and the keys people who live in a terminal press instead of arrows.
|
|
42
|
+
// Tab is here because R81's run list took it and taking it away would be a
|
|
43
|
+
// regression nobody asked for.
|
|
44
|
+
const DOWN = new Set([`${ESC}[B`, `${ESC}OB`, '\t', '\x0e', 'j']);
|
|
45
|
+
const UP = new Set([`${ESC}[A`, `${ESC}OA`, `${ESC}[Z`, '\x10', 'k']);
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A choice: a title, some rows, and a cursor.
|
|
49
|
+
*
|
|
50
|
+
* `kind` is for the caller — it is what tells the client whether a chosen row is
|
|
51
|
+
* an answer, a decision or a run to watch. The widget itself does not care.
|
|
52
|
+
*/
|
|
53
|
+
export class Select {
|
|
54
|
+
constructor({ kind = 'choice', title = '', rows = [], at = 0, empty = null, hint = null }) {
|
|
55
|
+
this.kind = kind;
|
|
56
|
+
this.title = title;
|
|
57
|
+
this.rows = rows;
|
|
58
|
+
this.empty = empty;
|
|
59
|
+
this.hint = hint;
|
|
60
|
+
this.at = Math.max(0, Math.min(at, Math.max(0, rows.length - 1)));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get row() {
|
|
64
|
+
return this.rows[this.at] ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Whether the last row opens a line editor — rule 2 above. */
|
|
68
|
+
get freeText() {
|
|
69
|
+
return this.rows.some((row) => row.id === WRITE_MY_OWN);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
move(by) {
|
|
73
|
+
if (!this.rows.length) {
|
|
74
|
+
this.at = 0;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
this.at = (this.at + by + this.rows.length) % this.rows.length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One key, and what it settled.
|
|
82
|
+
*
|
|
83
|
+
* Returns null for a key that means nothing here, so the caller can decide
|
|
84
|
+
* whether it means something to IT — which is how the single permission keys
|
|
85
|
+
* keep working while the picker is open.
|
|
86
|
+
*
|
|
87
|
+
* **A digit chooses rather than merely moving.** It is the only key on this
|
|
88
|
+
* widget that is faster than the arrows, and making it a two-step would spend
|
|
89
|
+
* the whole reason somebody reached for it.
|
|
90
|
+
*/
|
|
91
|
+
key(pressed) {
|
|
92
|
+
if (pressed === ESC) {
|
|
93
|
+
return { done: 'cancelled', row: null };
|
|
94
|
+
}
|
|
95
|
+
if (pressed === '\r' || pressed === '\n') {
|
|
96
|
+
return { done: 'chosen', row: this.row };
|
|
97
|
+
}
|
|
98
|
+
if (DOWN.has(pressed)) {
|
|
99
|
+
this.move(1);
|
|
100
|
+
return { done: null, moved: true };
|
|
101
|
+
}
|
|
102
|
+
if (UP.has(pressed)) {
|
|
103
|
+
this.move(-1);
|
|
104
|
+
return { done: null, moved: true };
|
|
105
|
+
}
|
|
106
|
+
if (/^[1-9]$/.test(pressed)) {
|
|
107
|
+
const at = Number(pressed) - 1;
|
|
108
|
+
if (at < this.rows.length) {
|
|
109
|
+
this.at = at;
|
|
110
|
+
return { done: 'chosen', row: this.rows[at] };
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
lines(width, ink = painter(3), room = 8) {
|
|
118
|
+
return selectLines(this, width, ink, room);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The widget, drawn.
|
|
124
|
+
*
|
|
125
|
+
* The window walks with the cursor rather than the list scrolling under it: a
|
|
126
|
+
* machine with twenty runs should still show the one you are on, and a question
|
|
127
|
+
* with a dozen options should still show the one about to be chosen.
|
|
128
|
+
*/
|
|
129
|
+
export function selectLines(select, width, ink = painter(3), room = 8) {
|
|
130
|
+
const lines = [];
|
|
131
|
+
if (select.title) {
|
|
132
|
+
lines.push(clip(` ${ink.muted(select.title)}`, width));
|
|
133
|
+
}
|
|
134
|
+
if (!select.rows.length) {
|
|
135
|
+
lines.push(clip(` ${ink.muted(select.empty ?? 'nothing to choose from')}`, width));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const shown = Math.max(1, Math.min(select.rows.length, room));
|
|
139
|
+
const from = Math.max(0, Math.min(select.at - Math.floor(shown / 2), select.rows.length - shown));
|
|
140
|
+
select.rows.slice(from, from + shown).forEach((row, offset) => {
|
|
141
|
+
const at = from + offset;
|
|
142
|
+
lines.push(rowLine(row, {
|
|
143
|
+
chosen: at === select.at,
|
|
144
|
+
marker: row.marker ?? ' ',
|
|
145
|
+
// Past nine there is no digit to offer, and a number nobody can press is
|
|
146
|
+
// worse than a space.
|
|
147
|
+
number: at < 9 ? String(at + 1) : ' ',
|
|
148
|
+
}, width, ink));
|
|
149
|
+
});
|
|
150
|
+
if (select.rows.length > shown) {
|
|
151
|
+
lines.push(clip(` ${ink.muted(`… ${select.rows.length - shown} more`)}`, width));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
lines.push(hintLine(select, width, ink));
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* What to press.
|
|
160
|
+
*
|
|
161
|
+
* **`esc leave` has its room reserved rather than taking its chances.**
|
|
162
|
+
* {@link keyList} drops whole keys from the right, which is the correct
|
|
163
|
+
* behaviour and would drop exactly the wrong one here: leaving is the key
|
|
164
|
+
* somebody reaches for when they do not want any of this, and it is the last
|
|
165
|
+
* thing that should go when the terminal is narrow. So the others are given
|
|
166
|
+
* what is left over and it is written after them, in the order somebody reads.
|
|
167
|
+
* It is the same rule R51's banner keeps about `n refuse`.
|
|
168
|
+
*/
|
|
169
|
+
function hintLine(select, width, ink) {
|
|
170
|
+
const leave = ink.muted('esc leave');
|
|
171
|
+
const room = width - visibleWidth(leave) - 4;
|
|
172
|
+
if (room < 10) {
|
|
173
|
+
return clip(` ${leave}`, width);
|
|
174
|
+
}
|
|
175
|
+
const keys = [ink.muted('↑↓ move'), ink.muted('enter choose')];
|
|
176
|
+
if (select.rows.length > 1) {
|
|
177
|
+
keys.push(ink.muted('1-9 pick'));
|
|
178
|
+
}
|
|
179
|
+
if (select.hint) {
|
|
180
|
+
keys.push(ink.muted(select.hint));
|
|
181
|
+
}
|
|
182
|
+
return clip(` ${keyList(keys, '', room, ink)}${ink.muted(' · ')}${leave}`, width);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* One row, and what gives way when there is not enough of it.
|
|
187
|
+
*
|
|
188
|
+
* A row may bring its own renderer — `runLine` narrows a run's LABEL and keeps
|
|
189
|
+
* the reason it is queued, which is R58's lesson about which half should survive
|
|
190
|
+
* and is not a rule a generic row could guess. Everything else is a label, a
|
|
191
|
+
* `short` wording of it, and a hint beside it, and they give way in that order:
|
|
192
|
+
*
|
|
193
|
+
* 1. **The hint goes first.** It is a gloss; the label is the thing being
|
|
194
|
+
* chosen. Rendered at forty columns the other way round, this row read
|
|
195
|
+
* `2 Allow Bash(mvn *) for the rest of dies with the session`, which
|
|
196
|
+
* spends the last columns on the aside and cuts the answer.
|
|
197
|
+
* 2. **Then the wording shortens, a whole phrasing at a time.** R78's rule,
|
|
198
|
+
* and this is the widget where it matters most: `Allow Bash(mvn *) for th`
|
|
199
|
+
* describes a promise nobody made, and these words are a decision about
|
|
200
|
+
* what a machine may do rather than a status.
|
|
201
|
+
* 3. Only a terminal too narrow for the short wording on its own reaches the
|
|
202
|
+
* clip, and there is nothing better than a cut line to give it.
|
|
203
|
+
*/
|
|
204
|
+
function rowLine(row, opts, width, ink) {
|
|
205
|
+
if (row.render) {
|
|
206
|
+
return row.render(opts, width, ink);
|
|
207
|
+
}
|
|
208
|
+
const head = `${opts.chosen ? ink.bold('❯') : ' '}${opts.marker ?? ' '}${ink.muted(opts.number ?? ' ')} `;
|
|
209
|
+
const room = width - visibleWidth(head);
|
|
210
|
+
const hint = row.hint ? ` ${row.hint}` : '';
|
|
211
|
+
|
|
212
|
+
const label = String(row.label ?? '');
|
|
213
|
+
const fits = (text, withHint) =>
|
|
214
|
+
visibleWidth(text) + (withHint ? visibleWidth(hint) : 0) <= room;
|
|
215
|
+
|
|
216
|
+
const withHint = fits(label, true);
|
|
217
|
+
const text = fits(label, false) || !row.short || !fits(row.short, false)
|
|
218
|
+
? clip(label, Math.max(8, room))
|
|
219
|
+
: row.short;
|
|
220
|
+
|
|
221
|
+
return clip(
|
|
222
|
+
`${head}${opts.chosen ? ink.text(text) : text}${withHint ? ink.muted(hint) : ''}`,
|
|
223
|
+
width,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The same choice where there is no cursor to move — R62's rule, R81's rule.
|
|
229
|
+
*
|
|
230
|
+
* Through a pipe, on a dumb terminal, or anywhere the live region does not
|
|
231
|
+
* exist, this is what a picker is: the rows PRINTED with numbers beside them and
|
|
232
|
+
* a line read from stdin. Not a lesser widget — a different one, and the honest
|
|
233
|
+
* shape for a stream that cannot be repainted.
|
|
234
|
+
*
|
|
235
|
+
* The free-text row does not need a number here. Anything that is not a number
|
|
236
|
+
* IS the free text, which is what a plain prompt has always meant.
|
|
237
|
+
*/
|
|
238
|
+
export function plainLines(select, { many = false } = {}) {
|
|
239
|
+
const lines = ['', select.title ? ` ${select.title}` : ' choose:'];
|
|
240
|
+
if (!select.rows.length) {
|
|
241
|
+
lines.push(` ${select.empty ?? 'nothing to choose from'}`);
|
|
242
|
+
}
|
|
243
|
+
select.rows.forEach((row, at) => {
|
|
244
|
+
// A hint that repeats the label is noise — "1) cawdev — cawdev" — and it
|
|
245
|
+
// happens whenever a thing's name and its identifier agree, which for a
|
|
246
|
+
// slug is most of the time.
|
|
247
|
+
const hint = row.hint && row.hint !== row.label ? ` — ${row.hint}` : '';
|
|
248
|
+
lines.push(` ${at + 1}) ${row.label}${hint}`);
|
|
249
|
+
});
|
|
250
|
+
// The LAST line is the instruction, and there is exactly one of it. R93 added
|
|
251
|
+
// a question with several answers and printed its own instruction under this
|
|
252
|
+
// one, so the screen said "a number:" and then "several numbers:" — the
|
|
253
|
+
// widget knowing both forms is what stops that.
|
|
254
|
+
lines.push(many
|
|
255
|
+
? ' several numbers, or enter for all:'
|
|
256
|
+
: select.freeText
|
|
257
|
+
? ' a number, or just type your answer:'
|
|
258
|
+
: ' a number:');
|
|
259
|
+
return lines;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A typed line, against the rows.
|
|
264
|
+
*
|
|
265
|
+
* Returns null for a line that settles nothing, so the caller asks again rather
|
|
266
|
+
* than guessing — except where free text is on offer, in which case a line that
|
|
267
|
+
* is not a number is the answer itself.
|
|
268
|
+
*/
|
|
269
|
+
export function pickFromLine(select, typed) {
|
|
270
|
+
const text = String(typed ?? '').trim();
|
|
271
|
+
if (!text) {
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
if (/^[0-9]+$/.test(text)) {
|
|
275
|
+
const row = select.rows[Number(text) - 1];
|
|
276
|
+
return row ? { done: 'chosen', row } : null;
|
|
277
|
+
}
|
|
278
|
+
const free = select.rows.find((row) => row.id === WRITE_MY_OWN);
|
|
279
|
+
return free ? { done: 'chosen', row: free, text } : null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Several rows from one typed line — R93.
|
|
284
|
+
*
|
|
285
|
+
* Here rather than in the setup walk because R83's rule is that choosing is
|
|
286
|
+
* this file's job, and a second parser for "1, 3" living next to the first
|
|
287
|
+
* would be the drift that rule exists to prevent. No free text: a question with
|
|
288
|
+
* more than one answer has no "write my own" row, since the rows are the
|
|
289
|
+
* choices and not a guess at them.
|
|
290
|
+
*
|
|
291
|
+
* Returns an empty array for a line that settles nothing, so the caller asks
|
|
292
|
+
* again. A number naming no row makes the whole line nothing rather than
|
|
293
|
+
* silently choosing the rest: somebody who typed `1 2 9` meant three, and
|
|
294
|
+
* giving them two of them is a wrong answer wearing a right one's clothes.
|
|
295
|
+
*/
|
|
296
|
+
export function pickManyFromLine(select, typed) {
|
|
297
|
+
const text = String(typed ?? '').trim();
|
|
298
|
+
if (!text) {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
const wanted = text.split(/[\s,]+/).filter(Boolean);
|
|
302
|
+
const picked = [];
|
|
303
|
+
for (const part of wanted) {
|
|
304
|
+
if (!/^[0-9]+$/.test(part)) {
|
|
305
|
+
return [];
|
|
306
|
+
}
|
|
307
|
+
const row = select.rows[Number(part) - 1];
|
|
308
|
+
if (!row) {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
if (!picked.includes(row)) {
|
|
312
|
+
picked.push(row);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return picked;
|
|
316
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Where the CLI remembers who you are — R81.
|
|
2
|
+
//
|
|
3
|
+
// One file, `~/.cawdev/session.json`, mode 0600, holding the session cookies
|
|
4
|
+
// the platform handed back after somebody approved a sign-in in their browser.
|
|
5
|
+
// **What is stored is a person's session and nothing else**: no password ever
|
|
6
|
+
// reaches this process, and the daemon's own token is not in here — R52's rule
|
|
7
|
+
// that a run's credential can never be borrowed for a decision is exactly why
|
|
8
|
+
// these two are different files.
|
|
9
|
+
//
|
|
10
|
+
// Keyed by URL, because one machine can point at more than one cawdev and
|
|
11
|
+
// reusing a cookie across instances is either a 401 or, worse, not one.
|
|
12
|
+
//
|
|
13
|
+
// Zero dependencies, like everything in tools/.
|
|
14
|
+
|
|
15
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { homedir } from 'node:os';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
/** Beside the sockets: one directory a person can chmod, back up, or delete. */
|
|
20
|
+
export function sessionFile() {
|
|
21
|
+
return join(homedir(), '.cawdev', 'session.json');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function key(url) {
|
|
25
|
+
return String(url).replace(/\/+$/, '');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function readAll() {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(await readFile(sessionFile(), 'utf8'));
|
|
31
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
32
|
+
} catch {
|
|
33
|
+
// No file, unreadable, or half-written. None of the three is worth an
|
|
34
|
+
// error: the answer to all of them is "you are not signed in yet".
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What was stored for one instance, or null. */
|
|
40
|
+
export async function loadSession(url) {
|
|
41
|
+
const stored = (await readAll())[key(url)];
|
|
42
|
+
if (!stored?.cookies || !stored.email) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
return { email: stored.email, cookies: new Map(Object.entries(stored.cookies)) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Remember a session for next time.
|
|
50
|
+
*
|
|
51
|
+
* The directory is created 0700 and the file forced to 0600 **after** writing:
|
|
52
|
+
* `writeFile`'s mode is masked by the process umask, so asking for 0600 and
|
|
53
|
+
* getting 0644 is the normal outcome rather than the unusual one, and a
|
|
54
|
+
* world-readable session cookie is the whole thing this file must not be.
|
|
55
|
+
*/
|
|
56
|
+
export async function saveSession(url, { email, cookies }) {
|
|
57
|
+
const all = await readAll();
|
|
58
|
+
all[key(url)] = {
|
|
59
|
+
email,
|
|
60
|
+
cookies: Object.fromEntries(cookies instanceof Map ? cookies : Object.entries(cookies ?? {})),
|
|
61
|
+
savedAt: new Date().toISOString(),
|
|
62
|
+
};
|
|
63
|
+
await mkdir(dirname(sessionFile()), { recursive: true, mode: 0o700 });
|
|
64
|
+
await writeFile(sessionFile(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
|
65
|
+
await chmod(sessionFile(), 0o600).catch(() => undefined);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Forget one instance. Signing out, and what a dead cookie earns. */
|
|
69
|
+
export async function clearSession(url) {
|
|
70
|
+
const all = await readAll();
|
|
71
|
+
if (!(key(url) in all)) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
delete all[key(url)];
|
|
75
|
+
await mkdir(dirname(sessionFile()), { recursive: true, mode: 0o700 });
|
|
76
|
+
await writeFile(sessionFile(), `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 });
|
|
77
|
+
await chmod(sessionFile(), 0o600).catch(() => undefined);
|
|
78
|
+
}
|