@harborppm/cli 0.41.0 → 0.42.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
@@ -83,7 +83,7 @@ waits: [CS-2]
83
83
  Old endpoints go away on Nov 20.
84
84
  ```
85
85
 
86
- A new file with any name is a new task: `harbor sync` adds it and renames it to its key. A field changed in a file and not on the website goes to Harbor; one changed in both places keeps the website's and says so. Removing a file doesn't delete the task (mark it done instead). On a fresh clone, run `harbor sync` before editing. `harbor init --hook` adds a git post-commit hook that syncs after each commit.
86
+ A new file with any name is a new task: `harbor sync` adds it and renames it to its key. A field changed in a file and not on the website goes to Harbor; one changed in both places keeps the website's and says so. Removing a file doesn't delete the task (mark it done instead). On a fresh clone, run `harbor sync` before editing. `harbor init --hook` adds a git post-commit hook that syncs after each commit, and `--dry-run` on either says what it would do without writing or sending anything.
87
87
 
88
88
  No account? `harbor init --local` makes a plan that lives only in the folder (the rest of it in `.harbor/plan.json`), and `harbor` in that folder opens it full screen, offline and free. Later, `harbor init PROJECT` moves its tasks into a project on harborppm.com.
89
89
 
@@ -161,9 +161,9 @@ harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
161
161
  harbor voyage PROJECT ["YOUR LINE"]
162
162
  harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
163
163
  harbor limit PROJECT [COLUMN N|none]
164
- harbor init [PROJECT] [--hook] a project's tasks as files in this repository
164
+ harbor init [PROJECT] [--hook] [--dry-run] a project's tasks as files in this repository
165
165
  harbor init --local [NAME] a plan in this folder alone, no account
166
- harbor sync [--quiet] the files and the website, in step both ways
166
+ harbor sync [--quiet] [--dry-run] the files and the website, in step both ways
167
167
  harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT] [--keep ID,ID] [--confirm]
168
168
  harbor handback PERSON
169
169
  harbor agent [--agent NAME]
package/dist/app.mjs CHANGED
@@ -3046,6 +3046,20 @@ function chanceText(chance) {
3046
3046
  if (chance > .95) return "over 95%";
3047
3047
  return `${Math.round(chance * 20) * 5}%`;
3048
3048
  }
3049
+ /**
3050
+ * The open task due last, when it's due after the likely finish. The pace
3051
+ * knows nothing of due dates, so a task due later means the plan runs
3052
+ * longer than the pace says.
3053
+ */
3054
+ function dueAfterFinish(tide, tasks) {
3055
+ if (tide.kind !== "forecast" || !tide.likely) return null;
3056
+ let last = null;
3057
+ for (const t of tasks) {
3058
+ if (t.status === "done" || !t.dueDate) continue;
3059
+ if (!last || t.dueDate > last.dueDate) last = t;
3060
+ }
3061
+ return last && last.dueDate.slice(0, 10) > tide.likely ? last : null;
3062
+ }
3049
3063
 
3050
3064
  //#endregion
3051
3065
  //#region src/lib/flow.ts
@@ -3956,11 +3970,13 @@ function filterTasks(tasks, f) {
3956
3970
  }
3957
3971
  /**
3958
3972
  * When the project will likely finish at its crew's recent pace, in one
3959
- * line under its name, and whether that misses the end date. Empty when
3973
+ * line under its name, whether that misses the end date, and an open task
3974
+ * due after it, which the pace can't see. Empty when
3960
3975
  * there's no forecast (all done, paused, or nothing finished lately).
3961
3976
  */
3962
3977
  function tideLine(data, project, today) {
3963
- const tide = tideForecast(project, tasksOf(data.tasks, project.id), parseIso(`${today}T12:00:00`));
3978
+ const tasks = tasksOf(data.tasks, project.id);
3979
+ const tide = tideForecast(project, tasks, parseIso(`${today}T12:00:00`));
3964
3980
  if (tide.kind === "no-pace") return {
3965
3981
  text: "No forecast yet: nothing finished in the last few weeks",
3966
3982
  late: false
@@ -3972,8 +3988,10 @@ function tideLine(data, project, today) {
3972
3988
  };
3973
3989
  const very = tide.veryLikely ? `, very likely by ${shortDay(tide.veryLikely, today)}` : "";
3974
3990
  const end = project.endDate >= today ? ` · ${chanceText(tide.chanceByEnd)} by its end, ${shortDay(project.endDate, today)}` : "";
3991
+ const later = dueAfterFinish(tide, tasks);
3992
+ const due = later ? ` · ${taskKey(later, project) ?? `“${later.title}”`} is due later, ${shortDay(later.dueDate.slice(0, 10), today)}` : "";
3975
3993
  return {
3976
- text: `Probably done ${shortDay(tide.likely, today)}${very}${end}${tide.rough ? " · rough" : ""}`,
3994
+ text: `Probably done ${shortDay(tide.likely, today)}${very}${end}${due}${tide.rough ? " · rough" : ""}`,
3977
3995
  late: tide.chanceByEnd < .5
3978
3996
  };
3979
3997
  }
@@ -15529,12 +15547,33 @@ async function saveOrStop(sync) {
15529
15547
  await sync.flush();
15530
15548
  if (sync.state !== "saved") throw new HarborError(sync.state === "offline" ? "Can't reach Harbor, so nothing was sent and the folder was left as it was." : "Harbor didn't take the changes, so the folder was left as it was. Try harbor sync again.");
15531
15549
  }
15532
- /** `harbor sync` on a linked plan. */
15550
+ /**
15551
+ * A --dry-run's folder and transport: everything is read as usual, and
15552
+ * nothing is written to disk or sent to Harbor.
15553
+ */
15554
+ function dry(folder, transport) {
15555
+ return {
15556
+ folder: {
15557
+ ...folder,
15558
+ write: async () => {},
15559
+ remove: async () => {}
15560
+ },
15561
+ transport: {
15562
+ ...transport,
15563
+ save: () => new Promise(() => {})
15564
+ }
15565
+ };
15566
+ }
15567
+ /** `harbor sync` on a linked plan; with dryRun, only what it would do. */
15533
15568
  async function syncLinked(opts) {
15534
- const { folder, config, say } = opts;
15569
+ const { config, say } = opts;
15570
+ const { folder, transport } = opts.dryRun ? dry(opts.folder, opts.transport) : {
15571
+ folder: opts.folder,
15572
+ transport: opts.transport
15573
+ };
15535
15574
  if (!config.harbor) throw new HarborError("This plan isn't linked to a harbor.");
15536
- const { account } = await opts.transport.harbors();
15537
- const { store, sync } = await openHarbor(opts.transport, account, config.harbor.id, say);
15575
+ const { account } = await transport.harbors();
15576
+ const { store, sync } = await openHarbor(transport, account, config.harbor.id, say);
15538
15577
  try {
15539
15578
  const projectId = config.project.id;
15540
15579
  if (!store.getState().projects.some((p) => p.id === projectId)) throw new HarborError(`${config.project.name} isn't in ${config.harbor.name} any more. Run harbor init PROJECT to link another.`);
@@ -15544,7 +15583,7 @@ async function syncLinked(opts) {
15544
15583
  say
15545
15584
  });
15546
15585
  if (done.report.sent.length + done.report.added.length > 0 && readOnlyHarbor(store.getState())) throw new HarborError(store.getState().keepPaused ? "This harbor's free trial has ended, so the folder's changes weren't sent. Its owner can subscribe at harborppm.com/keep." : "You can only view this harbor, so the folder's changes weren't sent. The folder was left as it was.");
15547
- await saveOrStop(sync);
15586
+ if (!opts.dryRun) await saveOrStop(sync);
15548
15587
  const project = store.getState().projects.find((p) => p.id === projectId);
15549
15588
  if (project.key && project.key !== config.project.key) await folder.write(CONFIG_FILE, json({
15550
15589
  ...config,
@@ -15579,18 +15618,22 @@ function projectNamed(projects, text, opts = {}) {
15579
15618
  * A local plan here moves its tasks into the project first.
15580
15619
  */
15581
15620
  async function initLinked(opts) {
15582
- const { folder, say } = opts;
15621
+ const { say } = opts;
15622
+ const { folder, transport } = opts.dryRun ? dry(opts.folder, opts.transport) : {
15623
+ folder: opts.folder,
15624
+ transport: opts.transport
15625
+ };
15583
15626
  const existing = await readConfig(folder);
15584
15627
  if (existing && !existing.local) throw new HarborError(`This folder already follows ${existing.project.name}. Run harbor sync to bring it up to date.`);
15585
- const answer = await opts.transport.harbors();
15628
+ const answer = await transport.harbors();
15586
15629
  if (answer.harbors.length === 0) throw new HarborError("You're not in a harbor yet. Run harbor to make one first.");
15587
15630
  const harbor = opts.pickHarbor(answer.harbors);
15588
- const { store, sync } = await openHarbor(opts.transport, answer.account, harbor.id, say);
15631
+ const { store, sync } = await openHarbor(transport, answer.account, harbor.id, say);
15589
15632
  try {
15590
15633
  const project = projectNamed(store.getState().projects, opts.project, { exactOnly: opts.exactOnly });
15591
15634
  let moved = 0;
15592
15635
  if (existing?.local) moved = await moveLocalTasks(folder, store, project.id, say);
15593
- await saveOrStop(sync);
15636
+ if (!opts.dryRun) await saveOrStop(sync);
15594
15637
  const settled = store.getState().projects.find((p) => p.id === project.id);
15595
15638
  const config = {
15596
15639
  version: 1,
@@ -15853,13 +15896,14 @@ var commands_exports = /* @__PURE__ */ __exportAll({
15853
15896
  syncHere: () => syncHere,
15854
15897
  syncLine: () => syncLine
15855
15898
  });
15856
- /** The one line a sync ends with. */
15857
- function syncLine(report) {
15899
+ /** The one line a sync ends with; a dry run's says what it would have done. */
15900
+ function syncLine(report, dryRun = false) {
15858
15901
  const parts = [
15859
- report.sent.length ? `sent ${plural(report.sent.length, "change")} to Harbor` : "",
15860
- report.added.length ? `added ${plural(report.added.length, "task")}` : "",
15861
- report.took.length ? `brought in ${plural(report.took.length, "change")} from Harbor` : ""
15902
+ report.sent.length ? `${dryRun ? "would send" : "sent"} ${plural(report.sent.length, "change")} to Harbor` : "",
15903
+ report.added.length ? `${dryRun ? "would add" : "added"} ${plural(report.added.length, "task")}` : "",
15904
+ report.took.length ? `${dryRun ? "would bring in" : "brought in"} ${plural(report.took.length, "change")} from Harbor` : ""
15862
15905
  ].filter(Boolean);
15906
+ if (dryRun) return `⚓ Dry run: ${parts.length ? parts.join(", ") : "already in step with Harbor"}. Nothing was written or sent.`;
15863
15907
  return parts.length ? `⚓ ${parts.join(", ")}.` : "⚓ Already in step with Harbor.";
15864
15908
  }
15865
15909
  /** The local plan above `cwd`, if that's what's there. */
@@ -15908,22 +15952,28 @@ async function initHere(opts) {
15908
15952
  project: opts.project?.trim() || basename(home),
15909
15953
  exactOnly: !opts.project?.trim(),
15910
15954
  pickHarbor: opts.pickHarbor,
15911
- say: opts.say
15955
+ say: opts.say,
15956
+ dryRun: opts.dryRun
15912
15957
  });
15913
15958
  const { project, harbor } = made.config;
15959
+ if (opts.dryRun) {
15960
+ opts.say(made.moved ? `Would move this folder's ${plural(made.moved, "task")} into ${project.name} (${project.key}) in ${harbor.name}.` : `Would make ${shownDir(opts.cwd, dir)} for ${project.name} (${project.key}) in ${harbor.name}, with ${plural(made.tasks, "task")}.`);
15961
+ opts.say("Dry run: nothing was written or sent. Run it again without --dry-run to do it.");
15962
+ return made.config;
15963
+ }
15914
15964
  opts.say(made.moved ? `Moved this folder's ${plural(made.moved, "task")} into ${project.name} in ${harbor.name}.` : `Made ${shownDir(opts.cwd, dir)} for ${project.name} (${project.key}) in ${harbor.name}, with ${plural(made.tasks, "task")}. Commit it like any code.`);
15915
15965
  opts.say("Edit a file, or add one, then run harbor sync. Changes on the website come back the same way.");
15916
15966
  if (opts.hook) opts.say(await addHook(home));
15917
15967
  return made.config;
15918
15968
  }
15919
- /** `harbor sync`: the plan above `cwd` and its project, both ways. */
15969
+ /** `harbor sync`: the plan above `cwd` and its project, both ways; with dryRun, what it would do. */
15920
15970
  async function syncHere(opts) {
15921
15971
  const dir = await findPlan(opts.cwd);
15922
15972
  if (!dir) throw new HarborError("There's no plan here. Run harbor init PROJECT in your repository to make one.");
15923
15973
  const folder = diskFolder(dir);
15924
15974
  const config = await readConfig(folder);
15925
15975
  if (config.local) {
15926
- await loadLocal(folder, opts.say);
15976
+ if (!opts.dryRun) await loadLocal(folder, opts.say);
15927
15977
  opts.say("⚓ This plan lives only in this folder, so there's nothing to sync with. harbor init PROJECT keeps it in step with a project on harborppm.com.");
15928
15978
  return null;
15929
15979
  }
@@ -15931,10 +15981,11 @@ async function syncHere(opts) {
15931
15981
  transport: await opts.transport(),
15932
15982
  folder,
15933
15983
  config,
15934
- say: opts.say
15984
+ say: opts.say,
15985
+ dryRun: opts.dryRun
15935
15986
  });
15936
15987
  for (const c of report.clashes) opts.say(c);
15937
- opts.say(syncLine(report));
15988
+ opts.say(syncLine(report, opts.dryRun));
15938
15989
  return report;
15939
15990
  }
15940
15991
  /** A post-commit hook that syncs after each commit; an existing hook is only added to. */
package/lib/cli.mjs CHANGED
@@ -22,8 +22,11 @@ import {
22
22
  forecastList,
23
23
  forecastOne,
24
24
  STATUS,
25
+ statusWord,
26
+ exportData,
25
27
  harbors,
26
28
  whoami,
29
+ whoamiData,
27
30
  limitsText,
28
31
  hillData,
29
32
  hillList,
@@ -94,13 +97,14 @@ export const HELP = `harbor: Harbor from the terminal
94
97
  a project's public roadmap: read,
95
98
  publish or take it down
96
99
 
97
- harbor init [PROJECT] [--hook] keep a project's tasks as files in
100
+ harbor init [PROJECT] [--hook] [--dry-run] keep a project's tasks as files in
98
101
  this repository (.harbor/), one
99
102
  per task, in step both ways
100
103
  harbor init --local [NAME] a plan in this folder alone, no
101
104
  account; harbor here opens it
102
- harbor sync [--quiet] send the files' edits to Harbor
105
+ harbor sync [--quiet] [--dry-run] send the files' edits to Harbor
103
106
  and bring its changes back
107
+ (--dry-run: only say what)
104
108
 
105
109
  harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
106
110
  [--ends DATE]
@@ -153,11 +157,12 @@ export const HELP = `harbor: Harbor from the terminal
153
157
  the crew, in the order to do them
154
158
  harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
155
159
  the agent's note; moves the task
156
- to In review (--start: doing)
160
+ to In review (--start: In play)
157
161
 
158
162
  Every command takes --json, and --harbor NAME when you're in more than one
159
163
  harbor (or set one with harbor use). harbor help COMMAND shows just that one.
160
- Statuses: backlog, todo, doing, review, done.
164
+ Statuses: backlog, todo, doing, review, done (the board's words work too:
165
+ "to do", "in play"; --json says doing for In play).
161
166
  DATE: YYYY-MM-DD, today, tomorrow, or +N for N days from today.
162
167
  Changes show in Harbor's History as yours, "via Harbor CLI". These commands
163
168
  never delete; the full-screen app can, as the website can, and asks first.
@@ -417,10 +422,17 @@ const DETAIL = {
417
422
  " repository, one file per task in",
418
423
  " PROJECT (a key like RR or a name;",
419
424
  " the folder's name when left out)",
425
+ " harbor init [PROJECT] --dry-run reads: says which project and how",
426
+ " many tasks, and writes nothing",
420
427
  " harbor init --local [NAME] writes: a plan that lives only in",
421
428
  " this folder, with no account;",
422
429
  " harbor here opens it full screen",
423
430
  "",
431
+ " .harbor/ goes at the top of the git repository you're in; outside one, in",
432
+ " this folder. Without PROJECT, only a project called exactly what the",
433
+ " folder is will do: when none is, nothing is made and Harbor lists the",
434
+ " projects there are, with their keys, to name one.",
435
+ "",
424
436
  " Each file has title, status, owner, due, priority and waits between --- lines,",
425
437
  " and the task's notes below. Edit them, add new ones (any name; Harbor gives",
426
438
  " them a key), review them in pull requests, then run harbor sync. --hook adds",
@@ -430,11 +442,14 @@ const DETAIL = {
430
442
  sync: [
431
443
  " harbor sync [--quiet] writes: sends what the files changed to",
432
444
  " Harbor and rewrites the files from it",
445
+ " harbor sync --dry-run reads: says what it would send and",
446
+ " bring in, and changes nothing",
433
447
  "",
434
448
  " A field you changed and nobody changed on the website goes to Harbor; one",
435
449
  " changed in both places keeps the website's, and says so. Removing a file",
436
450
  " doesn't delete the task (mark it done instead). On a fresh clone, run",
437
- " harbor sync before editing. --quiet prints only problems.",
451
+ " harbor sync before editing. --quiet prints only problems. It works from",
452
+ " anywhere inside the folder that holds .harbor/.",
438
453
  ],
439
454
  limit: [
440
455
  " harbor limit PROJECT reads its board's column limits",
@@ -443,7 +458,8 @@ const DETAIL = {
443
458
  " 1-5, 8, 10 and 15)",
444
459
  " harbor limit PROJECT COLUMN none writes: no limit on COLUMN",
445
460
  "",
446
- " COLUMN is a status: doing or review usually, or backlog, todo, done.",
461
+ " COLUMN is a status: doing (In play) or review usually, or backlog, todo,",
462
+ " done.",
447
463
  " Only the harbor's owner can change a limit. A move past a limit is",
448
464
  " refused until you run it again with --over-limit.",
449
465
  ],
@@ -467,12 +483,14 @@ const DETAIL = {
467
483
  " harbor roadmap PROJECT --publish --dates",
468
484
  " writes: also shows the day each thing",
469
485
  " shipped (--no-dates: titles only, the",
470
- " default)",
486
+ " first time's default; without either,",
487
+ " it stays as it is)",
471
488
  " harbor roadmap PROJECT --publish --site",
472
489
  " writes: also shows it at",
473
490
  " harborppm.com/roadmap, in place of any",
474
491
  " other (Harbor's own team only;",
475
- " --no-site, the default, takes it off)",
492
+ " --no-site takes it off; without",
493
+ " either, it stays as it is)",
476
494
  " harbor roadmap PROJECT --off writes: takes it down at once; the link",
477
495
  " then says it isn't published, and",
478
496
  " publishing again makes a new link",
@@ -509,7 +527,9 @@ const DETAIL = {
509
527
  " harbor export review reads the review as CSV",
510
528
  "",
511
529
  " The same spreadsheets the website's Export buttons download. They print;",
512
- " harbor export tasks RR > tasks.csv saves one.",
530
+ " harbor export tasks RR > tasks.csv saves one. A task export starts with",
531
+ " each task's key, so harbor import matches those tasks by key. With",
532
+ " --json, rows is one object per row, keyed by column.",
513
533
  ],
514
534
  import: [
515
535
  " harbor import FILE --project P writes: adds the file's tasks",
@@ -519,7 +539,8 @@ const DETAIL = {
519
539
  " FILE is CSV or tab-separated: a spreadsheet, or a Jira, Asana or Linear",
520
540
  " export. It needs a column of titles; rows naming a Project column go",
521
541
  " there. People it names who aren't in the harbor are added unless you say",
522
- " --no-people. A task already in its project by title is left alone.",
542
+ " --no-people. A task already in its project is left alone: by its key",
543
+ " (a Key column, as harbor export writes) or by title.",
523
544
  ],
524
545
  note: [
525
546
  " harbor note PROJECT reads its notes for later: who left",
@@ -564,14 +585,17 @@ const DETAIL = {
564
585
  agent: [
565
586
  " harbor agent reads the tasks people gave the crew's",
566
587
  " agent (an AI crew member on People),",
567
- " in progress first, with their notes",
588
+ " in play first, with their notes",
568
589
  ' harbor agent report TASK "TEXT" writes: the agent\'s note on the task,',
569
590
  " and moves it to In review for a person",
570
591
  " to check",
571
592
  ' harbor agent report TASK "TEXT" --start',
572
593
  " writes: the note, and moves it to",
573
- " In progress",
594
+ " In play",
574
595
  "",
596
+ " TASK is a task key, like RR-42 (harbor agent lists them), or an id. It",
597
+ " has to be assigned to an agent: for anyone else's task, report refuses",
598
+ " and nothing changes, so assign it to the agent on the website first.",
575
599
  " --agent NAME picks the agent when the crew has more than one. An agent",
576
600
  " never marks its own work Done; a person does, on the website or in the",
577
601
  " terminal app.",
@@ -976,7 +1000,7 @@ export async function run(argv, io = {}) {
976
1000
  if (command === "init" || command === "sync") {
977
1001
  const init = command === "init";
978
1002
  const { values, positionals } = parseArgs(rest, {
979
- flags: init ? ["local", "hook", "json"] : ["quiet", "json"],
1003
+ flags: init ? ["local", "hook", "json", "dry-run"] : ["quiet", "json", "dry-run"],
980
1004
  options: init ? ["harbor"] : [],
981
1005
  });
982
1006
  const cwd = io.cwd ?? process.cwd();
@@ -989,7 +1013,12 @@ export async function run(argv, io = {}) {
989
1013
  }
990
1014
  : (line) => out(plain(line));
991
1015
  const app = await loadApp();
1016
+ const dryRun = Boolean(values["dry-run"]);
992
1017
  if (init && values.local) {
1018
+ if (dryRun)
1019
+ throw new HarborError(
1020
+ "harbor init --local only writes files in this folder, with no account; leave out --dry-run.",
1021
+ );
993
1022
  if (positionals.length > 1)
994
1023
  throw new HarborError(`harbor init --local takes one name; put it in quotes.`);
995
1024
  const config = await app.initLocalHere({ cwd, name: positionals[0], say });
@@ -1023,6 +1052,7 @@ export async function run(argv, io = {}) {
1023
1052
  transport: await transport(),
1024
1053
  project: positionals[0],
1025
1054
  hook: Boolean(values.hook),
1055
+ dryRun,
1026
1056
  say,
1027
1057
  pickHarbor: (list) => {
1028
1058
  if (values.harbor) return app.harborNamed(list, values.harbor);
@@ -1034,11 +1064,14 @@ export async function run(argv, io = {}) {
1034
1064
  );
1035
1065
  },
1036
1066
  });
1037
- if (values.json) out(jsonText({ plan: config, notes: lines }));
1067
+ if (values.json) out(jsonText({ plan: config, ...(dryRun ? { dryRun } : {}), notes: lines }));
1038
1068
  return 0;
1039
1069
  }
1040
- const report = await app.syncHere({ cwd, transport, say });
1041
- if (values.json) out(jsonText({ ...(report ?? { local: true }), notes: lines }));
1070
+ const report = await app.syncHere({ cwd, transport, say, dryRun });
1071
+ if (values.json)
1072
+ out(
1073
+ jsonText({ ...(report ?? { local: true }), ...(dryRun ? { dryRun } : {}), notes: lines }),
1074
+ );
1042
1075
  return 0;
1043
1076
  }
1044
1077
 
@@ -1087,7 +1120,12 @@ export async function run(argv, io = {}) {
1087
1120
  }
1088
1121
  case "whoami": {
1089
1122
  none(parse(spec()).positionals);
1090
- return call("list_harbors", {}, (r) => whoami(r, creds));
1123
+ return call(
1124
+ "list_harbors",
1125
+ {},
1126
+ (r) => whoami(r, creds),
1127
+ (r) => whoamiData(r, creds),
1128
+ );
1091
1129
  }
1092
1130
  case "use": {
1093
1131
  const { positionals } = parse({ flags: ["json"], options: [] });
@@ -1251,6 +1289,7 @@ export async function run(argv, io = {}) {
1251
1289
  .split(",")
1252
1290
  .map((x) => x.trim())
1253
1291
  .filter(Boolean)
1292
+ .map(statusWord)
1254
1293
  : undefined,
1255
1294
  overdue: values.late || undefined,
1256
1295
  };
@@ -1463,7 +1502,7 @@ export async function run(argv, io = {}) {
1463
1502
  assignee: values.assignee ?? "me",
1464
1503
  due: dueDate(values.due),
1465
1504
  priority: values.priority,
1466
- status: values.status,
1505
+ status: values.status === undefined ? undefined : statusWord(values.status),
1467
1506
  overLimit: values["over-limit"] || undefined,
1468
1507
  },
1469
1508
  (r) => `Added: ${taskLine(r.created, opts)}`,
@@ -1512,7 +1551,7 @@ export async function run(argv, io = {}) {
1512
1551
  assignee: values.assignee,
1513
1552
  due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
1514
1553
  priority: values.priority,
1515
- status: values.status,
1554
+ status: values.status === undefined ? undefined : statusWord(values.status),
1516
1555
  description: values.notes,
1517
1556
  repeat: values.repeat,
1518
1557
  waitsOn:
@@ -1630,6 +1669,8 @@ export async function run(argv, io = {}) {
1630
1669
  "export_csv",
1631
1670
  { harbor, what, project: project || undefined, all: Boolean(values.all) },
1632
1671
  (r) => r.csv.replace(/\r\n/g, "\n"),
1672
+ // --json: the rows, each keyed by its column, not one CSV string.
1673
+ (r) => (asJson ? exportData(r) : r),
1633
1674
  );
1634
1675
  }
1635
1676
  case "import": {
@@ -1709,7 +1750,7 @@ export async function run(argv, io = {}) {
1709
1750
  if (limit !== null && !(Number.isInteger(limit) && limit >= 1 && limit <= 20)) {
1710
1751
  throw new HarborError("A limit is a whole number from 1 to 20, or none.");
1711
1752
  }
1712
- return call("set_column_limit", { harbor, project, column, limit }, (r) =>
1753
+ return call("set_column_limit", { harbor, project, column: statusWord(column), limit }, (r) =>
1713
1754
  limitsText(r.set.project, r.limits, r.tasks, r.hiddenColumns),
1714
1755
  );
1715
1756
  }
@@ -1724,7 +1765,13 @@ export async function run(argv, io = {}) {
1724
1765
  if (!note) {
1725
1766
  if (values.opens || values.milestone || values["off-track"])
1726
1767
  throw new HarborError("Write the note too, in quotes after the project.");
1727
- return call("get_project", { harbor, project }, (r) => notesText(r.name, r.notesForLater));
1768
+ // --json: the project and its notes, an empty list when there are none.
1769
+ return call(
1770
+ "get_project",
1771
+ { harbor, project },
1772
+ (r) => notesText(r.project, r.notes),
1773
+ (r) => ({ id: r.id, project: r.name, notes: r.notesForLater ?? [] }),
1774
+ );
1728
1775
  }
1729
1776
  return call(
1730
1777
  "leave_note",
@@ -1805,7 +1852,7 @@ export async function run(argv, io = {}) {
1805
1852
  "agent_update",
1806
1853
  { harbor, task, note, status: values.start ? "doing" : "review", agent: values.agent },
1807
1854
  (r) =>
1808
- `${r.updated.key ?? r.updated.title} is ${r.updated.status === "review" ? "in review for a person to check" : "in progress"}, with ${r.commentedAs}'s note.`,
1855
+ `${r.updated.key ?? r.updated.title} is ${r.updated.status === "review" ? "in review for a person to check" : "in play"}, with ${r.commentedAs}'s note.`,
1809
1856
  );
1810
1857
  }
1811
1858
  case "handback": {
package/lib/format.mjs CHANGED
@@ -1,13 +1,30 @@
1
1
  /** Plain text for a terminal: one task per line, its key (or id) first so it can be copied. */
2
2
 
3
+ /** Statuses in the board's words, as the website, the app and an export say them. */
3
4
  export const STATUS = {
4
5
  backlog: "backlog",
5
6
  todo: "to do",
6
- doing: "doing",
7
+ doing: "in play",
7
8
  review: "review",
8
9
  done: "done",
9
10
  };
10
11
 
12
+ /**
13
+ * A status as someone typed it: Harbor's own (doing) or the board's (In play,
14
+ * To do), any case, with a space, a hyphen or neither. Anything else comes
15
+ * back as typed, for Harbor to refuse with the list.
16
+ */
17
+ export function statusWord(typed) {
18
+ const v = String(typed)
19
+ .trim()
20
+ .toLowerCase()
21
+ .replace(/[\s_-]+/g, "");
22
+ return (
23
+ { inplay: "doing", inprogress: "doing", todo: "todo", inreview: "review" }[v] ??
24
+ (["backlog", "doing", "review", "done"].includes(v) ? v : String(typed).trim())
25
+ );
26
+ }
27
+
11
28
  /**
12
29
  * `mine` leaves out whose each task is: every one is yours. `width` (the
13
30
  * terminal's, when printing to one) shortens a long title so the line fits;
@@ -191,6 +208,25 @@ export function whoami(r, creds) {
191
208
  return lines.join("\n");
192
209
  }
193
210
 
211
+ /** whoami --json: the account and its harbors, with where it signed in and the harbor commands use. */
212
+ export function whoamiData(r, creds) {
213
+ const list = r.harbors ?? [];
214
+ const set = creds.harbor ? list.find((h) => h.id === creds.harbor) : null;
215
+ const using = set
216
+ ? { id: set.id, name: set.name, from: "harbor use" }
217
+ : creds.harbor
218
+ ? { id: creds.harbor, name: creds.harborName ?? null, from: "harbor use", left: true }
219
+ : list.length === 1
220
+ ? { id: list[0].id, name: list[0].name, from: "your only harbor" }
221
+ : null;
222
+ return {
223
+ ...r,
224
+ server: creds.server,
225
+ readOnly: !creds.scope?.split(" ").includes("harbor:write"),
226
+ harbor: using,
227
+ };
228
+ }
229
+
194
230
  export function harbors(r) {
195
231
  const list = r.harbors ?? [];
196
232
  if (!list.length) return "You aren't in any harbor yet. Run harbor to make one.";
@@ -318,13 +354,17 @@ export function forecastOne(p) {
318
354
 
319
355
  /** forecast --json: the finish forecast itself, with only what names the project around it. */
320
356
  export function forecastData(r) {
357
+ // The same fields every time, so a script needn't test for them; notes are
358
+ // what the pace can't see, as the list shows them.
321
359
  const one = (p) => ({
322
360
  id: p.id,
323
361
  name: p.name,
324
362
  ...(p.key ? { key: p.key } : {}),
325
363
  ...(p.program !== undefined ? { program: p.program } : {}),
326
364
  ends: p.ends ?? null,
365
+ ...(p.finish?.none ? {} : { rough: false, shortHistory: false, lastDue: null }),
327
366
  ...p.finish,
367
+ notes: p.finish?.none ? [] : finishNotes(p.finish ?? {}),
328
368
  });
329
369
  if (!r.projects) return one(r);
330
370
  return { projects: r.projects.filter((p) => p.health !== "done").map(one) };
@@ -533,7 +573,7 @@ export function agentWorkText(r) {
533
573
  const lines = [`Work for ${r.agent}${r.addedBy ? ` (added by ${r.addedBy})` : ""}`];
534
574
  if (!r.tasks?.length) lines.push("", "Nothing to do: no open tasks are assigned to it.");
535
575
  for (const t of r.tasks ?? []) {
536
- const bits = [t.status === "doing" ? "in progress" : t.status, t.priority];
576
+ const bits = [STATUS[t.status] ?? t.status, t.priority];
537
577
  if (t.due) bits.push(`due ${t.due}`);
538
578
  lines.push(
539
579
  "",
@@ -555,3 +595,43 @@ export function agentWorkText(r) {
555
595
  );
556
596
  return lines.join("\n");
557
597
  }
598
+
599
+ /** CSV text as rows of cells: quoted cells may hold commas, quotes ("") and line breaks. */
600
+ export function csvRows(text) {
601
+ const rows = [];
602
+ let row = [];
603
+ let cell = "";
604
+ let quoted = false;
605
+ for (let i = 0; i < text.length; i++) {
606
+ const c = text[i];
607
+ if (quoted) {
608
+ if (c === '"' && text[i + 1] === '"') {
609
+ cell += '"';
610
+ i++;
611
+ } else if (c === '"') quoted = false;
612
+ else cell += c;
613
+ } else if (c === '"') quoted = true;
614
+ else if (c === ",") {
615
+ row.push(cell);
616
+ cell = "";
617
+ } else if (c === "\n" || c === "\r") {
618
+ if (c === "\r" && text[i + 1] === "\n") i++;
619
+ row.push(cell);
620
+ rows.push(row);
621
+ row = [];
622
+ cell = "";
623
+ } else cell += c;
624
+ }
625
+ if (cell || row.length) rows.push([...row, cell]);
626
+ return rows;
627
+ }
628
+
629
+ /** export --json: each row as an object keyed by the file's headers, not one CSV string. */
630
+ export function exportData(r) {
631
+ const [columns = [], ...body] = csvRows(r.csv ?? "");
632
+ return {
633
+ filename: r.filename,
634
+ columns,
635
+ rows: body.map((cells) => Object.fromEntries(columns.map((c, i) => [c, cells[i] ?? ""]))),
636
+ };
637
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.41.0",
3
+ "version": "0.42.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",