cawdev-cli 1.0.9-beta → 1.0.11-beta
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/lib/background-shells.mjs +45 -3
- package/lib/roadmap-format.mjs +15 -1
- package/lib/runner-config.mjs +1 -1
- package/mcp/README.md +12 -2
- package/mcp/server.mjs +124 -10
- package/package.json +1 -1
- package/runner/README.md +38 -2
- package/runner/attach.mjs +211 -25
- package/runner/git-host.mjs +263 -0
- package/runner/runner.mjs +521 -126
- package/runner/split.mjs +77 -5
- package/runner/workspace-check.mjs +78 -0
package/runner/attach.mjs
CHANGED
|
@@ -60,8 +60,8 @@ import {
|
|
|
60
60
|
import { clip, keyList, padVisible, painter, stripAnsi, visibleWidth, wrap } from '../lib/ansi.mjs';
|
|
61
61
|
import { restartNeededFor } from '../lib/runner-config.mjs';
|
|
62
62
|
import {
|
|
63
|
-
BOARD_COLUMNS, MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, tabsLine, visibleSessions, actionsFor, composeFrame,
|
|
64
|
-
carriesOnHere, builtNothing, cardLines, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
|
|
63
|
+
BOARD_COLUMNS, MIN_ZONE_HEIGHT, PROMPT_ROWS, SplitScreen, readerFrame, tabsLine, visibleSessions, actionsFor, composeFrame,
|
|
64
|
+
carriesOnHere, builtNothing, cardLines, columnOf, cardTitleFor, commandWords, wordsText, subtitleFor, agentModelFor, wordWrap, isAutoCheckout, isPullRequest, parseNew, roadmapStrip, sessionTag, splitLayout, titleFor, toneFor,
|
|
65
65
|
windowLines,
|
|
66
66
|
} from './split.mjs';
|
|
67
67
|
import { KNOWN_AGENTS, agentOfCommand } from '../lib/agents.mjs';
|
|
@@ -298,8 +298,14 @@ export function permissionBanner(pending, width, ink = painter(3), machine = {})
|
|
|
298
298
|
.find((lines) => lines.length <= 2)
|
|
299
299
|
?? rows(without(short));
|
|
300
300
|
|
|
301
|
+
// A stage gate's summary is the whole PLAN, many lines long: one row here,
|
|
302
|
+
// and how much more there is — `/view` reads it whole. Clipped as one
|
|
303
|
+
// string, its newlines went through and filled the prompt.
|
|
304
|
+
const [first = '', ...more] = String(approval.summary ?? '').split('\n');
|
|
305
|
+
const rest = more.filter((line) => line.trim()).length;
|
|
306
|
+
const tail = rest ? ` (+${rest} lines)` : '';
|
|
301
307
|
return [
|
|
302
|
-
`${ink.bold(ink.warn(' permission '))} ${clip(
|
|
308
|
+
`${ink.bold(ink.warn(' permission '))} ${clip(first, Math.max(8, width - 13 - tail.length))}${ink.muted(tail)}`,
|
|
303
309
|
ink.muted(` ${approval.toolName} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`),
|
|
304
310
|
...keys,
|
|
305
311
|
];
|
|
@@ -832,8 +838,8 @@ const COMMANDS = [
|
|
|
832
838
|
['/continue', 'pick a failed or stopped session back up on this machine — the console\'s Carry on'],
|
|
833
839
|
['/projects', 'which projects advanced mode shows — tick one or several'],
|
|
834
840
|
['/refresh', "read the cards and their statuses from cawdev again — advanced mode's roadmap"],
|
|
835
|
-
['/open', "open the session's pull request in your browser —
|
|
836
|
-
['/view', "a card here in the terminal: /view R29, /view <words of its title> (@project), /view card for the session's; /view alone
|
|
841
|
+
['/open', "open the session's pull request in your browser — on a finished plan, its card and plan; on one waiting for you, its page on cawdev"],
|
|
842
|
+
['/view', "a card here in the terminal: /view R29, /view <words of its title> (@project), /view card for the session's; /view alone reads what a session waits on in full, or opens it on cawdev"],
|
|
837
843
|
['/drop', 'delete the checkout a finished session was made on demand for'],
|
|
838
844
|
['/close', "close an ended session's window"],
|
|
839
845
|
['/reload', 'quit and start again on the code on disk — the runner too, when nothing is running'],
|
|
@@ -1625,6 +1631,9 @@ export class Attached {
|
|
|
1625
1631
|
}
|
|
1626
1632
|
this.interrupting = false;
|
|
1627
1633
|
|
|
1634
|
+
if (this.reader && this.mode === 'keys') {
|
|
1635
|
+
return this.onReaderKey(key);
|
|
1636
|
+
}
|
|
1628
1637
|
if (this.mode === 'typing') {
|
|
1629
1638
|
return this.onTyping(key);
|
|
1630
1639
|
}
|
|
@@ -1755,7 +1764,8 @@ export class Attached {
|
|
|
1755
1764
|
|
|
1756
1765
|
/** Everything open, closed. Returns whether there was anything. */
|
|
1757
1766
|
closeEverything() {
|
|
1758
|
-
const open = this.mode !== 'keys';
|
|
1767
|
+
const open = this.mode !== 'keys' || Boolean(this.reader);
|
|
1768
|
+
this.reader = null;
|
|
1759
1769
|
this.mode = 'keys';
|
|
1760
1770
|
this.select = null;
|
|
1761
1771
|
this.input = null;
|
|
@@ -2082,6 +2092,16 @@ export class Attached {
|
|
|
2082
2092
|
this.select = null;
|
|
2083
2093
|
return this.note('');
|
|
2084
2094
|
}
|
|
2095
|
+
// A question's or a permission's list holds only the start of what is
|
|
2096
|
+
// asked; `/` leaves it for a command — `/view` reads it whole, `/open`
|
|
2097
|
+
// answers it on cawdev — and `a` (or `y`/`n`) brings the choice back.
|
|
2098
|
+
if (key === '/' && (select.kind === 'question' || select.kind === 'permission')) {
|
|
2099
|
+
this.mode = 'keys';
|
|
2100
|
+
this.select = null;
|
|
2101
|
+
this.answering = null;
|
|
2102
|
+
this.deciding = null;
|
|
2103
|
+
return this.type('command', 'cawdev ▸', '/');
|
|
2104
|
+
}
|
|
2085
2105
|
|
|
2086
2106
|
const outcome = select.key(key);
|
|
2087
2107
|
if (!outcome) {
|
|
@@ -2287,8 +2307,9 @@ export class Attached {
|
|
|
2287
2307
|
}
|
|
2288
2308
|
return this.openPicker(new Select({
|
|
2289
2309
|
kind: 'question',
|
|
2290
|
-
title: clip(`${this.tagOf(this.current())} asks: ${asking.question.question}`,
|
|
2310
|
+
title: clip(`${this.tagOf(this.current())} asks: ${String(asking.question.question).split('\n')[0]}`,
|
|
2291
2311
|
Math.max(20, this.screen.width - 2)),
|
|
2312
|
+
hint: '/view reads it all',
|
|
2292
2313
|
rows: [
|
|
2293
2314
|
...options.map((option) => ({ id: option, label: option })),
|
|
2294
2315
|
// Always last, and always there.
|
|
@@ -2316,7 +2337,10 @@ export class Attached {
|
|
|
2316
2337
|
kind: 'permission',
|
|
2317
2338
|
// The call, on the title, as well as printed in full above: the picker
|
|
2318
2339
|
// may still be on screen when the transcript under it has moved on.
|
|
2319
|
-
|
|
2340
|
+
// The first line only: a stage gate's summary is the whole plan, and
|
|
2341
|
+
// `/view` is where it is read.
|
|
2342
|
+
title: `${this.tagOf(this.current())} wants ${approval.toolName} · ${String(approval.summary ?? '').split('\n')[0]}`,
|
|
2343
|
+
hint: '/view reads it all',
|
|
2320
2344
|
rows: [
|
|
2321
2345
|
{ id: 'y', label: 'Allow once', hint: 'this call and no more' },
|
|
2322
2346
|
// R78: the middle grant NAMES what it covers, and it says so in the
|
|
@@ -2831,7 +2855,8 @@ export class Attached {
|
|
|
2831
2855
|
case 'open':
|
|
2832
2856
|
return void this.openInBrowser();
|
|
2833
2857
|
case 'view':
|
|
2834
|
-
|
|
2858
|
+
if (rest.length) return void this.viewCard(rest);
|
|
2859
|
+
return this.waitingOn(this.commandTarget()) ? this.openReader() : this.viewOnCawdev();
|
|
2835
2860
|
case 'drop':
|
|
2836
2861
|
return void this.dropCheckout();
|
|
2837
2862
|
case 'close':
|
|
@@ -3250,6 +3275,9 @@ export class Attached {
|
|
|
3250
3275
|
splitFrame() {
|
|
3251
3276
|
const { width, height } = this.split.screen;
|
|
3252
3277
|
const ink = this.ink;
|
|
3278
|
+
if (this.reader) {
|
|
3279
|
+
return composeFrame(width, height, [{ rect: { top: 0, left: 0, width, height }, lines: this.readerScreen(width, height) }]);
|
|
3280
|
+
}
|
|
3253
3281
|
const windows = this.windows();
|
|
3254
3282
|
const inner = Math.max(8, width - 2);
|
|
3255
3283
|
|
|
@@ -3269,6 +3297,11 @@ export class Attached {
|
|
|
3269
3297
|
+ ' · / commands · tab next window');
|
|
3270
3298
|
// A question or a request on screen says which window it came from.
|
|
3271
3299
|
const from = banner.length ? [ink.bold(ink.warn(` from ${this.tagOf(current)}`))] : [];
|
|
3300
|
+
// Under the banner and right above the line, so a long one never pushes
|
|
3301
|
+
// it out of the window: the way to read all of it, and the other door.
|
|
3302
|
+
// Also while its list is open, which hides the banner.
|
|
3303
|
+
const whole = banner.length || this.waitingOn(this.commandTarget())
|
|
3304
|
+
? [ink.muted(' /view reads it all · /open answers it on cawdev')] : [];
|
|
3272
3305
|
// The commands matching what is typed, above the line as in the footer —
|
|
3273
3306
|
// advanced mode drew none, so enter took a highlighted command nobody saw.
|
|
3274
3307
|
const matches = this.mode === 'typing' && this.input?.showing !== false ? this.matching() : { rows: [], at: 0 };
|
|
@@ -3278,7 +3311,7 @@ export class Attached {
|
|
|
3278
3311
|
const completing = completionLines(
|
|
3279
3312
|
matches.rows.slice(first, first + SPLIT_COMPLETIONS), matches.at - first, inner, ink,
|
|
3280
3313
|
);
|
|
3281
|
-
const fixed = [...from, ...banner, ...overlay, ...completing, input];
|
|
3314
|
+
const fixed = [...from, ...banner, ...whole, ...overlay, ...completing, input];
|
|
3282
3315
|
const promptRows = Math.max(PROMPT_ROWS, fixed.length + 5);
|
|
3283
3316
|
// The roadmap across the top: as tall as its fullest column needs, never
|
|
3284
3317
|
// more than a third of the screen, and never so tall that no window fits
|
|
@@ -3380,9 +3413,10 @@ export class Attached {
|
|
|
3380
3413
|
}
|
|
3381
3414
|
|
|
3382
3415
|
/**
|
|
3383
|
-
* Every card of every project this machine serves, whatever its
|
|
3384
|
-
* the strip draws them all, and `d` offers the PLANNED
|
|
3385
|
-
*
|
|
3416
|
+
* Every card AND ISSUE of every project this machine serves, whatever its
|
|
3417
|
+
* status — the strip draws them all, and `d` offers the PLANNED cards and
|
|
3418
|
+
* the CONFIRMED issues. Two requests per project, the two boards' own
|
|
3419
|
+
* (`brief`, so no bodies). Needs a person's
|
|
3386
3420
|
* session, like everything read off the roadmap here.
|
|
3387
3421
|
*
|
|
3388
3422
|
* A project that cannot be read keeps ITS last cards and no other's: this
|
|
@@ -3402,6 +3436,15 @@ export class Attached {
|
|
|
3402
3436
|
for (const card of Array.isArray(cards) ? cards : []) {
|
|
3403
3437
|
all.push({ ref: card.ref, title: card.title, slug, status: card.status, live: Boolean(card.activeRun) });
|
|
3404
3438
|
}
|
|
3439
|
+
// The issues are another list on the platform (R85), and were never
|
|
3440
|
+
// read here: the strip drew a project's ideas and not one of its bugs.
|
|
3441
|
+
const issues = await this.session.request(`/api/projects/${slug}/issues?brief=true`);
|
|
3442
|
+
for (const issue of Array.isArray(issues) ? issues : []) {
|
|
3443
|
+
all.push({
|
|
3444
|
+
ref: issue.ref, title: issue.title, slug, status: issue.status,
|
|
3445
|
+
live: Boolean(issue.activeRun), severity: issue.severity ?? 'MINOR',
|
|
3446
|
+
});
|
|
3447
|
+
}
|
|
3405
3448
|
read.push(slug);
|
|
3406
3449
|
this.gone.delete(slug);
|
|
3407
3450
|
} catch (failure) {
|
|
@@ -3414,7 +3457,8 @@ export class Attached {
|
|
|
3414
3457
|
}
|
|
3415
3458
|
}
|
|
3416
3459
|
this.cards = all;
|
|
3417
|
-
|
|
3460
|
+
// Ready to be developed: a planned card, or a confirmed issue.
|
|
3461
|
+
this.planned = all.filter((card) => columnOf(card.status) === 'PLANNED');
|
|
3418
3462
|
this.dirty = true;
|
|
3419
3463
|
if (!hold) this.sayGone();
|
|
3420
3464
|
return { signedIn: true, read, failed };
|
|
@@ -3449,7 +3493,7 @@ export class Attached {
|
|
|
3449
3493
|
const counts = read.filter((slug) => shown.has(slug)).map((slug) => {
|
|
3450
3494
|
const mine = this.cards.filter((card) => card.slug === slug);
|
|
3451
3495
|
const tally = BOARD_COLUMNS
|
|
3452
|
-
.map(([status, label]) => `${mine.filter((card) => card.status === status).length} ${label.toLowerCase()}`)
|
|
3496
|
+
.map(([status, label]) => `${mine.filter((card) => columnOf(card.status) === status).length} ${label.toLowerCase()}`)
|
|
3453
3497
|
.join(' · ');
|
|
3454
3498
|
return `${slug}: ${tally}`;
|
|
3455
3499
|
});
|
|
@@ -3465,8 +3509,8 @@ export class Attached {
|
|
|
3465
3509
|
}
|
|
3466
3510
|
|
|
3467
3511
|
/**
|
|
3468
|
-
* `d`, or `/new dev` with nothing after it: the planned cards
|
|
3469
|
-
* the one picked is developed — `/new dev R3 @slug`, on this machine.
|
|
3512
|
+
* `d`, or `/new dev` with nothing after it: the planned cards — and the
|
|
3513
|
+
* confirmed issues — as a list, and the one picked is developed — `/new dev R3 @slug`, on this machine.
|
|
3470
3514
|
*/
|
|
3471
3515
|
async pickPlanned(slug = null) {
|
|
3472
3516
|
if (!this.requireSignIn('start a session')) return;
|
|
@@ -3499,17 +3543,20 @@ export class Attached {
|
|
|
3499
3543
|
if (shown.length === 1 && !words.some((word) => word.startsWith('@'))) {
|
|
3500
3544
|
words = [...words, `@${shown[0]}`];
|
|
3501
3545
|
}
|
|
3502
|
-
const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway');
|
|
3546
|
+
const bare = words.filter((word) => word && !word.startsWith('@') && word !== '--anyway' && word !== '--again');
|
|
3503
3547
|
if (bare.length === 1 && ['dev', 'code'].includes(bare[0].toLowerCase())) {
|
|
3504
3548
|
const named = words.find((word) => word.startsWith('@'));
|
|
3505
3549
|
await this.pickPlanned(named ? named.slice(1) : null);
|
|
3506
3550
|
return;
|
|
3507
3551
|
}
|
|
3508
3552
|
const anyway = words.includes('--anyway');
|
|
3553
|
+
// R369: the answer to "a session already finished here" — `--anyway`'s
|
|
3554
|
+
// shape, for a different question.
|
|
3555
|
+
const again = words.includes('--again');
|
|
3509
3556
|
// `--codex`, `--claude`, `--agy`: this session's agent, over `/agent`'s.
|
|
3510
3557
|
const flagged = words.find((word) => AGENT_FLAGS.has(word));
|
|
3511
3558
|
let agent = flagged ? flagged.slice(2) : this.agent;
|
|
3512
|
-
const parsed = parseNew(words.filter((word) => word !== '--anyway' && !AGENT_FLAGS.has(word)), {
|
|
3559
|
+
const parsed = parseNew(words.filter((word) => word !== '--anyway' && word !== '--again' && !AGENT_FLAGS.has(word)), {
|
|
3513
3560
|
// In advanced mode, among the projects it shows.
|
|
3514
3561
|
projects: this.shownProjects(),
|
|
3515
3562
|
});
|
|
@@ -3557,7 +3604,7 @@ export class Attached {
|
|
|
3557
3604
|
// R150: the session writes the card first, then plans it.
|
|
3558
3605
|
: ['runs/plan', { prompt: parsed.text }, 'a plan — it writes its card first']
|
|
3559
3606
|
: ['runs', { entryNumber: parsed.ref }, `the development of ${parsed.ref}`];
|
|
3560
|
-
const started = await this.startSession(parsed.slug, path, withAgent(body, agent), withWho(what, agent), anyway);
|
|
3607
|
+
const started = await this.startSession(parsed.slug, path, withAgent(body, agent), withWho(what, agent), anyway, again);
|
|
3561
3608
|
if (started && plan) {
|
|
3562
3609
|
this.closeWindowOf(plan);
|
|
3563
3610
|
this.focusOnArrival = plan.projectSlug;
|
|
@@ -3819,9 +3866,10 @@ export class Attached {
|
|
|
3819
3866
|
* One POST that starts a session, aimed at THIS machine so its window opens
|
|
3820
3867
|
* here. A usage window over the machine's threshold is refused by the
|
|
3821
3868
|
* platform in a sentence; `--anyway` is the same decision the console's
|
|
3822
|
-
* panel asks for.
|
|
3869
|
+
* panel asks for. A card whose last session of that kind finished asks too
|
|
3870
|
+
* (R369), and `--again` answers it.
|
|
3823
3871
|
*/
|
|
3824
|
-
async startSession(slug, path, body, what, anyway) {
|
|
3872
|
+
async startSession(slug, path, body, what, anyway, again = false) {
|
|
3825
3873
|
try {
|
|
3826
3874
|
await this.session.request(`/api/projects/${slug}/${path}`, {
|
|
3827
3875
|
method: 'POST',
|
|
@@ -3829,15 +3877,20 @@ export class Attached {
|
|
|
3829
3877
|
...body,
|
|
3830
3878
|
...(this.runner?.id ? { targetRunnerId: this.runner.id } : {}),
|
|
3831
3879
|
...(anyway ? { acknowledgeUsage: true } : {}),
|
|
3880
|
+
...(again ? { again: true } : {}),
|
|
3832
3881
|
},
|
|
3833
3882
|
});
|
|
3834
3883
|
this.say(this.ink.success(` started ${what} on ${slug} — its window opens when this machine takes it`));
|
|
3835
3884
|
this.note('');
|
|
3836
3885
|
return true;
|
|
3837
3886
|
} catch (failure) {
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3887
|
+
// R369's sentence is checked first: it is the one that names a session,
|
|
3888
|
+
// and the usage test is loose enough to match words in other sentences.
|
|
3889
|
+
this.note(/session that finished/.test(failure.message)
|
|
3890
|
+
? `${failure.message} — add --again to start it regardless`
|
|
3891
|
+
: /usage|window/i.test(failure.message)
|
|
3892
|
+
? `${failure.message} — add --anyway to start it regardless`
|
|
3893
|
+
: failure.message);
|
|
3841
3894
|
return false;
|
|
3842
3895
|
}
|
|
3843
3896
|
}
|
|
@@ -3948,6 +4001,16 @@ export class Attached {
|
|
|
3948
4001
|
if (!meta) {
|
|
3949
4002
|
return this.note('/open needs a session — focus one');
|
|
3950
4003
|
}
|
|
4004
|
+
if (this.waitingOn(meta)) {
|
|
4005
|
+
// Stopped on a question or a permission: the run page on cawdev, where
|
|
4006
|
+
// the question panel and the decision are, whole.
|
|
4007
|
+
const url = this.consoleUrl(meta.projectSlug ?? meta.view?.projectSlug, `runs/${meta.id}`);
|
|
4008
|
+
if (!url) {
|
|
4009
|
+
return this.note('cannot tell where this session is on cawdev');
|
|
4010
|
+
}
|
|
4011
|
+
this.openLink(url);
|
|
4012
|
+
return this.note(`opened ${url}`);
|
|
4013
|
+
}
|
|
3951
4014
|
if (this.finishedPlan(meta)) {
|
|
3952
4015
|
const ref = meta.view.entryRef;
|
|
3953
4016
|
const board = /^i/i.test(ref) ? 'issues' : 'roadmap';
|
|
@@ -3989,6 +4052,129 @@ export class Attached {
|
|
|
3989
4052
|
opener(url);
|
|
3990
4053
|
}
|
|
3991
4054
|
|
|
4055
|
+
/** What a session is stopped on — a question or a permission request — or null. */
|
|
4056
|
+
waitingOn(meta) {
|
|
4057
|
+
const run = meta ? this.runs.find((each) => each.id === meta.id) : null;
|
|
4058
|
+
if (!run) return null;
|
|
4059
|
+
const pending = this.pendingOn(run);
|
|
4060
|
+
if (pending) return { kind: 'permission', pending };
|
|
4061
|
+
const asking = this.askingOn(run);
|
|
4062
|
+
return asking ? { kind: 'question', asking } : null;
|
|
4063
|
+
}
|
|
4064
|
+
|
|
4065
|
+
/**
|
|
4066
|
+
* `/view` on a session waiting for you: what it is waiting on, in FULL. In
|
|
4067
|
+
* advanced mode the reader — the session's output and the request under
|
|
4068
|
+
* it, over the whole screen, scrolled with the arrows (the wheel arrives as
|
|
4069
|
+
* arrows there), PgUp/PgDn, g/G; esc closes it, and any other key closes it
|
|
4070
|
+
* and does what it does (`a` answers, `y`/`n` decide). In the scrollback
|
|
4071
|
+
* the request is printed whole, where the terminal scrolls it.
|
|
4072
|
+
*/
|
|
4073
|
+
openReader() {
|
|
4074
|
+
const meta = this.commandTarget();
|
|
4075
|
+
if (!this.split) {
|
|
4076
|
+
this.say('');
|
|
4077
|
+
for (const line of this.requestLines(this.waitingOn(meta), this.screen.width)) this.say(line);
|
|
4078
|
+
this.say('');
|
|
4079
|
+
return this.note('');
|
|
4080
|
+
}
|
|
4081
|
+
this.reader = { runId: meta.id, top: null, room: 1, max: 0 };
|
|
4082
|
+
this.dirty = true;
|
|
4083
|
+
return this.note('');
|
|
4084
|
+
}
|
|
4085
|
+
|
|
4086
|
+
/** The request a session waits on, every word of it. */
|
|
4087
|
+
requestLines(waiting, width) {
|
|
4088
|
+
const ink = this.ink;
|
|
4089
|
+
if (!waiting) {
|
|
4090
|
+
return [ink.muted(' no longer waiting — answered, decided, or gone')];
|
|
4091
|
+
}
|
|
4092
|
+
const room = Math.max(20, width - 4);
|
|
4093
|
+
if (waiting.kind === 'question') {
|
|
4094
|
+
const { question, yours, waitingOn } = waiting.asking;
|
|
4095
|
+
return [
|
|
4096
|
+
ink.bold(ink.accent(' question ')),
|
|
4097
|
+
...String(question.question ?? '').split('\n').flatMap((part) => wordWrap(part, room)).map((line) => ` ${line}`),
|
|
4098
|
+
...(question.options?.length
|
|
4099
|
+
? ['', ...question.options.map((option, i) => ` ${ink.success(String(i + 1))} ${option}`)]
|
|
4100
|
+
: []),
|
|
4101
|
+
'',
|
|
4102
|
+
yours
|
|
4103
|
+
? ` ${ink.success('a')} ${ink.muted('answers it here · /open answers it on cawdev')}`
|
|
4104
|
+
: ink.muted(` waiting on ${waitingOn ?? 'somebody else'} — theirs to answer`),
|
|
4105
|
+
];
|
|
4106
|
+
}
|
|
4107
|
+
const approval = waiting.pending.approval ?? waiting.pending;
|
|
4108
|
+
return [
|
|
4109
|
+
`${ink.bold(ink.warn(' permission '))} ${ink.muted(`${approval.toolName ?? ''} · waiting since ${approval.askedAt?.slice(11, 19) ?? ''}`)}`,
|
|
4110
|
+
...String(approval.summary ?? '').split('\n').flatMap((part) => wordWrap(part, room)).map((line) => ` ${line}`),
|
|
4111
|
+
...(approval.toolInput && approval.toolInput !== approval.summary
|
|
4112
|
+
? ['', ink.muted(' input'), ...String(approval.toolInput).split('\n').map((line) => ` ${ink.text(line)}`)]
|
|
4113
|
+
: []),
|
|
4114
|
+
'',
|
|
4115
|
+
...permissionBanner(waiting.pending, width, ink, this.runner ?? {}).slice(2),
|
|
4116
|
+
];
|
|
4117
|
+
}
|
|
4118
|
+
|
|
4119
|
+
/** The reader, drawn over the whole screen — see `readerFrame` in split.mjs. */
|
|
4120
|
+
readerScreen(width, height) {
|
|
4121
|
+
const ink = this.ink;
|
|
4122
|
+
const meta = this.windows().find((each) => each.id === this.reader.runId)
|
|
4123
|
+
?? this.sessions.get(this.reader.runId) ?? { id: this.reader.runId };
|
|
4124
|
+
const output = (this.lines.get(this.reader.runId) ?? []).map((line) => this.render(line));
|
|
4125
|
+
const waiting = this.waitingOn(meta);
|
|
4126
|
+
const request = this.requestLines(waiting, width - 2);
|
|
4127
|
+
const n = this.windowNumberOf(meta.id);
|
|
4128
|
+
const keys = waiting?.kind === 'permission'
|
|
4129
|
+
? 'y once · s session · n refuse'
|
|
4130
|
+
: waiting?.asking?.yours ? 'a answer' : '';
|
|
4131
|
+
const drawn = readerFrame(width, height, {
|
|
4132
|
+
title: `${n ? `${n} ` : ''}${titleFor(meta, waiting ? `waiting for ${waiting.kind === 'question' ? 'an answer' : 'permission'}` : null)}`,
|
|
4133
|
+
lines: [...output, ink.muted('─'.repeat(Math.min(40, width - 2))), ...request],
|
|
4134
|
+
requestAt: output.length,
|
|
4135
|
+
top: this.reader.top,
|
|
4136
|
+
footer: keyList(['↑↓ scroll', 'PgUp/PgDn page', 'g/G top/end', 'esc close',
|
|
4137
|
+
...(keys ? [keys] : []), '/open on cawdev'], '', width - 2, ink),
|
|
4138
|
+
}, ink);
|
|
4139
|
+
Object.assign(this.reader, { top: drawn.top, max: drawn.max, room: drawn.room });
|
|
4140
|
+
return drawn.lines;
|
|
4141
|
+
}
|
|
4142
|
+
|
|
4143
|
+
/** Keys while the reader is open. */
|
|
4144
|
+
onReaderKey(key) {
|
|
4145
|
+
const reader = this.reader;
|
|
4146
|
+
const move = (to) => {
|
|
4147
|
+
reader.top = Math.min(Math.max(0, to), reader.max);
|
|
4148
|
+
this.dirty = true;
|
|
4149
|
+
return undefined;
|
|
4150
|
+
};
|
|
4151
|
+
const at = reader.top ?? 0;
|
|
4152
|
+
switch (key) {
|
|
4153
|
+
case `${ESC}[A`: case `${ESC}OA`: case 'k':
|
|
4154
|
+
return move(at - 1);
|
|
4155
|
+
case `${ESC}[B`: case `${ESC}OB`: case 'j':
|
|
4156
|
+
return move(at + 1);
|
|
4157
|
+
case `${ESC}[5~`: case 'b':
|
|
4158
|
+
return move(at - reader.room);
|
|
4159
|
+
case `${ESC}[6~`: case ' ':
|
|
4160
|
+
return move(at + reader.room);
|
|
4161
|
+
case `${ESC}[H`: case `${ESC}[1~`: case 'g':
|
|
4162
|
+
return move(0);
|
|
4163
|
+
case `${ESC}[F`: case `${ESC}[4~`: case 'G':
|
|
4164
|
+
return move(reader.max);
|
|
4165
|
+
case ESC: case 'q':
|
|
4166
|
+
this.reader = null;
|
|
4167
|
+
this.dirty = true;
|
|
4168
|
+
return this.note('');
|
|
4169
|
+
default:
|
|
4170
|
+
// Anything else is what it always is — `a` answers, `y`/`n` decide,
|
|
4171
|
+
// `/` a command — with the reader closed first.
|
|
4172
|
+
this.reader = null;
|
|
4173
|
+
this.dirty = true;
|
|
4174
|
+
return this.onKey(key);
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
|
|
3992
4178
|
/**
|
|
3993
4179
|
* `/view` alone — the focused session's page on cawdev (its transcript,
|
|
3994
4180
|
* stages, card and checkout). On the console this terminal signed in to:
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// The git host's side of the runner — R379.
|
|
2
|
+
//
|
|
3
|
+
// The runner has only ever spoken to GitHub, through `gh`. A GitLab project
|
|
4
|
+
// could be pushed to but not opened, found or merged against, and a run's link
|
|
5
|
+
// to its pull request came back empty. This is the one place that knows there
|
|
6
|
+
// are two: `adapterFor('github')` is the `gh` calls the daemon always made,
|
|
7
|
+
// moved here without change; `adapterFor('gitlab')` is the same questions asked
|
|
8
|
+
// of `glab`.
|
|
9
|
+
//
|
|
10
|
+
// No dependencies, and in `tools/runner/` rather than `tools/lib/` on purpose:
|
|
11
|
+
// this half of the tree is open-licence and must not import the other.
|
|
12
|
+
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
// --- the repository --------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* `{ host, path }` for an origin URL, or null when it is not one we can read.
|
|
19
|
+
* `git@h:a/b/c.git`, `https://h/a/b/c(.git)` and `ssh://git@h:2222/a/b.git` —
|
|
20
|
+
* `path` is the repository's path on the host without `.git`, subgroups kept.
|
|
21
|
+
*/
|
|
22
|
+
export function repoOf(originUrl) {
|
|
23
|
+
const url = (originUrl ?? '').trim();
|
|
24
|
+
if (!url) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const scp = /^[\w.-]+@([\w.-]+):(?!\/\/)(.+?)(?:\.git)?\/?$/.exec(url);
|
|
28
|
+
if (scp) {
|
|
29
|
+
return { host: scp[1], path: scp[2].replace(/^\/+/, '') };
|
|
30
|
+
}
|
|
31
|
+
const full = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([\w.-]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/i.exec(url);
|
|
32
|
+
if (full) {
|
|
33
|
+
return { host: full[1], path: full[2] };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Which host's CLI to use: `'github'` or `'gitlab'`.
|
|
40
|
+
*
|
|
41
|
+
* A host the project DECLARED wins; otherwise a host name containing `gitlab` is
|
|
42
|
+
* GitLab; otherwise GitHub, which is what the runner has always assumed — so a
|
|
43
|
+
* project that never set anything behaves exactly as it did.
|
|
44
|
+
*/
|
|
45
|
+
export function hostFor({ declared = null, originUrl = null } = {}) {
|
|
46
|
+
const said = String(declared ?? '').trim().toUpperCase();
|
|
47
|
+
if (said === 'GITLAB') return 'gitlab';
|
|
48
|
+
if (said === 'GITHUB') return 'github';
|
|
49
|
+
const repo = repoOf(originUrl);
|
|
50
|
+
return repo && /gitlab/i.test(repo.host) ? 'gitlab' : 'github';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A link somebody can click to open a pull or merge request themselves. It
|
|
55
|
+
* always contains `/compare/` — the platform tells a real pull request from this
|
|
56
|
+
* by that, and nothing else.
|
|
57
|
+
*/
|
|
58
|
+
export function compareUrl(host, repo, base, branch) {
|
|
59
|
+
if (!repo) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const name = encodeURIComponent(branch);
|
|
63
|
+
if (host === 'gitlab') {
|
|
64
|
+
return `https://${repo.host}/${repo.path}/-/compare/${encodeURIComponent(base || 'main')}...${name}`;
|
|
65
|
+
}
|
|
66
|
+
return `https://${repo.host}/${repo.path}/compare/${name}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The merge request URL a `glab mr create` printed, or null. */
|
|
70
|
+
export function mrUrlIn(text) {
|
|
71
|
+
const found = String(text ?? '').match(/https?:\/\/\S+?\/-\/merge_requests\/\d+/g);
|
|
72
|
+
return found ? found[found.length - 1] : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The iid at the end of a merge request URL. */
|
|
76
|
+
export function iidOf(url) {
|
|
77
|
+
return /\/merge_requests\/(\d+)/.exec(url ?? '')?.[1] ?? null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const GITLAB_WAIT = new Set(['checking', 'unchecked', 'preparing', 'ci_still_running']);
|
|
81
|
+
const GITLAB_BLOCKED = new Set([
|
|
82
|
+
'not_approved', 'blocked_status', 'ci_must_pass', 'discussions_not_resolved',
|
|
83
|
+
'draft_status', 'not_open', 'requested_changes',
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What `glab mr view --output json` says about whether a merge request can
|
|
88
|
+
* merge: `CONFLICT`, `UNKNOWN` (the host has not computed it — wait),
|
|
89
|
+
* `NOT_PERMITTED` or `KNOWN`.
|
|
90
|
+
*/
|
|
91
|
+
export function gitlabMergeability(view) {
|
|
92
|
+
if (!view || typeof view !== 'object') {
|
|
93
|
+
return 'KNOWN';
|
|
94
|
+
}
|
|
95
|
+
const status = String(view.detailed_merge_status ?? '').toLowerCase();
|
|
96
|
+
if (view.has_conflicts === true || status === 'conflict') return 'CONFLICT';
|
|
97
|
+
if (GITLAB_WAIT.has(status)) return 'UNKNOWN';
|
|
98
|
+
if (GITLAB_BLOCKED.has(status)) return 'NOT_PERMITTED';
|
|
99
|
+
return 'KNOWN';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// --- running a CLI ---------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
/** Runs `bin` and hands back everything it said, including on failure. */
|
|
105
|
+
export function runCli(bin, cwd, args) {
|
|
106
|
+
return new Promise((resolvePromise) => {
|
|
107
|
+
const child = spawn(bin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
108
|
+
let out = '';
|
|
109
|
+
let err = '';
|
|
110
|
+
child.stdout.on('data', (chunk) => (out += chunk));
|
|
111
|
+
child.stderr.on('data', (chunk) => (err += chunk));
|
|
112
|
+
child.on('error', (failure) => resolvePromise({
|
|
113
|
+
code: -1,
|
|
114
|
+
out: '',
|
|
115
|
+
err: failure.code === 'ENOENT'
|
|
116
|
+
? `${bin} is not installed on this machine, so it cannot talk to the git host.`
|
|
117
|
+
: failure.message,
|
|
118
|
+
}));
|
|
119
|
+
// `close`, not `exit`: stdout may still hold data when the process exits.
|
|
120
|
+
child.on('close', (code) => resolvePromise({ code, out: out.trim(), err: err.trim() }));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function parse(text) {
|
|
125
|
+
try {
|
|
126
|
+
return JSON.parse(text || '{}');
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- the adapters ----------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
const github = {
|
|
135
|
+
name: 'github',
|
|
136
|
+
bin: 'gh',
|
|
137
|
+
noun: 'pull request',
|
|
138
|
+
installHint: 'brew install gh && gh auth login',
|
|
139
|
+
|
|
140
|
+
run: (cwd, args) => runCli('gh', cwd, args),
|
|
141
|
+
|
|
142
|
+
/** The URL of the pull request for a branch, or null. */
|
|
143
|
+
async find(cwd, branch) {
|
|
144
|
+
const asked = await runCli('gh', cwd, ['pr', 'view', branch, '--json', 'url', '--jq', '.url']);
|
|
145
|
+
return asked.code === 0 && asked.out ? asked.out : null;
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
create: (cwd, { branch, base, title, body }) => runCli('gh', cwd, [
|
|
149
|
+
'pr', 'create',
|
|
150
|
+
'--head', branch,
|
|
151
|
+
...(base ? ['--base', base] : []),
|
|
152
|
+
'--title', title,
|
|
153
|
+
'--body', body,
|
|
154
|
+
]),
|
|
155
|
+
|
|
156
|
+
/** The URL a successful create printed. */
|
|
157
|
+
urlOf: (created) => created.out.split('\n').pop().trim(),
|
|
158
|
+
|
|
159
|
+
/** `'UNKNOWN'` while the host has not computed it; anything else is "stop waiting". */
|
|
160
|
+
async mergeability(cwd, url) {
|
|
161
|
+
const asked = await runCli('gh', cwd, ['pr', 'view', url, '--json', 'mergeable,mergeStateStatus']);
|
|
162
|
+
if (asked.code !== 0) return 'KNOWN';
|
|
163
|
+
const view = parse(asked.out);
|
|
164
|
+
return view && view.mergeable === 'UNKNOWN' ? 'UNKNOWN' : 'KNOWN';
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
merge: (cwd, url, { mergeCommit }) => runCli('gh', cwd,
|
|
168
|
+
['pr', 'merge', url, mergeCommit ? '--merge' : '--squash', '--delete-branch']),
|
|
169
|
+
|
|
170
|
+
/** Why a merge was refused, when the host can say: `CONFLICT`, `NOT_PERMITTED` or null. */
|
|
171
|
+
async refusal(cwd, url) {
|
|
172
|
+
const asked = await runCli('gh', cwd, ['pr', 'view', url, '--json', 'mergeable,mergeStateStatus']);
|
|
173
|
+
if (asked.code !== 0) return null;
|
|
174
|
+
const view = parse(asked.out);
|
|
175
|
+
if (!view) return null;
|
|
176
|
+
if (view.mergeable === 'CONFLICTING' || view.mergeStateStatus === 'DIRTY') return 'CONFLICT';
|
|
177
|
+
if (view.mergeStateStatus === 'BLOCKED') return 'NOT_PERMITTED';
|
|
178
|
+
return null;
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/** The compare link fallback — only for github.com, as it always was. */
|
|
182
|
+
fallbackLink(repo, _base, branch) {
|
|
183
|
+
return repo && repo.host === 'github.com' ? compareUrl('github', repo, null, branch) : null;
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// `glab mr merge` queues an auto-merge by default on versions that have the
|
|
188
|
+
// flag; asked once per process whether this glab does.
|
|
189
|
+
let glabHasAutoMerge = null;
|
|
190
|
+
|
|
191
|
+
async function autoMergeFlag(cwd) {
|
|
192
|
+
if (glabHasAutoMerge === null) {
|
|
193
|
+
const help = await runCli('glab', cwd, ['mr', 'merge', '--help']);
|
|
194
|
+
const has = /--auto-merge/.test(`${help.out}\n${help.err}`);
|
|
195
|
+
// Only a real answer is remembered: glab not installed yet, or a failed first
|
|
196
|
+
// call, must not decide for the life of the daemon.
|
|
197
|
+
if (help.code !== 0) {
|
|
198
|
+
return has ? ['--auto-merge=false'] : [];
|
|
199
|
+
}
|
|
200
|
+
glabHasAutoMerge = has;
|
|
201
|
+
}
|
|
202
|
+
return glabHasAutoMerge ? ['--auto-merge=false'] : [];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const gitlab = {
|
|
206
|
+
name: 'gitlab',
|
|
207
|
+
bin: 'glab',
|
|
208
|
+
noun: 'merge request',
|
|
209
|
+
installHint: 'install glab and run `glab auth login --hostname <your GitLab host>`',
|
|
210
|
+
|
|
211
|
+
run: (cwd, args) => runCli('glab', cwd, args),
|
|
212
|
+
|
|
213
|
+
async find(cwd, branch) {
|
|
214
|
+
const asked = await runCli('glab', cwd, ['mr', 'list', '--source-branch', branch, '--output', 'json']);
|
|
215
|
+
if (asked.code !== 0) return null;
|
|
216
|
+
const rows = parse(asked.out);
|
|
217
|
+
const first = Array.isArray(rows) ? rows[0] : null;
|
|
218
|
+
return first?.web_url ?? null;
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
create: (cwd, { branch, base, title, body }) => runCli('glab', cwd, [
|
|
222
|
+
'mr', 'create',
|
|
223
|
+
'--source-branch', branch,
|
|
224
|
+
...(base ? ['--target-branch', base] : []),
|
|
225
|
+
'--title', title,
|
|
226
|
+
'--description', body,
|
|
227
|
+
'--yes',
|
|
228
|
+
]),
|
|
229
|
+
|
|
230
|
+
urlOf: (created) => mrUrlIn(`${created.out}\n${created.err}`),
|
|
231
|
+
|
|
232
|
+
async mergeability(cwd, url) {
|
|
233
|
+
const iid = iidOf(url);
|
|
234
|
+
if (!iid) return 'KNOWN';
|
|
235
|
+
const asked = await runCli('glab', cwd, ['mr', 'view', iid, '--output', 'json']);
|
|
236
|
+
if (asked.code !== 0) return 'KNOWN';
|
|
237
|
+
return gitlabMergeability(parse(asked.out)) === 'UNKNOWN' ? 'UNKNOWN' : 'KNOWN';
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
async merge(cwd, url, { mergeCommit }) {
|
|
241
|
+
const iid = iidOf(url) ?? url;
|
|
242
|
+
return runCli('glab', cwd, [
|
|
243
|
+
'mr', 'merge', iid, '--yes', '--remove-source-branch',
|
|
244
|
+
...(mergeCommit ? [] : ['--squash']),
|
|
245
|
+
...(await autoMergeFlag(cwd)),
|
|
246
|
+
]);
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
async refusal(cwd, url) {
|
|
250
|
+
const iid = iidOf(url);
|
|
251
|
+
if (!iid) return null;
|
|
252
|
+
const asked = await runCli('glab', cwd, ['mr', 'view', iid, '--output', 'json']);
|
|
253
|
+
if (asked.code !== 0) return null;
|
|
254
|
+
const verdict = gitlabMergeability(parse(asked.out));
|
|
255
|
+
return verdict === 'CONFLICT' || verdict === 'NOT_PERMITTED' ? verdict : null;
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
fallbackLink: (repo, base, branch) => compareUrl('gitlab', repo, base, branch),
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
export function adapterFor(host) {
|
|
262
|
+
return host === 'gitlab' ? gitlab : github;
|
|
263
|
+
}
|