@harborppm/cli 0.2.0 → 0.4.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 CHANGED
@@ -14,9 +14,17 @@ export const hasFullAccess = (creds) => Boolean(creds?.scope?.split(" ").include
14
14
  /** The app's token source: the saved tokens, renewed (and saved again) when they run out. */
15
15
  export function tokenSource({ creds, save, fetcher = fetch, now = Date.now }) {
16
16
  let current = creds;
17
- const renew = async () => {
18
- current = await refresh(current, { fetcher, now });
19
- await save(current);
17
+ // One renewal at a time: the app's save and live wait often find the token
18
+ // spent together, and a refresh token is good for one renewal.
19
+ let renewing = null;
20
+ const renew = () => {
21
+ renewing ??= (async () => {
22
+ current = await refresh(current, { fetcher, now });
23
+ await save(current);
24
+ })().finally(() => {
25
+ renewing = null;
26
+ });
27
+ return renewing;
20
28
  };
21
29
  return {
22
30
  server: current.server,
package/lib/cli.mjs CHANGED
@@ -4,12 +4,15 @@
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";
15
+ import { jsonText, plain } from "./plain.mjs";
13
16
 
14
17
  export const HELP = `harbor: Harbor from the terminal
15
18
 
@@ -17,6 +20,7 @@ export const HELP = `harbor: Harbor from the terminal
17
20
  harbor login [--server URL] [--read-only] sign in (opens your browser once)
18
21
  harbor logout forget the sign-in on this machine
19
22
  harbor harbors the harbors you belong to
23
+ harbor --version which version this is
20
24
 
21
25
  harbor mine what's on you, late first
22
26
  harbor tasks [words] [--project P] [--assignee me|NAME] [--late] [--status S]
@@ -38,21 +42,50 @@ export const HELP = `harbor: Harbor from the terminal
38
42
  delete; the full-screen app can, as the website can, and asks first.
39
43
  `;
40
44
 
45
+ export const VERSION = JSON.parse(
46
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
47
+ ).version;
48
+
49
+ /** The mark with a few words beside it, for a terminal that shows colour; nothing otherwise. */
50
+ function banner(io, env, words) {
51
+ const depth = colourDepth(io.stdout ?? process.stdout, env);
52
+ if (depth === "none") return null;
53
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
54
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
55
+ return `\n${logoLines(depth, words({ bold, dim })).join("\n")}\n`;
56
+ }
57
+
41
58
  const COMMON = { flags: ["json", "help"], options: ["harbor"] };
42
59
  const spec = (flags = [], options = []) => ({
43
60
  flags: [...COMMON.flags, ...flags],
44
61
  options: [...COMMON.options, ...options],
45
62
  });
46
63
 
64
+ /**
65
+ * How to open a web address on this platform, as a program and its
66
+ * arguments (never through a shell). Windows' `cmd /c start` would read
67
+ * each & in the address as the end of a command: the sign-in link has
68
+ * several. Only http(s) addresses are opened.
69
+ */
70
+ export function browserCommand(url, platform = process.platform) {
71
+ let parsed;
72
+ try {
73
+ parsed = new URL(url);
74
+ } catch {
75
+ return null;
76
+ }
77
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
78
+ const href = parsed.toString();
79
+ if (platform === "darwin") return ["open", [href]];
80
+ if (platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", href]];
81
+ return ["xdg-open", [href]];
82
+ }
83
+
47
84
  function openBrowser(url) {
48
- const [cmd, args] =
49
- process.platform === "darwin"
50
- ? ["open", [url]]
51
- : process.platform === "win32"
52
- ? ["cmd", ["/c", "start", "", url]]
53
- : ["xdg-open", [url]];
85
+ const command = browserCommand(url);
86
+ if (!command) return;
54
87
  try {
55
- spawn(cmd, args, { stdio: "ignore", detached: true })
88
+ spawn(command[0], command[1], { stdio: "ignore", detached: true })
56
89
  .on("error", () => {})
57
90
  .unref();
58
91
  } catch {
@@ -94,7 +127,20 @@ export async function run(argv, io = {}) {
94
127
  const [command, ...rest] = argv;
95
128
 
96
129
  if (command === "help" || command === "--help" || command === "-h") {
97
- out(HELP);
130
+ const top = banner(io, env, ({ bold, dim }) => [
131
+ bold("Harbor"),
132
+ "Portfolios, programs and projects,",
133
+ "from the terminal.",
134
+ "",
135
+ dim(`cli ${VERSION} · harborppm.com`),
136
+ ]);
137
+ // The banner says what the help's first line does.
138
+ out(top ? `${top}${HELP.slice(HELP.indexOf("\n"))}` : HELP);
139
+ return 0;
140
+ }
141
+
142
+ if (command === "--version" || command === "-v" || command === "version") {
143
+ out(VERSION);
98
144
  return 0;
99
145
  }
100
146
 
@@ -158,7 +204,15 @@ export async function run(argv, io = {}) {
158
204
  });
159
205
  await writeCredentials(creds, env);
160
206
  const canWrite = creds.scope.split(" ").includes("harbor:write");
161
- out(`Signed in to ${server}${canWrite ? "." : ", reading only."}`);
207
+ const said = `Signed in to ${server}${canWrite ? "." : ", reading only."}`;
208
+ const welcome = banner(io, env, ({ bold, dim }) => [
209
+ bold("Welcome aboard."),
210
+ said,
211
+ "",
212
+ dim("harbor the full-screen app"),
213
+ dim("harbor help every command"),
214
+ ]);
215
+ out(welcome ?? said);
162
216
  return 0;
163
217
  }
164
218
 
@@ -180,7 +234,7 @@ export async function run(argv, io = {}) {
180
234
  if (err instanceof HarborError) throw new HarborError(hint(err.message));
181
235
  throw err;
182
236
  });
183
- out(asJson ? JSON.stringify(result, null, 2) : show(result));
237
+ out(asJson ? jsonText(result) : plain(show(result)));
184
238
  return 0;
185
239
  };
186
240
  let asJson = false;
package/lib/config.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  * Where the CLI keeps its sign-in: one JSON file per machine, readable only
3
3
  * by its owner. HARBOR_CONFIG_DIR moves it (tests, several accounts).
4
4
  */
5
- import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
6
  import { homedir } from "node:os";
7
7
  import { join } from "node:path";
8
8
 
@@ -27,9 +27,12 @@ export async function readCredentials(env = process.env) {
27
27
 
28
28
  export async function writeCredentials(creds, env = process.env) {
29
29
  await mkdir(configDir(env), { recursive: true, mode: 0o700 });
30
- await writeFile(file(env), JSON.stringify(creds, null, 2), { mode: 0o600 });
31
- // writeFile keeps an existing file's mode; make sure it's private.
32
- await chmod(file(env), 0o600);
30
+ // A fresh private file swapped in whole: the app renews tokens while it
31
+ // runs, and a write cut short must not leave half a sign-in behind.
32
+ const temp = `${file(env)}.${process.pid}.tmp`;
33
+ await writeFile(temp, JSON.stringify(creds, null, 2), { mode: 0o600, flag: "w" });
34
+ await chmod(temp, 0o600);
35
+ await rename(temp, file(env));
33
36
  }
34
37
 
35
38
  export async function forgetCredentials(env = process.env) {
package/lib/logo.mjs ADDED
@@ -0,0 +1,174 @@
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
+ /** 12 by 12, for the full-screen app's sidebar: posts and side boats a pixel wide, no bows. */
61
+ export const LOGO_MINI = [
62
+ "..TTTTTTTT..",
63
+ ".TTTTTTTTTT.",
64
+ "TTTWTTTTWTTT",
65
+ "TCTWTTTTWTTT",
66
+ "TCTWTTTTWTTT",
67
+ "TCTWWWWWWTTT",
68
+ "TTTWWWWWWTTT",
69
+ "TTTWTTTTWTCT",
70
+ "TTTWTCCTWTCT",
71
+ "TTTWTCCTWTCT",
72
+ ".TTTTTTTTTT.",
73
+ "..TTTTTTTT..",
74
+ ];
75
+
76
+ /** The brand's fills, as in favicon.svg and src/styles.css. */
77
+ export const LOGO_COLOURS = { T: "#0f6e64", W: "#ffffff", C: "#c44a2c" };
78
+
79
+ /**
80
+ * @typedef {{ text: string, fg?: string, bg?: string }} Run
81
+ * One stretch of a terminal row in the same colours.
82
+ */
83
+
84
+ /**
85
+ * The logo as terminal rows, each a list of runs: the top pixel is the
86
+ * half block's colour, the bottom one its background.
87
+ * @param {string[]} pixels
88
+ * @returns {Run[][]}
89
+ */
90
+ export function logoRows(pixels = LOGO) {
91
+ /** @type {Run[][]} */
92
+ const rows = [];
93
+ for (let y = 0; y < pixels.length; y += 2) {
94
+ /** @type {Run[]} */
95
+ const runs = [];
96
+ const top = pixels[y] ?? "";
97
+ const bottom = pixels[y + 1] ?? "";
98
+ for (let x = 0; x < top.length; x++) {
99
+ const t = LOGO_COLOURS[/** @type {"T"|"W"|"C"} */ (top[x])];
100
+ const b = LOGO_COLOURS[/** @type {"T"|"W"|"C"} */ (bottom[x])];
101
+ // A bow is two pixels wide; each half of it fills the inner half of its pixel.
102
+ const bowRow = top[x] === "P" ? top : bottom[x] === "P" ? bottom : null;
103
+ const left = bowRow ? bowRow[x - 1] !== "P" : false;
104
+ /** @type {Run} */
105
+ const cell =
106
+ top[x] === "P"
107
+ ? { text: left ? "▟" : "▙", fg: LOGO_COLOURS.C, bg: LOGO_COLOURS.T }
108
+ : bottom[x] === "P"
109
+ ? { text: left ? "▗" : "▖", fg: LOGO_COLOURS.C, bg: t }
110
+ : !t && !b
111
+ ? { text: " " }
112
+ : t === b || !b
113
+ ? { text: t === b ? "█" : "▀", fg: t }
114
+ : !t
115
+ ? { text: "▄", fg: b }
116
+ : { text: "▀", fg: t, bg: b };
117
+ const last = runs.at(-1);
118
+ if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
119
+ else runs.push(cell);
120
+ }
121
+ rows.push(runs);
122
+ }
123
+ return rows;
124
+ }
125
+
126
+ /** @param {string} hex */
127
+ const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
128
+
129
+ /** The nearest of xterm's 256 colours, for terminals without true colour. */
130
+ /** @param {string} hex */
131
+ export function xterm256(hex) {
132
+ const steps = [0, 95, 135, 175, 215, 255];
133
+ const near = (/** @type {number} */ v) =>
134
+ steps.reduce((best, s, i) => (Math.abs(s - v) < Math.abs(steps[best] - v) ? i : best), 0);
135
+ const [r, g, b] = rgb(hex).map(near);
136
+ return 16 + 36 * r + 6 * g + b;
137
+ }
138
+
139
+ /**
140
+ * Whether to draw in colour here, and how: none when piped, when NO_COLOR is
141
+ * set or the terminal is dumb; true colour when the terminal says so.
142
+ * @param {{ isTTY?: boolean }} stream
143
+ * @param {Record<string, string | undefined>} env
144
+ * @returns {"none" | "256" | "truecolor"}
145
+ */
146
+ export function colourDepth(stream, env) {
147
+ if (!stream.isTTY || env.NO_COLOR || env.TERM === "dumb") return "none";
148
+ return /truecolor|24bit/i.test(env.COLORTERM ?? "") ? "truecolor" : "256";
149
+ }
150
+
151
+ /**
152
+ * The logo as lines of ANSI text, with words beside it (vertically centred).
153
+ * @param {"256" | "truecolor"} depth
154
+ * @param {string[]} [beside]
155
+ * @param {string[]} [pixels]
156
+ */
157
+ export function logoLines(depth, beside = [], pixels = LOGO) {
158
+ /** @param {string} hex @param {38 | 48} layer */
159
+ const sgr = (hex, layer) =>
160
+ depth === "truecolor" ? `${layer};2;${rgb(hex).join(";")}` : `${layer};5;${xterm256(hex)}`;
161
+ const rows = logoRows(pixels).map((runs) =>
162
+ runs
163
+ .map((r) => {
164
+ const codes = [r.fg && sgr(r.fg, 38), r.bg && sgr(r.bg, 48)].filter(Boolean);
165
+ return codes.length ? `\x1b[${codes.join(";")}m${r.text}\x1b[0m` : r.text;
166
+ })
167
+ .join(""),
168
+ );
169
+ const from = Math.max(0, Math.floor((rows.length - beside.length) / 2));
170
+ return rows.map((row, i) => {
171
+ const words = beside[i - from];
172
+ return words ? ` ${row} ${words}` : ` ${row}`;
173
+ });
174
+ }
package/lib/plain.mjs ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Text from a harbor, made safe to show in a terminal. A task title can come
3
+ * from a public intake form, a GitHub pull request or any member, and a
4
+ * terminal acts on control characters it's sent: an escape sequence in a
5
+ * title could set the clipboard, retitle the window or hide what's around
6
+ * it. So they're taken out before anything is printed: the C0 and C1
7
+ * controls (tab and newline stay), DEL, and the bidirectional overrides that
8
+ * make text read in a different order than it's stored.
9
+ */
10
+ // eslint-disable-next-line no-control-regex
11
+ const UNSAFE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f‪-‮⁦-⁩]/g;
12
+
13
+ /** @param {string} text */
14
+ export function plain(text) {
15
+ return text.replace(UNSAFE, "");
16
+ }
17
+
18
+ /**
19
+ * Every string in a value made plain: what the full-screen app takes in from
20
+ * Harbor, before any of it reaches the screen.
21
+ * @template T
22
+ * @param {T} value
23
+ * @returns {T}
24
+ */
25
+ export function plainDeep(value) {
26
+ if (typeof value === "string") return /** @type {T} */ (/** @type {unknown} */ (plain(value)));
27
+ if (Array.isArray(value)) return /** @type {T} */ (/** @type {unknown} */ (value.map(plainDeep)));
28
+ if (value && typeof value === "object") {
29
+ /** @type {Record<string, unknown>} */
30
+ const out = {};
31
+ for (const [k, v] of Object.entries(value)) out[k] = plainDeep(v);
32
+ return /** @type {T} */ (/** @type {unknown} */ (out));
33
+ }
34
+ return value;
35
+ }
36
+
37
+ /**
38
+ * JSON for --json: the data exactly, with the characters a terminal would
39
+ * act on written as \u escapes (JSON.stringify already does this for C0).
40
+ * @param {unknown} value
41
+ */
42
+ export function jsonText(value) {
43
+ return JSON.stringify(value, null, 2).replace(
44
+ /[\u007f-\u009f‪-‮⁦-⁩]/g,
45
+ (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`,
46
+ );
47
+ }
package/lib/update.mjs ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * A line saying when a newer @harborppm/cli is out, since a global npm
3
+ * install never updates itself. npm is asked at most once a day, while the
4
+ * command runs (never holding it up more than a moment), and the answer is
5
+ * kept beside the sign-in. Nothing is said in scripts and CI: only to a
6
+ * terminal, and never when HARBOR_NO_UPDATE_CHECK or NO_UPDATE_NOTIFIER is set.
7
+ */
8
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { configDir } from "./config.mjs";
11
+
12
+ export const LATEST_URL = "https://registry.npmjs.org/@harborppm/cli/latest";
13
+ const DAY_MS = 24 * 60 * 60 * 1000;
14
+
15
+ /** Whether version `a` is newer than `b` (plain x.y.z; a pre-release never counts as newer). */
16
+ export function isNewer(a, b) {
17
+ const parse = (v) => /^(\d+)\.(\d+)\.(\d+)$/.exec(String(v))?.slice(1).map(Number);
18
+ const x = parse(a);
19
+ const y = parse(b);
20
+ if (!x || !y) return false;
21
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] > y[i];
22
+ return false;
23
+ }
24
+
25
+ /**
26
+ * The line to show after a command, or null.
27
+ * @param {{ version: string, env?: Record<string, string | undefined>, stream?: { isTTY?: boolean },
28
+ * fetcher?: typeof fetch, now?: () => number, timeoutMs?: number }} opts
29
+ */
30
+ export async function updateNotice({
31
+ version,
32
+ env = process.env,
33
+ stream = process.stderr,
34
+ fetcher = fetch,
35
+ now = Date.now,
36
+ timeoutMs = 1500,
37
+ }) {
38
+ if (!stream.isTTY || env.CI || env.HARBOR_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER) return null;
39
+ const file = join(configDir(env), "update-check.json");
40
+ let kept = {};
41
+ try {
42
+ kept = JSON.parse(await readFile(file, "utf8"));
43
+ } catch {
44
+ // Never checked here yet.
45
+ }
46
+ let latest = typeof kept.latest === "string" ? kept.latest : null;
47
+ if (!(Number(kept.checkedAt) > now() - DAY_MS)) {
48
+ // A plain timer, not AbortSignal.timeout: that one doesn't keep Node
49
+ // running, so a stalled answer after a quick command would never end.
50
+ const stop = new AbortController();
51
+ const timer = setTimeout(() => stop.abort(), timeoutMs);
52
+ try {
53
+ const res = await fetcher(LATEST_URL, { signal: stop.signal });
54
+ if (res.ok) {
55
+ const body = await res.json();
56
+ if (typeof body?.version === "string") latest = body.version;
57
+ }
58
+ } catch {
59
+ // Offline or slow: say nothing, and ask again tomorrow.
60
+ } finally {
61
+ clearTimeout(timer);
62
+ }
63
+ try {
64
+ await mkdir(configDir(env), { recursive: true, mode: 0o700 });
65
+ await writeFile(file, JSON.stringify({ checkedAt: now(), latest }));
66
+ } catch {
67
+ // Can't keep it: ask again next time.
68
+ }
69
+ }
70
+ return latest && isNewer(latest, version)
71
+ ? `Harbor CLI ${latest} is out (you have ${version}): npm install -g @harborppm/cli@latest`
72
+ : null;
73
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.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": {