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.
@@ -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
+ }
@@ -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
+ }