@harborppm/cli 0.5.1 → 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 +55 -12
- 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
|
}
|
|
@@ -2907,7 +2920,10 @@ function HistoryView({ active, width, height, go }) {
|
|
|
2907
2920
|
}),
|
|
2908
2921
|
/* @__PURE__ */ jsx(Text, {
|
|
2909
2922
|
bold: true,
|
|
2910
|
-
children: actorOf(
|
|
2923
|
+
children: actorOf({
|
|
2924
|
+
...row,
|
|
2925
|
+
via: null
|
|
2926
|
+
}, people)
|
|
2911
2927
|
}),
|
|
2912
2928
|
row.via ? /* @__PURE__ */ jsxs(Text, {
|
|
2913
2929
|
color: theme.muted,
|
|
@@ -2994,7 +3010,7 @@ function listNames(names) {
|
|
|
2994
3010
|
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
2995
3011
|
}
|
|
2996
3012
|
function excerpt(body) {
|
|
2997
|
-
const t = body.replace(
|
|
3013
|
+
const t = body.replace(/(?<![\p{L}\p{N}_])@[\p{L}\p{N}_-]+/gu, "").replace(/\s+/g, " ").trim();
|
|
2998
3014
|
if (t.length <= 96) return t;
|
|
2999
3015
|
return `${t.slice(0, 94)}…`;
|
|
3000
3016
|
}
|
|
@@ -8946,6 +8962,17 @@ function createSync(opts) {
|
|
|
8946
8962
|
};
|
|
8947
8963
|
}
|
|
8948
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
|
+
|
|
8949
8976
|
//#endregion
|
|
8950
8977
|
//#region src/tui/theme-detect.ts
|
|
8951
8978
|
const isThemeSetting = (v) => v === "auto" || v === "light" || v === "dark";
|
|
@@ -9063,20 +9090,36 @@ async function startApp(deps, options = {}) {
|
|
|
9063
9090
|
ui
|
|
9064
9091
|
};
|
|
9065
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
|
+
}
|
|
9066
9112
|
/** `harbor`: sign-in already done, take over the terminal until q. */
|
|
9067
9113
|
async function runApp(opts) {
|
|
9068
9114
|
const theme = await pickTheme(opts.theme);
|
|
9069
9115
|
const transport = httpTransport(opts.source, opts.fetcher);
|
|
9070
9116
|
const { account, harbors } = await transport.harbors();
|
|
9071
|
-
if (harbors.length === 0) throw new
|
|
9072
|
-
const wanted = opts.harbor?.toLowerCase();
|
|
9073
|
-
const pick = wanted ? harbors.find((h) => h.id === opts.harbor || h.name.toLowerCase() === wanted) : void 0;
|
|
9074
|
-
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.`);
|
|
9075
9118
|
const { instance } = await startApp({
|
|
9076
9119
|
transport,
|
|
9077
9120
|
account,
|
|
9078
9121
|
harbors,
|
|
9079
|
-
workspaceId:
|
|
9122
|
+
workspaceId: (opts.harbor ? harborNamed(harbors, opts.harbor) : void 0)?.id,
|
|
9080
9123
|
splashMs: SPLASH_MS,
|
|
9081
9124
|
theme
|
|
9082
9125
|
}, { alternateScreen: true });
|
|
@@ -9084,4 +9127,4 @@ async function runApp(opts) {
|
|
|
9084
9127
|
}
|
|
9085
9128
|
|
|
9086
9129
|
//#endregion
|
|
9087
|
-
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",
|