@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/README.md +22 -6
- package/bin/harbor.mjs +22 -5
- package/dist/app.mjs +4672 -1356
- package/lib/app.mjs +11 -3
- package/lib/cli.mjs +64 -10
- package/lib/config.mjs +7 -4
- package/lib/logo.mjs +174 -0
- package/lib/plain.mjs +47 -0
- package/lib/update.mjs +73 -0
- package/package.json +1 -1
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
49
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
31
|
-
//
|
|
32
|
-
|
|
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
|
+
}
|