@harborppm/cli 0.36.0 → 0.38.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/lib/cli.mjs CHANGED
@@ -17,6 +17,8 @@ import {
17
17
  writeSettings,
18
18
  } from "./config.mjs";
19
19
  import {
20
+ forecastData,
21
+ importText,
20
22
  forecastList,
21
23
  forecastOne,
22
24
  STATUS,
@@ -86,6 +88,14 @@ export const HELP = `harbor: Harbor from the terminal
86
88
  harbor roadmap PROJECT [--dates|--no-dates] [--off]
87
89
  publish a project's public roadmap
88
90
 
91
+ harbor init [PROJECT] [--hook] keep a project's tasks as files in
92
+ this repository (.harbor/), one
93
+ per task, in step both ways
94
+ harbor init --local [NAME] a plan in this folder alone, no
95
+ account; harbor here opens it
96
+ harbor sync [--quiet] send the files' edits to Harbor
97
+ and bring its changes back
98
+
89
99
  harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
90
100
  [--ends DATE]
91
101
  harbor project "NAME" --program P [--lead me|NAME] [--starts DATE]
@@ -114,6 +124,10 @@ export const HELP = `harbor: Harbor from the terminal
114
124
  --say reads it aloud as a
115
125
  shipping forecast
116
126
  harbor report PROGRAM a program's status report
127
+ harbor export tasks PROJECT|risks|review [--all]
128
+ a spreadsheet (CSV) to stdout
129
+ harbor import FILE [--project P] [--portfolio P] [--no-people]
130
+ add tasks from a CSV file
117
131
  harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
118
132
  a project's pre-mortem: start,
119
133
  answer or read it
@@ -389,6 +403,30 @@ export function voyageForTerminal(r, project = r.project) {
389
403
 
390
404
  /** Commands whose forms do different things: one line each, saying which read and which write. */
391
405
  const DETAIL = {
406
+ init: [
407
+ " harbor init [PROJECT] [--hook] writes: .harbor/ at the top of this",
408
+ " repository, one file per task in",
409
+ " PROJECT (a key like RR or a name;",
410
+ " the folder's name when left out)",
411
+ " harbor init --local [NAME] writes: a plan that lives only in",
412
+ " this folder, with no account;",
413
+ " harbor here opens it full screen",
414
+ "",
415
+ " Each file has title, status, owner, due, priority and waits between --- lines,",
416
+ " and the task's notes below. Edit them, add new ones (any name; Harbor gives",
417
+ " them a key), review them in pull requests, then run harbor sync. --hook adds",
418
+ " a git post-commit hook that runs harbor sync after each commit. Run init on",
419
+ " a local plan to move its tasks into PROJECT.",
420
+ ],
421
+ sync: [
422
+ " harbor sync [--quiet] writes: sends what the files changed to",
423
+ " Harbor and rewrites the files from it",
424
+ "",
425
+ " A field you changed and nobody changed on the website goes to Harbor; one",
426
+ " changed in both places keeps the website's, and says so. Removing a file",
427
+ " doesn't delete the task (mark it done instead). On a fresh clone, run",
428
+ " harbor sync before editing. --quiet prints only problems.",
429
+ ],
392
430
  limit: [
393
431
  " harbor limit PROJECT reads its board's column limits",
394
432
  " harbor limit PROJECT COLUMN N writes: COLUMN takes at most N tasks",
@@ -412,6 +450,45 @@ const DETAIL = {
412
450
  " PROJECT is a name, a key like RR, or an id. When two projects share a",
413
451
  " name, give the key.",
414
452
  ],
453
+ forecast: [
454
+ " harbor forecast [--program P] reads when each open project will",
455
+ " likely finish",
456
+ " harbor forecast PROJECT reads one project's forecast and what",
457
+ " it rests on",
458
+ "",
459
+ " Worked out from the crew's pace alone: how many tasks they finished each",
460
+ " week over the last six (or the project's life, if shorter), replayed a",
461
+ " thousand times until its open tasks run out. Probably is half of those",
462
+ " runs, very likely 85%, almost certainly 95%. No estimates or points.",
463
+ " It warns when under two weeks of pace is all there is, and when an open",
464
+ " task is due after the likely finish, since the pace can't see due dates.",
465
+ ],
466
+ report: [
467
+ " harbor report PROGRAM reads the program's status report",
468
+ "",
469
+ " Prints Markdown, as the website's Status report button writes it:",
470
+ " health, progress, late work, milestones, risks and the next move.",
471
+ " Ready to paste; harbor report PROGRAM > report.md saves it.",
472
+ ],
473
+ export: [
474
+ " harbor export tasks PROJECT reads a project's tasks as CSV",
475
+ " harbor export risks [--all] reads the risk register as CSV",
476
+ " (--all adds closed ones)",
477
+ " harbor export review reads the review as CSV",
478
+ "",
479
+ " The same spreadsheets the website's Export buttons download. They print;",
480
+ " harbor export tasks RR > tasks.csv saves one.",
481
+ ],
482
+ import: [
483
+ " harbor import FILE --project P writes: adds the file's tasks",
484
+ " harbor import FILE --portfolio P writes: makes projects as the file",
485
+ " names them",
486
+ "",
487
+ " FILE is CSV or tab-separated: a spreadsheet, or a Jira, Asana or Linear",
488
+ " export. It needs a column of titles; rows naming a Project column go",
489
+ " there. People it names who aren't in the harbor are added unless you say",
490
+ " --no-people. A task already in its project by title is left alone.",
491
+ ],
415
492
  note: [
416
493
  " harbor note PROJECT reads its notes for later: who left",
417
494
  " each, what opens it, and the opened ones",
@@ -475,6 +552,21 @@ const DETAIL = {
475
552
  " PROJECT is a name, a key like RR, or an id. When two projects share a",
476
553
  " name, give the key.",
477
554
  ],
555
+ risk: [
556
+ ' harbor risk "TITLE" --project P writes: logs a risk on the project',
557
+ ' harbor risk "TITLE" --program P writes: logs one on the program',
558
+ " [--impact 1-5] [--probability 1-5] [--owner NAME] [--due DATE]",
559
+ ],
560
+ issue: [
561
+ ' harbor issue "TITLE" --project P writes: logs an issue on the project',
562
+ ' harbor issue "TITLE" --program P writes: logs one on the program',
563
+ " [--impact 1-5] [--owner NAME] [--due DATE]",
564
+ ],
565
+ decision: [
566
+ ' harbor decision "TITLE" --project P writes: logs a decision to make',
567
+ ' harbor decision "TITLE" --program P writes: logs one on the program',
568
+ " [--owner NAME] [--due DATE] (the date to decide by)",
569
+ ],
478
570
  };
479
571
 
480
572
  /** The lines of the help about one command, or null when it has none. */
@@ -642,6 +734,15 @@ export async function run(argv, io = {}) {
642
734
  }
643
735
  if (!terminal) throw new HarborError("The full-screen app needs a terminal.");
644
736
  const { values } = parseArgs(rest, { flags: [], options: ["harbor", "server"] });
737
+ // In a folder with a local plan (harbor init --local), the app opens on it.
738
+ if (!values.harbor && !values.server) {
739
+ const bundled = await loadApp();
740
+ const dir = await bundled.localPlanAt(io.cwd ?? process.cwd());
741
+ if (dir) {
742
+ await bundled.runLocalApp({ dir, theme: (await readSettings(env)).theme });
743
+ return 0;
744
+ }
745
+ }
645
746
  let creds = await readCredentials(env);
646
747
  if (!hasFullAccess(creds)) {
647
748
  out(
@@ -840,6 +941,75 @@ export async function run(argv, io = {}) {
840
941
  return 0;
841
942
  }
842
943
 
944
+ if (command === "init" || command === "sync") {
945
+ const init = command === "init";
946
+ const { values, positionals } = parseArgs(rest, {
947
+ flags: init ? ["local", "hook", "json"] : ["quiet", "json"],
948
+ options: init ? ["harbor"] : [],
949
+ });
950
+ const cwd = io.cwd ?? process.cwd();
951
+ const lines = [];
952
+ const say = values.json
953
+ ? (line) => lines.push(line)
954
+ : values.quiet
955
+ ? (line) => {
956
+ if (!line.startsWith("⚓")) out(plain(line));
957
+ }
958
+ : (line) => out(plain(line));
959
+ const app = await loadApp();
960
+ if (init && values.local) {
961
+ if (positionals.length > 1)
962
+ throw new HarborError(`harbor init --local takes one name; put it in quotes.`);
963
+ const config = await app.initLocalHere({ cwd, name: positionals[0], say });
964
+ if (values.json) out(jsonText({ plan: config, notes: lines }));
965
+ return 0;
966
+ }
967
+ if (positionals.length > (init ? 1 : 0))
968
+ throw new HarborError(
969
+ init
970
+ ? "harbor init takes one project; put a name with spaces in quotes."
971
+ : "harbor sync takes no words; it syncs the plan of the folder you're in.",
972
+ );
973
+ // Plans move through /api/app, as the full-screen app does: full access.
974
+ const transport = async () => {
975
+ const creds = await readCredentials(env);
976
+ if (!hasFullAccess(creds))
977
+ throw new HarborError(
978
+ creds
979
+ ? "This sign-in can only read. Run harbor login (without --read-only) to keep plans in step."
980
+ : "Not signed in. Run harbor login, or harbor signup if you're new; harbor init --local needs no account.",
981
+ );
982
+ return app.planTransport(
983
+ tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
984
+ fetcher,
985
+ );
986
+ };
987
+ if (init) {
988
+ const creds = await readCredentials(env);
989
+ const config = await app.initHere({
990
+ cwd,
991
+ transport: await transport(),
992
+ project: positionals[0],
993
+ hook: Boolean(values.hook),
994
+ say,
995
+ pickHarbor: (list) => {
996
+ if (values.harbor) return app.harborNamed(list, values.harbor);
997
+ const saved = list.find((h) => h.id === creds?.harbor);
998
+ if (saved) return saved;
999
+ if (list.length === 1) return list[0];
1000
+ throw new HarborError(
1001
+ `You're in ${list.length} harbors (${list.map((h) => h.name).join(", ")}). Add --harbor NAME, or run harbor use NAME first.`,
1002
+ );
1003
+ },
1004
+ });
1005
+ if (values.json) out(jsonText({ plan: config, notes: lines }));
1006
+ return 0;
1007
+ }
1008
+ const report = await app.syncHere({ cwd, transport, say });
1009
+ if (values.json) out(jsonText({ ...(report ?? { local: true }), notes: lines }));
1010
+ return 0;
1011
+ }
1012
+
843
1013
  // A mistyped command says so, signed in or not.
844
1014
  if (commandHelp(command) === null) {
845
1015
  throw new HarborError(noCommand(command));
@@ -984,8 +1154,21 @@ export async function run(argv, io = {}) {
984
1154
  case "forecast": {
985
1155
  const { positionals, values, harbor } = parse(spec([], ["program"]));
986
1156
  const project = positionals.join(" ").trim();
987
- if (project) return call("get_project", { harbor, project }, forecastOne);
988
- return call("list_projects", { harbor, program: values.program, limit: 100 }, forecastList);
1157
+ // --json gets each forecast flat beside the project's name (forecastData).
1158
+ const withFinish = (p) => ({ ...p, finish: p });
1159
+ if (project)
1160
+ return call(
1161
+ "get_project",
1162
+ { harbor, project },
1163
+ (d) => forecastOne(withFinish(d)),
1164
+ forecastData,
1165
+ );
1166
+ return call(
1167
+ "list_projects",
1168
+ { harbor, program: values.program, limit: 100 },
1169
+ (d) => forecastList({ projects: d.projects.map(withFinish) }),
1170
+ forecastData,
1171
+ );
989
1172
  }
990
1173
  case "hill": {
991
1174
  const { positionals, values, harbor } = parse(spec([], ["program", "at"]));
@@ -1365,6 +1548,44 @@ export async function run(argv, io = {}) {
1365
1548
  if (!program) throw new HarborError('Give a program: harbor report "NAME".');
1366
1549
  return call("status_report", { harbor, program }, (r) => r.markdown);
1367
1550
  }
1551
+ case "export": {
1552
+ const { positionals, values, harbor } = parse(spec(["all"]));
1553
+ const [what, ...name] = positionals;
1554
+ if (!["tasks", "risks", "review"].includes(what))
1555
+ throw new HarborError("Say what to export: harbor export tasks PROJECT, risks or review.");
1556
+ const project = name.join(" ").trim();
1557
+ if (what === "tasks" && !project)
1558
+ throw new HarborError('Give the project: harbor export tasks "NAME".');
1559
+ if (what !== "tasks") none(name);
1560
+ return call(
1561
+ "export_csv",
1562
+ { harbor, what, project: project || undefined, all: Boolean(values.all) },
1563
+ (r) => r.csv.replace(/\r\n/g, "\n"),
1564
+ );
1565
+ }
1566
+ case "import": {
1567
+ const { positionals, values, harbor } = parse(spec(["no-people"], ["project", "portfolio"]));
1568
+ const [file, ...extra] = positionals;
1569
+ if (!file) throw new HarborError("Give the file: harbor import tasks.csv --project NAME.");
1570
+ none(extra);
1571
+ let csv;
1572
+ try {
1573
+ csv = readFileSync(file, "utf8");
1574
+ } catch {
1575
+ throw new HarborError(`Can't read ${file}.`);
1576
+ }
1577
+ return call(
1578
+ "import_tasks",
1579
+ {
1580
+ harbor,
1581
+ csv,
1582
+ project: values.project,
1583
+ portfolio: values.portfolio,
1584
+ addPeople: !values["no-people"],
1585
+ },
1586
+ importText,
1587
+ );
1588
+ }
1368
1589
  case "premortem": {
1369
1590
  const { positionals, values, harbor } = parse(spec(["start"]));
1370
1591
  const [project, ...words] = positionals;
package/lib/format.mjs CHANGED
@@ -216,9 +216,32 @@ function chance(c) {
216
216
  return `${Math.round(c * 20) * 5}%`;
217
217
  }
218
218
 
219
+ /**
220
+ * The forecast's dates as phrases, a date said once: when half and 85% of
221
+ * runs finish the same day, "very likely by" it is. At most `most` of them.
222
+ */
223
+ function finishPhrases(f, most = 3) {
224
+ const steps = [
225
+ [f.likely, "probably"],
226
+ [f.veryLikely, "very likely by"],
227
+ [f.almostCertain, "almost certainly by"],
228
+ ].filter(([date]) => date);
229
+ const kept = steps.filter(([date], i) => date !== steps[i + 1]?.[0]);
230
+ return kept.slice(0, most).map(([date, words]) => `${words} ${date}`);
231
+ }
232
+
219
233
  function finishRange(f) {
220
234
  if (!f.likely) return "not in the next two years at this pace";
221
- return `probably ${f.likely}${f.veryLikely ? `, very likely by ${f.veryLikely}` : ""}`;
235
+ return finishPhrases(f, 2).join(", ");
236
+ }
237
+
238
+ /** What the pace can't see, as short notes for the list. */
239
+ function finishNotes(f) {
240
+ return [
241
+ f.rough ? "rough" : null,
242
+ f.shortHistory ? "under 2 weeks of pace" : null,
243
+ f.lastDue ? `${f.lastDue.key ?? `“${f.lastDue.title}”`} due ${f.lastDue.due}` : null,
244
+ ].filter(Boolean);
222
245
  }
223
246
 
224
247
  /** When each open project will likely finish at its crew's recent pace. */
@@ -232,7 +255,7 @@ export function forecastList(r) {
232
255
  const name = p.name.length > width ? `${p.name.slice(0, width - 1)}…` : p.name.padEnd(width);
233
256
  if (f.none) return `${name} no forecast: ${f.none.replace(/^\w/, (c) => c.toLowerCase())}`;
234
257
  const end = p.ends ? ` · ${chance(f.chanceByEnd)} by its end, ${p.ends}` : "";
235
- return `${name} ${finishRange(f)}${end}${f.rough ? " · rough" : ""}`;
258
+ return [`${name} ${finishRange(f)}${end}`, ...finishNotes(f)].join(" · ");
236
259
  })
237
260
  .join("\n");
238
261
  }
@@ -241,10 +264,10 @@ export function forecastList(r) {
241
264
  export function forecastOne(p) {
242
265
  const f = p.finish ?? {};
243
266
  if (f.none) return `${p.name}: ${f.none}`;
244
- const range = finishRange(f);
267
+ const [first, ...more] = finishPhrases(f);
245
268
  const lines = [
246
269
  f.likely
247
- ? `${p.name} will ${range.replace(/^probably/, "probably finish")}${f.almostCertain ? `, almost certainly by ${f.almostCertain}` : ""}.`
270
+ ? `${p.name} will ${first.replace(/^(probably|very likely|almost certainly)/, "$1 finish")}${more.map((x) => `, ${x}`).join("")}.`
248
271
  : `${p.name} won't finish in the next two years at this pace.`,
249
272
  ];
250
273
  if (p.ends) {
@@ -255,9 +278,33 @@ export function forecastOne(p) {
255
278
  `${f.openTasks} task${f.openTasks === 1 ? "" : "s"} open; the crew finishes about ${f.finishedPerWeek} a week (${f.basedOn}).`,
256
279
  );
257
280
  if (f.rough) lines.push("Only a few tasks have finished, so treat this as rough.");
281
+ if (f.shortHistory)
282
+ lines.push(
283
+ "That pace comes from under two weeks of work; tasks logged as done in a batch make it look faster than it is.",
284
+ );
285
+ if (f.lastDue) {
286
+ const which = f.lastDue.key ? `${f.lastDue.key} “${f.lastDue.title}”` : `“${f.lastDue.title}”`;
287
+ lines.push(
288
+ `${which} is due ${f.lastDue.due}, after the likely finish, so the plan runs longer than the pace says.`,
289
+ );
290
+ }
258
291
  return lines.join("\n");
259
292
  }
260
293
 
294
+ /** forecast --json: the finish forecast itself, with only what names the project around it. */
295
+ export function forecastData(r) {
296
+ const one = (p) => ({
297
+ id: p.id,
298
+ name: p.name,
299
+ ...(p.key ? { key: p.key } : {}),
300
+ ...(p.program !== undefined ? { program: p.program } : {}),
301
+ ends: p.ends ?? null,
302
+ ...p.finish,
303
+ });
304
+ if (!r.projects) return one(r);
305
+ return { projects: r.projects.filter((p) => p.health !== "done").map(one) };
306
+ }
307
+
261
308
  /** A project's pre-mortem, as get_premortem answers it. */
262
309
  export function premortemText(r) {
263
310
  if (!r.running) {
@@ -370,6 +417,25 @@ export function hillList(r) {
370
417
  return blocks.join("\n\n");
371
418
  }
372
419
 
420
+ /** What harbor import did, as import_tasks answers it. */
421
+ export function importText(r) {
422
+ const n = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
423
+ const from = r.from ? ` from ${r.from}` : "";
424
+ const lines = [
425
+ r.imported
426
+ ? `Imported ${n(r.imported, "task")}${from} into ${r.portfolio}.`
427
+ : `Nothing imported${from}.`,
428
+ ];
429
+ if (r.newPrograms?.length) lines.push(`New programs: ${r.newPrograms.join(", ")}.`);
430
+ if (r.newProjects?.length) lines.push(`New projects: ${r.newProjects.join(", ")}.`);
431
+ if (r.newPeople?.length) lines.push(`New people: ${r.newPeople.join(", ")}.`);
432
+ if (r.alreadyThere) lines.push(`${n(r.alreadyThere, "task")} already there, left alone.`);
433
+ if (r.leftOut) lines.push(`${n(r.leftOut, "row")} left out (canceled, won't do or duplicate).`);
434
+ if (r.waitsOnLinks) lines.push(`${n(r.waitsOnLinks, "link")} between tasks became "waits on".`);
435
+ for (const p of r.problems ?? []) lines.push(`Row ${p.row}: ${p.message}`);
436
+ return lines.join("\n");
437
+ }
438
+
373
439
  /** A project's notes for later, as get_project lists them. */
374
440
  export function notesText(project, notes) {
375
441
  if (!notes?.length)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.36.0",
3
+ "version": "0.38.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",