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,106 @@
|
|
|
1
|
+
// What the daemon says about itself when it starts — R62.
|
|
2
|
+
//
|
|
3
|
+
// This is the mark, and then the five things that decide what this machine
|
|
4
|
+
// will actually do. They used to be spread across a config file nobody has
|
|
5
|
+
// open and a log line each, in the same grey as everything after them — so
|
|
6
|
+
// "why did that not happen" started with reading JSON.
|
|
7
|
+
//
|
|
8
|
+
// The five, and why each is here rather than assumed:
|
|
9
|
+
//
|
|
10
|
+
// the URL a machine can have three checkouts and a --config flag, so
|
|
11
|
+
// "which cawdev is this" is a real question with a wrong
|
|
12
|
+
// answer available
|
|
13
|
+
// the name it is the identity runs are claimed under
|
|
14
|
+
// projects with how many checkouts each has, because R47 made a
|
|
15
|
+
// project's concurrency min(workspaces, maxSessions) and
|
|
16
|
+
// neither number was ever on screen. It is a cap on CODING
|
|
17
|
+
// runs and on nothing else — R70
|
|
18
|
+
// the cap the other half of that, and the one that counts every
|
|
19
|
+
// profile: a question is bounded here and nowhere else
|
|
20
|
+
// the browser R61, and the one line that says an agent may reach Chrome
|
|
21
|
+
//
|
|
22
|
+
// It prints once, at boot. Anything that changes afterwards belongs in the
|
|
23
|
+
// log, not here.
|
|
24
|
+
|
|
25
|
+
import { mark } from './brand.mjs';
|
|
26
|
+
import { padVisible } from '../lib/ansi.mjs';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The launch banner, as lines.
|
|
30
|
+
*
|
|
31
|
+
* Lines rather than output, so the caller decides where it goes — and so this
|
|
32
|
+
* can be tested by reading it rather than by capturing a stream.
|
|
33
|
+
*/
|
|
34
|
+
export function bannerLines(config, ink) {
|
|
35
|
+
const lines = ['', ...mark(ink, { tagline: 'the runner' }), ''];
|
|
36
|
+
|
|
37
|
+
const label = (text) => ink.muted(padVisible(text, 12));
|
|
38
|
+
const say = (name, value) => lines.push(` ${label(name)}${value}`);
|
|
39
|
+
|
|
40
|
+
say('platform', ink.accent(config.url));
|
|
41
|
+
say('runner', ink.text(config.name));
|
|
42
|
+
|
|
43
|
+
const projects = Object.entries(config.projects);
|
|
44
|
+
projects.forEach(([slug, project], at) => {
|
|
45
|
+
const count = project.workspaces.length;
|
|
46
|
+
// The number is the point: it is this project's ceiling on concurrent
|
|
47
|
+
// coding runs, and it is the one people are surprised by. The word is
|
|
48
|
+
// "coding" because that is all it bounds — R70. A question, a roadmap
|
|
49
|
+
// session and an audit take no checkout and are held back by the cap
|
|
50
|
+
// below and by nothing here.
|
|
51
|
+
const s = count === 1 ? '' : 's';
|
|
52
|
+
const many = ink.muted(`${count} checkout${s}, so ${count} coding run${s}`);
|
|
53
|
+
say(at === 0 ? 'serving' : '', `${ink.text(padVisible(slug, 14))}${many}`);
|
|
54
|
+
});
|
|
55
|
+
if (!projects.length) {
|
|
56
|
+
say('serving', ink.danger('nothing'));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// R109 removed the machine-wide `maxSessions`: it bounded PROCESSES, and a
|
|
60
|
+
// delegated expert runs inside its parent's session and costs none, so the
|
|
61
|
+
// number it capped was never the number anybody was worried about. The line
|
|
62
|
+
// stayed and printed `undefined sessions, this machine`, which reads as a
|
|
63
|
+
// misconfiguration on a machine that has none. What bounds a run is the
|
|
64
|
+
// workspace, and the line above already says how many there are.
|
|
65
|
+
say('at once', ink.muted('one coding run per checkout — the only gate there is'));
|
|
66
|
+
|
|
67
|
+
// Said either way. "Off" is the answer to a question somebody will ask when
|
|
68
|
+
// a run reports it could not look at the page, and a line that only appears
|
|
69
|
+
// when enabled cannot answer it.
|
|
70
|
+
say('browser', config.browser
|
|
71
|
+
? `${ink.success('allowed')} ${ink.muted('— runs may drive Claude in Chrome')}`
|
|
72
|
+
: `${ink.muted('off')} ${ink.muted('— set "browser": true to allow it')}`);
|
|
73
|
+
|
|
74
|
+
lines.push('');
|
|
75
|
+
return lines;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* How a log line is coloured — R62.
|
|
80
|
+
*
|
|
81
|
+
* **By what it is, never only by colour.** Every one of these already reads
|
|
82
|
+
* correctly in black and white; the colour is there to let the eye skip to the
|
|
83
|
+
* failure, not to carry the meaning. That is why this matches on the words the
|
|
84
|
+
* daemon already writes rather than on a level nobody sets.
|
|
85
|
+
*/
|
|
86
|
+
export function tintLog(line, ink) {
|
|
87
|
+
if (!ink.enabled) return line;
|
|
88
|
+
|
|
89
|
+
// FIRST, and this order is the whole design. Several benign lines contain
|
|
90
|
+
// the word "failed" inside a sentence that says the daemon handled it —
|
|
91
|
+
// `fetch skipped: git fetch --prune origin failed: no origin` is a
|
|
92
|
+
// repository with no remote, which is fine and happens on every survey of
|
|
93
|
+
// every scratch checkout. Painting those red teaches people that red means
|
|
94
|
+
// nothing, which costs more than having no colour at all.
|
|
95
|
+
if (/\bskipped\b|\bnothing to\b|\balready\b/i.test(line)) return ink.muted(line);
|
|
96
|
+
|
|
97
|
+
if (/\bfailed\b|\bcould not\b|\berror\b|\brefus|exited \(code [1-9]/i.test(line)) {
|
|
98
|
+
return ink.danger(line);
|
|
99
|
+
}
|
|
100
|
+
if (/\bwaiting\b|\bqueued\b|\bno free workspace\b|\bdoes not allow\b/i.test(line)) {
|
|
101
|
+
return ink.warn(line);
|
|
102
|
+
}
|
|
103
|
+
if (/\bclaiming\b|\bspawning\b|\bregistered\b/i.test(line)) return ink.accent(line);
|
|
104
|
+
if (/\bserving\b|\bavailable\b/i.test(line)) return ink.success(line);
|
|
105
|
+
return ink.muted(line);
|
|
106
|
+
}
|
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
// Setting a machine up — R93, and what it removes is the copy-and-paste.
|
|
2
|
+
//
|
|
3
|
+
// Before this, a new laptop needed three things before `cawdev` would start a
|
|
4
|
+
// daemon: a URL, a `runner:operate` token minted by hand in the console, and a
|
|
5
|
+
// `projects` map naming checkouts that were not there yet. Two of those are
|
|
6
|
+
// secrets or paths a person had to carry between a browser and a terminal, and
|
|
7
|
+
// the third could not be written until the repositories had been cloned.
|
|
8
|
+
//
|
|
9
|
+
// **R81 already signed you in through the browser, and that is the credential
|
|
10
|
+
// this file spends.** A `runner:operate` token is user-grantable, so the CLI
|
|
11
|
+
// can mint its own — against your CURRENT membership, by the same
|
|
12
|
+
// grant-what-you-hold rule as the console's own picker. Nothing about R52
|
|
13
|
+
// changes: the token is still the machine's, still scoped to named projects,
|
|
14
|
+
// still revocable on its own, and still written to a different file from the
|
|
15
|
+
// person's session. What stops is a human being the transport for it.
|
|
16
|
+
//
|
|
17
|
+
// **Cloning is your git, not cawdev's.** The platform holds no git credentials
|
|
18
|
+
// and this does not give it any: `git clone` runs in the FOREGROUND, on the
|
|
19
|
+
// machine somebody is sitting at, under whatever ssh agent or credential helper
|
|
20
|
+
// that machine already has. cawdev supplies the URL it was told at project
|
|
21
|
+
// creation and nothing else.
|
|
22
|
+
//
|
|
23
|
+
// The one thing this cannot do for you is Claude Code's own sign-in, so it
|
|
24
|
+
// asks. A runner whose `claude` is not logged in boots perfectly and then fails
|
|
25
|
+
// every run, which is a worse way to find out than a question.
|
|
26
|
+
//
|
|
27
|
+
// Zero dependencies, like everything in tools/.
|
|
28
|
+
|
|
29
|
+
import { spawn } from 'node:child_process';
|
|
30
|
+
import { access, chmod, mkdir, writeFile } from 'node:fs/promises';
|
|
31
|
+
import { createInterface } from 'node:readline/promises';
|
|
32
|
+
import { homedir, hostname } from 'node:os';
|
|
33
|
+
import { dirname, join, resolve } from 'node:path';
|
|
34
|
+
import { painter } from '../lib/ansi.mjs';
|
|
35
|
+
import { Select, pickFromLine, pickManyFromLine, plainLines } from './select.mjs';
|
|
36
|
+
import { signInThroughBrowser, storedSession } from './sign-in.mjs';
|
|
37
|
+
import { saveToken, tokenFile } from './token-store.mjs';
|
|
38
|
+
|
|
39
|
+
/** Where a machine set up this way keeps its config — `findConfig`'s last candidate. */
|
|
40
|
+
export function runnerConfigPath() {
|
|
41
|
+
return join(homedir(), '.cawdev', 'runner.config.json');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Where checkouts go unless somebody says otherwise. */
|
|
45
|
+
export function defaultCheckoutRoot() {
|
|
46
|
+
return join(homedir(), 'cawdev');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The projects this machine could be pointed at.
|
|
51
|
+
*
|
|
52
|
+
* <p>Two filters and both are the server's rule rather than a guess at it: an
|
|
53
|
+
* archived project is not somewhere work happens, and `runner:operate` needs
|
|
54
|
+
* WRITER to mint — so a project you can only read is one the mint would refuse.
|
|
55
|
+
* Offering it and failing afterwards would teach somebody that setup is flaky.
|
|
56
|
+
*
|
|
57
|
+
* Pure, so the rule can be read without a platform.
|
|
58
|
+
*/
|
|
59
|
+
export function servable(projects) {
|
|
60
|
+
return (projects ?? [])
|
|
61
|
+
.filter((project) => !project.archived)
|
|
62
|
+
.filter((project) => project.yourRole === 'WRITER' || project.yourRole === 'OWNER');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The config a daemon boots from, as an object.
|
|
67
|
+
*
|
|
68
|
+
* Deliberately the SMALLEST file that works — a url, a token, a name, and one
|
|
69
|
+
* path per project. Everything else `readConfig` has a default for, and a
|
|
70
|
+
* generated file that writes out every default is one nobody dares edit
|
|
71
|
+
* afterwards because they cannot tell what they chose from what they were
|
|
72
|
+
* given.
|
|
73
|
+
*
|
|
74
|
+
* Pure: entries in, the file's contents out.
|
|
75
|
+
*/
|
|
76
|
+
export function configFor({ url, token, name, entries }) {
|
|
77
|
+
const projects = {};
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
projects[entry.slug] = entry.path;
|
|
80
|
+
}
|
|
81
|
+
return { url, token, name, projects };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** What this machine calls itself, unless told. Short: it is a row in a list. */
|
|
85
|
+
export function defaultRunnerName() {
|
|
86
|
+
try {
|
|
87
|
+
return hostname().replace(/\.local$/, '');
|
|
88
|
+
} catch {
|
|
89
|
+
return 'this machine';
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Where a project's checkout goes.
|
|
95
|
+
*
|
|
96
|
+
* Its own function because it is the one piece of path arithmetic here, and a
|
|
97
|
+
* slug that arrived with a slash in it would otherwise write outside the root.
|
|
98
|
+
* Slugs cannot contain one — `Slug` sees to that — so this is belt and braces
|
|
99
|
+
* against a platform that changes its mind later.
|
|
100
|
+
*/
|
|
101
|
+
export function checkoutFor(root, slug) {
|
|
102
|
+
const safe = String(slug)
|
|
103
|
+
.replace(/[^a-z0-9._-]/gi, '-')
|
|
104
|
+
// A name that is only dots is `.` or `..`, and `join` walks up for the
|
|
105
|
+
// second one — so the sanitised form of a hostile slug would land OUTSIDE
|
|
106
|
+
// the root the person named. Everything else is already inside it, because
|
|
107
|
+
// the separator is the character the line above removes.
|
|
108
|
+
.replace(/^\.+$/, '-');
|
|
109
|
+
return join(resolve(root), safe);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function exists(path) {
|
|
113
|
+
try {
|
|
114
|
+
await access(path);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Write the config, readable by nobody else.
|
|
123
|
+
*
|
|
124
|
+
* 0600 for the same reason `session.json` is: a token that grants a machine the
|
|
125
|
+
* right to run agents in your repositories is not a world-readable file, and a
|
|
126
|
+
* multi-user box is exactly where a runner ends up.
|
|
127
|
+
*/
|
|
128
|
+
export async function writeRunnerConfig(path, config) {
|
|
129
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
130
|
+
await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
131
|
+
// Explicit, because `mode` on writeFile is ignored for a file that already
|
|
132
|
+
// exists — re-running setup over a config from an older version would leave
|
|
133
|
+
// whatever mode that one had.
|
|
134
|
+
await chmod(path, 0o600);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Mint the machine's own `runner:operate` token.
|
|
139
|
+
*
|
|
140
|
+
* The label says which machine, because the tokens page is where somebody goes
|
|
141
|
+
* to retire one and "cawdev runner" three times over is not a list you can act
|
|
142
|
+
* on.
|
|
143
|
+
*/
|
|
144
|
+
export async function mintRunnerToken(session, slugs, name) {
|
|
145
|
+
const grants = {};
|
|
146
|
+
for (const slug of slugs) {
|
|
147
|
+
grants[slug] = ['runner:operate'];
|
|
148
|
+
}
|
|
149
|
+
// `/api/tokens`, which is what the spec has always called it. This said
|
|
150
|
+
// `/api/agent-tokens` — the name on the console's PAGE rather than the one on
|
|
151
|
+
// the endpoint — so R93's walk had never once minted a token against a real
|
|
152
|
+
// platform. Every test passed because the fake session next door was written
|
|
153
|
+
// from the same wrong guess, which is the failure `openapi.test.mjs` now
|
|
154
|
+
// makes impossible: paths are checked against `openapi.yaml`, not against a
|
|
155
|
+
// second copy of the assumption.
|
|
156
|
+
const minted = await session.request('/api/tokens', {
|
|
157
|
+
method: 'POST',
|
|
158
|
+
body: { label: `${name} (runner)`, grants },
|
|
159
|
+
});
|
|
160
|
+
if (!minted?.secret) {
|
|
161
|
+
throw new Error('The platform minted a token but did not return it.');
|
|
162
|
+
}
|
|
163
|
+
return minted.secret;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* `git clone`, in the foreground, with its output on the terminal.
|
|
168
|
+
*
|
|
169
|
+
* Inherited stdio rather than captured: a clone asks for a passphrase, prints a
|
|
170
|
+
* progress bar, and may want a host key confirmed. Swallowing all three to
|
|
171
|
+
* print a tidy spinner is how this hangs with no explanation on the one machine
|
|
172
|
+
* whose ssh agent was not running.
|
|
173
|
+
*/
|
|
174
|
+
export function cloneInto(gitUrl, path) {
|
|
175
|
+
return new Promise((done, fail) => {
|
|
176
|
+
const child = spawn('git', ['clone', gitUrl, path], { stdio: 'inherit' });
|
|
177
|
+
child.on('error', (failure) => fail(new Error(`Could not run git: ${failure.message}`)));
|
|
178
|
+
child.on('close', (code) => {
|
|
179
|
+
if (code === 0) {
|
|
180
|
+
done();
|
|
181
|
+
} else {
|
|
182
|
+
fail(new Error(`git clone exited ${code}.`));
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Whether `claude` is on the PATH at all — a cheaper question than "is it signed in". */
|
|
189
|
+
export function findAgent(command = 'claude') {
|
|
190
|
+
return new Promise((done) => {
|
|
191
|
+
const child = spawn(process.platform === 'win32' ? 'where' : 'which', [command], {
|
|
192
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
193
|
+
});
|
|
194
|
+
let out = '';
|
|
195
|
+
child.stdout.on('data', (chunk) => {
|
|
196
|
+
out += chunk;
|
|
197
|
+
});
|
|
198
|
+
child.on('error', () => done(null));
|
|
199
|
+
child.on('close', (code) => done(code === 0 && out.trim() ? out.trim().split('\n')[0] : null));
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Asking, on a terminal that has not been taken over yet.
|
|
205
|
+
*
|
|
206
|
+
* `readline` and not R81's `input.mjs`: this runs BEFORE the full-screen client
|
|
207
|
+
* exists, in an ordinary cooked-mode terminal, and the line editor next door is
|
|
208
|
+
* built for a raw-mode screen with a footer. Two different situations that only
|
|
209
|
+
* look like the same one.
|
|
210
|
+
*/
|
|
211
|
+
export function asker(input = process.stdin, output = process.stdout) {
|
|
212
|
+
const rl = createInterface({ input, output });
|
|
213
|
+
return {
|
|
214
|
+
line: (prompt) => rl.question(prompt),
|
|
215
|
+
close: () => rl.close(),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* One choice, drawn the way a terminal that cannot be drawn on gets one.
|
|
221
|
+
*
|
|
222
|
+
* R83's rule is that everything offering a choice is `select.mjs`, and this is
|
|
223
|
+
* that widget's plain form: the same rows, the same numbering, the same
|
|
224
|
+
* parsing. What differs is only where the keys come from.
|
|
225
|
+
*/
|
|
226
|
+
export async function askOne(ask, say, select) {
|
|
227
|
+
for (;;) {
|
|
228
|
+
for (const line of plainLines(select)) {
|
|
229
|
+
say(line);
|
|
230
|
+
}
|
|
231
|
+
const picked = pickFromLine(select, await ask.line(' > '));
|
|
232
|
+
if (picked?.done === 'chosen') {
|
|
233
|
+
return picked;
|
|
234
|
+
}
|
|
235
|
+
say(' Not one of those.');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The same, for a question whose answer is several rows. */
|
|
240
|
+
export async function askMany(ask, say, select) {
|
|
241
|
+
for (;;) {
|
|
242
|
+
for (const line of plainLines(select, { many: true })) {
|
|
243
|
+
say(line);
|
|
244
|
+
}
|
|
245
|
+
const typed = await ask.line(' > ');
|
|
246
|
+
// Enter is "all of them", which is what somebody pointing a machine at
|
|
247
|
+
// their projects usually means — and the line above says so, so it is an
|
|
248
|
+
// offer rather than a default nobody was told about.
|
|
249
|
+
if (!String(typed).trim()) {
|
|
250
|
+
return select.rows;
|
|
251
|
+
}
|
|
252
|
+
const picked = pickManyFromLine(select, typed);
|
|
253
|
+
if (picked?.length) {
|
|
254
|
+
return picked;
|
|
255
|
+
}
|
|
256
|
+
say(' Not one of those.');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A yes/no where enter means yes, because every one of them here is a confirmation. */
|
|
261
|
+
export async function confirm(ask, question) {
|
|
262
|
+
const typed = String(await ask.line(` ${question} [Y/n] `)).trim().toLowerCase();
|
|
263
|
+
return typed === '' || typed === 'y' || typed === 'yes';
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The walk.
|
|
268
|
+
*
|
|
269
|
+
* Ordered so that **nothing is created until everything is known**: sign in,
|
|
270
|
+
* choose, resolve every checkout, and only then mint a token and write a file.
|
|
271
|
+
* A setup abandoned halfway leaves no token on the tokens page and no config
|
|
272
|
+
* pointing at half a machine — which is what makes running it again the whole
|
|
273
|
+
* recovery procedure.
|
|
274
|
+
*
|
|
275
|
+
* Every side effect is injected, so the walk itself can be tested without a
|
|
276
|
+
* network, a git host, or a home directory.
|
|
277
|
+
*/
|
|
278
|
+
export async function setUpThisMachine({
|
|
279
|
+
url,
|
|
280
|
+
ask,
|
|
281
|
+
say,
|
|
282
|
+
ink = painter(3),
|
|
283
|
+
clone = cloneInto,
|
|
284
|
+
write = writeRunnerConfig,
|
|
285
|
+
configPath = runnerConfigPath(),
|
|
286
|
+
signIn = signInThroughBrowser,
|
|
287
|
+
session: given = null,
|
|
288
|
+
agent = findAgent,
|
|
289
|
+
} = {}) {
|
|
290
|
+
const session = given ?? (await storedSession(url));
|
|
291
|
+
|
|
292
|
+
say('');
|
|
293
|
+
say(` ${ink.bold('Setting up this machine')} ${ink.muted(`for ${url}`)}`);
|
|
294
|
+
|
|
295
|
+
if (!session.signedIn) {
|
|
296
|
+
say('');
|
|
297
|
+
say(` ${ink.muted('Signing in — a browser is about to open.')}`);
|
|
298
|
+
const result = await signIn(session, ({ url: verify, code }) => {
|
|
299
|
+
say('');
|
|
300
|
+
say(` ${ink.muted('Approve this sign-in at')} ${ink.accent(verify)}`);
|
|
301
|
+
say(` ${ink.muted('The code is')} ${ink.bold(code)}`);
|
|
302
|
+
say('');
|
|
303
|
+
say(` ${ink.muted('Waiting…')}`);
|
|
304
|
+
});
|
|
305
|
+
if (!result.signedIn) {
|
|
306
|
+
throw new Error(result.refused
|
|
307
|
+
? 'That sign-in was refused.'
|
|
308
|
+
: 'That sign-in expired. Run cawdev again to get a new code.');
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
say(` ${ink.success('✓')} ${ink.muted('Signed in as')} ${ink.text(session.email)}`);
|
|
312
|
+
|
|
313
|
+
const projects = servable(await session.request('/api/projects'));
|
|
314
|
+
if (!projects.length) {
|
|
315
|
+
throw new Error(
|
|
316
|
+
'You are not a writer on any project, so this machine has nothing to run.\n'
|
|
317
|
+
+ ` Create one at ${url}, or ask an owner to add you.`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const chosen = projects.length === 1
|
|
322
|
+
? projects
|
|
323
|
+
: (await askMany(ask, say, new Select({
|
|
324
|
+
title: 'Which projects should this machine run agents for?',
|
|
325
|
+
rows: projects.map((project) => ({
|
|
326
|
+
id: project.slug,
|
|
327
|
+
label: project.name,
|
|
328
|
+
hint: project.slug,
|
|
329
|
+
})),
|
|
330
|
+
}))).map((row) => projects.find((project) => project.slug === row.id));
|
|
331
|
+
|
|
332
|
+
if (projects.length === 1) {
|
|
333
|
+
say('');
|
|
334
|
+
say(` ${ink.muted('One project to serve:')} ${ink.text(projects[0].name)}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Where the checkouts go. Asked once rather than per project: a machine that
|
|
338
|
+
// serves four repositories keeps them together, and four questions to learn
|
|
339
|
+
// one answer is a form pretending to be a conversation.
|
|
340
|
+
const suggested = defaultCheckoutRoot();
|
|
341
|
+
const typedRoot = String(await ask.line(` Where should the checkouts live? [${suggested}] `)).trim();
|
|
342
|
+
const root = typedRoot || suggested;
|
|
343
|
+
|
|
344
|
+
const entries = [];
|
|
345
|
+
for (const project of chosen) {
|
|
346
|
+
const path = checkoutFor(root, project.slug);
|
|
347
|
+
if (await exists(path)) {
|
|
348
|
+
say(` ${ink.success('✓')} ${ink.text(project.slug)} ${ink.muted(`is already at ${path}`)}`);
|
|
349
|
+
entries.push({ slug: project.slug, path });
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (!project.gitUrl) {
|
|
353
|
+
// Not fatal and not skipped silently: the project is real, cawdev simply
|
|
354
|
+
// was not told where its repository is, and the person in front of us
|
|
355
|
+
// knows.
|
|
356
|
+
say('');
|
|
357
|
+
say(` ${ink.warn('!')} ${ink.text(project.slug)} ${ink.muted('has no git URL on the platform.')}`);
|
|
358
|
+
const typed = String(await ask.line(' Path to an existing checkout (enter to skip): ')).trim();
|
|
359
|
+
if (!typed) {
|
|
360
|
+
say(` ${ink.muted(`Skipping ${project.slug}.`)}`);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
entries.push({ slug: project.slug, path: resolve(typed) });
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
say('');
|
|
367
|
+
say(` ${ink.muted('Cloning')} ${ink.accent(project.gitUrl)} ${ink.muted('into')} ${ink.text(path)}`);
|
|
368
|
+
await mkdir(dirname(path), { recursive: true });
|
|
369
|
+
await clone(project.gitUrl, path);
|
|
370
|
+
entries.push({ slug: project.slug, path });
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (!entries.length) {
|
|
374
|
+
throw new Error('No project ended up with a checkout, so there is nothing to configure.');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// R93's one question this cannot answer for itself. Asked BEFORE the token is
|
|
378
|
+
// minted, so somebody who has to go and log in elsewhere has not left a
|
|
379
|
+
// credential behind them.
|
|
380
|
+
const where = await agent();
|
|
381
|
+
say('');
|
|
382
|
+
if (where) {
|
|
383
|
+
say(` ${ink.muted('This machine spawns')} ${ink.text('claude')} ${ink.muted(`(${where}) for every run.`)}`);
|
|
384
|
+
} else {
|
|
385
|
+
say(` ${ink.warn('!')} ${ink.muted('No')} ${ink.text('claude')} ${ink.muted('on this PATH. Install Claude Code before a run can start.')}`);
|
|
386
|
+
}
|
|
387
|
+
say(` ${ink.muted('It cannot sign in for you: a runner whose Claude Code is logged out')}`);
|
|
388
|
+
say(` ${ink.muted('boots fine and then fails every run.')}`);
|
|
389
|
+
if (!await confirm(ask, 'Is Claude Code signed in on this machine?')) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
'Sign in first — run `claude` once in a terminal and follow it — then run cawdev again.\n'
|
|
392
|
+
+ ' Nothing has been created, so there is nothing to undo.',
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const name = defaultRunnerName();
|
|
397
|
+
const token = await mintRunnerToken(session, entries.map((entry) => entry.slug), name);
|
|
398
|
+
const config = configFor({ url, token, name, entries });
|
|
399
|
+
await write(configPath, config);
|
|
400
|
+
|
|
401
|
+
say('');
|
|
402
|
+
say(` ${ink.success('✓')} ${ink.muted('Minted a')} ${ink.text('runner:operate')} `
|
|
403
|
+
+ `${ink.muted(`token for ${entries.length} project${entries.length === 1 ? '' : 's'}`)}`);
|
|
404
|
+
say(` ${ink.success('✓')} ${ink.muted('Wrote')} ${ink.accent(configPath)}`);
|
|
405
|
+
say('');
|
|
406
|
+
|
|
407
|
+
return { configPath, config, entries };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* The credential, and only the credential — for a machine already configured.
|
|
412
|
+
*
|
|
413
|
+
* `setUpThisMachine` above answers "what does this machine serve?", which is
|
|
414
|
+
* four questions and a clone. A machine whose config already answers all of
|
|
415
|
+
* them and is missing only a token has nothing to be asked: the projects are
|
|
416
|
+
* named, the checkouts are there, and the one thing absent is the thing R93
|
|
417
|
+
* says a person should never have to carry.
|
|
418
|
+
*
|
|
419
|
+
* So this is the walk with everything it can already know taken out. Sign in
|
|
420
|
+
* through the browser, mint `runner:operate` on **the slugs the config already
|
|
421
|
+
* serves** — never a wider set, because a top-up that quietly granted more
|
|
422
|
+
* would be a privilege escalation performed by a convenience — and store it
|
|
423
|
+
* beside the session rather than in the config, for the reason `token-store`
|
|
424
|
+
* opens with.
|
|
425
|
+
*
|
|
426
|
+
* Claude Code's own sign-in is not asked about here. That question belongs to
|
|
427
|
+
* setting a machine up, and this machine has been set up; asking it again on
|
|
428
|
+
* every token renewal would make the answer noise.
|
|
429
|
+
*
|
|
430
|
+
* Every side effect is injected, so this can be tested without a network or a
|
|
431
|
+
* home directory.
|
|
432
|
+
*/
|
|
433
|
+
export async function mintForThisMachine({
|
|
434
|
+
url,
|
|
435
|
+
storeUrl = url,
|
|
436
|
+
config,
|
|
437
|
+
configPath = null,
|
|
438
|
+
say,
|
|
439
|
+
ink = painter(3),
|
|
440
|
+
signIn = signInThroughBrowser,
|
|
441
|
+
store = saveToken,
|
|
442
|
+
session: given = null,
|
|
443
|
+
} = {}) {
|
|
444
|
+
const slugs = Object.keys(config?.projects ?? {});
|
|
445
|
+
if (!slugs.length) {
|
|
446
|
+
throw new Error(
|
|
447
|
+
`${configPath ?? 'That config'} serves no projects, so there is no token to mint.\n`
|
|
448
|
+
+ ' Add a "projects" map, or run cawdev --setup to be walked through it.',
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const session = given ?? (await storedSession(url));
|
|
453
|
+
|
|
454
|
+
say('');
|
|
455
|
+
say(` ${ink.bold('This machine has a config but no token')}`);
|
|
456
|
+
if (configPath) {
|
|
457
|
+
say(` ${ink.muted('Config:')} ${ink.accent(configPath)}`);
|
|
458
|
+
}
|
|
459
|
+
say(` ${ink.muted('Minting one for')} ${ink.text(slugs.join(', '))}${ink.muted(' — nothing to copy.')}`);
|
|
460
|
+
|
|
461
|
+
if (!session.signedIn) {
|
|
462
|
+
say('');
|
|
463
|
+
say(` ${ink.muted('Signing in — a browser is about to open.')}`);
|
|
464
|
+
const result = await signIn(session, ({ url: verify, code }) => {
|
|
465
|
+
say('');
|
|
466
|
+
say(` ${ink.muted('Approve this sign-in at')} ${ink.accent(verify)}`);
|
|
467
|
+
say(` ${ink.muted('The code is')} ${ink.bold(code)}`);
|
|
468
|
+
say('');
|
|
469
|
+
say(` ${ink.muted('Waiting…')}`);
|
|
470
|
+
});
|
|
471
|
+
if (!result.signedIn) {
|
|
472
|
+
throw new Error(result.refused
|
|
473
|
+
? 'That sign-in was refused.'
|
|
474
|
+
: 'That sign-in expired. Run cawdev again to get a new code.');
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
say(` ${ink.success('✓')} ${ink.muted('Signed in as')} ${ink.text(session.email)}`);
|
|
478
|
+
|
|
479
|
+
const name = config.name ?? defaultRunnerName();
|
|
480
|
+
const token = await mintRunnerToken(session, slugs, name);
|
|
481
|
+
|
|
482
|
+
// Filed under the name the DAEMON will look for, which is not always the door
|
|
483
|
+
// a person came through — in development the console is on `:4200` and the
|
|
484
|
+
// API it proxies to is on `:8091`, and both are this one cawdev. Storing it
|
|
485
|
+
// under the sign-in URL puts a working credential somewhere nothing reads.
|
|
486
|
+
await store(storeUrl, token, { name });
|
|
487
|
+
|
|
488
|
+
say(` ${ink.success('✓')} ${ink.muted('Minted a')} ${ink.text('runner:operate')} `
|
|
489
|
+
+ `${ink.muted(`token for ${slugs.length} project${slugs.length === 1 ? '' : 's'}`)}`);
|
|
490
|
+
say(` ${ink.success('✓')} ${ink.muted('Stored it in')} ${ink.accent(tokenFile())} `
|
|
491
|
+
+ `${ink.muted('— not in the config, which is a file people commit')}`);
|
|
492
|
+
if (storeUrl !== url) {
|
|
493
|
+
// Said out loud, once. Two URLs for one platform is ordinary in
|
|
494
|
+
// development and baffling in a log file six weeks later.
|
|
495
|
+
say(` ${ink.muted('Signed in at')} ${ink.text(url)}${ink.muted(', filed under')} `
|
|
496
|
+
+ `${ink.text(storeUrl)}${ink.muted(' — the config says that is this machine.')}`);
|
|
497
|
+
}
|
|
498
|
+
say('');
|
|
499
|
+
|
|
500
|
+
return { token, slugs, name };
|
|
501
|
+
}
|
package/runner/brand.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The cawdev mark, in a terminal — R62.
|
|
2
|
+
//
|
|
3
|
+
// **Not ASCII art.** The first version of this drew the crow from
|
|
4
|
+
// `cawdev-mark.svg` in half blocks, three rows tall, and it was rendered and
|
|
5
|
+
// looked at: at cell resolution the head does not cohere. The rows do not
|
|
6
|
+
// touch, so it reads as three violet bars, and the white eye punches a hole
|
|
7
|
+
// that splits it into two. Six variants, all the same verdict.
|
|
8
|
+
//
|
|
9
|
+
// So the mark here is the mark reduced to what a character cell can honestly
|
|
10
|
+
// hold: the violet head and the amber beak, `●▸`, beside the wordmark. That is
|
|
11
|
+
// the same reduction a favicon makes at 16px, and it is what the CLIs this
|
|
12
|
+
// sits beside do — a glyph and clean type, not a picture of a bird.
|
|
13
|
+
//
|
|
14
|
+
// `●` is U+25CF, Geometric Shapes, which every monospace font ships. The
|
|
15
|
+
// larger `⬤` (U+2B24) looked better in a comparison and is in a block with
|
|
16
|
+
// patchy coverage — and a missing glyph renders as a box, which looks broken
|
|
17
|
+
// rather than plain. Not worth it.
|
|
18
|
+
//
|
|
19
|
+
// The two colours are `cawdev-mark.svg`'s own: `#4B3FD4` and `#F0A22E`. If the
|
|
20
|
+
// mark changes, this is the one place that has to follow it.
|
|
21
|
+
|
|
22
|
+
import { painter } from '../lib/ansi.mjs';
|
|
23
|
+
|
|
24
|
+
/** `caw` heavy, `dev` light — the wordmark's own construction, from R53. */
|
|
25
|
+
function wordmark(ink) {
|
|
26
|
+
return `${ink.bold('caw')}${ink.muted('dev')}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The head and the beak. Nothing without colour, where it would be a dot. */
|
|
30
|
+
function glyph(ink) {
|
|
31
|
+
return ink.enabled ? `${ink.violet('●')}${ink.amber('▸')}` : '';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The launch form: the mark, the name, and what this program is.
|
|
36
|
+
*
|
|
37
|
+
* Returned as lines rather than printed, so a caller can indent it, box it or
|
|
38
|
+
* measure it without this file knowing anything about the layout it goes into.
|
|
39
|
+
*/
|
|
40
|
+
export function mark(ink = painter(), { tagline = '' } = {}) {
|
|
41
|
+
const after = tagline ? ` ${ink.muted(tagline)}` : '';
|
|
42
|
+
if (!ink.enabled) {
|
|
43
|
+
return [tagline ? `cawdev — ${tagline}` : 'cawdev'];
|
|
44
|
+
}
|
|
45
|
+
return [` ${glyph(ink)} ${wordmark(ink)}${after}`];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One line, for a bar that has a few columns to spare. */
|
|
49
|
+
export function oneLine(ink = painter()) {
|
|
50
|
+
if (!ink.enabled) return 'cawdev';
|
|
51
|
+
return `${glyph(ink)} ${wordmark(ink)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** For anything that is not a terminal. */
|
|
55
|
+
export function plain() {
|
|
56
|
+
return 'cawdev';
|
|
57
|
+
}
|