@harborppm/cli 0.5.1 → 0.7.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 +25 -8
- package/bin/harbor.mjs +1 -0
- package/dist/app.mjs +150 -15
- package/lib/args.mjs +5 -1
- package/lib/auth.mjs +31 -20
- package/lib/cli.mjs +204 -23
- package/lib/config.mjs +6 -1
- package/lib/errors.mjs +28 -0
- package/lib/format.mjs +1 -1
- package/lib/mcp.mjs +3 -2
- package/package.json +13 -1
package/README.md
CHANGED
|
@@ -10,6 +10,18 @@ harbor mine # or one command at a time
|
|
|
10
10
|
|
|
11
11
|
Needs Node 22 or later.
|
|
12
12
|
|
|
13
|
+
## New to Harbor?
|
|
14
|
+
|
|
15
|
+
You can start from the terminal. Harbor is free to start.
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install -g @harborppm/cli
|
|
19
|
+
harbor signup # make your account in the browser (email or Google), then come back
|
|
20
|
+
harbor # name your harbor and its first project, and you're in
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`harbor signup` opens Harbor's page on **Create**; confirm your email from the letter it sends when you get a moment. The first time you run `harbor` with no harbor yet, it asks for a name (Enter takes a suggestion) and, if you like, a first project, then opens on it. Joining a team's harbor instead? Open the invite they sent you, and `harbor` opens on their harbor.
|
|
24
|
+
|
|
13
25
|
## The full-screen app
|
|
14
26
|
|
|
15
27
|
`harbor` on its own takes over the terminal, like `htop` or `lazygit`, and gives it back when you press `q`. It opens on Harbor's mark (the teal tile, the H and three boats, drawn in half blocks) while your harbor loads; after that the sidebar holds just the screens. Then:
|
|
@@ -43,7 +55,7 @@ It runs on the same engine as the website: health, task keys, limits and undo wo
|
|
|
43
55
|
|
|
44
56
|
## Signing in
|
|
45
57
|
|
|
46
|
-
`harbor login` registers the CLI with Harbor and opens the same page Claude and other apps use: it names "Harbor CLI" and asks what to allow. Choose **Allow reading only** (or run `harbor login --read-only`) if it shouldn't change anything. The sign-in is saved in `~/.config/harbor/credentials.json`, readable only by you, and refreshes itself for 30 days.
|
|
58
|
+
`harbor signup` is for a first visit: the same sign-in, with the page opened on making an account. `harbor login` registers the CLI with Harbor and opens the same page Claude and other apps use: it names "Harbor CLI" and asks what to allow. Choose **Allow reading only** (or run `harbor login --read-only`) if it shouldn't change anything. The sign-in is saved in `~/.config/harbor/credentials.json`, readable only by you, and refreshes itself for 30 days.
|
|
47
59
|
|
|
48
60
|
`harbor logout` forgets it on this machine. To end its access everywhere, disconnect **Harbor CLI** under **Connected apps** in Harbor's menu.
|
|
49
61
|
|
|
@@ -51,22 +63,27 @@ It runs on the same engine as the website: health, task keys, limits and undo wo
|
|
|
51
63
|
|
|
52
64
|
```
|
|
53
65
|
harbor harbors the harbors you belong to
|
|
66
|
+
harbor use HARBOR|none the harbor commands mean when you're in several
|
|
54
67
|
harbor mine what's on you, late first
|
|
55
|
-
harbor tasks [words] [--project P] [--assignee me|NAME] [--late] [--status S]
|
|
68
|
+
harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
|
|
56
69
|
harbor show TASK one task in full
|
|
57
70
|
|
|
58
71
|
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]
|
|
72
|
+
[--priority low|medium|high|urgent] [--status S] [--over-limit]
|
|
73
|
+
harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
|
|
61
74
|
harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
|
|
62
|
-
[--priority P] [--status S]
|
|
75
|
+
[--priority P] [--status S] [--confirm] [--over-limit]
|
|
63
76
|
harbor comment TASK "TEXT"
|
|
64
77
|
harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
|
|
65
|
-
harbor risk "TITLE"
|
|
66
|
-
|
|
78
|
+
harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
|
|
79
|
+
[--owner NAME] [--due YYYY-MM-DD]
|
|
80
|
+
harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
81
|
+
[--due YYYY-MM-DD]
|
|
67
82
|
```
|
|
68
83
|
|
|
69
|
-
Every command takes `--
|
|
84
|
+
Every command takes `--json` for the raw result, and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done.
|
|
85
|
+
|
|
86
|
+
When you belong to more than one harbor, say which: `--harbor NAME` on the command, or `harbor use NAME` once (it's saved with your sign-in; `harbor use none` forgets it). With neither, the commands ask rather than guess, so nothing lands in the wrong harbor. The full-screen app opens the one `harbor use` set too.
|
|
70
87
|
|
|
71
88
|
Harbor holds back what it would hold back in the app: finishing a task that still waits on another, or going past a column's limit. The CLI says so and names the flag that goes ahead (`--confirm`, `--over-limit`).
|
|
72
89
|
|
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Built from src/tui by cli/build.mjs. Edit the source, not this file.
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
2
3
|
import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
|
|
3
4
|
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
4
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -1707,9 +1708,17 @@ function numberNewTasks(before, after) {
|
|
|
1707
1708
|
projects
|
|
1708
1709
|
};
|
|
1709
1710
|
const was = byId(before.tasks);
|
|
1711
|
+
const holders = /* @__PURE__ */ new Map();
|
|
1712
|
+
for (const t of after.tasks) {
|
|
1713
|
+
if (!Number.isInteger(t.number)) continue;
|
|
1714
|
+
const k = `${t.projectId}:${t.number}`;
|
|
1715
|
+
holders.set(k, (holders.get(k) ?? 0) + 1);
|
|
1716
|
+
}
|
|
1717
|
+
const keepsNumber = (t) => Number.isInteger(t.number) && holders.get(`${t.projectId}:${t.number}`) === 1;
|
|
1710
1718
|
const fresh = (t) => {
|
|
1711
1719
|
const old = was.get(t.id);
|
|
1712
|
-
|
|
1720
|
+
if (!old) return !keepsNumber(t);
|
|
1721
|
+
return old.projectId !== t.projectId && old.number === t.number;
|
|
1713
1722
|
};
|
|
1714
1723
|
if (!after.tasks.some(fresh)) return projects === after.projects ? after : {
|
|
1715
1724
|
...after,
|
|
@@ -1855,12 +1864,17 @@ function openBlockerNames(task, tasks) {
|
|
|
1855
1864
|
function waitingOn(task, tasks) {
|
|
1856
1865
|
return tasks.filter((t) => t.id !== task.id && waitsOn(t, task.id) && t.status !== "done");
|
|
1857
1866
|
}
|
|
1867
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1868
|
+
/**
|
|
1869
|
+
* Who a comment mentions: "@" and a first name as a whole word, so @Samantha
|
|
1870
|
+
* isn't Sam and an address (jane@nico.com) mentions nobody.
|
|
1871
|
+
*/
|
|
1858
1872
|
function parseMentions(body, people) {
|
|
1859
1873
|
const ids = /* @__PURE__ */ new Set();
|
|
1860
|
-
const lower = body.toLowerCase();
|
|
1861
1874
|
for (const person of people) {
|
|
1862
|
-
const
|
|
1863
|
-
if (
|
|
1875
|
+
const first = person.name.trim().split(/\s+/)[0];
|
|
1876
|
+
if (!first) continue;
|
|
1877
|
+
if (new RegExp(`(?<![\\p{L}\\p{N}_])@${escapeRegExp(first)}(?![\\p{L}\\p{N}_-])`, "iu").test(body)) ids.add(person.id);
|
|
1864
1878
|
}
|
|
1865
1879
|
return [...ids];
|
|
1866
1880
|
}
|
|
@@ -2907,7 +2921,10 @@ function HistoryView({ active, width, height, go }) {
|
|
|
2907
2921
|
}),
|
|
2908
2922
|
/* @__PURE__ */ jsx(Text, {
|
|
2909
2923
|
bold: true,
|
|
2910
|
-
children: actorOf(
|
|
2924
|
+
children: actorOf({
|
|
2925
|
+
...row,
|
|
2926
|
+
via: null
|
|
2927
|
+
}, people)
|
|
2911
2928
|
}),
|
|
2912
2929
|
row.via ? /* @__PURE__ */ jsxs(Text, {
|
|
2913
2930
|
color: theme.muted,
|
|
@@ -2994,7 +3011,7 @@ function listNames(names) {
|
|
|
2994
3011
|
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
2995
3012
|
}
|
|
2996
3013
|
function excerpt(body) {
|
|
2997
|
-
const t = body.replace(
|
|
3014
|
+
const t = body.replace(/(?<![\p{L}\p{N}_])@[\p{L}\p{N}_-]+/gu, "").replace(/\s+/g, " ").trim();
|
|
2998
3015
|
if (t.length <= 96) return t;
|
|
2999
3016
|
return `${t.slice(0, 94)}…`;
|
|
3000
3017
|
}
|
|
@@ -8679,7 +8696,8 @@ function plainTransport(t) {
|
|
|
8679
8696
|
load: async (workspaceId) => plainDeep(await t.load(workspaceId)),
|
|
8680
8697
|
save: async (body) => plainDeep(await t.save(body)),
|
|
8681
8698
|
wait: async (workspaceId, since) => plainDeep(await t.wait(workspaceId, since)),
|
|
8682
|
-
history: async (query) => plainDeep(await t.history(query))
|
|
8699
|
+
history: async (query) => plainDeep(await t.history(query)),
|
|
8700
|
+
create: async (name) => plainDeep(await t.create(name))
|
|
8683
8701
|
};
|
|
8684
8702
|
}
|
|
8685
8703
|
/** Harbor said no, or couldn't be reached (`offline`). */
|
|
@@ -8737,6 +8755,10 @@ function httpTransport(source, fetcher = fetch) {
|
|
|
8737
8755
|
history: (history) => post({
|
|
8738
8756
|
op: "history",
|
|
8739
8757
|
history
|
|
8758
|
+
}),
|
|
8759
|
+
create: (name) => post({
|
|
8760
|
+
op: "create",
|
|
8761
|
+
name
|
|
8740
8762
|
})
|
|
8741
8763
|
};
|
|
8742
8764
|
}
|
|
@@ -8946,6 +8968,17 @@ function createSync(opts) {
|
|
|
8946
8968
|
};
|
|
8947
8969
|
}
|
|
8948
8970
|
|
|
8971
|
+
//#endregion
|
|
8972
|
+
//#region cli/lib/errors.mjs
|
|
8973
|
+
/**
|
|
8974
|
+
* What the CLI says when something stops a command: a HarborError is
|
|
8975
|
+
* already worded for the person and printed as it is; anything else is
|
|
8976
|
+
* unexpected and printed as "Something went wrong".
|
|
8977
|
+
*/
|
|
8978
|
+
var HarborError = class extends Error {
|
|
8979
|
+
name = "HarborError";
|
|
8980
|
+
};
|
|
8981
|
+
|
|
8949
8982
|
//#endregion
|
|
8950
8983
|
//#region src/tui/theme-detect.ts
|
|
8951
8984
|
const isThemeSetting = (v) => v === "auto" || v === "light" || v === "dark";
|
|
@@ -9003,6 +9036,47 @@ function createUiStore() {
|
|
|
9003
9036
|
}));
|
|
9004
9037
|
}
|
|
9005
9038
|
|
|
9039
|
+
//#endregion
|
|
9040
|
+
//#region src/lib/empty.ts
|
|
9041
|
+
/** What a new harbor is called until its owner names it: "Sam's harbor". */
|
|
9042
|
+
function suggestedHarborName(displayName, email) {
|
|
9043
|
+
const first = (displayName || email || "").split(/[\s@]/)[0]?.trim();
|
|
9044
|
+
if (!first) return "My harbor";
|
|
9045
|
+
return `${first}'s harbor`;
|
|
9046
|
+
}
|
|
9047
|
+
|
|
9048
|
+
//#endregion
|
|
9049
|
+
//#region src/tui/first-run.ts
|
|
9050
|
+
/**
|
|
9051
|
+
* A new account's first `harbor`: it isn't in a harbor yet, so the terminal
|
|
9052
|
+
* asks for a name (and, if they like, a first project) and makes the same
|
|
9053
|
+
* empty harbor the website's launch page makes, instead of sending them off
|
|
9054
|
+
* to the website.
|
|
9055
|
+
*/
|
|
9056
|
+
async function firstHarbor(deps) {
|
|
9057
|
+
const fallback = suggestedHarborName(deps.account.name, deps.account.email);
|
|
9058
|
+
deps.say("You're signed in, but you aren't in a harbor yet, so let's make yours.");
|
|
9059
|
+
deps.say("Joining a team's harbor instead? Open the invite they sent you.\n");
|
|
9060
|
+
const name = (await deps.ask(`Name your harbor (Enter for "${fallback}"): `)).trim() || fallback;
|
|
9061
|
+
const project = (await deps.ask("Name your first project (Enter to skip): ")).trim() || null;
|
|
9062
|
+
const { harbor } = await deps.transport.create(name.slice(0, 120));
|
|
9063
|
+
return {
|
|
9064
|
+
harbor,
|
|
9065
|
+
project: project?.slice(0, 120) ?? null
|
|
9066
|
+
};
|
|
9067
|
+
}
|
|
9068
|
+
/** The first project, in the new harbor's starter program, led by its owner. */
|
|
9069
|
+
function addFirstProject(store, name, accountId, today) {
|
|
9070
|
+
const s = store.getState();
|
|
9071
|
+
const program = s.programs[0];
|
|
9072
|
+
if (!program) return null;
|
|
9073
|
+
const me = s.people.find((p) => p.accountId === accountId) ?? s.people[0];
|
|
9074
|
+
return commitCreate(store, "project", {
|
|
9075
|
+
name,
|
|
9076
|
+
ownerId: me?.id ?? ""
|
|
9077
|
+
}, { programId: program.id }, today);
|
|
9078
|
+
}
|
|
9079
|
+
|
|
9006
9080
|
//#endregion
|
|
9007
9081
|
//#region src/tui/main.tsx
|
|
9008
9082
|
/**
|
|
@@ -9045,6 +9119,11 @@ async function startApp(deps, options = {}) {
|
|
|
9045
9119
|
instance.unmount();
|
|
9046
9120
|
throw err;
|
|
9047
9121
|
}
|
|
9122
|
+
if (deps.firstRun) {
|
|
9123
|
+
const project = deps.firstRun.project;
|
|
9124
|
+
if (project) addFirstProject(store, project, account.id, today());
|
|
9125
|
+
ui.setState({ notice: project ? `Your harbor is ready, with ${project} in it. n adds more, ? lists the keys.` : "Your harbor is ready. n adds a project, ? lists the keys." });
|
|
9126
|
+
}
|
|
9048
9127
|
instance.rerender(/* @__PURE__ */ jsx(DepsProvider, {
|
|
9049
9128
|
value: {
|
|
9050
9129
|
store,
|
|
@@ -9063,25 +9142,81 @@ async function startApp(deps, options = {}) {
|
|
|
9063
9142
|
ui
|
|
9064
9143
|
};
|
|
9065
9144
|
}
|
|
9145
|
+
/**
|
|
9146
|
+
* The harbor --harbor names, as the commands read it: its id, its whole
|
|
9147
|
+
* name, or the one name it best matches.
|
|
9148
|
+
*/
|
|
9149
|
+
function harborNamed(harbors, key) {
|
|
9150
|
+
const want = key.trim();
|
|
9151
|
+
const exact = harbors.find((h) => h.id === want) ?? harbors.find((h) => h.name.trim().toLowerCase() === want.toLowerCase());
|
|
9152
|
+
if (exact) return exact;
|
|
9153
|
+
const hits = harbors.map((h) => ({
|
|
9154
|
+
h,
|
|
9155
|
+
s: scoreMatch(want, h.name)
|
|
9156
|
+
})).filter((x) => x.s > 0).sort((a, b) => b.s - a.s);
|
|
9157
|
+
if (hits.length === 0) throw new HarborError(`No harbor matches "${want}". Run harbor harbors.`);
|
|
9158
|
+
if (hits.length > 1 && hits[0].s === hits[1].s) {
|
|
9159
|
+
const names = hits.map((x) => x.h.name).join(", ");
|
|
9160
|
+
throw new HarborError(`"${want}" matches more than one harbor: ${names}.`);
|
|
9161
|
+
}
|
|
9162
|
+
return hits[0].h;
|
|
9163
|
+
}
|
|
9066
9164
|
/** `harbor`: sign-in already done, take over the terminal until q. */
|
|
9067
9165
|
async function runApp(opts) {
|
|
9068
9166
|
const theme = await pickTheme(opts.theme);
|
|
9069
9167
|
const transport = httpTransport(opts.source, opts.fetcher);
|
|
9070
|
-
const
|
|
9071
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
9168
|
+
const answer = await transport.harbors();
|
|
9169
|
+
const account = answer.account;
|
|
9170
|
+
let harbors = answer.harbors;
|
|
9171
|
+
let firstRun;
|
|
9172
|
+
let workspaceId;
|
|
9173
|
+
if (harbors.length === 0) {
|
|
9174
|
+
const terminal = terminalAsk();
|
|
9175
|
+
try {
|
|
9176
|
+
const made = await firstHarbor({
|
|
9177
|
+
ask: terminal.ask,
|
|
9178
|
+
say: (line) => process.stdout.write(`${line}\n`),
|
|
9179
|
+
transport: plainTransport(transport),
|
|
9180
|
+
account: plainDeep(account)
|
|
9181
|
+
});
|
|
9182
|
+
harbors = [made.harbor];
|
|
9183
|
+
workspaceId = made.harbor.id;
|
|
9184
|
+
firstRun = { project: made.project };
|
|
9185
|
+
} finally {
|
|
9186
|
+
terminal.close();
|
|
9187
|
+
}
|
|
9188
|
+
} else if (opts.harbor) workspaceId = harborNamed(harbors, opts.harbor).id;
|
|
9075
9189
|
const { instance } = await startApp({
|
|
9076
9190
|
transport,
|
|
9077
9191
|
account,
|
|
9078
9192
|
harbors,
|
|
9079
|
-
workspaceId
|
|
9193
|
+
workspaceId,
|
|
9080
9194
|
splashMs: SPLASH_MS,
|
|
9081
|
-
theme
|
|
9195
|
+
theme,
|
|
9196
|
+
firstRun
|
|
9082
9197
|
}, { alternateScreen: true });
|
|
9083
9198
|
await instance.waitUntilExit();
|
|
9084
9199
|
}
|
|
9200
|
+
/** Questions asked on the plain terminal, before the app takes it over. Ctrl+C stops. */
|
|
9201
|
+
function terminalAsk() {
|
|
9202
|
+
const rl = createInterface({
|
|
9203
|
+
input: process.stdin,
|
|
9204
|
+
output: process.stdout
|
|
9205
|
+
});
|
|
9206
|
+
let stop = () => {};
|
|
9207
|
+
const stopped = new Promise((_, reject) => stop = reject);
|
|
9208
|
+
stopped.catch(() => {});
|
|
9209
|
+
const nothing = () => stop(new HarborError("Stopped. Nothing was made."));
|
|
9210
|
+
rl.on("SIGINT", nothing);
|
|
9211
|
+
rl.on("close", nothing);
|
|
9212
|
+
return {
|
|
9213
|
+
ask: (question) => Promise.race([rl.question(question), stopped]),
|
|
9214
|
+
close: () => {
|
|
9215
|
+
rl.off("close", nothing);
|
|
9216
|
+
rl.close();
|
|
9217
|
+
}
|
|
9218
|
+
};
|
|
9219
|
+
}
|
|
9085
9220
|
|
|
9086
9221
|
//#endregion
|
|
9087
|
-
export { SPLASH_MS, TransportError, httpTransport, runApp, startApp };
|
|
9222
|
+
export { SPLASH_MS, TransportError, firstHarbor, 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
|
|
|
@@ -89,16 +90,23 @@ export async function listenForCode(state, { timeoutMs = 5 * 60_000 } = {}) {
|
|
|
89
90
|
|
|
90
91
|
/**
|
|
91
92
|
* The whole sign-in. `open(url)` shows the asking page (a browser, or a test
|
|
92
|
-
* driving one). Returns credentials to save.
|
|
93
|
+
* driving one). `signUp` opens it on making an account. Returns credentials to save.
|
|
93
94
|
*/
|
|
94
|
-
export async function login({
|
|
95
|
+
export async function login({
|
|
96
|
+
server,
|
|
97
|
+
readOnly = false,
|
|
98
|
+
signUp = false,
|
|
99
|
+
open,
|
|
100
|
+
fetcher = fetch,
|
|
101
|
+
now = Date.now,
|
|
102
|
+
}) {
|
|
95
103
|
const meta = await discover(server, fetcher);
|
|
96
104
|
const state = b64url(randomBytes(16));
|
|
97
105
|
const { verifier, challenge } = pkcePair();
|
|
98
106
|
const { redirectUri, code } = await listenForCode(state);
|
|
99
107
|
|
|
100
108
|
const client = await json(
|
|
101
|
-
await fetcher
|
|
109
|
+
await reach(fetcher, meta.registration_endpoint, {
|
|
102
110
|
method: "POST",
|
|
103
111
|
headers: { "content-type": "application/json" },
|
|
104
112
|
body: JSON.stringify({
|
|
@@ -120,14 +128,14 @@ export async function login({ server, readOnly = false, open, fetcher = fetch, n
|
|
|
120
128
|
state,
|
|
121
129
|
code_challenge: challenge,
|
|
122
130
|
code_challenge_method: "S256",
|
|
123
|
-
scope: readOnly
|
|
124
|
-
|
|
125
|
-
|
|
131
|
+
scope: readOnly ? "openid offline_access" : `openid offline_access ${WRITE_SCOPE} ${APP_SCOPE}`,
|
|
132
|
+
// OpenID's way to say "new here": Harbor's page opens on Create.
|
|
133
|
+
...(signUp ? { prompt: "create" } : {}),
|
|
126
134
|
}).toString();
|
|
127
135
|
await open(authorize.toString());
|
|
128
136
|
|
|
129
137
|
const tokens = await json(
|
|
130
|
-
await fetcher
|
|
138
|
+
await reach(fetcher, meta.token_endpoint, {
|
|
131
139
|
method: "POST",
|
|
132
140
|
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
133
141
|
body: new URLSearchParams({
|
|
@@ -157,18 +165,21 @@ export function toCredentials(server, tokenEndpoint, clientId, tokens, at) {
|
|
|
157
165
|
|
|
158
166
|
/** New tokens from the refresh token; the old one stops working. */
|
|
159
167
|
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
|
-
}),
|
|
168
|
+
if (!creds.refreshToken) throw new HarborError("Signed out. Run harbor login.");
|
|
169
|
+
const res = await reach(fetcher, creds.tokenEndpoint, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
172
|
+
body: new URLSearchParams({
|
|
173
|
+
grant_type: "refresh_token",
|
|
174
|
+
refresh_token: creds.refreshToken,
|
|
175
|
+
client_id: creds.clientId,
|
|
170
176
|
}),
|
|
171
|
-
|
|
172
|
-
|
|
177
|
+
});
|
|
178
|
+
// Refused: the sign-in was disconnected in Harbor, or renewed by another
|
|
179
|
+
// copy of these credentials (a refresh token is good once).
|
|
180
|
+
if (res.status === 400 || res.status === 401) {
|
|
181
|
+
throw new HarborError("Your sign-in on this machine has ended. Run harbor login.");
|
|
182
|
+
}
|
|
183
|
+
const tokens = await json(res, "Refreshing your sign-in");
|
|
173
184
|
return toCredentials(creds.server, creds.tokenEndpoint, creds.clientId, tokens, now());
|
|
174
185
|
}
|
package/lib/cli.mjs
CHANGED
|
@@ -24,27 +24,32 @@ import { jsonText, plain } from "./plain.mjs";
|
|
|
24
24
|
export const HELP = `harbor: Harbor from the terminal
|
|
25
25
|
|
|
26
26
|
harbor the full-screen app: everything, with the keyboard
|
|
27
|
+
harbor signup [--server URL] new to Harbor: make an account (opens your browser)
|
|
27
28
|
harbor login [--server URL] [--read-only] sign in (opens your browser once)
|
|
28
29
|
harbor logout forget the sign-in on this machine
|
|
29
30
|
harbor harbors the harbors you belong to
|
|
31
|
+
harbor use HARBOR|none the harbor commands mean when you're in several
|
|
30
32
|
harbor theme [auto|light|dark] the app's colours (auto asks the terminal)
|
|
31
33
|
harbor --version which version this is
|
|
32
34
|
|
|
33
35
|
harbor mine what's on you, late first
|
|
34
|
-
harbor tasks [words] [--project P] [--assignee me|NAME] [--late] [--status S]
|
|
36
|
+
harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
|
|
35
37
|
harbor show TASK one task in full (TASK: its key, RR-42, or id)
|
|
36
38
|
|
|
37
39
|
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]
|
|
40
|
+
[--priority low|medium|high|urgent] [--status S] [--over-limit]
|
|
41
|
+
harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
|
|
40
42
|
harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
|
|
41
|
-
[--priority P] [--status S]
|
|
43
|
+
[--priority P] [--status S] [--confirm] [--over-limit]
|
|
42
44
|
harbor comment TASK "TEXT"
|
|
43
45
|
harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
|
|
44
|
-
harbor risk "TITLE"
|
|
45
|
-
|
|
46
|
+
harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
|
|
47
|
+
[--owner NAME] [--due YYYY-MM-DD]
|
|
48
|
+
harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
49
|
+
[--due YYYY-MM-DD]
|
|
46
50
|
|
|
47
|
-
Every command takes --harbor NAME
|
|
51
|
+
Every command takes --json, and --harbor NAME when you're in more than one harbor
|
|
52
|
+
(or set one with harbor use). harbor COMMAND --help shows just that command.
|
|
48
53
|
Statuses: backlog, todo, doing, review, done.
|
|
49
54
|
Changes show in Harbor's History as yours, "via Harbor CLI". These commands never
|
|
50
55
|
delete; the full-screen app can, as the website can, and asks first.
|
|
@@ -120,21 +125,112 @@ const level = (v, name) => {
|
|
|
120
125
|
};
|
|
121
126
|
const nullable = (v) => (v === "none" ? null : v);
|
|
122
127
|
|
|
123
|
-
/** Harbor's
|
|
128
|
+
/** Harbor's tool fields, as the CLI's flags. */
|
|
129
|
+
const FLAG = {
|
|
130
|
+
due: "--due",
|
|
131
|
+
priority: "--priority",
|
|
132
|
+
status: "--status",
|
|
133
|
+
assignee: "--assignee",
|
|
134
|
+
title: "--title",
|
|
135
|
+
project: "--project",
|
|
136
|
+
program: "--program",
|
|
137
|
+
owner: "--owner",
|
|
138
|
+
impact: "--impact",
|
|
139
|
+
probability: "--probability",
|
|
140
|
+
query: "the words",
|
|
141
|
+
task: "the task",
|
|
142
|
+
note: "the check-in",
|
|
143
|
+
body: "the comment",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/** Harbor's refusals are worded for agents; say them as the commands and flags that answer them. */
|
|
124
147
|
export function hint(message) {
|
|
125
148
|
const said = message.replace(/\s*Ask the person;.*$/s, "");
|
|
126
149
|
if (/confirmed: true/.test(message))
|
|
127
150
|
return `${said}\nTo finish it anyway, run it again with --confirm.`;
|
|
128
151
|
if (/overLimit: true/.test(message))
|
|
129
152
|
return `${said}\nTo go past the limit, run it again with --over-limit.`;
|
|
130
|
-
|
|
153
|
+
const fit = /^Those arguments don't fit \w+: (.*)$/s.exec(message);
|
|
154
|
+
if (fit) {
|
|
155
|
+
// One line per flag: a bad date fails two checks that say the same thing.
|
|
156
|
+
const seen = new Map();
|
|
157
|
+
for (const issue of fit[1].split("; ")) {
|
|
158
|
+
const [path, ...why] = issue.split(": ");
|
|
159
|
+
const field = path.replace(/\.\d+$/, "");
|
|
160
|
+
const flag = FLAG[field] ?? `--${field}`;
|
|
161
|
+
if (!seen.has(flag)) seen.set(flag, why.join(": "));
|
|
162
|
+
}
|
|
163
|
+
return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
|
|
164
|
+
}
|
|
165
|
+
return message
|
|
166
|
+
.replace(
|
|
167
|
+
/Say which one with harbor\.$/,
|
|
168
|
+
"Say which with --harbor NAME, or set one with harbor use NAME.",
|
|
169
|
+
)
|
|
170
|
+
.replace(/Find it with search_tasks\.$/, "Find it with harbor tasks.")
|
|
171
|
+
.replace(
|
|
172
|
+
/^This connection was allowed to read only\..*$/s,
|
|
173
|
+
"This sign-in can only read. To make changes, run harbor login again.",
|
|
174
|
+
)
|
|
175
|
+
.replace(
|
|
176
|
+
/^Say which project or program it belongs to\.$/,
|
|
177
|
+
"Say where it belongs: --project NAME or --program NAME.",
|
|
178
|
+
)
|
|
179
|
+
.replace(
|
|
180
|
+
/^You don't belong to a harbor yet\..*$/s,
|
|
181
|
+
"You aren't in a harbor yet. Run harbor to make one, or open the invite a team sent you.",
|
|
182
|
+
);
|
|
131
183
|
}
|
|
132
184
|
|
|
185
|
+
/** The lines of the help about one command, or null when it has none. */
|
|
186
|
+
export function commandHelp(command) {
|
|
187
|
+
const lines = HELP.split("\n");
|
|
188
|
+
const picked = [];
|
|
189
|
+
for (let i = 0; i < lines.length; i++) {
|
|
190
|
+
const words = lines[i].trim().split(/\s+/);
|
|
191
|
+
const names = words.filter(
|
|
192
|
+
(_, j) => j > 0 && (words[j - 1] === "harbor" || words[j - 1] === "|"),
|
|
193
|
+
);
|
|
194
|
+
if (words[0] !== "harbor" || !names.includes(command)) continue;
|
|
195
|
+
picked.push(lines[i]);
|
|
196
|
+
// Its options carry on below it, further in.
|
|
197
|
+
while (lines[i + 1]?.startsWith(" ") && /^\s+\[|^\s+--/.test(lines[i + 1]))
|
|
198
|
+
picked.push(lines[++i]);
|
|
199
|
+
}
|
|
200
|
+
if (picked.length === 0) return null;
|
|
201
|
+
const common = ["signup", "login", "logout", "use", "theme", "help", "app"].includes(command)
|
|
202
|
+
? []
|
|
203
|
+
: ["", " Also takes --json, and --harbor NAME when you're in more than one harbor."];
|
|
204
|
+
return [...picked, ...common].join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Room for --json and --harbor before the command too: `harbor --json mine`. */
|
|
208
|
+
function commandFirst(argv) {
|
|
209
|
+
const front = [];
|
|
210
|
+
let i = 0;
|
|
211
|
+
while (argv[i] === "--json" || argv[i] === "--harbor" || argv[i]?.startsWith("--harbor=")) {
|
|
212
|
+
if (argv[i] === "--harbor") front.push(argv[i], argv[i + 1] ?? "");
|
|
213
|
+
else front.push(argv[i]);
|
|
214
|
+
i += argv[i] === "--harbor" ? 2 : 1;
|
|
215
|
+
}
|
|
216
|
+
return front.length && argv[i] ? [argv[i], ...front, ...argv.slice(i + 1)] : argv;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The command's arguments up to a bare --, where words stop being options. */
|
|
220
|
+
const asksHelp = (rest) => {
|
|
221
|
+
const end = rest.indexOf("--");
|
|
222
|
+
return (end < 0 ? rest : rest.slice(0, end)).some((a) => a === "--help" || a === "-h");
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const none = (positionals) => {
|
|
226
|
+
if (positionals.length) throw new HarborError(`Unexpected "${positionals.join(" ")}".`);
|
|
227
|
+
};
|
|
228
|
+
|
|
133
229
|
export async function run(argv, io = {}) {
|
|
134
230
|
const out = io.out ?? ((s) => process.stdout.write(`${s}\n`));
|
|
135
231
|
const env = io.env ?? process.env;
|
|
136
232
|
const fetcher = io.fetch ?? fetch;
|
|
137
|
-
const [command, ...rest] = argv;
|
|
233
|
+
const [command, ...rest] = commandFirst(argv);
|
|
138
234
|
|
|
139
235
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
140
236
|
const top = banner(io, env, ({ bold, dim }) => [
|
|
@@ -154,6 +250,16 @@ export async function run(argv, io = {}) {
|
|
|
154
250
|
return 0;
|
|
155
251
|
}
|
|
156
252
|
|
|
253
|
+
// `harbor done RR-4 --help` asks how, so nothing runs.
|
|
254
|
+
if (command && asksHelp(rest)) {
|
|
255
|
+
const help = commandHelp(command === "app" ? "" : command);
|
|
256
|
+
if (help === null && command !== "app") {
|
|
257
|
+
throw new HarborError(`No command "${command}". Run harbor help.`);
|
|
258
|
+
}
|
|
259
|
+
out(help ?? HELP);
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
157
263
|
// On its own in a terminal: the full-screen app. Piped or scripted: the help.
|
|
158
264
|
if (!command || command === "app") {
|
|
159
265
|
const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
@@ -193,37 +299,57 @@ export async function run(argv, io = {}) {
|
|
|
193
299
|
await runApp({
|
|
194
300
|
source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
|
|
195
301
|
fetcher,
|
|
196
|
-
harbor: values.harbor,
|
|
302
|
+
harbor: values.harbor ?? creds.harbor ?? undefined,
|
|
197
303
|
theme: (await readSettings(env)).theme,
|
|
198
304
|
});
|
|
199
305
|
return 0;
|
|
200
306
|
}
|
|
201
307
|
|
|
202
|
-
if (command === "login") {
|
|
203
|
-
const
|
|
308
|
+
if (command === "login" || command === "signup") {
|
|
309
|
+
const signUp = command === "signup";
|
|
310
|
+
const { values } = parseArgs(rest, {
|
|
311
|
+
flags: signUp ? [] : ["read-only"],
|
|
312
|
+
options: ["server"],
|
|
313
|
+
});
|
|
204
314
|
const server = (values.server ?? env.HARBOR_SERVER ?? DEFAULT_SERVER).replace(/\/$/, "");
|
|
315
|
+
if (!/^https?:\/\/[^/]/.test(server) || !URL.canParse(server)) {
|
|
316
|
+
throw new HarborError(`--server needs a web address, like ${DEFAULT_SERVER}.`);
|
|
317
|
+
}
|
|
318
|
+
const before = await readCredentials(env);
|
|
205
319
|
const creds = await login({
|
|
206
320
|
server,
|
|
207
321
|
readOnly: Boolean(values["read-only"]),
|
|
322
|
+
signUp,
|
|
208
323
|
fetcher,
|
|
209
324
|
open:
|
|
210
325
|
io.open ??
|
|
211
326
|
((url) => {
|
|
212
|
-
out(
|
|
327
|
+
out(
|
|
328
|
+
signUp
|
|
329
|
+
? `Opening your browser to make your Harbor account. If it doesn't open, go to:\n\n ${url}\n`
|
|
330
|
+
: `Opening your browser to sign in (new to Harbor? choose Create there). If it doesn't open, go to:\n\n ${url}\n`,
|
|
331
|
+
);
|
|
213
332
|
openBrowser(url);
|
|
214
333
|
}),
|
|
215
334
|
});
|
|
335
|
+
// Signing in again keeps the harbor set with harbor use.
|
|
336
|
+
if (before?.harbor && before.server === server) {
|
|
337
|
+
creds.harbor = before.harbor;
|
|
338
|
+
creds.harborName = before.harborName ?? null;
|
|
339
|
+
}
|
|
216
340
|
await writeCredentials(creds, env);
|
|
217
341
|
const canWrite = creds.scope.split(" ").includes("harbor:write");
|
|
218
342
|
const said = `Signed in to ${server}${canWrite ? "." : ", reading only."}`;
|
|
343
|
+
const next = signUp ? "Next: run harbor, and it helps you make your first harbor." : null;
|
|
219
344
|
const welcome = banner(io, env, ({ bold, dim }) => [
|
|
220
345
|
bold("Welcome aboard."),
|
|
221
346
|
said,
|
|
347
|
+
...(next ? [next] : []),
|
|
222
348
|
"",
|
|
223
349
|
dim("harbor the full-screen app"),
|
|
224
350
|
dim("harbor help every command"),
|
|
225
351
|
]);
|
|
226
|
-
out(welcome ?? said);
|
|
352
|
+
out(welcome ?? (next ? `${said}\n${next}` : said));
|
|
227
353
|
return 0;
|
|
228
354
|
}
|
|
229
355
|
|
|
@@ -248,6 +374,7 @@ export async function run(argv, io = {}) {
|
|
|
248
374
|
}
|
|
249
375
|
|
|
250
376
|
if (command === "logout") {
|
|
377
|
+
none(parseArgs(rest, {}).positionals);
|
|
251
378
|
await forgetCredentials(env);
|
|
252
379
|
out(
|
|
253
380
|
"Signed out on this machine. To end its access everywhere, disconnect Harbor CLI under Connected apps in Harbor's menu.",
|
|
@@ -256,7 +383,8 @@ export async function run(argv, io = {}) {
|
|
|
256
383
|
}
|
|
257
384
|
|
|
258
385
|
const creds = await readCredentials(env);
|
|
259
|
-
if (!creds)
|
|
386
|
+
if (!creds)
|
|
387
|
+
throw new HarborError("Not signed in. Run harbor login, or harbor signup if you're new.");
|
|
260
388
|
const client = createClient({ creds, save: (c) => writeCredentials(c, env), fetcher });
|
|
261
389
|
const opts = { today: todayLocal() };
|
|
262
390
|
|
|
@@ -272,16 +400,56 @@ export async function run(argv, io = {}) {
|
|
|
272
400
|
const parse = (s) => {
|
|
273
401
|
const p = parseArgs(rest, s);
|
|
274
402
|
asJson = Boolean(p.values.json);
|
|
275
|
-
return { ...p, harbor: p.values.harbor };
|
|
403
|
+
return { ...p, harbor: p.values.harbor ?? creds.harbor ?? undefined };
|
|
276
404
|
};
|
|
277
405
|
|
|
278
406
|
switch (command) {
|
|
279
407
|
case "harbors": {
|
|
280
|
-
parse(spec());
|
|
281
|
-
return call("list_harbors", {},
|
|
408
|
+
none(parse(spec()).positionals);
|
|
409
|
+
return call("list_harbors", {}, (r) =>
|
|
410
|
+
creds.harbor
|
|
411
|
+
? `${harbors(r)}\n\nCommands use ${creds.harborName ?? creds.harbor} (harbor use).`
|
|
412
|
+
: harbors(r),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
case "use": {
|
|
416
|
+
const { positionals } = parse({ flags: ["json"], options: [] });
|
|
417
|
+
const wanted = positionals.join(" ").trim();
|
|
418
|
+
if (!wanted) {
|
|
419
|
+
out(
|
|
420
|
+
creds.harbor
|
|
421
|
+
? plain(`Commands use ${creds.harborName ?? creds.harbor}.`)
|
|
422
|
+
: "No harbor set: commands use your only harbor, or the one --harbor names.",
|
|
423
|
+
);
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
if (wanted.toLowerCase() === "none") {
|
|
427
|
+
await writeCredentials({ ...creds, harbor: null, harborName: null }, env);
|
|
428
|
+
out("Commands no longer pick a harbor for you.");
|
|
429
|
+
return 0;
|
|
430
|
+
}
|
|
431
|
+
const list = await client.call("list_harbors", {});
|
|
432
|
+
const key = wanted.toLowerCase();
|
|
433
|
+
const exact = list.harbors.filter(
|
|
434
|
+
(h) => h.id === wanted || h.name.trim().toLowerCase() === key,
|
|
435
|
+
);
|
|
436
|
+
const hits = exact.length
|
|
437
|
+
? exact
|
|
438
|
+
: list.harbors.filter((h) => h.name.toLowerCase().includes(key));
|
|
439
|
+
if (hits.length === 0)
|
|
440
|
+
throw new HarborError(`No harbor matches "${wanted}". Run harbor harbors.`);
|
|
441
|
+
if (hits.length > 1) {
|
|
442
|
+
throw new HarborError(
|
|
443
|
+
`"${wanted}" matches more than one harbor: ${hits.map((h) => h.name).join(", ")}.`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
await writeCredentials({ ...creds, harbor: hits[0].id, harborName: hits[0].name }, env);
|
|
447
|
+
out(plain(`Commands now use ${hits[0].name}.`));
|
|
448
|
+
return 0;
|
|
282
449
|
}
|
|
283
450
|
case "mine": {
|
|
284
|
-
const { harbor } = parse(spec());
|
|
451
|
+
const { positionals, harbor } = parse(spec());
|
|
452
|
+
none(positionals);
|
|
285
453
|
return call("my_work", { harbor }, (r) => myWork(r, opts));
|
|
286
454
|
}
|
|
287
455
|
case "tasks": {
|
|
@@ -295,7 +463,12 @@ export async function run(argv, io = {}) {
|
|
|
295
463
|
query: positionals.join(" ") || undefined,
|
|
296
464
|
project: values.project,
|
|
297
465
|
assignee: values.assignee,
|
|
298
|
-
status: values.status
|
|
466
|
+
status: values.status
|
|
467
|
+
? values.status
|
|
468
|
+
.split(",")
|
|
469
|
+
.map((x) => x.trim())
|
|
470
|
+
.filter(Boolean)
|
|
471
|
+
: undefined,
|
|
299
472
|
overdue: values.late || undefined,
|
|
300
473
|
},
|
|
301
474
|
(r) => taskList(r, opts),
|
|
@@ -422,8 +595,16 @@ export async function run(argv, io = {}) {
|
|
|
422
595
|
case "risk":
|
|
423
596
|
case "issue": {
|
|
424
597
|
const { positionals, values, harbor } = parse(
|
|
425
|
-
spec(
|
|
598
|
+
spec(
|
|
599
|
+
[],
|
|
600
|
+
["project", "program", "owner", "impact", "due"].concat(
|
|
601
|
+
command === "risk" ? ["probability"] : [],
|
|
602
|
+
),
|
|
603
|
+
),
|
|
426
604
|
);
|
|
605
|
+
if (!values.project && !values.program) {
|
|
606
|
+
throw new HarborError("Say where it belongs: --project NAME or --program NAME.");
|
|
607
|
+
}
|
|
427
608
|
return call(
|
|
428
609
|
"log_raid",
|
|
429
610
|
{
|
|
@@ -434,7 +615,7 @@ export async function run(argv, io = {}) {
|
|
|
434
615
|
program: values.program,
|
|
435
616
|
owner: values.owner,
|
|
436
617
|
impact: level(values.impact, "impact"),
|
|
437
|
-
probability:
|
|
618
|
+
probability: level(values.probability, "probability"),
|
|
438
619
|
due: values.due,
|
|
439
620
|
},
|
|
440
621
|
(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/format.mjs
CHANGED
|
@@ -52,7 +52,7 @@ export function taskList(r, opts) {
|
|
|
52
52
|
|
|
53
53
|
export function harbors(r) {
|
|
54
54
|
const list = r.harbors ?? [];
|
|
55
|
-
if (!list.length) return "You aren't in any harbor yet.";
|
|
55
|
+
if (!list.length) return "You aren't in any harbor yet. Run harbor to make one.";
|
|
56
56
|
return list.map((h) => `${h.id} ${h.name}${h.role ? ` (${h.role})` : ""}`).join("\n");
|
|
57
57
|
}
|
|
58
58
|
|
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",
|
package/package.json
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harborppm/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"harbor",
|
|
7
|
+
"project-management",
|
|
8
|
+
"portfolio-management",
|
|
9
|
+
"ppm",
|
|
10
|
+
"kanban",
|
|
11
|
+
"tasks",
|
|
12
|
+
"cli",
|
|
13
|
+
"tui",
|
|
14
|
+
"terminal",
|
|
15
|
+
"mcp"
|
|
16
|
+
],
|
|
5
17
|
"type": "module",
|
|
6
18
|
"bin": {
|
|
7
19
|
"harbor": "bin/harbor.mjs"
|