@harborppm/cli 0.1.0 → 0.2.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 +20 -6
- package/dist/app.mjs +5674 -0
- package/lib/app.mjs +41 -0
- package/lib/auth.mjs +5 -1
- package/lib/cli.mjs +49 -2
- package/lib/format.mjs +4 -3
- package/package.json +11 -5
package/lib/app.mjs
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `harbor` on its own: the full-screen app (src/tui, bundled into
|
|
3
|
+
* dist/app.mjs by build.mjs). It needs full access, which `harbor login`
|
|
4
|
+
* asks for; an older sign-in without it is asked again first.
|
|
5
|
+
*/
|
|
6
|
+
import { refresh } from "./auth.mjs";
|
|
7
|
+
import { HarborError } from "./mcp.mjs";
|
|
8
|
+
|
|
9
|
+
export const APP_SCOPE = "harbor:app";
|
|
10
|
+
const SKEW_MS = 60_000;
|
|
11
|
+
|
|
12
|
+
export const hasFullAccess = (creds) => Boolean(creds?.scope?.split(" ").includes(APP_SCOPE));
|
|
13
|
+
|
|
14
|
+
/** The app's token source: the saved tokens, renewed (and saved again) when they run out. */
|
|
15
|
+
export function tokenSource({ creds, save, fetcher = fetch, now = Date.now }) {
|
|
16
|
+
let current = creds;
|
|
17
|
+
const renew = async () => {
|
|
18
|
+
current = await refresh(current, { fetcher, now });
|
|
19
|
+
await save(current);
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
server: current.server,
|
|
23
|
+
async token() {
|
|
24
|
+
if (current.expiresAt - SKEW_MS < now()) await renew();
|
|
25
|
+
return current.accessToken;
|
|
26
|
+
},
|
|
27
|
+
renew,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Load the bundled app; a checkout that hasn't built it says how. */
|
|
32
|
+
export async function loadApp() {
|
|
33
|
+
try {
|
|
34
|
+
return await import("../dist/app.mjs");
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error?.code === "ERR_MODULE_NOT_FOUND" && /dist\/app\.mjs/.test(error.message)) {
|
|
37
|
+
throw new HarborError("The full-screen app isn't built here. Run: node cli/build.mjs");
|
|
38
|
+
}
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/lib/auth.mjs
CHANGED
|
@@ -8,6 +8,8 @@ import { createServer } from "node:http";
|
|
|
8
8
|
|
|
9
9
|
export const CLIENT_NAME = "Harbor CLI";
|
|
10
10
|
export const WRITE_SCOPE = "harbor:write";
|
|
11
|
+
/** Full access, as on the website: what the full-screen app needs (/api/app). */
|
|
12
|
+
export const APP_SCOPE = "harbor:app";
|
|
11
13
|
|
|
12
14
|
const b64url = (buf) => Buffer.from(buf).toString("base64url");
|
|
13
15
|
|
|
@@ -118,7 +120,9 @@ export async function login({ server, readOnly = false, open, fetcher = fetch, n
|
|
|
118
120
|
state,
|
|
119
121
|
code_challenge: challenge,
|
|
120
122
|
code_challenge_method: "S256",
|
|
121
|
-
scope: readOnly
|
|
123
|
+
scope: readOnly
|
|
124
|
+
? "openid offline_access"
|
|
125
|
+
: `openid offline_access ${WRITE_SCOPE} ${APP_SCOPE}`,
|
|
122
126
|
}).toString();
|
|
123
127
|
await open(authorize.toString());
|
|
124
128
|
|
package/lib/cli.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
7
|
import { parseArgs } from "./args.mjs";
|
|
8
|
+
import { hasFullAccess, loadApp, tokenSource } from "./app.mjs";
|
|
8
9
|
import { login } from "./auth.mjs";
|
|
9
10
|
import { DEFAULT_SERVER, forgetCredentials, readCredentials, writeCredentials } from "./config.mjs";
|
|
10
11
|
import { harbors, myWork, taskLine, taskList, todayLocal } from "./format.mjs";
|
|
@@ -12,6 +13,7 @@ import { HarborError, createClient } from "./mcp.mjs";
|
|
|
12
13
|
|
|
13
14
|
export const HELP = `harbor: Harbor from the terminal
|
|
14
15
|
|
|
16
|
+
harbor the full-screen app: everything, with the keyboard
|
|
15
17
|
harbor login [--server URL] [--read-only] sign in (opens your browser once)
|
|
16
18
|
harbor logout forget the sign-in on this machine
|
|
17
19
|
harbor harbors the harbors you belong to
|
|
@@ -32,7 +34,8 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
32
34
|
|
|
33
35
|
Every command takes --harbor NAME (when you're in more than one) and --json.
|
|
34
36
|
Statuses: backlog, todo, doing, review, done.
|
|
35
|
-
Changes show in Harbor's History as yours, "via Harbor CLI".
|
|
37
|
+
Changes show in Harbor's History as yours, "via Harbor CLI". These commands never
|
|
38
|
+
delete; the full-screen app can, as the website can, and asks first.
|
|
36
39
|
`;
|
|
37
40
|
|
|
38
41
|
const COMMON = { flags: ["json", "help"], options: ["harbor"] };
|
|
@@ -90,11 +93,55 @@ export async function run(argv, io = {}) {
|
|
|
90
93
|
const fetcher = io.fetch ?? fetch;
|
|
91
94
|
const [command, ...rest] = argv;
|
|
92
95
|
|
|
93
|
-
if (
|
|
96
|
+
if (command === "help" || command === "--help" || command === "-h") {
|
|
94
97
|
out(HELP);
|
|
95
98
|
return 0;
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
// On its own in a terminal: the full-screen app. Piped or scripted: the help.
|
|
102
|
+
if (!command || command === "app") {
|
|
103
|
+
const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
104
|
+
if (!command && !terminal) {
|
|
105
|
+
out(HELP);
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
if (!terminal) throw new HarborError("The full-screen app needs a terminal.");
|
|
109
|
+
const { values } = parseArgs(rest, { flags: [], options: ["harbor", "server"] });
|
|
110
|
+
let creds = await readCredentials(env);
|
|
111
|
+
if (!hasFullAccess(creds)) {
|
|
112
|
+
out(
|
|
113
|
+
creds
|
|
114
|
+
? "The full-screen app needs full access, as on the website. Signing in again…"
|
|
115
|
+
: "Signing in first…",
|
|
116
|
+
);
|
|
117
|
+
const code = await run(
|
|
118
|
+
[
|
|
119
|
+
"login",
|
|
120
|
+
...(values.server
|
|
121
|
+
? ["--server", values.server]
|
|
122
|
+
: creds
|
|
123
|
+
? ["--server", creds.server]
|
|
124
|
+
: []),
|
|
125
|
+
],
|
|
126
|
+
io,
|
|
127
|
+
);
|
|
128
|
+
if (code !== 0) return code;
|
|
129
|
+
creds = await readCredentials(env);
|
|
130
|
+
if (!hasFullAccess(creds)) {
|
|
131
|
+
throw new HarborError(
|
|
132
|
+
"That sign-in allowed reading only, so the app can't open. The commands still work.",
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const { runApp } = await loadApp();
|
|
137
|
+
await runApp({
|
|
138
|
+
source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
|
|
139
|
+
fetcher,
|
|
140
|
+
harbor: values.harbor,
|
|
141
|
+
});
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
98
145
|
if (command === "login") {
|
|
99
146
|
const { values } = parseArgs(rest, { flags: ["read-only"], options: ["server"] });
|
|
100
147
|
const server = (values.server ?? env.HARBOR_SERVER ?? DEFAULT_SERVER).replace(/\/$/, "");
|
package/lib/format.mjs
CHANGED
|
@@ -8,13 +8,14 @@ const STATUS = {
|
|
|
8
8
|
done: "done",
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/** `mine` leaves out whose each task is: every one is yours. */
|
|
12
|
+
export function taskLine(t, { today, mine = false } = {}) {
|
|
12
13
|
const late = t.due && today && t.due < today && t.status !== "done";
|
|
13
14
|
const bits = [
|
|
14
15
|
STATUS[t.status] ?? t.status,
|
|
15
16
|
t.due ? (late ? `late, due ${t.due}` : `due ${t.due}`) : null,
|
|
16
17
|
t.priority && t.priority !== "medium" ? t.priority : null,
|
|
17
|
-
t.assignee ?? null,
|
|
18
|
+
mine ? null : (t.assignee ?? null),
|
|
18
19
|
t.project ?? null,
|
|
19
20
|
t.waitingOn ? `waits on ${t.waitingOn}` : null,
|
|
20
21
|
].filter(Boolean);
|
|
@@ -24,7 +25,7 @@ export function taskLine(t, { today } = {}) {
|
|
|
24
25
|
export function myWork(w, opts) {
|
|
25
26
|
if (!w.you) return w.note ?? "Nothing is yours in this harbor yet.";
|
|
26
27
|
const out = [`${w.you}: ${w.openTasks} open${w.late ? `, ${w.late} late` : ""}`];
|
|
27
|
-
if (w.tasks?.length) out.push("", ...w.tasks.map((t) => taskLine(t, opts)));
|
|
28
|
+
if (w.tasks?.length) out.push("", ...w.tasks.map((t) => taskLine(t, { ...opts, mine: true })));
|
|
28
29
|
if (w.waitingOnYou?.length) {
|
|
29
30
|
out.push("", "Waiting on you:");
|
|
30
31
|
for (const x of w.waitingOnYou)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@harborppm/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Harbor from the terminal:
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"harbor": "bin/harbor.mjs"
|
|
@@ -9,15 +9,21 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
11
|
"lib",
|
|
12
|
+
"dist",
|
|
12
13
|
"README.md",
|
|
13
14
|
"LICENSE"
|
|
14
15
|
],
|
|
15
16
|
"engines": {
|
|
16
|
-
"node": ">=
|
|
17
|
+
"node": ">=22"
|
|
17
18
|
},
|
|
18
19
|
"scripts": {
|
|
19
|
-
"test": "node --test test/*.test.mjs"
|
|
20
|
+
"test": "node --test test/*.test.mjs",
|
|
21
|
+
"build": "node build.mjs"
|
|
20
22
|
},
|
|
21
23
|
"homepage": "https://harborppm.com/ai",
|
|
22
|
-
"license": "MIT"
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"ink": "^7.1.1",
|
|
27
|
+
"react": "^19.2.0"
|
|
28
|
+
}
|
|
23
29
|
}
|