@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 +12 -7
- package/bin/harbor.mjs +1 -0
- package/dist/app.mjs +75 -29
- package/lib/args.mjs +5 -1
- package/lib/auth.mjs +20 -18
- package/lib/cli.mjs +182 -18
- package/lib/config.mjs +6 -1
- package/lib/errors.mjs +28 -0
- package/lib/mcp.mjs +3 -2
- package/package.json +1 -1
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"
|
|
66
|
-
|
|
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 `--
|
|
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
|
-
|
|
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
|
|
1863
|
-
if (
|
|
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,
|
|
2082
|
-
*
|
|
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: "
|
|
2093
|
-
warn: "
|
|
2094
|
-
bad: "
|
|
2095
|
-
done: "
|
|
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(
|
|
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(
|
|
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: `${
|
|
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,
|
|
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
|
|
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:
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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"
|
|
45
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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", {},
|
|
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
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
23
|
+
return reach(fetcher, new URL("/api/mcp", current.server), {
|
|
23
24
|
method: "POST",
|
|
24
25
|
headers: {
|
|
25
26
|
"content-type": "application/json",
|