@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 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] the weekly brief, ready to send
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] the weekly brief, ready to send
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("list_projects", { harbor, program: values.program, limit: 100 }, hillList);
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
- /** A project's column limits with how full each is: "doing 4 of 5; no limit on backlog, todo, done". */
148
- export function limitsText(name, limits, tasks = {}) {
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 capped = entries
155
+ const shown = entries.filter(([status]) => !hidden.includes(status));
156
+ const capped = shown
151
157
  .filter(([, n]) => n != null)
152
- .map(([status, n]) => `${STATUS[status] ?? status} ${tasks[status] ?? 0} of ${n}`);
153
- const open = entries.filter(([, n]) => n == null).map(([status]) => STATUS[status] ?? status);
154
- if (!capped.length) return `${name}'s board has no column limits.`;
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) return "No open projects match.";
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");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",