@harborppm/cli 0.1.1 → 0.3.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
@@ -4,17 +4,22 @@
4
4
  * drive it without a terminal.
5
5
  */
6
6
  import { spawn } from "node:child_process";
7
+ import { readFileSync } from "node:fs";
7
8
  import { parseArgs } from "./args.mjs";
9
+ import { hasFullAccess, loadApp, tokenSource } from "./app.mjs";
8
10
  import { login } from "./auth.mjs";
9
11
  import { DEFAULT_SERVER, forgetCredentials, readCredentials, writeCredentials } from "./config.mjs";
10
12
  import { harbors, myWork, taskLine, taskList, todayLocal } from "./format.mjs";
13
+ import { colourDepth, logoLines } from "./logo.mjs";
11
14
  import { HarborError, createClient } from "./mcp.mjs";
12
15
 
13
16
  export const HELP = `harbor: Harbor from the terminal
14
17
 
18
+ harbor the full-screen app: everything, with the keyboard
15
19
  harbor login [--server URL] [--read-only] sign in (opens your browser once)
16
20
  harbor logout forget the sign-in on this machine
17
21
  harbor harbors the harbors you belong to
22
+ harbor --version which version this is
18
23
 
19
24
  harbor mine what's on you, late first
20
25
  harbor tasks [words] [--project P] [--assignee me|NAME] [--late] [--status S]
@@ -32,9 +37,23 @@ export const HELP = `harbor: Harbor from the terminal
32
37
 
33
38
  Every command takes --harbor NAME (when you're in more than one) and --json.
34
39
  Statuses: backlog, todo, doing, review, done.
35
- Changes show in Harbor's History as yours, "via Harbor CLI". Nothing here deletes.
40
+ Changes show in Harbor's History as yours, "via Harbor CLI". These commands never
41
+ delete; the full-screen app can, as the website can, and asks first.
36
42
  `;
37
43
 
44
+ export const VERSION = JSON.parse(
45
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
46
+ ).version;
47
+
48
+ /** The mark with a few words beside it, for a terminal that shows colour; nothing otherwise. */
49
+ function banner(io, env, words) {
50
+ const depth = colourDepth(io.stdout ?? process.stdout, env);
51
+ if (depth === "none") return null;
52
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
53
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
54
+ return `\n${logoLines(depth, words({ bold, dim })).join("\n")}\n`;
55
+ }
56
+
38
57
  const COMMON = { flags: ["json", "help"], options: ["harbor"] };
39
58
  const spec = (flags = [], options = []) => ({
40
59
  flags: [...COMMON.flags, ...flags],
@@ -90,8 +109,65 @@ export async function run(argv, io = {}) {
90
109
  const fetcher = io.fetch ?? fetch;
91
110
  const [command, ...rest] = argv;
92
111
 
93
- if (!command || command === "help" || command === "--help" || command === "-h") {
94
- out(HELP);
112
+ if (command === "help" || command === "--help" || command === "-h") {
113
+ const top = banner(io, env, ({ bold, dim }) => [
114
+ bold("Harbor"),
115
+ "Portfolios, programs and projects,",
116
+ "from the terminal.",
117
+ "",
118
+ dim(`cli ${VERSION} · harborppm.com`),
119
+ ]);
120
+ // The banner says what the help's first line does.
121
+ out(top ? `${top}${HELP.slice(HELP.indexOf("\n"))}` : HELP);
122
+ return 0;
123
+ }
124
+
125
+ if (command === "--version" || command === "-v" || command === "version") {
126
+ out(VERSION);
127
+ return 0;
128
+ }
129
+
130
+ // On its own in a terminal: the full-screen app. Piped or scripted: the help.
131
+ if (!command || command === "app") {
132
+ const terminal = io.terminal ?? Boolean(process.stdout.isTTY && process.stdin.isTTY);
133
+ if (!command && !terminal) {
134
+ out(HELP);
135
+ return 0;
136
+ }
137
+ if (!terminal) throw new HarborError("The full-screen app needs a terminal.");
138
+ const { values } = parseArgs(rest, { flags: [], options: ["harbor", "server"] });
139
+ let creds = await readCredentials(env);
140
+ if (!hasFullAccess(creds)) {
141
+ out(
142
+ creds
143
+ ? "The full-screen app needs full access, as on the website. Signing in again…"
144
+ : "Signing in first…",
145
+ );
146
+ const code = await run(
147
+ [
148
+ "login",
149
+ ...(values.server
150
+ ? ["--server", values.server]
151
+ : creds
152
+ ? ["--server", creds.server]
153
+ : []),
154
+ ],
155
+ io,
156
+ );
157
+ if (code !== 0) return code;
158
+ creds = await readCredentials(env);
159
+ if (!hasFullAccess(creds)) {
160
+ throw new HarborError(
161
+ "That sign-in allowed reading only, so the app can't open. The commands still work.",
162
+ );
163
+ }
164
+ }
165
+ const { runApp } = await loadApp();
166
+ await runApp({
167
+ source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
168
+ fetcher,
169
+ harbor: values.harbor,
170
+ });
95
171
  return 0;
96
172
  }
97
173
 
@@ -111,7 +187,15 @@ export async function run(argv, io = {}) {
111
187
  });
112
188
  await writeCredentials(creds, env);
113
189
  const canWrite = creds.scope.split(" ").includes("harbor:write");
114
- out(`Signed in to ${server}${canWrite ? "." : ", reading only."}`);
190
+ const said = `Signed in to ${server}${canWrite ? "." : ", reading only."}`;
191
+ const welcome = banner(io, env, ({ bold, dim }) => [
192
+ bold("Welcome aboard."),
193
+ said,
194
+ "",
195
+ dim("harbor the full-screen app"),
196
+ dim("harbor help every command"),
197
+ ]);
198
+ out(welcome ?? said);
115
199
  return 0;
116
200
  }
117
201
 
package/lib/logo.mjs ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Harbor's mark for the terminal: the favicon (public/favicon.svg) as
3
+ * pixels, a teal tile with the white H and three coral boats. Each
4
+ * terminal row draws two pixel rows with half blocks (▀ ▄ █), so a square
5
+ * grid comes out square. `harbor help` prints it; the full-screen app draws
6
+ * it on its splash and its keys overlay (src/tui/logo.tsx).
7
+ *
8
+ * T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
9
+ * the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
10
+ */
11
+
12
+ /** 24 by 24: posts three wide, a pixel of water between each boat and the posts. */
13
+ export const LOGO = [
14
+ "...TTTTTTTTTTTTTTTTTT...",
15
+ "..TTTTTTTTTTTTTTTTTTTT..",
16
+ ".TTTTTTTTTTTTTTTTTTTTTT.",
17
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
18
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
19
+ "TTPPTWWWTTTTTTTTWWWTTTTT",
20
+ "TTCCTWWWTTTTTTTTWWWTTTTT",
21
+ "TTCCTWWWTTTTTTTTWWWTTTTT",
22
+ "TTCCTWWWTTTTTTTTWWWTTTTT",
23
+ "TTCCTWWWTTTTTTTTWWWTTTTT",
24
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
25
+ "TTTTTWWWWWWWWWWWWWWTTTTT",
26
+ "TTTTTWWWWWWWWWWWWWWTTTTT",
27
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
28
+ "TTTTTWWWTTTTTTTTWWWTPPTT",
29
+ "TTTTTWWWTTTPPTTTWWWTCCTT",
30
+ "TTTTTWWWTTTCCTTTWWWTCCTT",
31
+ "TTTTTWWWTTTCCTTTWWWTCCTT",
32
+ "TTTTTWWWTTTCCTTTWWWTCCTT",
33
+ "TTTTTWWWTTTCCTTTWWWTTTTT",
34
+ "TTTTTWWWTTTTTTTTWWWTTTTT",
35
+ ".TTTTTTTTTTTTTTTTTTTTTT.",
36
+ "..TTTTTTTTTTTTTTTTTTTT..",
37
+ "...TTTTTTTTTTTTTTTTTT...",
38
+ ];
39
+
40
+ /** 16 by 16, for narrow places. */
41
+ export const LOGO_SMALL = [
42
+ "..TTTTTTTTTTTT..",
43
+ ".TTTTTTTTTTTTTT.",
44
+ "TTTTWWTTTTWWTTTT",
45
+ "TTTTWWTTTTWWTTTT",
46
+ "TPPTWWTTTTWWTTTT",
47
+ "TCCTWWTTTTWWTTTT",
48
+ "TCCTWWTTTTWWTTTT",
49
+ "TTTTWWWWWWWWTTTT",
50
+ "TTTTWWWWWWWWTTTT",
51
+ "TTTTWWTTTTWWTPPT",
52
+ "TTTTWWTPPTWWTCCT",
53
+ "TTTTWWTCCTWWTCCT",
54
+ "TTTTWWTCCTWWTTTT",
55
+ "TTTTWWTTTTWWTTTT",
56
+ ".TTTTTTTTTTTTTT.",
57
+ "..TTTTTTTTTTTT..",
58
+ ];
59
+
60
+ /** The brand's fills, as in favicon.svg and src/styles.css. */
61
+ export const LOGO_COLOURS = { T: "#0f6e64", W: "#ffffff", C: "#c44a2c" };
62
+
63
+ /**
64
+ * @typedef {{ text: string, fg?: string, bg?: string }} Run
65
+ * One stretch of a terminal row in the same colours.
66
+ */
67
+
68
+ /**
69
+ * The logo as terminal rows, each a list of runs: the top pixel is the
70
+ * half block's colour, the bottom one its background.
71
+ * @param {string[]} pixels
72
+ * @returns {Run[][]}
73
+ */
74
+ export function logoRows(pixels = LOGO) {
75
+ /** @type {Run[][]} */
76
+ const rows = [];
77
+ for (let y = 0; y < pixels.length; y += 2) {
78
+ /** @type {Run[]} */
79
+ const runs = [];
80
+ const top = pixels[y] ?? "";
81
+ const bottom = pixels[y + 1] ?? "";
82
+ for (let x = 0; x < top.length; x++) {
83
+ const t = LOGO_COLOURS[/** @type {"T"|"W"|"C"} */ (top[x])];
84
+ const b = LOGO_COLOURS[/** @type {"T"|"W"|"C"} */ (bottom[x])];
85
+ // A bow is two pixels wide; each half of it fills the inner half of its pixel.
86
+ const bowRow = top[x] === "P" ? top : bottom[x] === "P" ? bottom : null;
87
+ const left = bowRow ? bowRow[x - 1] !== "P" : false;
88
+ /** @type {Run} */
89
+ const cell =
90
+ top[x] === "P"
91
+ ? { text: left ? "▟" : "▙", fg: LOGO_COLOURS.C, bg: LOGO_COLOURS.T }
92
+ : bottom[x] === "P"
93
+ ? { text: left ? "▗" : "▖", fg: LOGO_COLOURS.C, bg: t }
94
+ : !t && !b
95
+ ? { text: " " }
96
+ : t === b || !b
97
+ ? { text: t === b ? "█" : "▀", fg: t }
98
+ : !t
99
+ ? { text: "▄", fg: b }
100
+ : { text: "▀", fg: t, bg: b };
101
+ const last = runs.at(-1);
102
+ if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
103
+ else runs.push(cell);
104
+ }
105
+ rows.push(runs);
106
+ }
107
+ return rows;
108
+ }
109
+
110
+ /** @param {string} hex */
111
+ const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
112
+
113
+ /** The nearest of xterm's 256 colours, for terminals without true colour. */
114
+ /** @param {string} hex */
115
+ export function xterm256(hex) {
116
+ const steps = [0, 95, 135, 175, 215, 255];
117
+ const near = (/** @type {number} */ v) =>
118
+ steps.reduce((best, s, i) => (Math.abs(s - v) < Math.abs(steps[best] - v) ? i : best), 0);
119
+ const [r, g, b] = rgb(hex).map(near);
120
+ return 16 + 36 * r + 6 * g + b;
121
+ }
122
+
123
+ /**
124
+ * Whether to draw in colour here, and how: none when piped, when NO_COLOR is
125
+ * set or the terminal is dumb; true colour when the terminal says so.
126
+ * @param {{ isTTY?: boolean }} stream
127
+ * @param {Record<string, string | undefined>} env
128
+ * @returns {"none" | "256" | "truecolor"}
129
+ */
130
+ export function colourDepth(stream, env) {
131
+ if (!stream.isTTY || env.NO_COLOR || env.TERM === "dumb") return "none";
132
+ return /truecolor|24bit/i.test(env.COLORTERM ?? "") ? "truecolor" : "256";
133
+ }
134
+
135
+ /**
136
+ * The logo as lines of ANSI text, with words beside it (vertically centred).
137
+ * @param {"256" | "truecolor"} depth
138
+ * @param {string[]} [beside]
139
+ * @param {string[]} [pixels]
140
+ */
141
+ export function logoLines(depth, beside = [], pixels = LOGO) {
142
+ /** @param {string} hex @param {38 | 48} layer */
143
+ const sgr = (hex, layer) =>
144
+ depth === "truecolor" ? `${layer};2;${rgb(hex).join(";")}` : `${layer};5;${xterm256(hex)}`;
145
+ const rows = logoRows(pixels).map((runs) =>
146
+ runs
147
+ .map((r) => {
148
+ const codes = [r.fg && sgr(r.fg, 38), r.bg && sgr(r.bg, 48)].filter(Boolean);
149
+ return codes.length ? `\x1b[${codes.join(";")}m${r.text}\x1b[0m` : r.text;
150
+ })
151
+ .join(""),
152
+ );
153
+ const from = Math.max(0, Math.floor((rows.length - beside.length) / 2));
154
+ return rows.map((row, i) => {
155
+ const words = beside[i - from];
156
+ return words ? ` ${row} ${words}` : ` ${row}`;
157
+ });
158
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.1.1",
4
- "description": "Harbor from the terminal: what's on you, add and finish tasks, comment, check in, log risks.",
3
+ "version": "0.3.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
  }