dw-sdlc 0.11.0 → 0.12.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,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.11.0",
3
+ "version": "0.12.0",
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": {