@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/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 ? "openid offline_access" : `openid offline_access ${WRITE_SCOPE}`,
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". Nothing here deletes.
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 (!command || command === "help" || command === "--help" || command === "-h") {
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
- export function taskLine(t, { today } = {}) {
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.1.0",
4
- "description": "Harbor from the terminal: what's on you, add and finish tasks, comment, check in, log risks.",
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": ">=20"
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
  }