@harborppm/cli 0.2.0 → 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/cli.mjs CHANGED
@@ -4,11 +4,13 @@
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";
8
9
  import { hasFullAccess, loadApp, tokenSource } from "./app.mjs";
9
10
  import { login } from "./auth.mjs";
10
11
  import { DEFAULT_SERVER, forgetCredentials, readCredentials, writeCredentials } from "./config.mjs";
11
12
  import { harbors, myWork, taskLine, taskList, todayLocal } from "./format.mjs";
13
+ import { colourDepth, logoLines } from "./logo.mjs";
12
14
  import { HarborError, createClient } from "./mcp.mjs";
13
15
 
14
16
  export const HELP = `harbor: Harbor from the terminal
@@ -17,6 +19,7 @@ export const HELP = `harbor: Harbor from the terminal
17
19
  harbor login [--server URL] [--read-only] sign in (opens your browser once)
18
20
  harbor logout forget the sign-in on this machine
19
21
  harbor harbors the harbors you belong to
22
+ harbor --version which version this is
20
23
 
21
24
  harbor mine what's on you, late first
22
25
  harbor tasks [words] [--project P] [--assignee me|NAME] [--late] [--status S]
@@ -38,6 +41,19 @@ export const HELP = `harbor: Harbor from the terminal
38
41
  delete; the full-screen app can, as the website can, and asks first.
39
42
  `;
40
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
+
41
57
  const COMMON = { flags: ["json", "help"], options: ["harbor"] };
42
58
  const spec = (flags = [], options = []) => ({
43
59
  flags: [...COMMON.flags, ...flags],
@@ -94,7 +110,20 @@ export async function run(argv, io = {}) {
94
110
  const [command, ...rest] = argv;
95
111
 
96
112
  if (command === "help" || command === "--help" || command === "-h") {
97
- out(HELP);
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);
98
127
  return 0;
99
128
  }
100
129
 
@@ -158,7 +187,15 @@ export async function run(argv, io = {}) {
158
187
  });
159
188
  await writeCredentials(creds, env);
160
189
  const canWrite = creds.scope.split(" ").includes("harbor:write");
161
- 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);
162
199
  return 0;
163
200
  }
164
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
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": {