@harborppm/cli 0.5.0 → 0.6.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
@@ -51,22 +51,27 @@ It runs on the same engine as the website: health, task keys, limits and undo wo
51
51
 
52
52
  ```
53
53
  harbor harbors the harbors you belong to
54
+ harbor use HARBOR|none the harbor commands mean when you're in several
54
55
  harbor mine what's on you, late first
55
- harbor tasks [words] [--project P] [--assignee me|NAME] [--late] [--status S]
56
+ harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
56
57
  harbor show TASK one task in full
57
58
 
58
59
  harbor add "TITLE" --project P [--assignee me|NAME] [--due YYYY-MM-DD]
59
- [--priority low|medium|high|urgent] [--status S]
60
- harbor start TASK | review TASK | done TASK [--confirm]
60
+ [--priority low|medium|high|urgent] [--status S] [--over-limit]
61
+ harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
61
62
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
62
- [--priority P] [--status S]
63
+ [--priority P] [--status S] [--confirm] [--over-limit]
63
64
  harbor comment TASK "TEXT"
64
65
  harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
65
- harbor risk "TITLE" [--project P] [--impact 1-5] [--probability 1-5]
66
- harbor issue "TITLE" [--project P]
66
+ harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
67
+ [--owner NAME] [--due YYYY-MM-DD]
68
+ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
69
+ [--due YYYY-MM-DD]
67
70
  ```
68
71
 
69
- Every command takes `--harbor NAME` when you're in more than one, and `--json` for the raw result. Statuses are backlog, todo, doing, review and done.
72
+ Every command takes `--json` for the raw result, and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done.
73
+
74
+ 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.
70
75
 
71
76
  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`).
72
77
 
package/bin/harbor.mjs CHANGED
@@ -22,6 +22,7 @@ run(argv).then(
22
22
  async (err) => {
23
23
  const known =
24
24
  err instanceof HarborError ||
25
+ err?.name === "HarborError" ||
25
26
  /^(Unknown option|--\S+ (needs|takes|is))/.test(err?.message ?? "");
26
27
  // Harbor's refusals can quote a task's title; print them plain.
27
28
  process.stderr.write(
package/dist/app.mjs CHANGED
@@ -1707,9 +1707,17 @@ function numberNewTasks(before, after) {
1707
1707
  projects
1708
1708
  };
1709
1709
  const was = byId(before.tasks);
1710
+ const holders = /* @__PURE__ */ new Map();
1711
+ for (const t of after.tasks) {
1712
+ if (!Number.isInteger(t.number)) continue;
1713
+ const k = `${t.projectId}:${t.number}`;
1714
+ holders.set(k, (holders.get(k) ?? 0) + 1);
1715
+ }
1716
+ const keepsNumber = (t) => Number.isInteger(t.number) && holders.get(`${t.projectId}:${t.number}`) === 1;
1710
1717
  const fresh = (t) => {
1711
1718
  const old = was.get(t.id);
1712
- return !old || old.projectId !== t.projectId && old.number === t.number;
1719
+ if (!old) return !keepsNumber(t);
1720
+ return old.projectId !== t.projectId && old.number === t.number;
1713
1721
  };
1714
1722
  if (!after.tasks.some(fresh)) return projects === after.projects ? after : {
1715
1723
  ...after,
@@ -1855,12 +1863,17 @@ function openBlockerNames(task, tasks) {
1855
1863
  function waitingOn(task, tasks) {
1856
1864
  return tasks.filter((t) => t.id !== task.id && waitsOn(t, task.id) && t.status !== "done");
1857
1865
  }
1866
+ const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1867
+ /**
1868
+ * Who a comment mentions: "@" and a first name as a whole word, so @Samantha
1869
+ * isn't Sam and an address (jane@nico.com) mentions nobody.
1870
+ */
1858
1871
  function parseMentions(body, people) {
1859
1872
  const ids = /* @__PURE__ */ new Set();
1860
- const lower = body.toLowerCase();
1861
1873
  for (const person of people) {
1862
- const needle = `@${person.name.split(" ")[0].toLowerCase()}`;
1863
- if (lower.includes(needle)) ids.add(person.id);
1874
+ const first = person.name.trim().split(/\s+/)[0];
1875
+ if (!first) continue;
1876
+ if (new RegExp(`(?<![\\p{L}\\p{N}_])@${escapeRegExp(first)}(?![\\p{L}\\p{N}_-])`, "iu").test(body)) ids.add(person.id);
1864
1877
  }
1865
1878
  return [...ids];
1866
1879
  }
@@ -2078,8 +2091,9 @@ function forecastLine(f) {
2078
2091
  //#endregion
2079
2092
  //#region src/tui/theme.ts
2080
2093
  /**
2081
- * Dark: Harbor's teal and coral lifted so they read on black, with the plain
2082
- * colours every terminal draws for status. Light: the brand's own darker teal
2094
+ * Dark: Harbor's teal and coral lifted so they read on black, and status in
2095
+ * softened hues rather than the terminal's own loud green and yellow (a
2096
+ * 16-colour terminal still gets its nearest plain colour). Light: the brand's own darker teal
2083
2097
  * and coral, and status in hues dark enough for text on white (4.5:1 or more).
2084
2098
  */
2085
2099
  const PALETTES = {
@@ -2089,10 +2103,10 @@ const PALETTES = {
2089
2103
  muted: "gray",
2090
2104
  selected: "#3dbcab",
2091
2105
  border: "gray",
2092
- good: "green",
2093
- warn: "yellow",
2094
- bad: "red",
2095
- done: "cyan"
2106
+ good: "#86c294",
2107
+ warn: "#e3b55c",
2108
+ bad: "#f2665d",
2109
+ done: "#7fb6cc"
2096
2110
  },
2097
2111
  light: {
2098
2112
  accent: "#0f6e64",
@@ -2906,7 +2920,10 @@ function HistoryView({ active, width, height, go }) {
2906
2920
  }),
2907
2921
  /* @__PURE__ */ jsx(Text, {
2908
2922
  bold: true,
2909
- children: actorOf(row, people)
2923
+ children: actorOf({
2924
+ ...row,
2925
+ via: null
2926
+ }, people)
2910
2927
  }),
2911
2928
  row.via ? /* @__PURE__ */ jsxs(Text, {
2912
2929
  color: theme.muted,
@@ -2993,7 +3010,7 @@ function listNames(names) {
2993
3010
  return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
2994
3011
  }
2995
3012
  function excerpt(body) {
2996
- const t = body.replace(/@\w+/g, "").replace(/\s+/g, " ").trim();
3013
+ const t = body.replace(/(?<![\p{L}\p{N}_])@[\p{L}\p{N}_-]+/gu, "").replace(/\s+/g, " ").trim();
2997
3014
  if (t.length <= 96) return t;
2998
3015
  return `${t.slice(0, 94)}…`;
2999
3016
  }
@@ -3462,17 +3479,19 @@ function PeopleView({ active, width, height, go }) {
3462
3479
  const { today } = useDeps();
3463
3480
  const rows = useMemo(() => {
3464
3481
  const now = /* @__PURE__ */ new Date(`${today()}T12:00:00`);
3482
+ const nameWidth = Math.max(0, ...people.map((p) => p.name.length));
3465
3483
  return people.map((p) => {
3466
3484
  const open = tasks.filter((t) => t.assigneeId === p.id && t.status !== "done");
3467
3485
  const load = weeklyLoad(tasks, p.id, now, 4);
3468
3486
  const cap = p.weeklyCapacity ?? 5;
3469
- const weeks = load.weeks.map((w) => w.due > cap ? `${w.due}!` : String(w.due)).join(" ");
3487
+ const weeks = load.weeks.map((w) => (w.due === 0 ? "·" : w.due > cap ? `${w.due}!` : String(w.due)).padStart(3)).join("");
3488
+ const late = load.overdue ? `${load.overdue} late` : "";
3470
3489
  return {
3471
3490
  id: p.id,
3472
3491
  kind: "person",
3473
3492
  depth: 0,
3474
- title: p.name,
3475
- detail: `${p.role} · ${open.length} open${load.overdue ? ` · ${load.overdue} late` : ""} · next 4 weeks ${weeks} of ${cap}`
3493
+ title: p.name.padEnd(nameWidth),
3494
+ detail: `${String(open.length).padStart(3)} open ${late.padEnd(7)} weeks${weeks} of ${cap} · ${p.role}`
3476
3495
  };
3477
3496
  });
3478
3497
  }, [
@@ -5166,7 +5185,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5166
5185
  ]
5167
5186
  });
5168
5187
  }
5169
- /** One card on the board: its key, owner and due date, then the title on up to two lines. */
5188
+ /** One card on the board: its key, due date and owner, then the title on up to two lines. */
5170
5189
  function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
5171
5190
  return /* @__PURE__ */ jsxs(Box, {
5172
5191
  flexDirection: "column",
@@ -5182,10 +5201,6 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
5182
5201
  }),
5183
5202
  " ",
5184
5203
  /* @__PURE__ */ jsx(PriorityMark, { priority }),
5185
- owner ? /* @__PURE__ */ jsxs(Text, {
5186
- color: selected ? void 0 : theme.muted,
5187
- children: [" ", owner]
5188
- }) : null,
5189
5204
  due ? /* @__PURE__ */ jsxs(Text, {
5190
5205
  color: late ? theme.bad : selected ? void 0 : theme.muted,
5191
5206
  children: [" ", due]
@@ -5193,6 +5208,10 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
5193
5208
  waits ? /* @__PURE__ */ jsx(Text, {
5194
5209
  color: theme.warn,
5195
5210
  children: " waits"
5211
+ }) : null,
5212
+ owner ? /* @__PURE__ */ jsxs(Text, {
5213
+ color: selected ? void 0 : theme.muted,
5214
+ children: [" ", owner]
5196
5215
  }) : null
5197
5216
  ]
5198
5217
  }), /* @__PURE__ */ jsx(Box, {
@@ -5464,7 +5483,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5464
5483
  status: STATUS_LABEL[task.status],
5465
5484
  assignee: owner?.name ?? "Nobody yet",
5466
5485
  due: task.dueDate ? `${shortDay(task.dueDate, day)}${isLate(task, day) ? " (late)" : ""}` : "None",
5467
- priority: task.priority,
5486
+ priority: PRIORITY_LABEL[task.priority],
5468
5487
  project: project ? `${project.name}${project.key ? ` (${project.key})` : ""}` : "",
5469
5488
  notes: task.description.replace(/\s+/g, " ").trim() || "None"
5470
5489
  };
@@ -5525,7 +5544,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5525
5544
  } else if (f === "priority") editor = /* @__PURE__ */ jsx(Picker, {
5526
5545
  title: "Priority",
5527
5546
  choices: PRIORITIES.map((p) => ({
5528
- label: p,
5547
+ label: PRIORITY_LABEL[p],
5529
5548
  value: p
5530
5549
  })),
5531
5550
  initial: PRIORITIES.indexOf(task.priority),
@@ -5628,7 +5647,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5628
5647
  children: [
5629
5648
  " ",
5630
5649
  "Checklist",
5631
- task.checklist.length ? ` ${task.checklist.filter((c) => c.done).length}/${task.checklist.length}` : ""
5650
+ task.checklist.length ? ` ${task.checklist.filter((c) => c.done).length}/${task.checklist.length}` : " · none yet, a adds a step"
5632
5651
  ]
5633
5652
  }),
5634
5653
  task.checklist.map((item, i) => {
@@ -8943,6 +8962,17 @@ function createSync(opts) {
8943
8962
  };
8944
8963
  }
8945
8964
 
8965
+ //#endregion
8966
+ //#region cli/lib/errors.mjs
8967
+ /**
8968
+ * What the CLI says when something stops a command: a HarborError is
8969
+ * already worded for the person and printed as it is; anything else is
8970
+ * unexpected and printed as "Something went wrong".
8971
+ */
8972
+ var HarborError = class extends Error {
8973
+ name = "HarborError";
8974
+ };
8975
+
8946
8976
  //#endregion
8947
8977
  //#region src/tui/theme-detect.ts
8948
8978
  const isThemeSetting = (v) => v === "auto" || v === "light" || v === "dark";
@@ -9060,20 +9090,36 @@ async function startApp(deps, options = {}) {
9060
9090
  ui
9061
9091
  };
9062
9092
  }
9093
+ /**
9094
+ * The harbor --harbor names, as the commands read it: its id, its whole
9095
+ * name, or the one name it best matches.
9096
+ */
9097
+ function harborNamed(harbors, key) {
9098
+ const want = key.trim();
9099
+ const exact = harbors.find((h) => h.id === want) ?? harbors.find((h) => h.name.trim().toLowerCase() === want.toLowerCase());
9100
+ if (exact) return exact;
9101
+ const hits = harbors.map((h) => ({
9102
+ h,
9103
+ s: scoreMatch(want, h.name)
9104
+ })).filter((x) => x.s > 0).sort((a, b) => b.s - a.s);
9105
+ if (hits.length === 0) throw new HarborError(`No harbor matches "${want}". Run harbor harbors.`);
9106
+ if (hits.length > 1 && hits[0].s === hits[1].s) {
9107
+ const names = hits.map((x) => x.h.name).join(", ");
9108
+ throw new HarborError(`"${want}" matches more than one harbor: ${names}.`);
9109
+ }
9110
+ return hits[0].h;
9111
+ }
9063
9112
  /** `harbor`: sign-in already done, take over the terminal until q. */
9064
9113
  async function runApp(opts) {
9065
9114
  const theme = await pickTheme(opts.theme);
9066
9115
  const transport = httpTransport(opts.source, opts.fetcher);
9067
9116
  const { account, harbors } = await transport.harbors();
9068
- if (harbors.length === 0) throw new Error("You don't belong to a harbor yet. Make one at harborppm.com first.");
9069
- const wanted = opts.harbor?.toLowerCase();
9070
- const pick = wanted ? harbors.find((h) => h.id === opts.harbor || h.name.toLowerCase() === wanted) : void 0;
9071
- if (wanted && !pick) throw new Error(`No harbor called "${opts.harbor}".`);
9117
+ if (harbors.length === 0) throw new HarborError(`You don't belong to a harbor yet. Make one at ${opts.source.server} first.`);
9072
9118
  const { instance } = await startApp({
9073
9119
  transport,
9074
9120
  account,
9075
9121
  harbors,
9076
- workspaceId: pick?.id,
9122
+ workspaceId: (opts.harbor ? harborNamed(harbors, opts.harbor) : void 0)?.id,
9077
9123
  splashMs: SPLASH_MS,
9078
9124
  theme
9079
9125
  }, { alternateScreen: true });
@@ -9081,4 +9127,4 @@ async function runApp(opts) {
9081
9127
  }
9082
9128
 
9083
9129
  //#endregion
9084
- export { SPLASH_MS, TransportError, httpTransport, runApp, startApp };
9130
+ export { SPLASH_MS, TransportError, harborNamed, httpTransport, runApp, startApp };
package/lib/args.mjs CHANGED
@@ -22,7 +22,11 @@ export function parseArgs(argv, { flags = [], options = [] } = {}) {
22
22
  values[key] = true;
23
23
  } else if (options.includes(key)) {
24
24
  const value = inline ?? argv[++i];
25
- if (value === undefined || (inline === undefined && value.startsWith("--"))) {
25
+ if (
26
+ value === undefined ||
27
+ value.trim() === "" ||
28
+ (inline === undefined && value.startsWith("--"))
29
+ ) {
26
30
  throw new Error(`--${key} needs a value.`);
27
31
  }
28
32
  values[key] = value;
package/lib/auth.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { createHash, randomBytes } from "node:crypto";
7
7
  import { createServer } from "node:http";
8
+ import { HarborError, reach } from "./errors.mjs";
8
9
 
9
10
  export const CLIENT_NAME = "Harbor CLI";
10
11
  export const WRITE_SCOPE = "harbor:write";
@@ -36,7 +37,7 @@ async function json(res, what) {
36
37
 
37
38
  /** The server's OAuth endpoints, from its discovery document. */
38
39
  export async function discover(server, fetcher = fetch) {
39
- const res = await fetcher(new URL("/.well-known/oauth-authorization-server", server));
40
+ const res = await reach(fetcher, new URL("/.well-known/oauth-authorization-server", server));
40
41
  return json(res, "Finding Harbor's sign-in");
41
42
  }
42
43
 
@@ -98,7 +99,7 @@ export async function login({ server, readOnly = false, open, fetcher = fetch, n
98
99
  const { redirectUri, code } = await listenForCode(state);
99
100
 
100
101
  const client = await json(
101
- await fetcher(meta.registration_endpoint, {
102
+ await reach(fetcher, meta.registration_endpoint, {
102
103
  method: "POST",
103
104
  headers: { "content-type": "application/json" },
104
105
  body: JSON.stringify({
@@ -120,14 +121,12 @@ export async function login({ server, readOnly = false, open, fetcher = fetch, n
120
121
  state,
121
122
  code_challenge: challenge,
122
123
  code_challenge_method: "S256",
123
- scope: readOnly
124
- ? "openid offline_access"
125
- : `openid offline_access ${WRITE_SCOPE} ${APP_SCOPE}`,
124
+ scope: readOnly ? "openid offline_access" : `openid offline_access ${WRITE_SCOPE} ${APP_SCOPE}`,
126
125
  }).toString();
127
126
  await open(authorize.toString());
128
127
 
129
128
  const tokens = await json(
130
- await fetcher(meta.token_endpoint, {
129
+ await reach(fetcher, meta.token_endpoint, {
131
130
  method: "POST",
132
131
  headers: { "content-type": "application/x-www-form-urlencoded" },
133
132
  body: new URLSearchParams({
@@ -157,18 +156,21 @@ export function toCredentials(server, tokenEndpoint, clientId, tokens, at) {
157
156
 
158
157
  /** New tokens from the refresh token; the old one stops working. */
159
158
  export async function refresh(creds, { fetcher = fetch, now = Date.now } = {}) {
160
- if (!creds.refreshToken) throw new Error("Signed out. Run harbor login.");
161
- const tokens = await json(
162
- await fetcher(creds.tokenEndpoint, {
163
- method: "POST",
164
- headers: { "content-type": "application/x-www-form-urlencoded" },
165
- body: new URLSearchParams({
166
- grant_type: "refresh_token",
167
- refresh_token: creds.refreshToken,
168
- client_id: creds.clientId,
169
- }),
159
+ if (!creds.refreshToken) throw new HarborError("Signed out. Run harbor login.");
160
+ const res = await reach(fetcher, creds.tokenEndpoint, {
161
+ method: "POST",
162
+ headers: { "content-type": "application/x-www-form-urlencoded" },
163
+ body: new URLSearchParams({
164
+ grant_type: "refresh_token",
165
+ refresh_token: creds.refreshToken,
166
+ client_id: creds.clientId,
170
167
  }),
171
- "Refreshing your sign-in",
172
- );
168
+ });
169
+ // Refused: the sign-in was disconnected in Harbor, or renewed by another
170
+ // copy of these credentials (a refresh token is good once).
171
+ if (res.status === 400 || res.status === 401) {
172
+ throw new HarborError("Your sign-in on this machine has ended. Run harbor login.");
173
+ }
174
+ const tokens = await json(res, "Refreshing your sign-in");
173
175
  return toCredentials(creds.server, creds.tokenEndpoint, creds.clientId, tokens, now());
174
176
  }
package/lib/cli.mjs CHANGED
@@ -27,24 +27,28 @@ export const HELP = `harbor: Harbor from the terminal
27
27
  harbor login [--server URL] [--read-only] sign in (opens your browser once)
28
28
  harbor logout forget the sign-in on this machine
29
29
  harbor harbors the harbors you belong to
30
+ harbor use HARBOR|none the harbor commands mean when you're in several
30
31
  harbor theme [auto|light|dark] the app's colours (auto asks the terminal)
31
32
  harbor --version which version this is
32
33
 
33
34
  harbor mine what's on you, late first
34
- harbor tasks [words] [--project P] [--assignee me|NAME] [--late] [--status S]
35
+ harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
35
36
  harbor show TASK one task in full (TASK: its key, RR-42, or id)
36
37
 
37
38
  harbor add "TITLE" --project P [--assignee me|NAME] [--due YYYY-MM-DD]
38
- [--priority low|medium|high|urgent] [--status S]
39
- harbor start TASK | review TASK | done TASK [--confirm]
39
+ [--priority low|medium|high|urgent] [--status S] [--over-limit]
40
+ harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
40
41
  harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
41
- [--priority P] [--status S]
42
+ [--priority P] [--status S] [--confirm] [--over-limit]
42
43
  harbor comment TASK "TEXT"
43
44
  harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
44
- harbor risk "TITLE" [--project P] [--impact 1-5] [--probability 1-5]
45
- harbor issue "TITLE" [--project P]
45
+ harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
46
+ [--owner NAME] [--due YYYY-MM-DD]
47
+ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
48
+ [--due YYYY-MM-DD]
46
49
 
47
- Every command takes --harbor NAME (when you're in more than one) and --json.
50
+ Every command takes --json, and --harbor NAME when you're in more than one harbor
51
+ (or set one with harbor use). harbor COMMAND --help shows just that command.
48
52
  Statuses: backlog, todo, doing, review, done.
49
53
  Changes show in Harbor's History as yours, "via Harbor CLI". These commands never
50
54
  delete; the full-screen app can, as the website can, and asks first.
@@ -120,21 +124,108 @@ const level = (v, name) => {
120
124
  };
121
125
  const nullable = (v) => (v === "none" ? null : v);
122
126
 
123
- /** Harbor's refusals are worded for agents; say which flag answers them. */
127
+ /** Harbor's tool fields, as the CLI's flags. */
128
+ const FLAG = {
129
+ due: "--due",
130
+ priority: "--priority",
131
+ status: "--status",
132
+ assignee: "--assignee",
133
+ title: "--title",
134
+ project: "--project",
135
+ program: "--program",
136
+ owner: "--owner",
137
+ impact: "--impact",
138
+ probability: "--probability",
139
+ query: "the words",
140
+ task: "the task",
141
+ note: "the check-in",
142
+ body: "the comment",
143
+ };
144
+
145
+ /** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
124
146
  export function hint(message) {
125
147
  const said = message.replace(/\s*Ask the person;.*$/s, "");
126
148
  if (/confirmed: true/.test(message))
127
149
  return `${said}\nTo finish it anyway, run it again with --confirm.`;
128
150
  if (/overLimit: true/.test(message))
129
151
  return `${said}\nTo go past the limit, run it again with --over-limit.`;
130
- return message;
152
+ const fit = /^Those arguments don't fit \w+: (.*)$/s.exec(message);
153
+ if (fit) {
154
+ // One line per flag: a bad date fails two checks that say the same thing.
155
+ const seen = new Map();
156
+ for (const issue of fit[1].split("; ")) {
157
+ const [path, ...why] = issue.split(": ");
158
+ const field = path.replace(/\.\d+$/, "");
159
+ const flag = FLAG[field] ?? `--${field}`;
160
+ if (!seen.has(flag)) seen.set(flag, why.join(": "));
161
+ }
162
+ return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
163
+ }
164
+ return message
165
+ .replace(
166
+ /Say which one with harbor\.$/,
167
+ "Say which with --harbor NAME, or set one with harbor use NAME.",
168
+ )
169
+ .replace(/Find it with search_tasks\.$/, "Find it with harbor tasks.")
170
+ .replace(
171
+ /^This connection was allowed to read only\..*$/s,
172
+ "This sign-in can only read. To make changes, run harbor login again.",
173
+ )
174
+ .replace(
175
+ /^Say which project or program it belongs to\.$/,
176
+ "Say where it belongs: --project NAME or --program NAME.",
177
+ );
178
+ }
179
+
180
+ /** The lines of the help about one command, or null when it has none. */
181
+ export function commandHelp(command) {
182
+ const lines = HELP.split("\n");
183
+ const picked = [];
184
+ for (let i = 0; i < lines.length; i++) {
185
+ const words = lines[i].trim().split(/\s+/);
186
+ const names = words.filter(
187
+ (_, j) => j > 0 && (words[j - 1] === "harbor" || words[j - 1] === "|"),
188
+ );
189
+ if (words[0] !== "harbor" || !names.includes(command)) continue;
190
+ picked.push(lines[i]);
191
+ // Its options carry on below it, further in.
192
+ while (lines[i + 1]?.startsWith(" ") && /^\s+\[|^\s+--/.test(lines[i + 1]))
193
+ picked.push(lines[++i]);
194
+ }
195
+ if (picked.length === 0) return null;
196
+ const common = ["login", "logout", "use", "theme", "help", "app"].includes(command)
197
+ ? []
198
+ : ["", " Also takes --json, and --harbor NAME when you're in more than one harbor."];
199
+ return [...picked, ...common].join("\n");
131
200
  }
132
201
 
202
+ /** Room for --json and --harbor before the command too: `harbor --json mine`. */
203
+ function commandFirst(argv) {
204
+ const front = [];
205
+ let i = 0;
206
+ while (argv[i] === "--json" || argv[i] === "--harbor" || argv[i]?.startsWith("--harbor=")) {
207
+ if (argv[i] === "--harbor") front.push(argv[i], argv[i + 1] ?? "");
208
+ else front.push(argv[i]);
209
+ i += argv[i] === "--harbor" ? 2 : 1;
210
+ }
211
+ return front.length && argv[i] ? [argv[i], ...front, ...argv.slice(i + 1)] : argv;
212
+ }
213
+
214
+ /** The command's arguments up to a bare --, where words stop being options. */
215
+ const asksHelp = (rest) => {
216
+ const end = rest.indexOf("--");
217
+ return (end < 0 ? rest : rest.slice(0, end)).some((a) => a === "--help" || a === "-h");
218
+ };
219
+
220
+ const none = (positionals) => {
221
+ if (positionals.length) throw new HarborError(`Unexpected "${positionals.join(" ")}".`);
222
+ };
223
+
133
224
  export async function run(argv, io = {}) {
134
225
  const out = io.out ?? ((s) => process.stdout.write(`${s}\n`));
135
226
  const env = io.env ?? process.env;
136
227
  const fetcher = io.fetch ?? fetch;
137
- const [command, ...rest] = argv;
228
+ const [command, ...rest] = commandFirst(argv);
138
229
 
139
230
  if (command === "help" || command === "--help" || command === "-h") {
140
231
  const top = banner(io, env, ({ bold, dim }) => [
@@ -154,6 +245,16 @@ export async function run(argv, io = {}) {
154
245
  return 0;
155
246
  }
156
247
 
248
+ // `harbor done RR-4 --help` asks how, so nothing runs.
249
+ if (command && asksHelp(rest)) {
250
+ const help = commandHelp(command === "app" ? "" : command);
251
+ if (help === null && command !== "app") {
252
+ throw new HarborError(`No command "${command}". Run harbor help.`);
253
+ }
254
+ out(help ?? HELP);
255
+ return 0;
256
+ }
257
+
157
258
  // On its own in a terminal: the full-screen app. Piped or scripted: the help.
158
259
  if (!command || command === "app") {
159
260
  const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
@@ -193,7 +294,7 @@ export async function run(argv, io = {}) {
193
294
  await runApp({
194
295
  source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
195
296
  fetcher,
196
- harbor: values.harbor,
297
+ harbor: values.harbor ?? creds.harbor ?? undefined,
197
298
  theme: (await readSettings(env)).theme,
198
299
  });
199
300
  return 0;
@@ -202,6 +303,10 @@ export async function run(argv, io = {}) {
202
303
  if (command === "login") {
203
304
  const { values } = parseArgs(rest, { flags: ["read-only"], options: ["server"] });
204
305
  const server = (values.server ?? env.HARBOR_SERVER ?? DEFAULT_SERVER).replace(/\/$/, "");
306
+ if (!/^https?:\/\/[^/]/.test(server) || !URL.canParse(server)) {
307
+ throw new HarborError(`--server needs a web address, like ${DEFAULT_SERVER}.`);
308
+ }
309
+ const before = await readCredentials(env);
205
310
  const creds = await login({
206
311
  server,
207
312
  readOnly: Boolean(values["read-only"]),
@@ -213,6 +318,11 @@ export async function run(argv, io = {}) {
213
318
  openBrowser(url);
214
319
  }),
215
320
  });
321
+ // Signing in again keeps the harbor set with harbor use.
322
+ if (before?.harbor && before.server === server) {
323
+ creds.harbor = before.harbor;
324
+ creds.harborName = before.harborName ?? null;
325
+ }
216
326
  await writeCredentials(creds, env);
217
327
  const canWrite = creds.scope.split(" ").includes("harbor:write");
218
328
  const said = `Signed in to ${server}${canWrite ? "." : ", reading only."}`;
@@ -248,6 +358,7 @@ export async function run(argv, io = {}) {
248
358
  }
249
359
 
250
360
  if (command === "logout") {
361
+ none(parseArgs(rest, {}).positionals);
251
362
  await forgetCredentials(env);
252
363
  out(
253
364
  "Signed out on this machine. To end its access everywhere, disconnect Harbor CLI under Connected apps in Harbor's menu.",
@@ -272,16 +383,56 @@ export async function run(argv, io = {}) {
272
383
  const parse = (s) => {
273
384
  const p = parseArgs(rest, s);
274
385
  asJson = Boolean(p.values.json);
275
- return { ...p, harbor: p.values.harbor };
386
+ return { ...p, harbor: p.values.harbor ?? creds.harbor ?? undefined };
276
387
  };
277
388
 
278
389
  switch (command) {
279
390
  case "harbors": {
280
- parse(spec());
281
- return call("list_harbors", {}, harbors);
391
+ none(parse(spec()).positionals);
392
+ return call("list_harbors", {}, (r) =>
393
+ creds.harbor
394
+ ? `${harbors(r)}\n\nCommands use ${creds.harborName ?? creds.harbor} (harbor use).`
395
+ : harbors(r),
396
+ );
397
+ }
398
+ case "use": {
399
+ const { positionals } = parse({ flags: ["json"], options: [] });
400
+ const wanted = positionals.join(" ").trim();
401
+ if (!wanted) {
402
+ out(
403
+ creds.harbor
404
+ ? plain(`Commands use ${creds.harborName ?? creds.harbor}.`)
405
+ : "No harbor set: commands use your only harbor, or the one --harbor names.",
406
+ );
407
+ return 0;
408
+ }
409
+ if (wanted.toLowerCase() === "none") {
410
+ await writeCredentials({ ...creds, harbor: null, harborName: null }, env);
411
+ out("Commands no longer pick a harbor for you.");
412
+ return 0;
413
+ }
414
+ const list = await client.call("list_harbors", {});
415
+ const key = wanted.toLowerCase();
416
+ const exact = list.harbors.filter(
417
+ (h) => h.id === wanted || h.name.trim().toLowerCase() === key,
418
+ );
419
+ const hits = exact.length
420
+ ? exact
421
+ : list.harbors.filter((h) => h.name.toLowerCase().includes(key));
422
+ if (hits.length === 0)
423
+ throw new HarborError(`No harbor matches "${wanted}". Run harbor harbors.`);
424
+ if (hits.length > 1) {
425
+ throw new HarborError(
426
+ `"${wanted}" matches more than one harbor: ${hits.map((h) => h.name).join(", ")}.`,
427
+ );
428
+ }
429
+ await writeCredentials({ ...creds, harbor: hits[0].id, harborName: hits[0].name }, env);
430
+ out(plain(`Commands now use ${hits[0].name}.`));
431
+ return 0;
282
432
  }
283
433
  case "mine": {
284
- const { harbor } = parse(spec());
434
+ const { positionals, harbor } = parse(spec());
435
+ none(positionals);
285
436
  return call("my_work", { harbor }, (r) => myWork(r, opts));
286
437
  }
287
438
  case "tasks": {
@@ -295,7 +446,12 @@ export async function run(argv, io = {}) {
295
446
  query: positionals.join(" ") || undefined,
296
447
  project: values.project,
297
448
  assignee: values.assignee,
298
- status: values.status ? values.status.split(",") : undefined,
449
+ status: values.status
450
+ ? values.status
451
+ .split(",")
452
+ .map((x) => x.trim())
453
+ .filter(Boolean)
454
+ : undefined,
299
455
  overdue: values.late || undefined,
300
456
  },
301
457
  (r) => taskList(r, opts),
@@ -422,8 +578,16 @@ export async function run(argv, io = {}) {
422
578
  case "risk":
423
579
  case "issue": {
424
580
  const { positionals, values, harbor } = parse(
425
- spec([], ["project", "program", "owner", "impact", "probability", "due"]),
581
+ spec(
582
+ [],
583
+ ["project", "program", "owner", "impact", "due"].concat(
584
+ command === "risk" ? ["probability"] : [],
585
+ ),
586
+ ),
426
587
  );
588
+ if (!values.project && !values.program) {
589
+ throw new HarborError("Say where it belongs: --project NAME or --program NAME.");
590
+ }
427
591
  return call(
428
592
  "log_raid",
429
593
  {
@@ -434,7 +598,7 @@ export async function run(argv, io = {}) {
434
598
  program: values.program,
435
599
  owner: values.owner,
436
600
  impact: level(values.impact, "impact"),
437
- probability: command === "risk" ? level(values.probability, "probability") : undefined,
601
+ probability: level(values.probability, "probability"),
438
602
  due: values.due,
439
603
  },
440
604
  (r) => `Logged the ${command} (${r.raised.id}).`,
package/lib/config.mjs CHANGED
@@ -19,7 +19,12 @@ const file = (env) => join(configDir(env), "credentials.json");
19
19
  /** The saved sign-in, or null when there isn't one. */
20
20
  export async function readCredentials(env = process.env) {
21
21
  try {
22
- return JSON.parse(await readFile(file(env), "utf8"));
22
+ const creds = JSON.parse(await readFile(file(env), "utf8"));
23
+ // A file that isn't a whole sign-in is no sign-in.
24
+ const whole = ["server", "accessToken", "tokenEndpoint"].every(
25
+ (k) => typeof creds?.[k] === "string" && creds[k],
26
+ );
27
+ return whole ? creds : null;
23
28
  } catch {
24
29
  return null;
25
30
  }
package/lib/errors.mjs ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * What the CLI says when something stops a command: a HarborError is
3
+ * already worded for the person and printed as it is; anything else is
4
+ * unexpected and printed as "Something went wrong".
5
+ */
6
+ export class HarborError extends Error {
7
+ // By name too: the full-screen app is bundled with its own copy of this class.
8
+ name = "HarborError";
9
+ }
10
+
11
+ /**
12
+ * fetch, with "the server isn't there" said plainly rather than as
13
+ * "fetch failed" (no network, a wrong --server, the server down).
14
+ * @param {typeof fetch} fetcher
15
+ * @param {string | URL} url
16
+ * @param {RequestInit} [init]
17
+ */
18
+ export async function reach(fetcher, url, init) {
19
+ try {
20
+ return await fetcher(url, init);
21
+ } catch (err) {
22
+ if (/** @type {Error} */ (err)?.name === "AbortError") throw err;
23
+ const where = new URL(url).origin;
24
+ throw new HarborError(
25
+ `Can't reach ${where}. Check your connection, or the address you signed in with.`,
26
+ );
27
+ }
28
+ }
package/lib/mcp.mjs CHANGED
@@ -4,8 +4,9 @@
4
4
  * refreshed once and the call retried.
5
5
  */
6
6
  import { refresh } from "./auth.mjs";
7
+ import { HarborError, reach } from "./errors.mjs";
7
8
 
8
- export class HarborError extends Error {}
9
+ export { HarborError };
9
10
 
10
11
  const SKEW_MS = 60_000;
11
12
 
@@ -19,7 +20,7 @@ export function createClient({ creds, save, fetcher = fetch, now = Date.now }) {
19
20
  }
20
21
 
21
22
  async function post(body) {
22
- return fetcher(new URL("/api/mcp", current.server), {
23
+ return reach(fetcher, new URL("/api/mcp", current.server), {
23
24
  method: "POST",
24
25
  headers: {
25
26
  "content-type": "application/json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "type": "module",
6
6
  "bin": {