@harborppm/cli 0.7.1 → 0.9.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
@@ -63,12 +63,12 @@ It runs on the same engine as the website: health, task keys, limits and undo wo
63
63
 
64
64
  ```
65
65
  harbor harbors the harbors you belong to
66
- harbor use HARBOR|none the harbor commands mean when you're in several
66
+ harbor use HARBOR|none the harbor to work in when you're in several
67
67
  harbor mine what's on you, late first
68
68
  harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
69
69
  harbor show TASK one task in full
70
70
 
71
- harbor add "TITLE" --project P [--assignee me|NAME] [--due YYYY-MM-DD]
71
+ harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
72
72
  [--priority low|medium|high|urgent] [--status S] [--over-limit]
73
73
  harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
74
74
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
@@ -76,14 +76,14 @@ harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
76
76
  harbor comment TASK "TEXT"
77
77
  harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
78
78
  harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
79
- [--owner NAME] [--due YYYY-MM-DD]
79
+ [--owner NAME] [--due DATE]
80
80
  harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
81
- [--due YYYY-MM-DD]
81
+ [--due DATE]
82
82
  ```
83
83
 
84
- Every command takes `--json` for the raw result, and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done.
84
+ Every command takes `--json` for the raw result, 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.
85
85
 
86
- 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 opens the one `harbor use` set too.
86
+ 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.
87
87
 
88
88
  Harbor holds back what it would hold back in the app: finishing a task that still waits on another, or going past a column's limit. The CLI says so and names the flag that goes ahead (`--confirm`, `--over-limit`).
89
89
 
package/dist/app.mjs CHANGED
@@ -2298,12 +2298,21 @@ function PriorityMark({ priority }) {
2298
2298
  function Heading({ children, right }) {
2299
2299
  return /* @__PURE__ */ jsxs(Box, {
2300
2300
  justifyContent: "space-between",
2301
- children: [/* @__PURE__ */ jsx(Text, {
2302
- bold: true,
2303
- children
2304
- }), right ? /* @__PURE__ */ jsx(Text, {
2305
- color: theme.muted,
2306
- children: right
2301
+ columnGap: 2,
2302
+ children: [/* @__PURE__ */ jsx(Box, {
2303
+ flexShrink: 1,
2304
+ children: /* @__PURE__ */ jsx(Text, {
2305
+ bold: true,
2306
+ wrap: "truncate-end",
2307
+ children
2308
+ })
2309
+ }), right ? /* @__PURE__ */ jsx(Box, {
2310
+ flexShrink: 2,
2311
+ children: /* @__PURE__ */ jsx(Text, {
2312
+ color: theme.muted,
2313
+ wrap: "truncate-end",
2314
+ children: right
2315
+ })
2307
2316
  }) : null]
2308
2317
  });
2309
2318
  }
@@ -2917,7 +2926,7 @@ function HistoryView({ active, width, height, go }) {
2917
2926
  on ? "› " : " ",
2918
2927
  /* @__PURE__ */ jsx(Text, {
2919
2928
  color: theme.muted,
2920
- children: timeAgo(parseIso(row.at)).padEnd(10)
2929
+ children: `${timeAgo(parseIso(row.at))} `.padEnd(16)
2921
2930
  }),
2922
2931
  /* @__PURE__ */ jsx(Text, {
2923
2932
  bold: true,
@@ -2972,7 +2981,9 @@ function constraintIdle(data, constraint) {
2972
2981
  if (constraint.status !== "active" || !constraint.ownerId) return false;
2973
2982
  const tasks = tasksInProgramOf(data, constraint.programId);
2974
2983
  if (!tasks.some((t) => t.status !== "done")) return false;
2975
- return !tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing");
2984
+ if (tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing")) return false;
2985
+ const here = new Set(tasks.map((t) => t.id));
2986
+ return data.tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing" && !here.has(t.id));
2976
2987
  }
2977
2988
 
2978
2989
  //#endregion
@@ -3041,7 +3052,7 @@ function inboxOf(data, personId, now = clockNow()) {
3041
3052
  });
3042
3053
  }
3043
3054
  for (const blocker of mine) {
3044
- const waiting = (waitersByBlocker(data.tasks).get(blocker.id) ?? []).filter((t) => t.status !== "done" && t.id !== blocker.id);
3055
+ const waiting = (waitersByBlocker(data.tasks).get(blocker.id) ?? []).filter((t) => t.status !== "done" && t.id !== blocker.id && t.assigneeId !== blocker.assigneeId);
3045
3056
  if (waiting.length === 0) continue;
3046
3057
  const project = projectOf(data, blocker);
3047
3058
  const names = [...new Set(waiting.map((t) => {
@@ -3267,6 +3278,7 @@ function weeklyLoad(tasks, personId, now, weeks = 6) {
3267
3278
 
3268
3279
  //#endregion
3269
3280
  //#region src/tui/views/lists.tsx
3281
+ const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
3270
3282
  function RowList({ title, right, rows, empty, active, width, height, onOpen, keys = {} }) {
3271
3283
  const [at, setAt] = useState(0);
3272
3284
  const row = rows[Math.min(at, rows.length - 1)];
@@ -3368,7 +3380,7 @@ function ProjectsView({ active, width, height, go }) {
3368
3380
  ]);
3369
3381
  return /* @__PURE__ */ jsx(RowList, {
3370
3382
  title: "Portfolios, programs and projects",
3371
- right: `${projects.length} projects · n new project · e edit`,
3383
+ right: `${count(projects.length, "project")} · n new project · e edit`,
3372
3384
  rows,
3373
3385
  empty: "No projects yet. n adds one.",
3374
3386
  active,
@@ -3413,7 +3425,7 @@ function ProgramsView({ active, width, height, go }) {
3413
3425
  ]);
3414
3426
  return /* @__PURE__ */ jsx(RowList, {
3415
3427
  title: "Programs",
3416
- right: `${programs.length} programs · n new · e edit`,
3428
+ right: `${count(programs.length, "program")} · n new · e edit`,
3417
3429
  rows,
3418
3430
  empty: "No programs yet. n adds one.",
3419
3431
  active,
@@ -3502,7 +3514,7 @@ function PeopleView({ active, width, height, go }) {
3502
3514
  ]);
3503
3515
  return /* @__PURE__ */ jsx(RowList, {
3504
3516
  title: "People",
3505
- right: `${people.length} people · Enter to change`,
3517
+ right: `${people.length === 1 ? "1 person" : `${people.length} people`} · Enter to change`,
3506
3518
  rows,
3507
3519
  empty: "Nobody here yet. Invite people from the website.",
3508
3520
  active,
@@ -3801,7 +3813,7 @@ function NewTask({ projectId, status, width, onDone, onFull }) {
3801
3813
  if (!chosen) return /* @__PURE__ */ jsx(Box, {
3802
3814
  width,
3803
3815
  children: /* @__PURE__ */ jsx(Picker, {
3804
- title: "New task in which project?",
3816
+ title: projects.length ? "New task in which project?" : "No projects yet. Esc, then 3 for Projects and n to start one.",
3805
3817
  choices: projects.map((p) => ({
3806
3818
  label: p.name,
3807
3819
  value: p.id,
@@ -3816,7 +3828,7 @@ function NewTask({ projectId, status, width, onDone, onFull }) {
3816
3828
  children: /* @__PURE__ */ jsx(TextField, {
3817
3829
  label: `New task in ${chosen.name}${status ? ` · ${STATUS_LABEL[status]}` : ""}`,
3818
3830
  placeholder: "What needs doing?",
3819
- hint: "Enter adds it (yours, to do) · Esc leaves it",
3831
+ hint: `Enter adds it (yours, ${STATUS_LABEL[status ?? "todo"].toLowerCase()}) · Esc leaves it`,
3820
3832
  onCancel: () => onDone(null),
3821
3833
  onSubmit: (title) => {
3822
3834
  if (!title.trim()) return onDone(null);
@@ -3853,24 +3865,21 @@ function HarborSwitch({ harbors, current, width, onPick, onClose }) {
3853
3865
  });
3854
3866
  }
3855
3867
  const HELP = [
3856
- ["1–8", "Today, Inbox, Projects, Programs, Risks, People, Roadmap, History"],
3857
- [":", "anything: a screen, something new, a project or program by name"],
3868
+ ["1–8", "the screens, as the sidebar lists them"],
3869
+ [":", "anything: a screen, something new, a name"],
3858
3870
  ["/", "find a task by key or words"],
3859
3871
  ["H", "switch harbor"],
3860
3872
  ["Tab", "between the sidebar and the screen"],
3861
- ["↑↓ or j k", "move; ←→ or h l across board columns and lists"],
3862
- ["Enter", "open"],
3863
- ["Esc", "back"],
3864
- ["n", "new: a task, or what the screen lists (project, program, risk)"],
3865
- ["e", "change what's open: its health, lead, dates, budget"],
3873
+ ["↑↓ or j k", "move; ←→ or h l across columns and lists"],
3874
+ ["Enter / Esc", "open / back"],
3875
+ ["n", "new: a task, or what the screen lists"],
3876
+ ["e", "change what's open (health, lead, dates)"],
3866
3877
  ["[ ]", "move a card one column left or right"],
3867
- ["x", "mark done"],
3868
- ["d", "delete (asks first)"],
3869
- ["u", "undo your last change"],
3878
+ ["x / d", "mark done / delete (asks first)"],
3879
+ ["u / r", "undo your last change / retry saving"],
3870
3880
  ["v", "on a project: board, list, budget"],
3871
- ["m / R", "on a program: new milestone; anywhere: log a risk"],
3881
+ ["m / R", "new milestone on a program / log a risk"],
3872
3882
  ["c / a", "on a task: comment / add a checklist step"],
3873
- ["r", "retry saving now"],
3874
3883
  ["q", "quit (saves first)"]
3875
3884
  ];
3876
3885
  /**
@@ -3878,7 +3887,7 @@ const HELP = [
3878
3887
  * the 16-pixel mark, and the box's border and padding. Narrower, the lines
3879
3888
  * would wrap and split the mark.
3880
3889
  */
3881
- const HELP_WITH_MARK = 11 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
3890
+ const HELP_WITH_MARK = 13 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
3882
3891
  function Help({ width, onClose }) {
3883
3892
  useInput((input, key) => {
3884
3893
  if (key.escape || key.return || input === "?" || input === "q") onClose();
@@ -3899,11 +3908,11 @@ function Help({ width, onClose }) {
3899
3908
  }),
3900
3909
  HELP.map(([k, what]) => /* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsx(Text, {
3901
3910
  bold: true,
3902
- children: k.padEnd(11)
3911
+ children: k.padEnd(13)
3903
3912
  }), what] }, k)),
3904
3913
  /* @__PURE__ */ jsx(Text, {
3905
3914
  color: theme.muted,
3906
- children: "Everything saves as you go, and shows on the website for everyone."
3915
+ children: "Everything saves as you go and shows on the website."
3907
3916
  })
3908
3917
  ]
3909
3918
  }), width >= HELP_WITH_MARK ? /* @__PURE__ */ jsxs(Box, {
@@ -4911,7 +4920,7 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
4911
4920
  }),
4912
4921
  /* @__PURE__ */ jsx(Text, { children: " " }),
4913
4922
  /* @__PURE__ */ jsx(Heading, {
4914
- right: `${list.length} programs`,
4923
+ right: `${list.length} program${list.length === 1 ? "" : "s"}`,
4915
4924
  children: "Programs"
4916
4925
  }),
4917
4926
  list.length === 0 ? /* @__PURE__ */ jsx(Text, {
@@ -5047,6 +5056,9 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5047
5056
  const { done, total } = projectSummary(data, project);
5048
5057
  const lead = people.find((p) => p.id === project.ownerId);
5049
5058
  const body = height - 3;
5059
+ const fit = Math.max(1, Math.floor((width - 2) / 14));
5060
+ const firstColumn = Math.min(Math.max(0, col - Math.floor(fit / 2)), Math.max(0, columns.length - fit));
5061
+ const shownColumns = columns.slice(firstColumn, firstColumn + fit);
5050
5062
  return /* @__PURE__ */ jsxs(Box, {
5051
5063
  flexDirection: "column",
5052
5064
  width,
@@ -5055,36 +5067,44 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5055
5067
  children: [
5056
5068
  /* @__PURE__ */ jsxs(Box, {
5057
5069
  justifyContent: "space-between",
5058
- children: [/* @__PURE__ */ jsxs(Text, {
5059
- wrap: "truncate-end",
5060
- children: [
5061
- /* @__PURE__ */ jsx(Text, {
5062
- bold: true,
5063
- children: project.name
5064
- }),
5065
- " ",
5066
- /* @__PURE__ */ jsx(Text, {
5067
- color: theme.muted,
5068
- children: project.key
5069
- }),
5070
- " ",
5071
- /* @__PURE__ */ jsx(HealthMark, {
5072
- health: project.health,
5073
- label: true
5074
- })
5075
- ]
5076
- }), /* @__PURE__ */ jsxs(Text, {
5077
- color: theme.muted,
5078
- children: [
5079
- done,
5080
- " of ",
5081
- total,
5082
- " done",
5083
- lead ? ` · ${lead.name}` : "",
5084
- " · ",
5085
- mode,
5086
- " · v next · e details"
5087
- ]
5070
+ columnGap: 2,
5071
+ children: [/* @__PURE__ */ jsx(Box, {
5072
+ flexShrink: 1,
5073
+ children: /* @__PURE__ */ jsxs(Text, {
5074
+ wrap: "truncate-end",
5075
+ children: [
5076
+ /* @__PURE__ */ jsx(Text, {
5077
+ bold: true,
5078
+ children: project.name
5079
+ }),
5080
+ " ",
5081
+ /* @__PURE__ */ jsx(Text, {
5082
+ color: theme.muted,
5083
+ children: project.key
5084
+ }),
5085
+ " ",
5086
+ /* @__PURE__ */ jsx(HealthMark, {
5087
+ health: project.health,
5088
+ label: true
5089
+ })
5090
+ ]
5091
+ })
5092
+ }), /* @__PURE__ */ jsx(Box, {
5093
+ flexShrink: 2,
5094
+ children: /* @__PURE__ */ jsxs(Text, {
5095
+ color: theme.muted,
5096
+ wrap: "truncate-end",
5097
+ children: [
5098
+ done,
5099
+ " of ",
5100
+ total,
5101
+ " done",
5102
+ lead ? ` · ${lead.name}` : "",
5103
+ " · ",
5104
+ mode,
5105
+ " · v next · e details"
5106
+ ]
5107
+ })
5088
5108
  })]
5089
5109
  }),
5090
5110
  /* @__PURE__ */ jsx(Text, {
@@ -5100,8 +5120,9 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5100
5120
  today: day
5101
5121
  }) : mode === "board" ? /* @__PURE__ */ jsx(Box, {
5102
5122
  height: body,
5103
- children: columns.map((c, ci) => {
5104
- const w = Math.max(14, Math.floor((width - 2) / columns.length));
5123
+ children: shownColumns.map((c, i) => {
5124
+ const ci = firstColumn + i;
5125
+ const w = Math.floor((width - 2) / shownColumns.length);
5105
5126
  const on = active && ci === col;
5106
5127
  const r = Math.min(rowByCol[ci] ?? 0, Math.max(0, c.tasks.length - 1));
5107
5128
  const view = windowOf(c.tasks, r, Math.max(1, Math.floor((body - 3) / 3)));
@@ -5113,19 +5134,33 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5113
5134
  borderColor: on ? theme.accent : theme.border,
5114
5135
  paddingX: 1,
5115
5136
  children: [
5116
- /* @__PURE__ */ jsxs(Text, {
5117
- bold: true,
5118
- color: on ? theme.accent : void 0,
5119
- wrap: "truncate-end",
5120
- children: [
5121
- c.label.toUpperCase(),
5122
- " ",
5123
- /* @__PURE__ */ jsxs(Text, {
5124
- color: over ? theme.bad : theme.muted,
5125
- children: [c.tasks.length, c.limit != null ? `/${c.limit}` : ""]
5137
+ /* @__PURE__ */ jsxs(Box, { children: [
5138
+ i === 0 && firstColumn > 0 ? /* @__PURE__ */ jsx(Text, {
5139
+ color: theme.muted,
5140
+ children: "‹ "
5141
+ }) : null,
5142
+ /* @__PURE__ */ jsx(Box, {
5143
+ flexGrow: 1,
5144
+ flexShrink: 1,
5145
+ children: /* @__PURE__ */ jsxs(Text, {
5146
+ bold: true,
5147
+ color: on ? theme.accent : void 0,
5148
+ wrap: "truncate-end",
5149
+ children: [
5150
+ c.label.toUpperCase(),
5151
+ " ",
5152
+ /* @__PURE__ */ jsxs(Text, {
5153
+ color: over ? theme.bad : theme.muted,
5154
+ children: [c.tasks.length, c.limit != null ? `/${c.limit}` : ""]
5155
+ })
5156
+ ]
5126
5157
  })
5127
- ]
5128
- }),
5158
+ }),
5159
+ i === shownColumns.length - 1 && ci < columns.length - 1 ? /* @__PURE__ */ jsx(Text, {
5160
+ color: theme.muted,
5161
+ children: " ›"
5162
+ }) : null
5163
+ ] }),
5129
5164
  c.tasks.length === 0 ? /* @__PURE__ */ jsx(Text, {
5130
5165
  color: theme.muted,
5131
5166
  children: "—"
@@ -5883,7 +5918,7 @@ const SAVE_LABEL = {
5883
5918
  offline: ["● offline, will save", "bad"]
5884
5919
  };
5885
5920
  function App({ ui }) {
5886
- const { store, sync, account, harbors, today } = useDeps();
5921
+ const { store, sync, account, harbors, today, remember } = useDeps();
5887
5922
  const { exit } = useApp();
5888
5923
  const { columns, rows } = useWindowSize();
5889
5924
  const harborName = useHarbor((s) => s.harborName);
@@ -5976,7 +6011,10 @@ function App({ ui }) {
5976
6011
  const switchHarbor = (id) => {
5977
6012
  goScreen("today");
5978
6013
  notify("Opening…");
5979
- sync.flush().then(() => sync.open(id)).then((h) => notify(`Opened ${h.name}.`)).catch((e) => notify(`Couldn't open it: ${e.message}`));
6014
+ sync.flush().then(() => sync.open(id)).then(async (h) => {
6015
+ const saved = remember ? await remember(h).then(() => true, () => false) : false;
6016
+ notify(saved ? `Opened ${h.name}. Commands use it too.` : `Opened ${h.name}.`);
6017
+ }).catch((e) => notify(`Couldn't open it: ${e.message}`));
5980
6018
  };
5981
6019
  const quit = async () => {
5982
6020
  setQuitting(true);
@@ -7209,6 +7247,104 @@ function normalizeRules(rules) {
7209
7247
  };
7210
7248
  }
7211
7249
 
7250
+ //#endregion
7251
+ //#region src/lib/sample-project.ts
7252
+ /**
7253
+ * A small project a new harbor can add to try things on: a few cards in each
7254
+ * column, one that waits on another, a checklist and a risk. Nothing is late,
7255
+ * so it doesn't turn a fresh harbor red, and every card says what to try.
7256
+ */
7257
+ const SAMPLE_PROJECT_NAME = "Website refresh (sample)";
7258
+ function sampleProject(opts) {
7259
+ const { ownerId, today, now, makeId } = opts;
7260
+ const day = (n) => todayISO(addDays(parseDate$1(today), n));
7261
+ const end = opts.programEnd && opts.programEnd < day(30) ? opts.programEnd : day(30);
7262
+ const projectId = makeId("pj");
7263
+ const project = {
7264
+ id: projectId,
7265
+ programId: opts.programId,
7266
+ name: SAMPLE_PROJECT_NAME,
7267
+ description: "A project to try things on. Move cards, add tasks, change dates. Delete it from Details when you’re done.",
7268
+ swatch: "sea",
7269
+ ownerId,
7270
+ health: "on-track",
7271
+ startDate: today,
7272
+ endDate: end,
7273
+ budget: 0,
7274
+ spent: 0
7275
+ };
7276
+ const task = (title, status, o = {}) => ({
7277
+ id: makeId("t"),
7278
+ projectId,
7279
+ title,
7280
+ description: o.description ?? "",
7281
+ status,
7282
+ priority: o.priority ?? "medium",
7283
+ assigneeId: ownerId,
7284
+ dueDate: o.due == null ? null : day(o.due),
7285
+ createdAt: now,
7286
+ completedAt: status === "done" ? now : null,
7287
+ checklist: (o.checklist ?? []).map(([t, done]) => ({
7288
+ id: makeId("ck"),
7289
+ title: t,
7290
+ done
7291
+ })),
7292
+ blockedById: o.waitsOn ?? null,
7293
+ blockedByIds: o.waitsOn ? [o.waitsOn] : []
7294
+ });
7295
+ const draft = task("Draft the new home page", "doing", {
7296
+ description: "A sample card. Tick the checklist, leave a comment, or drag it to Review on the board.",
7297
+ due: 3,
7298
+ priority: "high",
7299
+ checklist: [
7300
+ ["Headline and one line under it", true],
7301
+ ["Three reasons to stay", false],
7302
+ ["One clear button", false]
7303
+ ]
7304
+ });
7305
+ return {
7306
+ project,
7307
+ tasks: [
7308
+ task("Agree what the refresh is for", "done", { description: "Finished. Move it back if it turns out it wasn’t." }),
7309
+ task("List the pages people actually use", "done"),
7310
+ draft,
7311
+ task("Write the pricing page copy", "review", {
7312
+ description: "Waiting for a second pair of eyes. Move it to Done when it reads well.",
7313
+ due: 5
7314
+ }),
7315
+ task("Pick the photos", "todo", {
7316
+ description: "Hand this one to someone else from the card’s Owner field.",
7317
+ due: 7
7318
+ }),
7319
+ task("Check every page on a phone", "todo", {
7320
+ description: "Waits on the home page draft, so it shows as waiting until that card is done.",
7321
+ due: 10,
7322
+ waitsOn: draft.id
7323
+ }),
7324
+ task("Send old links to the new pages", "backlog", { description: "No date yet. Backlog is for what isn’t planned." }),
7325
+ task("Tell customers what changed", "backlog", { priority: "low" })
7326
+ ],
7327
+ raidItems: [{
7328
+ id: makeId("r"),
7329
+ kind: "risk",
7330
+ projectId,
7331
+ title: "Photos may not be cleared in time",
7332
+ description: "A sample risk. If the photos aren’t licensed before launch, the home page ships with placeholders.",
7333
+ severity: "medium",
7334
+ status: "open",
7335
+ ownerId,
7336
+ dueDate: day(7),
7337
+ probability: 3,
7338
+ impact: 3,
7339
+ mitigation: "Shortlist stock photos we already own as a fallback.",
7340
+ createdById: ownerId,
7341
+ createdAt: now,
7342
+ updatedAt: now,
7343
+ updates: []
7344
+ }]
7345
+ };
7346
+ }
7347
+
7212
7348
  //#endregion
7213
7349
  //#region src/lib/slices/hierarchy.ts
7214
7350
  /** Portfolios, programs and projects: create, edit, custom fields, automations, health overrides, boards and cascading delete. */
@@ -7256,6 +7392,31 @@ function hierarchySlice({ get, write }) {
7256
7392
  get().log(`opened project ${row.name}`, `/projects/${id}`);
7257
7393
  return id;
7258
7394
  },
7395
+ addSampleProject: () => {
7396
+ const state = get();
7397
+ const program = state.programs[0];
7398
+ if (!program) return null;
7399
+ const rows = sampleProject({
7400
+ programId: program.id,
7401
+ ownerId: state.currentUserId,
7402
+ today: todayISO(),
7403
+ programEnd: program.endDate,
7404
+ now: stamp(),
7405
+ makeId: uid
7406
+ });
7407
+ const id = rows.project.id;
7408
+ write({
7409
+ projects: [...state.projects, rows.project],
7410
+ tasks: [...state.tasks, ...rows.tasks],
7411
+ raidItems: [...state.raidItems, ...rows.raidItems],
7412
+ boardConfigs: {
7413
+ ...state.boardConfigs ?? {},
7414
+ [id]: defaultBoardConfig()
7415
+ }
7416
+ });
7417
+ get().log(`added the sample project`, `/projects/${id}`);
7418
+ return id;
7419
+ },
7259
7420
  updatePortfolio: (id, patch) => {
7260
7421
  write({ portfolios: get().portfolios.map((p) => p.id === id ? {
7261
7422
  ...p,
@@ -8729,6 +8890,7 @@ function httpTransport(source, fetcher = fetch) {
8729
8890
  }
8730
8891
  } catch (error) {
8731
8892
  if (error instanceof TransportError) throw error;
8893
+ if (error?.signedOut) throw error;
8732
8894
  throw new TransportError("Can't reach Harbor.", 0, true);
8733
8895
  }
8734
8896
  const answer = await res.json().catch(() => ({}));
@@ -9076,6 +9238,26 @@ function addFirstProject(store, name, accountId, today) {
9076
9238
  ownerId: me?.id ?? ""
9077
9239
  }, { programId: program.id }, today);
9078
9240
  }
9241
+ /**
9242
+ * Someone in several harbors with none picked: ask which, as the commands
9243
+ * would refuse to guess. A number from the list or a name answers; anything
9244
+ * else asks again.
9245
+ */
9246
+ async function pickHarbor(deps) {
9247
+ deps.say(`You're in ${deps.harbors.length} harbors. Which one should harbor open?`);
9248
+ deps.harbors.forEach((h, i) => deps.say(` ${i + 1}. ${h.name}`));
9249
+ for (;;) {
9250
+ const answer = (await deps.ask(`Number or name (1-${deps.harbors.length}): `)).trim();
9251
+ if (!answer) continue;
9252
+ const n = Number(answer);
9253
+ if (Number.isInteger(n) && n >= 1 && n <= deps.harbors.length) return deps.harbors[n - 1];
9254
+ try {
9255
+ return deps.named(answer);
9256
+ } catch (error) {
9257
+ deps.say(error.message);
9258
+ }
9259
+ }
9260
+ }
9079
9261
 
9080
9262
  //#endregion
9081
9263
  //#region src/tui/main.tsx
@@ -9131,7 +9313,8 @@ async function startApp(deps, options = {}) {
9131
9313
  transport,
9132
9314
  account,
9133
9315
  harbors,
9134
- today
9316
+ today,
9317
+ remember: deps.remember
9135
9318
  },
9136
9319
  children: /* @__PURE__ */ jsx(App, { ui })
9137
9320
  }));
@@ -9186,6 +9369,22 @@ async function runApp(opts) {
9186
9369
  terminal.close();
9187
9370
  }
9188
9371
  } else if (opts.harbor) workspaceId = harborNamed(harbors, opts.harbor).id;
9372
+ else if (opts.saved && harbors.some((h) => h.id === opts.saved)) workspaceId = opts.saved;
9373
+ else if (harbors.length > 1) {
9374
+ const terminal = terminalAsk();
9375
+ try {
9376
+ const picked = await pickHarbor({
9377
+ ask: terminal.ask,
9378
+ say: (line) => process.stdout.write(`${plain(line)}\n`),
9379
+ harbors,
9380
+ named: (key) => harborNamed(harbors, key)
9381
+ });
9382
+ workspaceId = picked.id;
9383
+ await opts.remember?.(picked);
9384
+ } finally {
9385
+ terminal.close();
9386
+ }
9387
+ }
9189
9388
  const { instance } = await startApp({
9190
9389
  transport,
9191
9390
  account,
@@ -9193,7 +9392,8 @@ async function runApp(opts) {
9193
9392
  workspaceId,
9194
9393
  splashMs: SPLASH_MS,
9195
9394
  theme,
9196
- firstRun
9395
+ firstRun,
9396
+ remember: opts.remember
9197
9397
  }, { alternateScreen: true });
9198
9398
  await instance.waitUntilExit();
9199
9399
  }
package/lib/auth.mjs CHANGED
@@ -67,7 +67,7 @@ export async function listenForCode(state, { timeoutMs = 5 * 60_000 } = {}) {
67
67
  if (ok) settle.resolve(url.searchParams.get("code"));
68
68
  else
69
69
  settle.reject(
70
- new Error(
70
+ new HarborError(
71
71
  error === "access_denied"
72
72
  ? "You declined in the browser."
73
73
  : "The sign-in didn't come back as expected.",
@@ -77,7 +77,7 @@ export async function listenForCode(state, { timeoutMs = 5 * 60_000 } = {}) {
77
77
  await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
78
78
  const { port } = server.address();
79
79
  const timer = setTimeout(
80
- () => settle.reject(new Error("No answer from the browser in 5 minutes.")),
80
+ () => settle.reject(new HarborError("No answer from the browser in 5 minutes. Run harbor login to try again.")),
81
81
  timeoutMs,
82
82
  );
83
83
  const done = code.finally(() => {
@@ -178,7 +178,10 @@ export async function refresh(creds, { fetcher = fetch, now = Date.now } = {}) {
178
178
  // Refused: the sign-in was disconnected in Harbor, or renewed by another
179
179
  // copy of these credentials (a refresh token is good once).
180
180
  if (res.status === 400 || res.status === 401) {
181
- throw new HarborError("Your sign-in on this machine has ended. Run harbor login.");
181
+ const ended = new HarborError("Your sign-in on this machine has ended. Run harbor login.");
182
+ // The full-screen app says this rather than "Can't reach Harbor".
183
+ Object.assign(ended, { signedOut: true });
184
+ throw ended;
182
185
  }
183
186
  const tokens = await json(res, "Refreshing your sign-in");
184
187
  return toCredentials(creds.server, creds.tokenEndpoint, creds.clientId, tokens, now());
package/lib/cli.mjs CHANGED
@@ -23,36 +23,39 @@ import { jsonText, plain } from "./plain.mjs";
23
23
 
24
24
  export const HELP = `harbor: Harbor from the terminal
25
25
 
26
- harbor the full-screen app: everything, with the keyboard
27
- harbor signup [--server URL] new to Harbor: make an account (opens your browser)
26
+ harbor the full-screen app, by keyboard
27
+ harbor signup [--server URL] new to Harbor: make an account
28
28
  harbor login [--server URL] [--read-only] sign in (opens your browser once)
29
29
  harbor logout forget the sign-in on this machine
30
30
  harbor harbors the harbors you belong to
31
- harbor use HARBOR|none the harbor commands mean when you're in several
32
- harbor theme [auto|light|dark] the app's colours (auto asks the terminal)
31
+ harbor use HARBOR|none the harbor to work in
32
+ harbor theme [auto|light|dark] the app's colours
33
33
  harbor --version which version this is
34
34
 
35
35
  harbor mine what's on you, late first
36
- harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
37
- harbor show TASK one task in full (TASK: its key, RR-42, or id)
36
+ harbor tasks [words] [--project P] [--assignee me|none|NAME]
37
+ [--late] [--status S,S]
38
+ harbor show TASK one task (TASK: a key like RR-42)
38
39
 
39
- harbor add "TITLE" --project P [--assignee me|NAME] [--due YYYY-MM-DD]
40
- [--priority low|medium|high|urgent] [--status S] [--over-limit]
40
+ harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
41
+ [--priority low|medium|high|urgent] [--status S]
42
+ [--over-limit]
41
43
  harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
42
44
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
43
45
  [--priority P] [--status S] [--confirm] [--over-limit]
44
46
  harbor comment TASK "TEXT"
45
47
  harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
46
48
  harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
47
- [--owner NAME] [--due YYYY-MM-DD]
49
+ [--owner NAME] [--due DATE]
48
50
  harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
49
- [--due YYYY-MM-DD]
51
+ [--due DATE]
50
52
 
51
- Every command takes --json, and --harbor NAME when you're in more than one harbor
52
- (or set one with harbor use). harbor COMMAND --help shows just that command.
53
+ Every command takes --json, and --harbor NAME when you're in more than one
54
+ harbor (or set one with harbor use). harbor help COMMAND shows just that one.
53
55
  Statuses: backlog, todo, doing, review, done.
54
- Changes show in Harbor's History as yours, "via Harbor CLI". These commands never
55
- delete; the full-screen app can, as the website can, and asks first.
56
+ DATE: YYYY-MM-DD, today, tomorrow, or +N for N days from today.
57
+ Changes show in Harbor's History as yours, "via Harbor CLI". These commands
58
+ never delete; the full-screen app can, as the website can, and asks first.
56
59
  `;
57
60
 
58
61
  export const VERSION = JSON.parse(
@@ -125,6 +128,16 @@ const level = (v, name) => {
125
128
  };
126
129
  const nullable = (v) => (v === "none" ? null : v);
127
130
 
131
+ /** --due as people say it: today, tomorrow, +3, or a date. Anything else goes to Harbor to judge. */
132
+ export function dueDate(v, today = new Date()) {
133
+ if (v === undefined || v === null) return v;
134
+ const words = { today: 0, tomorrow: 1 };
135
+ const days = v in words ? words[v] : /^\+\d{1,3}$/.test(v) ? Number(v.slice(1)) : null;
136
+ if (days === null) return v;
137
+ const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + days);
138
+ return todayLocal(d);
139
+ }
140
+
128
141
  /** Harbor's tool fields, as the CLI's flags. */
129
142
  const FLAG = {
130
143
  due: "--due",
@@ -158,7 +171,13 @@ export function hint(message) {
158
171
  const [path, ...why] = issue.split(": ");
159
172
  const field = path.replace(/\.\d+$/, "");
160
173
  const flag = FLAG[field] ?? `--${field}`;
161
- if (!seen.has(flag)) seen.set(flag, why.join(": "));
174
+ // zod's "Invalid option: expected one of "low"|"high"" reads as the choices.
175
+ const said = why
176
+ .join(": ")
177
+ .replace(/^Invalid option: expected one of (.*)$/, (_, list) =>
178
+ `pick one of ${list.replace(/"/g, "").split("|").join(", ")}`,
179
+ );
180
+ if (!seen.has(flag)) seen.set(flag, said);
162
181
  }
163
182
  return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
164
183
  }
@@ -168,6 +187,10 @@ export function hint(message) {
168
187
  "Say which with --harbor NAME, or set one with harbor use NAME.",
169
188
  )
170
189
  .replace(/Find it with search_tasks\.$/, "Find it with harbor tasks.")
190
+ .replace(
191
+ /^Nothing to change: pass a field to update\.$/,
192
+ "Nothing to change. Say what with --title, --assignee, --due, --priority or --status.",
193
+ )
171
194
  .replace(
172
195
  /^This connection was allowed to read only\..*$/s,
173
196
  "This sign-in can only read. To make changes, run harbor login again.",
@@ -233,6 +256,13 @@ export async function run(argv, io = {}) {
233
256
  const [command, ...rest] = commandFirst(argv);
234
257
 
235
258
  if (command === "help" || command === "--help" || command === "-h") {
259
+ // `harbor help mine`: that command's help, as `harbor mine --help` gives it.
260
+ if (rest[0]) {
261
+ const help = commandHelp(rest[0]);
262
+ if (help === null) throw new HarborError(`No command "${rest[0]}". Run harbor help.`);
263
+ out(help);
264
+ return 0;
265
+ }
236
266
  const top = banner(io, env, ({ bold, dim }) => [
237
267
  bold("Harbor"),
238
268
  "Portfolios, programs and projects,",
@@ -296,10 +326,26 @@ export async function run(argv, io = {}) {
296
326
  }
297
327
  }
298
328
  const { runApp } = await loadApp();
329
+ // The app and the commands share one harbor choice: what harbor use set,
330
+ // or what's picked or switched to in the app. Each write reads the file
331
+ // first, so a renewed token and a new choice don't undo each other.
332
+ const keepHarbor = async (c) => {
333
+ const now = await readCredentials(env);
334
+ return now ? { ...c, harbor: now.harbor ?? null, harborName: now.harborName ?? null } : c;
335
+ };
299
336
  await runApp({
300
- source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
337
+ source: tokenSource({
338
+ creds,
339
+ save: async (c) => writeCredentials(await keepHarbor(c), env),
340
+ fetcher,
341
+ }),
301
342
  fetcher,
302
- harbor: values.harbor ?? creds.harbor ?? undefined,
343
+ harbor: values.harbor,
344
+ saved: creds.harbor ?? null,
345
+ remember: async (h) => {
346
+ const now = (await readCredentials(env)) ?? creds;
347
+ await writeCredentials({ ...now, harbor: h.id, harborName: h.name }, env);
348
+ },
303
349
  theme: (await readSettings(env)).theme,
304
350
  });
305
351
  return 0;
@@ -382,6 +428,10 @@ export async function run(argv, io = {}) {
382
428
  return 0;
383
429
  }
384
430
 
431
+ // A mistyped command says so, signed in or not.
432
+ if (commandHelp(command) === null) {
433
+ throw new HarborError(`No command "${command}". Run harbor help.`);
434
+ }
385
435
  const creds = await readCredentials(env);
386
436
  if (!creds)
387
437
  throw new HarborError("Not signed in. Run harbor login, or harbor signup if you're new.");
@@ -419,7 +469,7 @@ export async function run(argv, io = {}) {
419
469
  out(
420
470
  creds.harbor
421
471
  ? plain(`Commands use ${creds.harborName ?? creds.harbor}.`)
422
- : "No harbor set: commands use your only harbor, or the one --harbor names.",
472
+ : "No harbor set. In one harbor, commands use it; in several, set one with harbor use NAME or say --harbor NAME.",
423
473
  );
424
474
  return 0;
425
475
  }
@@ -518,7 +568,7 @@ export async function run(argv, io = {}) {
518
568
  project: values.project,
519
569
  title: text(positionals, "the task"),
520
570
  assignee: values.assignee,
521
- due: values.due,
571
+ due: dueDate(values.due),
522
572
  priority: values.priority,
523
573
  status: values.status,
524
574
  overLimit: values["over-limit"] || undefined,
@@ -560,7 +610,7 @@ export async function run(argv, io = {}) {
560
610
  task: one(positionals, "task id"),
561
611
  title: values.title,
562
612
  assignee: values.assignee,
563
- due: values.due === undefined ? undefined : nullable(values.due),
613
+ due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
564
614
  priority: values.priority,
565
615
  status: values.status,
566
616
  confirmed: values.confirm || undefined,
@@ -616,9 +666,9 @@ export async function run(argv, io = {}) {
616
666
  owner: values.owner,
617
667
  impact: level(values.impact, "impact"),
618
668
  probability: level(values.probability, "probability"),
619
- due: values.due,
669
+ due: dueDate(values.due),
620
670
  },
621
- (r) => `Logged the ${command} (${r.raised.id}).`,
671
+ (r) => `Logged the ${command} “${r.raised.title}”.`,
622
672
  );
623
673
  }
624
674
  default:
package/lib/format.mjs CHANGED
@@ -36,7 +36,8 @@ export function myWork(w, opts) {
36
36
  if (w.raid?.length) {
37
37
  out.push("", "Yours in the register:");
38
38
  for (const r of w.raid)
39
- out.push(` ${r.id} ${r.kind}: ${r.title}${r.due ? ` (review by ${r.due})` : ""}`);
39
+ // No command takes a risk's id, so it isn't shown.
40
+ out.push(` ${r.kind}: ${r.title}${r.due ? ` (review by ${r.due})` : ""}`);
40
41
  }
41
42
  return out.join("\n");
42
43
  }
@@ -53,7 +54,14 @@ export function taskList(r, opts) {
53
54
  export function harbors(r) {
54
55
  const list = r.harbors ?? [];
55
56
  if (!list.length) return "You aren't in any harbor yet. Run harbor to make one.";
56
- return list.map((h) => `${h.id} ${h.name}${h.role ? ` (${h.role})` : ""}`).join("\n");
57
+ // The website's words for a role, not the stored ones; names, not ids
58
+ // (harbor use and --harbor take a name).
59
+ const label = (role) =>
60
+ role === "owner" ? "Owner" : role === "viewer" ? "Can view" : role ? "Can edit" : "";
61
+ const width = Math.min(40, Math.max(...list.map((h) => h.name.length)));
62
+ return list
63
+ .map((h) => (h.role ? `${h.name.padEnd(width)} ${label(h.role)}` : h.name))
64
+ .join("\n");
57
65
  }
58
66
 
59
67
  export function todayLocal(d = new Date()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.7.1",
3
+ "version": "0.9.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",