dw-sdlc 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agentic-config.d.ts +1 -1
- package/dist/agentic-config.js +1 -1
- package/dist/cli.js +7 -1
- package/dist/commands/doctor.d.ts +0 -5
- package/dist/commands/doctor.js +11 -180
- package/dist/commands/pr-ship.js +6 -4
- package/dist/commands/recap.js +82 -20
- package/dist/commands/worker-ls.d.ts +2 -0
- package/dist/commands/worker-ls.js +53 -0
- package/dist/commands/worker-report.d.ts +2 -0
- package/dist/commands/worker-report.js +117 -0
- package/dist/commands/worker-start.d.ts +2 -0
- package/dist/commands/worker-start.js +234 -0
- package/dist/doctor-plugins.d.ts +14 -0
- package/dist/doctor-plugins.js +182 -0
- package/dist/pr/decide.js +2 -1
- package/dist/pr/read.d.ts +13 -2
- package/dist/pr/read.js +45 -7
- package/dist/pr/state.d.ts +11 -3
- package/dist/pr/state.js +3 -2
- package/dist/worker/herdr.d.ts +48 -0
- package/dist/worker/herdr.js +115 -0
- package/dist/worker/tabs.d.ts +9 -0
- package/dist/worker/tabs.js +62 -0
- package/package.json +1 -1
package/dist/pr/read.js
CHANGED
|
@@ -93,18 +93,33 @@ export function read(named, command, sized) {
|
|
|
93
93
|
requireAuth();
|
|
94
94
|
return query(targetOf(named, command), sized);
|
|
95
95
|
}
|
|
96
|
-
/** The
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const view = gh(["pr", "view", "--json", "url"]);
|
|
96
|
+
/** The pull request `gh pr view` resolves for the head branch `branch`, else the current one; null when none. */
|
|
97
|
+
function viewed(branch) {
|
|
98
|
+
const view = gh(["pr", "view", ...(branch === undefined ? [] : [branch]), "--json", "url"]);
|
|
100
99
|
if (view.status === 0) {
|
|
101
|
-
return
|
|
100
|
+
return parseOrCannotRun(() => parseView(view.stdout));
|
|
102
101
|
}
|
|
103
102
|
if (said(view.stderr).startsWith("no pull requests found")) {
|
|
104
103
|
return null;
|
|
105
104
|
}
|
|
106
105
|
throw new CannotRun(`gh pr view failed: ${said(view.stderr)}`, REMEDIES.read);
|
|
107
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The current branch's pull request, open or not, or null. gh tries `@{push}`, else the local name, which
|
|
109
|
+
* misses a branch pushed under another name; then `upstreamName` is asked, and `byUpstream` says so.
|
|
110
|
+
*/
|
|
111
|
+
export function readBranch(sized, upstreamName) {
|
|
112
|
+
requireAuth();
|
|
113
|
+
const own = viewed(undefined);
|
|
114
|
+
if (own !== null) {
|
|
115
|
+
return { pr: query(own, sized), byUpstream: false };
|
|
116
|
+
}
|
|
117
|
+
if (upstreamName === undefined) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const found = viewed(upstreamName);
|
|
121
|
+
return found === null ? null : { pr: query(found, sized), byUpstream: true };
|
|
122
|
+
}
|
|
108
123
|
/** The default branch of the repository gh resolves from the current directory. */
|
|
109
124
|
export function defaultBranch() {
|
|
110
125
|
const view = gh(["repo", "view", "--json", "defaultBranchRef"]);
|
|
@@ -129,12 +144,23 @@ export function now() {
|
|
|
129
144
|
export function readAgain(pr, sized) {
|
|
130
145
|
return query(pr, sized);
|
|
131
146
|
}
|
|
132
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* The compares this process has read, by repository and the two commits, so reads that share one ask gh
|
|
149
|
+
* once: a compare of two commits never changes.
|
|
150
|
+
*/
|
|
151
|
+
const COMPARES = new Map();
|
|
152
|
+
/** The reply of the compare from `from` to `commit`, read once per process; `what` names it when gh refuses it. */
|
|
133
153
|
function compared(pr, from, commit, what) {
|
|
154
|
+
const key = `${pr.repo} ${from}...${commit}`;
|
|
155
|
+
const known = COMPARES.get(key);
|
|
156
|
+
if (known !== undefined) {
|
|
157
|
+
return known;
|
|
158
|
+
}
|
|
134
159
|
const reply = gh(compareArgs(pr.repo, from, commit));
|
|
135
160
|
if (reply.status !== 0) {
|
|
136
161
|
throw new CannotRun(`${pr.repo}#${pr.number}: ${what}: ${said(reply.stderr)}`, REMEDIES.read);
|
|
137
162
|
}
|
|
163
|
+
COMPARES.set(key, reply.stdout);
|
|
138
164
|
return reply.stdout;
|
|
139
165
|
}
|
|
140
166
|
/** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
|
|
@@ -142,6 +168,17 @@ export function carriesBase(before, after) {
|
|
|
142
168
|
const reply = compared(after, before.baseOid, after.head, `the compare with ${before.baseOid.slice(0, 7)}`);
|
|
143
169
|
return parseOrCannotRun(() => behindOf(reply)) === 0;
|
|
144
170
|
}
|
|
171
|
+
/**
|
|
172
|
+
* `pr` and how many commits of its base its head lacks, from the compare of the base with the head,
|
|
173
|
+
* read only where the decision can reach the update: open, mergeable, not reported BEHIND already.
|
|
174
|
+
*/
|
|
175
|
+
export function withBehind(pr) {
|
|
176
|
+
if (pr.state !== "OPEN" || pr.mergeable !== "MERGEABLE" || pr.mergeStateStatus === "BEHIND") {
|
|
177
|
+
return pr;
|
|
178
|
+
}
|
|
179
|
+
const reply = compared(pr, pr.baseOid, pr.head, `the compare with ${pr.base}`);
|
|
180
|
+
return { ...pr, behindBy: parseOrCannotRun(() => behindOf(reply)) };
|
|
181
|
+
}
|
|
145
182
|
/** The own changes of `pr` at `commit`, from the compare with its base; null when it cannot show them whole. */
|
|
146
183
|
function ownChanges(pr, commit) {
|
|
147
184
|
const reply = compared(pr, pr.baseOid, commit, `the compare with ${commit.slice(0, 7)}`);
|
|
@@ -190,5 +227,6 @@ function query({ repo, number }, sized) {
|
|
|
190
227
|
const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
|
|
191
228
|
throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
|
|
192
229
|
}
|
|
193
|
-
|
|
230
|
+
const parsed = parseOrCannotRun(() => parsePr(reply.stdout, repo));
|
|
231
|
+
return withChanged(withCarried({ ...parsed, behindBy: null }), sized);
|
|
194
232
|
}
|
package/dist/pr/state.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export declare const REVIEW = "dw-sdlc/review";
|
|
3
3
|
/** How the description of a success the script posts under off begins: no one read that head. */
|
|
4
4
|
export declare const OFF_PREFIX = "off:";
|
|
5
|
-
export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
|
|
5
|
+
export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid\n baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n reviewThreads(first: 100) { nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
|
|
6
6
|
export type Bucket = "pass" | "fail" | "pending" | "skipping";
|
|
7
7
|
export type CiCheck = {
|
|
8
8
|
name: string;
|
|
@@ -40,7 +40,10 @@ export type PrState = {
|
|
|
40
40
|
headCommittedAt: string;
|
|
41
41
|
branch: string;
|
|
42
42
|
base: string;
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* The commit the base branch points at, read from the branch itself: GitHub's `baseRefOid` lags a
|
|
45
|
+
* base that just moved. A deleted base branch leaves `baseRefOid`.
|
|
46
|
+
*/
|
|
44
47
|
baseOid: string;
|
|
45
48
|
mergeCommit: string | null;
|
|
46
49
|
checks: CiCheck[];
|
|
@@ -57,6 +60,11 @@ export type PrState = {
|
|
|
57
60
|
* auto` to route on: `unsized` when the compare cannot list them all, null when not read.
|
|
58
61
|
*/
|
|
59
62
|
changed: ChangedFile[] | "unsized" | null;
|
|
63
|
+
/**
|
|
64
|
+
* How many commits of the base the head lacks, from the compare of the base with the head; null when
|
|
65
|
+
* not read.
|
|
66
|
+
*/
|
|
67
|
+
behindBy: number | null;
|
|
60
68
|
};
|
|
61
69
|
/**
|
|
62
70
|
* A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
|
|
@@ -100,5 +108,5 @@ export declare function parseView(reply: string): {
|
|
|
100
108
|
/** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
|
|
101
109
|
export declare function parseDefaultBranch(reply: string): string;
|
|
102
110
|
/** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
|
|
103
|
-
export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed">;
|
|
111
|
+
export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed" | "behindBy">;
|
|
104
112
|
export {};
|
package/dist/pr/state.js
CHANGED
|
@@ -8,7 +8,7 @@ export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
|
|
|
8
8
|
repository(owner: $owner, name: $name) {
|
|
9
9
|
pullRequest(number: $number) {
|
|
10
10
|
url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid
|
|
11
|
-
viewerDidAuthor mergeCommit { oid }
|
|
11
|
+
baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }
|
|
12
12
|
reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
|
|
13
13
|
comments(first: 1) { nodes { author { login } body } }
|
|
14
14
|
latest: comments(last: 1) { nodes { viewerDidAuthor } } } }
|
|
@@ -232,6 +232,7 @@ export function parsePr(reply, repo) {
|
|
|
232
232
|
const rollup = childOrNull(commit, "statusCheckRollup");
|
|
233
233
|
const mergeCommit = childOrNull(pr, "mergeCommit");
|
|
234
234
|
const head = string(pr, "headRefOid");
|
|
235
|
+
const baseRef = childOrNull(pr, "baseRef");
|
|
235
236
|
return {
|
|
236
237
|
url: string(pr, "url"),
|
|
237
238
|
repo,
|
|
@@ -246,7 +247,7 @@ export function parsePr(reply, repo) {
|
|
|
246
247
|
headCommittedAt: time(commit, "committedDate"),
|
|
247
248
|
branch: string(pr, "headRefName"),
|
|
248
249
|
base: string(pr, "baseRefName"),
|
|
249
|
-
baseOid: string(pr, "baseRefOid"),
|
|
250
|
+
baseOid: baseRef === null ? string(pr, "baseRefOid") : string(child(baseRef, "target"), "oid"),
|
|
250
251
|
mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
|
|
251
252
|
checks: rollup === null ? [] : nodes(rollup, "contexts").map((context) => checkOf(context, repo)),
|
|
252
253
|
unresolvedThreads: nodes(pr, "reviewThreads")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { type JsonObject } from "../json-text.ts";
|
|
2
|
+
/** What stops a `worker` command before it acts: exit 2, the problem and, when there is one, its remedy. */
|
|
3
|
+
export declare class Refusal extends Error {
|
|
4
|
+
remedy: string | undefined;
|
|
5
|
+
constructor(message: string, remedy?: string);
|
|
6
|
+
}
|
|
7
|
+
export type Called = {
|
|
8
|
+
status: number;
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
};
|
|
12
|
+
/** The first line of a tool's output that says anything, or the exit it ended with. */
|
|
13
|
+
export declare function said(called: Called): string;
|
|
14
|
+
/** `tool args` as a child process; a tool that is not on PATH stops the command. */
|
|
15
|
+
export declare function call(tool: "hc" | "herdr-app", args: string[]): Called;
|
|
16
|
+
/** The JSON object `hc <args>` prints; a failing call or a reply that is no object stops the command. */
|
|
17
|
+
export declare function hcObject(args: string[]): JsonObject;
|
|
18
|
+
/** What an error says, whatever was thrown. */
|
|
19
|
+
export declare function describe(error: unknown): string;
|
|
20
|
+
/** Reports a `Refusal` of `command` on stderr and answers exit 2; any other error is a bug and goes on up. */
|
|
21
|
+
export declare function refuse(command: string, error: unknown): number;
|
|
22
|
+
/** The option `flag` as a whole number from `min`, `fallback` when the command line leaves it out. */
|
|
23
|
+
export declare function wholeNumber(flag: string, value: string | undefined, fallback: number, min: number): number | {
|
|
24
|
+
error: string;
|
|
25
|
+
};
|
|
26
|
+
/** The terminal the command runs in, which is the Lead of the Workers it starts or reads. */
|
|
27
|
+
export declare function lead(): string;
|
|
28
|
+
export type Worker = {
|
|
29
|
+
name: string;
|
|
30
|
+
address: string;
|
|
31
|
+
/** herdr's state: live or finished. */
|
|
32
|
+
state: string;
|
|
33
|
+
/** What the Worker reports (idle, working, done, blocked, error), `finished` once it has ended, `unknown` before it reports. */
|
|
34
|
+
status: string;
|
|
35
|
+
/** The folder it was launched in, or the one it works in once `workingFolders` has asked herdr. */
|
|
36
|
+
folder: string;
|
|
37
|
+
};
|
|
38
|
+
/** Whether herdr still runs the Worker's terminal; the one test of it every command shares. */
|
|
39
|
+
export declare function isLive(worker: Worker): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The Workers with the folder each live one works in, its worktree, which `hc get` reports as
|
|
42
|
+
* `foreground.cwd` and `hc ls` leaves out. A Worker herdr cannot tell it for keeps its launch folder.
|
|
43
|
+
*/
|
|
44
|
+
export declare function workingFolders(workers: Worker[]): Worker[];
|
|
45
|
+
/** The Workers `lead` started, in the order herdr lists them; `all` adds the finished ones. */
|
|
46
|
+
export declare function workersOf(leadId: string, all: boolean): Worker[];
|
|
47
|
+
/** A table of rows, every column but the last padded to its widest cell and two spaces apart. */
|
|
48
|
+
export declare function table(rows: string[][]): string;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// What the `worker` commands share: `hc` and `herdr-app` as child processes, the Lead a command acts for,
|
|
2
|
+
// and the Workers herdr lists for it.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { isObject, parseObject } from "../json-text.js";
|
|
5
|
+
/** What stops a `worker` command before it acts: exit 2, the problem and, when there is one, its remedy. */
|
|
6
|
+
export class Refusal extends Error {
|
|
7
|
+
remedy;
|
|
8
|
+
constructor(message, remedy) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.remedy = remedy;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/** The first line of a tool's output that says anything, or the exit it ended with. */
|
|
14
|
+
export function said(called) {
|
|
15
|
+
const line = `${called.stderr}\n${called.stdout}`.split("\n").find((text) => text.trim() !== "");
|
|
16
|
+
return line?.trim() ?? `exited ${called.status}`;
|
|
17
|
+
}
|
|
18
|
+
/** `tool args` as a child process; a tool that is not on PATH stops the command. */
|
|
19
|
+
export function call(tool, args) {
|
|
20
|
+
const result = spawnSync(tool, args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
21
|
+
if (result.error !== undefined) {
|
|
22
|
+
if ("code" in result.error && result.error.code === "ENOENT") {
|
|
23
|
+
throw new Refusal(`${tool} is not on PATH`, "install herdr, or run the command in a terminal herdr started");
|
|
24
|
+
}
|
|
25
|
+
throw result.error;
|
|
26
|
+
}
|
|
27
|
+
return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
|
|
28
|
+
}
|
|
29
|
+
/** The JSON object `hc <args>` prints; a failing call or a reply that is no object stops the command. */
|
|
30
|
+
export function hcObject(args) {
|
|
31
|
+
const called = call("hc", args);
|
|
32
|
+
if (called.status !== 0) {
|
|
33
|
+
throw new Refusal(`hc ${args[0] ?? ""} failed: ${said(called)}`);
|
|
34
|
+
}
|
|
35
|
+
const parsed = parseObject(called.stdout);
|
|
36
|
+
if (parsed === undefined) {
|
|
37
|
+
throw new Refusal(`hc ${args[0] ?? ""} answered something that is no JSON object`, "check hc --version");
|
|
38
|
+
}
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
/** What an error says, whatever was thrown. */
|
|
42
|
+
export function describe(error) {
|
|
43
|
+
return error instanceof Error ? error.message : String(error);
|
|
44
|
+
}
|
|
45
|
+
/** Reports a `Refusal` of `command` on stderr and answers exit 2; any other error is a bug and goes on up. */
|
|
46
|
+
export function refuse(command, error) {
|
|
47
|
+
if (!(error instanceof Refusal)) {
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
process.stderr.write(`dw-sdlc ${command}: ${error.message}${error.remedy === undefined ? "" : ` — ${error.remedy}`}\n`);
|
|
51
|
+
return 2;
|
|
52
|
+
}
|
|
53
|
+
/** The option `flag` as a whole number from `min`, `fallback` when the command line leaves it out. */
|
|
54
|
+
export function wholeNumber(flag, value, fallback, min) {
|
|
55
|
+
const number = value === undefined ? fallback : Number(value);
|
|
56
|
+
return Number.isInteger(number) && number >= min
|
|
57
|
+
? number
|
|
58
|
+
: { error: `${flag} takes a whole number from ${min}, not ${JSON.stringify(value)}` };
|
|
59
|
+
}
|
|
60
|
+
/** The terminal the command runs in, which is the Lead of the Workers it starts or reads. */
|
|
61
|
+
export function lead() {
|
|
62
|
+
const id = process.env.HC_TERMINAL_ID;
|
|
63
|
+
if (id === undefined || id === "") {
|
|
64
|
+
throw new Refusal("outside herdr: $HC_TERMINAL_ID is not set", "run it in a terminal herdr started, where the Lead has an address");
|
|
65
|
+
}
|
|
66
|
+
return id;
|
|
67
|
+
}
|
|
68
|
+
/** Whether herdr still runs the Worker's terminal; the one test of it every command shares. */
|
|
69
|
+
export function isLive(worker) {
|
|
70
|
+
return worker.state === "live";
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The Workers with the folder each live one works in, its worktree, which `hc get` reports as
|
|
74
|
+
* `foreground.cwd` and `hc ls` leaves out. A Worker herdr cannot tell it for keeps its launch folder.
|
|
75
|
+
*/
|
|
76
|
+
export function workingFolders(workers) {
|
|
77
|
+
return workers.map((worker) => {
|
|
78
|
+
if (!isLive(worker)) {
|
|
79
|
+
return worker;
|
|
80
|
+
}
|
|
81
|
+
const got = call("hc", ["get", worker.address, "--json"]);
|
|
82
|
+
const foreground = got.status === 0 ? parseObject(got.stdout)?.foreground : undefined;
|
|
83
|
+
const cwd = isObject(foreground) ? foreground.cwd : undefined;
|
|
84
|
+
return typeof cwd === "string" && cwd !== "" ? { ...worker, folder: cwd } : worker;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
function word(value) {
|
|
88
|
+
return typeof value === "string" ? value : undefined;
|
|
89
|
+
}
|
|
90
|
+
/** The Workers `lead` started, in the order herdr lists them; `all` adds the finished ones. */
|
|
91
|
+
export function workersOf(leadId, all) {
|
|
92
|
+
const listing = hcObject(["ls", "--json", ...(all ? ["--all"] : []), "--label", "role=worker"]);
|
|
93
|
+
const { terminals } = listing;
|
|
94
|
+
if (!Array.isArray(terminals)) {
|
|
95
|
+
throw new Refusal("hc ls answered no list of terminals", "check hc --version");
|
|
96
|
+
}
|
|
97
|
+
return terminals.flatMap((entry) => {
|
|
98
|
+
if (!isObject(entry) || entry.parent !== leadId) {
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
const { address, state, cwd } = entry;
|
|
102
|
+
if (typeof address !== "string" || typeof state !== "string" || typeof cwd !== "string") {
|
|
103
|
+
throw new Refusal("hc ls answered a terminal without an address, state or folder", "check hc --version");
|
|
104
|
+
}
|
|
105
|
+
const status = state === "finished" ? "finished" : (word(entry.status) ?? "unknown");
|
|
106
|
+
return [{ name: word(entry.name) ?? address, address, state, status, folder: cwd }];
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** A table of rows, every column but the last padded to its widest cell and two spaces apart. */
|
|
110
|
+
export function table(rows) {
|
|
111
|
+
const widths = (rows[0] ?? []).map((_, column) => Math.max(...rows.map((row) => (row[column] ?? "").length)));
|
|
112
|
+
return rows
|
|
113
|
+
.map((row) => row.map((cell, column) => (column === row.length - 1 ? cell : cell.padEnd((widths[column] ?? 0) + 2))).join(""))
|
|
114
|
+
.join("\n");
|
|
115
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** What naming a tab came to: `kept` says why it stays numbered, a warning and never a failure. */
|
|
2
|
+
export type Named = "renamed" | {
|
|
3
|
+
kept: string;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Names the tab holding `address`, reading `herdr-app ls` again for up to `boundSeconds`, since the window
|
|
7
|
+
* lists a terminal's tab a moment after `hc run` returns.
|
|
8
|
+
*/
|
|
9
|
+
export declare function nameTab(address: string, name: string, boundSeconds: number): Promise<Named>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The tab of a Worker's terminal in the herdr window: `hc run` with a `herdr.space` label opens one that herdr
|
|
2
|
+
// numbers, and `herdr-app` names it.
|
|
3
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
|
+
import { isObject, parseObject } from "../json-text.js";
|
|
5
|
+
import { call, Refusal, said } from "./herdr.js";
|
|
6
|
+
/** The tabs `herdr-app ls` shows, each with the addresses of its terminals. */
|
|
7
|
+
function tabsOf() {
|
|
8
|
+
const listed = call("herdr-app", ["ls"]);
|
|
9
|
+
if (listed.status !== 0) {
|
|
10
|
+
throw new Refusal(`herdr-app ls failed: ${said(listed)}`);
|
|
11
|
+
}
|
|
12
|
+
const parsed = parseObject(listed.stdout);
|
|
13
|
+
if (parsed === undefined) {
|
|
14
|
+
throw new Refusal("herdr-app ls answered no JSON object");
|
|
15
|
+
}
|
|
16
|
+
const spaces = Array.isArray(parsed.spaces) ? parsed.spaces : [];
|
|
17
|
+
return spaces
|
|
18
|
+
.flatMap((space) => (isObject(space) && Array.isArray(space.tabs) ? space.tabs : []))
|
|
19
|
+
.flatMap((tab) => {
|
|
20
|
+
if (!isObject(tab) || typeof tab.id !== "string" || !Array.isArray(tab.terminals)) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
const terminals = tab.terminals.flatMap((entry) => isObject(entry) && typeof entry.terminal === "string" ? [entry.terminal] : []);
|
|
24
|
+
return [{ id: tab.id, terminals }];
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** The `error` of a herdr-app reply `{ "error": "..." }`. */
|
|
28
|
+
function errorOf(stdout) {
|
|
29
|
+
const parsed = parseObject(stdout);
|
|
30
|
+
return typeof parsed?.error === "string" ? parsed.error : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** How long `nameTab` waits between two reads of `herdr-app ls`. */
|
|
33
|
+
const RETRY_MS = 250;
|
|
34
|
+
/**
|
|
35
|
+
* Names the tab holding `address`, reading `herdr-app ls` again for up to `boundSeconds`, since the window
|
|
36
|
+
* lists a terminal's tab a moment after `hc run` returns.
|
|
37
|
+
*/
|
|
38
|
+
export async function nameTab(address, name, boundSeconds) {
|
|
39
|
+
try {
|
|
40
|
+
const deadline = Date.now() + boundSeconds * 1000;
|
|
41
|
+
const find = () => tabsOf().find((candidate) => candidate.terminals.includes(address));
|
|
42
|
+
let tab = find();
|
|
43
|
+
while (tab === undefined && Date.now() < deadline) {
|
|
44
|
+
await sleep(RETRY_MS);
|
|
45
|
+
tab = find();
|
|
46
|
+
}
|
|
47
|
+
if (tab === undefined) {
|
|
48
|
+
return { kept: `the tab of ${address} is not in herdr-app ls within ${boundSeconds} s, so it keeps herdr's name` };
|
|
49
|
+
}
|
|
50
|
+
const renamed = call("herdr-app", ["do", "tab.rename", "--id", tab.id, "--name", name]);
|
|
51
|
+
if (renamed.status !== 0) {
|
|
52
|
+
return { kept: `herdr-app could not rename the tab of ${address}: ${errorOf(renamed.stdout) ?? said(renamed)}` };
|
|
53
|
+
}
|
|
54
|
+
return "renamed";
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (error instanceof Refusal) {
|
|
58
|
+
return { kept: `the tab of ${address} keeps herdr's name: ${error.message}` };
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dw-sdlc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|