@harborppm/cli 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/lib/cli.mjs +85 -9
- package/lib/format.mjs +56 -8
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -124,7 +124,7 @@ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
|
124
124
|
[--due DATE]
|
|
125
125
|
harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
126
126
|
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
127
|
-
harbor brief [--portfolio P]
|
|
127
|
+
harbor brief [--portfolio P] [--say] the weekly brief, ready to send
|
|
128
128
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
129
129
|
harbor voyage PROJECT ["YOUR LINE"]
|
|
130
130
|
harbor limit PROJECT [COLUMN N|none]
|
|
@@ -134,6 +134,8 @@ harbor limit PROJECT [COLUMN N|none]
|
|
|
134
134
|
|
|
135
135
|
`harbor premortem` runs a pre-mortem with your crew: `--start` asks everyone "it's after the end date and this project failed: what happened?", each person adds answers alone for five minutes, and then the answers show together on the project's Risks & issues tab, where each becomes a risk in one click. On its own it prints the question and the answers you can see. Once a project is Done, `harbor voyage` prints its voyage log (planned against actual dates, the biggest slips, the risks that came true, who closed the most, and the crew's lines) and adds yours.
|
|
136
136
|
|
|
137
|
+
`harbor brief --say` turns the brief into the BBC's Shipping Forecast: each program an area, wind for late work, visibility for health, gale warnings for anything off track. It prints the forecast and speaks it with your computer's own voice (`say` on a Mac, Windows' speech, or `espeak-ng` on Linux).
|
|
138
|
+
|
|
137
139
|
Every command takes `--json` for the raw result (a refusal then comes out as `{"error": "…"}` on standard output, still with exit code 1), and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done. A `DATE` is YYYY-MM-DD, `today`, `tomorrow`, or `+N` for N days from today.
|
|
138
140
|
|
|
139
141
|
When you belong to more than one harbor, say which: `--harbor NAME` on the command, or `harbor use NAME` once (it's saved with your sign-in; `harbor use none` forgets it). With neither, the commands ask rather than guess, so nothing lands in the wrong harbor. The full-screen app shares the same choice: it opens the harbor `harbor use` set, asks which one the first time if none is set, and a harbor you switch to in the app becomes the one the commands use.
|
package/lib/cli.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
STATUS,
|
|
23
23
|
harbors,
|
|
24
24
|
limitsText,
|
|
25
|
+
hillData,
|
|
25
26
|
hillList,
|
|
26
27
|
myWork,
|
|
27
28
|
premortemText,
|
|
@@ -106,7 +107,9 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
106
107
|
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
107
108
|
the register, worst first
|
|
108
109
|
(--closed adds closed ones)
|
|
109
|
-
harbor brief [--portfolio P]
|
|
110
|
+
harbor brief [--portfolio P] [--say] the weekly brief, ready to send;
|
|
111
|
+
--say reads it aloud as a
|
|
112
|
+
shipping forecast
|
|
110
113
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
111
114
|
a project's pre-mortem: start,
|
|
112
115
|
answer or read it
|
|
@@ -174,6 +177,56 @@ function openBrowser(url) {
|
|
|
174
177
|
}
|
|
175
178
|
}
|
|
176
179
|
|
|
180
|
+
/**
|
|
181
|
+
* How to read text aloud here, as programs to try in turn, each with its
|
|
182
|
+
* arguments (never through a shell). Text goes on stdin where it can, so
|
|
183
|
+
* nothing in it is read as an option.
|
|
184
|
+
*/
|
|
185
|
+
export function speechCommands(platform = process.platform) {
|
|
186
|
+
if (platform === "darwin") return [["say", ["-f", "-"]]];
|
|
187
|
+
if (platform === "win32")
|
|
188
|
+
return [
|
|
189
|
+
[
|
|
190
|
+
"powershell",
|
|
191
|
+
[
|
|
192
|
+
"-NoProfile",
|
|
193
|
+
"-Command",
|
|
194
|
+
"Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak([Console]::In.ReadToEnd())",
|
|
195
|
+
],
|
|
196
|
+
],
|
|
197
|
+
];
|
|
198
|
+
return [
|
|
199
|
+
["espeak-ng", ["--stdin", "-s", "155"]],
|
|
200
|
+
["espeak", ["--stdin", "-s", "155"]],
|
|
201
|
+
["spd-say", ["-w", "-e"]],
|
|
202
|
+
];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Read `text` aloud with the first speech program this machine has; false when none ran. */
|
|
206
|
+
async function speak(text) {
|
|
207
|
+
for (const [program, args] of speechCommands()) {
|
|
208
|
+
const ok = await new Promise((resolve) => {
|
|
209
|
+
try {
|
|
210
|
+
const child = spawn(program, args, { stdio: ["pipe", "ignore", "ignore"] });
|
|
211
|
+
child.on("error", () => resolve(false));
|
|
212
|
+
child.on("close", (code) => resolve(code === 0));
|
|
213
|
+
child.stdin.on("error", () => {});
|
|
214
|
+
child.stdin.end(text);
|
|
215
|
+
} catch {
|
|
216
|
+
resolve(false);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
if (ok) return true;
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** This machine's wall-clock time as YYYY-MM-DDTHH:MM, which Harbor reads as the caller's hour. */
|
|
225
|
+
export function localMinute(now = new Date()) {
|
|
226
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
227
|
+
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
177
230
|
const one = (positionals, what) => {
|
|
178
231
|
if (positionals.length !== 1) throw new HarborError(`Give one ${what}.`);
|
|
179
232
|
return positionals[0];
|
|
@@ -847,7 +900,12 @@ export async function run(argv, io = {}) {
|
|
|
847
900
|
if (!project) {
|
|
848
901
|
if (values.at !== undefined)
|
|
849
902
|
throw new HarborError("Say which project: harbor hill PROJECT --at N.");
|
|
850
|
-
return call(
|
|
903
|
+
return call(
|
|
904
|
+
"list_projects",
|
|
905
|
+
{ harbor, program: values.program, limit: 100 },
|
|
906
|
+
hillList,
|
|
907
|
+
(r) => hillData(r, values.program),
|
|
908
|
+
);
|
|
851
909
|
}
|
|
852
910
|
if (values.at === undefined) {
|
|
853
911
|
throw new HarborError("Say where with --at: 0 to 100, or start, top, doing or done.");
|
|
@@ -1165,6 +1223,29 @@ export async function run(argv, io = {}) {
|
|
|
1165
1223
|
() => "Checked in for today.",
|
|
1166
1224
|
);
|
|
1167
1225
|
}
|
|
1226
|
+
case "brief": {
|
|
1227
|
+
const { positionals, values, harbor } = parse(spec(["say"], ["portfolio"]));
|
|
1228
|
+
none(positionals);
|
|
1229
|
+
if (!values.say) {
|
|
1230
|
+
return call("weekly_brief", { harbor, portfolio: values.portfolio }, (r) => r.text);
|
|
1231
|
+
}
|
|
1232
|
+
const result = await client
|
|
1233
|
+
.call("shipping_forecast", { harbor, portfolio: values.portfolio, at: localMinute() })
|
|
1234
|
+
.catch((err) => {
|
|
1235
|
+
if (err instanceof HarborError) throw new HarborError(hint(err.message));
|
|
1236
|
+
throw err;
|
|
1237
|
+
});
|
|
1238
|
+
const script = plain(result.script ?? "");
|
|
1239
|
+
out(asJson ? jsonText(result) : script);
|
|
1240
|
+
if (!(await (io.speak ?? speak)(script))) {
|
|
1241
|
+
throw new HarborError(
|
|
1242
|
+
process.platform === "linux"
|
|
1243
|
+
? "Nothing here can read aloud. Install espeak-ng (or speech-dispatcher) and try again."
|
|
1244
|
+
: "Nothing here can read aloud.",
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
return 0;
|
|
1248
|
+
}
|
|
1168
1249
|
case "risks": {
|
|
1169
1250
|
const { positionals, values, harbor } = parse(spec(["closed"], ["kind", "project"]));
|
|
1170
1251
|
const kind = values.kind
|
|
@@ -1185,11 +1266,6 @@ export async function run(argv, io = {}) {
|
|
|
1185
1266
|
(r) => raidList(r, positionals.join(" ")),
|
|
1186
1267
|
);
|
|
1187
1268
|
}
|
|
1188
|
-
case "brief": {
|
|
1189
|
-
const { positionals, values, harbor } = parse(spec([], ["portfolio"]));
|
|
1190
|
-
none(positionals);
|
|
1191
|
-
return call("weekly_brief", { harbor, portfolio: values.portfolio }, (r) => r.text);
|
|
1192
|
-
}
|
|
1193
1269
|
case "premortem": {
|
|
1194
1270
|
const { positionals, values, harbor } = parse(spec(["start"]));
|
|
1195
1271
|
const [project, ...words] = positionals;
|
|
@@ -1229,7 +1305,7 @@ export async function run(argv, io = {}) {
|
|
|
1229
1305
|
if (!project) throw new HarborError("Give a project, like harbor limit RR doing 5.");
|
|
1230
1306
|
if (!column) {
|
|
1231
1307
|
return call("get_project", { harbor, project }, (r) =>
|
|
1232
|
-
limitsText(r.name, r.limits, r.tasks),
|
|
1308
|
+
limitsText(r.name, r.limits, r.tasks, r.hiddenColumns),
|
|
1233
1309
|
);
|
|
1234
1310
|
}
|
|
1235
1311
|
if (value === undefined || extra.length) {
|
|
@@ -1240,7 +1316,7 @@ export async function run(argv, io = {}) {
|
|
|
1240
1316
|
throw new HarborError("A limit is a whole number from 1 to 20, or none.");
|
|
1241
1317
|
}
|
|
1242
1318
|
return call("set_column_limit", { harbor, project, column, limit }, (r) =>
|
|
1243
|
-
limitsText(r.set.project, r.limits, r.tasks),
|
|
1319
|
+
limitsText(r.set.project, r.limits, r.tasks, r.hiddenColumns),
|
|
1244
1320
|
);
|
|
1245
1321
|
}
|
|
1246
1322
|
case "voyage": {
|
package/lib/format.mjs
CHANGED
|
@@ -144,16 +144,26 @@ export function projectList(r, { done = false } = {}) {
|
|
|
144
144
|
);
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
/**
|
|
148
|
-
|
|
147
|
+
/**
|
|
148
|
+
* A project's column limits with how full each is: "doing 4 of 5; no limit on
|
|
149
|
+
* backlog, todo, done". Columns the board hides are named after, with any cap
|
|
150
|
+
* they keep for when they're shown again.
|
|
151
|
+
*/
|
|
152
|
+
export function limitsText(name, limits, tasks = {}, hidden = []) {
|
|
153
|
+
const label = (status) => STATUS[status] ?? status;
|
|
149
154
|
const entries = Object.entries(limits ?? {});
|
|
150
|
-
const
|
|
155
|
+
const shown = entries.filter(([status]) => !hidden.includes(status));
|
|
156
|
+
const capped = shown
|
|
151
157
|
.filter(([, n]) => n != null)
|
|
152
|
-
.map(([status, n]) => `${
|
|
153
|
-
const open =
|
|
154
|
-
|
|
158
|
+
.map(([status, n]) => `${label(status)} ${tasks[status] ?? 0} of ${n}`);
|
|
159
|
+
const open = shown.filter(([, n]) => n == null).map(([status]) => label(status));
|
|
160
|
+
const off = entries
|
|
161
|
+
.filter(([status]) => hidden.includes(status))
|
|
162
|
+
.map(([status, n]) => (n == null ? label(status) : `${label(status)} (limit ${n})`));
|
|
163
|
+
const offNote = off.length ? ` Hidden on the board: ${off.join(", ")}.` : "";
|
|
164
|
+
if (!capped.length) return `${name}'s board has no column limits.${offNote}`;
|
|
155
165
|
const rest = open.length ? `; no limit on ${open.join(", ")}` : "";
|
|
156
|
-
return `${name}: ${capped.join(", ")}${rest}
|
|
166
|
+
return `${name}: ${capped.join(", ")}${rest}.${offNote}`;
|
|
157
167
|
}
|
|
158
168
|
|
|
159
169
|
export function harbors(r) {
|
|
@@ -295,10 +305,43 @@ function hillTrack(at) {
|
|
|
295
305
|
return cells.join("");
|
|
296
306
|
}
|
|
297
307
|
|
|
308
|
+
/**
|
|
309
|
+
* What harbor hill shows, for the chart and --json alike: open projects with
|
|
310
|
+
* their place on the hill (null when not on it), how many Done ones were left
|
|
311
|
+
* out, and the program asked for.
|
|
312
|
+
*/
|
|
313
|
+
export function hillData(r, program) {
|
|
314
|
+
const all = r.projects ?? [];
|
|
315
|
+
const open = all.filter((p) => p.health !== "done");
|
|
316
|
+
// The program's own name when every project is in it, else what was typed.
|
|
317
|
+
const named =
|
|
318
|
+
all.length && all.every((p) => p.program === all[0].program) ? all[0].program : null;
|
|
319
|
+
return {
|
|
320
|
+
...(program ? { program: named ?? program } : {}),
|
|
321
|
+
projects: open.map((p) => ({
|
|
322
|
+
id: p.id,
|
|
323
|
+
name: p.name,
|
|
324
|
+
...(p.key ? { key: p.key } : {}),
|
|
325
|
+
program: p.program ?? null,
|
|
326
|
+
health: p.health,
|
|
327
|
+
hill: p.hill ?? null,
|
|
328
|
+
})),
|
|
329
|
+
done: all.length - open.length,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
298
333
|
/** Each open project's place on its program's hill, by program, unplaced ones named after. */
|
|
299
334
|
export function hillList(r) {
|
|
300
335
|
const projects = (r.projects ?? []).filter((p) => p.health !== "done");
|
|
301
|
-
if (!projects.length)
|
|
336
|
+
if (!projects.length) {
|
|
337
|
+
if (r.done) {
|
|
338
|
+
const where = r.program ? `in ${r.program} ` : "";
|
|
339
|
+
return r.done === 1
|
|
340
|
+
? `The one project ${where}is Done, so nothing is on the hill.`
|
|
341
|
+
: `All ${r.done} projects ${where}are Done, so nothing is on the hill.`;
|
|
342
|
+
}
|
|
343
|
+
return "No open projects match.";
|
|
344
|
+
}
|
|
302
345
|
const byProgram = new Map();
|
|
303
346
|
for (const p of projects) {
|
|
304
347
|
const key = p.program ?? "No program";
|
|
@@ -317,6 +360,11 @@ export function hillList(r) {
|
|
|
317
360
|
lines.push(` ${name} ${hillTrack(p.hill.at)} ${String(p.hill.at).padStart(3)}${still}`);
|
|
318
361
|
}
|
|
319
362
|
if (unplaced.length) lines.push(` Not on the hill: ${unplaced.map((p) => p.name).join(", ")}`);
|
|
363
|
+
if (!placed.length) {
|
|
364
|
+
lines.push(
|
|
365
|
+
` Place one with harbor hill "${unplaced[0].name}" --at 30 (0 to 100, top is 50).`,
|
|
366
|
+
);
|
|
367
|
+
}
|
|
320
368
|
blocks.push(lines.join("\n"));
|
|
321
369
|
}
|
|
322
370
|
return blocks.join("\n\n");
|