@harborppm/cli 0.3.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 +9 -1
- package/bin/harbor.mjs +22 -5
- package/dist/app.mjs +272 -193
- package/lib/app.mjs +11 -3
- package/lib/cli.mjs +25 -8
- package/lib/config.mjs +7 -4
- package/lib/logo.mjs +16 -0
- package/lib/plain.mjs +47 -0
- package/lib/update.mjs +73 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Needs Node 22 or later.
|
|
|
12
12
|
|
|
13
13
|
## The full-screen app
|
|
14
14
|
|
|
15
|
-
`harbor` on its own takes over the terminal, like `htop` or `lazygit`, and gives it back when you press `q`. It opens on Harbor's mark (the teal tile, the H and three boats, drawn in half blocks) while your harbor loads,
|
|
15
|
+
`harbor` on its own takes over the terminal, like `htop` or `lazygit`, and gives it back when you press `q`. It opens on Harbor's mark (the teal tile, the H and three boats, drawn in half blocks) while your harbor loads, and keeps a small one at the foot of the sidebar when the terminal is tall enough (21 rows or more; the larger one from 110 columns). Then:
|
|
16
16
|
|
|
17
17
|
1. **Today**: what's on you, late first; who's waiting on you; your risks and issues.
|
|
18
18
|
2. **Inbox**: the same pings as the website: waiting on you, sign-offs, mentions. Enter opens one; it's marked read.
|
|
@@ -29,6 +29,14 @@ Keys: `↑↓` (or `j k`) and `←→` (or `h l`) move, Enter opens, Esc goes ba
|
|
|
29
29
|
|
|
30
30
|
`harbor help` shows the mark too, in a terminal with colour; piped, or with `NO_COLOR` set, it's plain text. `harbor --version` says which version you have.
|
|
31
31
|
|
|
32
|
+
## Staying up to date
|
|
33
|
+
|
|
34
|
+
npm doesn't update a global install by itself, so once a day `harbor` asks npm whether a newer version is out, alongside whatever command you ran, and says so in one line when there is (`npm install -g @harborppm/cli@latest` updates it). It says nothing in scripts, pipes or CI; set `HARBOR_NO_UPDATE_CHECK=1` (or `NO_UPDATE_NOTIFIER=1`) to turn it off.
|
|
35
|
+
|
|
36
|
+
## Text from others
|
|
37
|
+
|
|
38
|
+
Task titles, names and notes can come from an intake form, a GitHub pull request or anyone in a harbor. Before the commands or the app show them, `harbor` takes out the control characters a terminal would act on (escape sequences, backspaces, text-direction overrides), so a title can't set your clipboard, retitle the window or hide what's around it. `--json` keeps the data exact, with those characters written as `\u` escapes.
|
|
39
|
+
|
|
32
40
|
It runs on the same engine as the website: health, task keys, limits and undo work the same way, changes save as you go (the header says when, and holds them while you're offline), and changes anyone makes on the website or elsewhere show up within a couple of seconds. It needs full access, which `harbor login` asks for by name; an older sign-in is asked again the first time.
|
|
33
41
|
|
|
34
42
|
## Signing in
|
package/bin/harbor.mjs
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { run } from "../lib/cli.mjs";
|
|
2
|
+
import { VERSION, run } from "../lib/cli.mjs";
|
|
3
3
|
import { HarborError } from "../lib/mcp.mjs";
|
|
4
|
+
import { plain } from "../lib/plain.mjs";
|
|
5
|
+
import { updateNotice } from "../lib/update.mjs";
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
const argv = process.argv.slice(2);
|
|
8
|
+
// Asked alongside the command, so it costs nothing extra (see update.mjs).
|
|
9
|
+
const notice = argv.includes("--json")
|
|
10
|
+
? Promise.resolve(null)
|
|
11
|
+
: updateNotice({ version: VERSION }).catch(() => null);
|
|
12
|
+
const tell = async () => {
|
|
13
|
+
const line = await notice;
|
|
14
|
+
if (line) process.stderr.write(`\n${line}\n`);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
run(argv).then(
|
|
18
|
+
async (code) => {
|
|
19
|
+
await tell();
|
|
20
|
+
process.exit(code);
|
|
21
|
+
},
|
|
22
|
+
async (err) => {
|
|
8
23
|
const known =
|
|
9
24
|
err instanceof HarborError ||
|
|
10
25
|
/^(Unknown option|--\S+ (needs|takes|is))/.test(err?.message ?? "");
|
|
26
|
+
// Harbor's refusals can quote a task's title; print them plain.
|
|
11
27
|
process.stderr.write(
|
|
12
|
-
`${known ? err.message : `Something went wrong: ${err?.message ?? err}`}\n`,
|
|
28
|
+
`${plain(known ? err.message : `Something went wrong: ${err?.message ?? err}`)}\n`,
|
|
13
29
|
);
|
|
30
|
+
await tell();
|
|
14
31
|
process.exit(1);
|
|
15
32
|
},
|
|
16
33
|
);
|
package/dist/app.mjs
CHANGED
|
@@ -192,6 +192,137 @@ const createImpl = (createState) => {
|
|
|
192
192
|
};
|
|
193
193
|
const create$1 = ((createState) => createState ? createImpl(createState) : createImpl);
|
|
194
194
|
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region cli/lib/logo.mjs
|
|
197
|
+
/**
|
|
198
|
+
* Harbor's mark for the terminal: the favicon (public/favicon.svg) as
|
|
199
|
+
* pixels, a teal tile with the white H and three coral boats. Each
|
|
200
|
+
* terminal row draws two pixel rows with half blocks (▀ ▄ █), so a square
|
|
201
|
+
* grid comes out square. `harbor help` prints it; the full-screen app draws
|
|
202
|
+
* it on its splash and its keys overlay (src/tui/logo.tsx).
|
|
203
|
+
*
|
|
204
|
+
* T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
|
|
205
|
+
* the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
|
|
206
|
+
*/
|
|
207
|
+
/** 24 by 24: posts three wide, a pixel of water between each boat and the posts. */
|
|
208
|
+
const LOGO = [
|
|
209
|
+
"...TTTTTTTTTTTTTTTTTT...",
|
|
210
|
+
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
211
|
+
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
212
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
213
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
214
|
+
"TTPPTWWWTTTTTTTTWWWTTTTT",
|
|
215
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
216
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
217
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
218
|
+
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
219
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
220
|
+
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
221
|
+
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
222
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
223
|
+
"TTTTTWWWTTTTTTTTWWWTPPTT",
|
|
224
|
+
"TTTTTWWWTTTPPTTTWWWTCCTT",
|
|
225
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
226
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
227
|
+
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
228
|
+
"TTTTTWWWTTTCCTTTWWWTTTTT",
|
|
229
|
+
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
230
|
+
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
231
|
+
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
232
|
+
"...TTTTTTTTTTTTTTTTTT..."
|
|
233
|
+
];
|
|
234
|
+
/** 16 by 16, for narrow places. */
|
|
235
|
+
const LOGO_SMALL = [
|
|
236
|
+
"..TTTTTTTTTTTT..",
|
|
237
|
+
".TTTTTTTTTTTTTT.",
|
|
238
|
+
"TTTTWWTTTTWWTTTT",
|
|
239
|
+
"TTTTWWTTTTWWTTTT",
|
|
240
|
+
"TPPTWWTTTTWWTTTT",
|
|
241
|
+
"TCCTWWTTTTWWTTTT",
|
|
242
|
+
"TCCTWWTTTTWWTTTT",
|
|
243
|
+
"TTTTWWWWWWWWTTTT",
|
|
244
|
+
"TTTTWWWWWWWWTTTT",
|
|
245
|
+
"TTTTWWTTTTWWTPPT",
|
|
246
|
+
"TTTTWWTPPTWWTCCT",
|
|
247
|
+
"TTTTWWTCCTWWTCCT",
|
|
248
|
+
"TTTTWWTCCTWWTTTT",
|
|
249
|
+
"TTTTWWTTTTWWTTTT",
|
|
250
|
+
".TTTTTTTTTTTTTT.",
|
|
251
|
+
"..TTTTTTTTTTTT.."
|
|
252
|
+
];
|
|
253
|
+
/** 12 by 12, for the full-screen app's sidebar: posts and side boats a pixel wide, no bows. */
|
|
254
|
+
const LOGO_MINI = [
|
|
255
|
+
"..TTTTTTTT..",
|
|
256
|
+
".TTTTTTTTTT.",
|
|
257
|
+
"TTTWTTTTWTTT",
|
|
258
|
+
"TCTWTTTTWTTT",
|
|
259
|
+
"TCTWTTTTWTTT",
|
|
260
|
+
"TCTWWWWWWTTT",
|
|
261
|
+
"TTTWWWWWWTTT",
|
|
262
|
+
"TTTWTTTTWTCT",
|
|
263
|
+
"TTTWTCCTWTCT",
|
|
264
|
+
"TTTWTCCTWTCT",
|
|
265
|
+
".TTTTTTTTTT.",
|
|
266
|
+
"..TTTTTTTT.."
|
|
267
|
+
];
|
|
268
|
+
/** The brand's fills, as in favicon.svg and src/styles.css. */
|
|
269
|
+
const LOGO_COLOURS = {
|
|
270
|
+
T: "#0f6e64",
|
|
271
|
+
W: "#ffffff",
|
|
272
|
+
C: "#c44a2c"
|
|
273
|
+
};
|
|
274
|
+
/**
|
|
275
|
+
* @typedef {{ text: string, fg?: string, bg?: string }} Run
|
|
276
|
+
* One stretch of a terminal row in the same colours.
|
|
277
|
+
*/
|
|
278
|
+
/**
|
|
279
|
+
* The logo as terminal rows, each a list of runs: the top pixel is the
|
|
280
|
+
* half block's colour, the bottom one its background.
|
|
281
|
+
* @param {string[]} pixels
|
|
282
|
+
* @returns {Run[][]}
|
|
283
|
+
*/
|
|
284
|
+
function logoRows(pixels = LOGO) {
|
|
285
|
+
/** @type {Run[][]} */
|
|
286
|
+
const rows = [];
|
|
287
|
+
for (let y = 0; y < pixels.length; y += 2) {
|
|
288
|
+
/** @type {Run[]} */
|
|
289
|
+
const runs = [];
|
|
290
|
+
const top = pixels[y] ?? "";
|
|
291
|
+
const bottom = pixels[y + 1] ?? "";
|
|
292
|
+
for (let x = 0; x < top.length; x++) {
|
|
293
|
+
const t = LOGO_COLOURS[top[x]];
|
|
294
|
+
const b = LOGO_COLOURS[bottom[x]];
|
|
295
|
+
const bowRow = top[x] === "P" ? top : bottom[x] === "P" ? bottom : null;
|
|
296
|
+
const left = bowRow ? bowRow[x - 1] !== "P" : false;
|
|
297
|
+
/** @type {Run} */
|
|
298
|
+
const cell = top[x] === "P" ? {
|
|
299
|
+
text: left ? "▟" : "▙",
|
|
300
|
+
fg: LOGO_COLOURS.C,
|
|
301
|
+
bg: LOGO_COLOURS.T
|
|
302
|
+
} : bottom[x] === "P" ? {
|
|
303
|
+
text: left ? "▗" : "▖",
|
|
304
|
+
fg: LOGO_COLOURS.C,
|
|
305
|
+
bg: t
|
|
306
|
+
} : !t && !b ? { text: " " } : t === b || !b ? {
|
|
307
|
+
text: t === b ? "█" : "▀",
|
|
308
|
+
fg: t
|
|
309
|
+
} : !t ? {
|
|
310
|
+
text: "▄",
|
|
311
|
+
fg: b
|
|
312
|
+
} : {
|
|
313
|
+
text: "▀",
|
|
314
|
+
fg: t,
|
|
315
|
+
bg: b
|
|
316
|
+
};
|
|
317
|
+
const last = runs.at(-1);
|
|
318
|
+
if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
|
|
319
|
+
else runs.push(cell);
|
|
320
|
+
}
|
|
321
|
+
rows.push(runs);
|
|
322
|
+
}
|
|
323
|
+
return rows;
|
|
324
|
+
}
|
|
325
|
+
|
|
195
326
|
//#endregion
|
|
196
327
|
//#region src/tui/context.tsx
|
|
197
328
|
/**
|
|
@@ -210,6 +341,71 @@ function useHarbor(selector) {
|
|
|
210
341
|
return useStore(useDeps().store, selector);
|
|
211
342
|
}
|
|
212
343
|
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/tui/theme.ts
|
|
346
|
+
const theme = {
|
|
347
|
+
accent: "#3dbcab",
|
|
348
|
+
coral: "#e0714f",
|
|
349
|
+
muted: "gray",
|
|
350
|
+
selected: "#3dbcab"
|
|
351
|
+
};
|
|
352
|
+
const HEALTH_COLOR = {
|
|
353
|
+
"on-track": "green",
|
|
354
|
+
"at-risk": "yellow",
|
|
355
|
+
"off-track": "red",
|
|
356
|
+
paused: "gray",
|
|
357
|
+
done: "cyan"
|
|
358
|
+
};
|
|
359
|
+
const PRIORITY_COLOR = {
|
|
360
|
+
urgent: "red",
|
|
361
|
+
high: "#e0714f",
|
|
362
|
+
medium: void 0,
|
|
363
|
+
low: "gray"
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/tui/logo.tsx
|
|
368
|
+
/** Harbor's mark in half blocks (cli/lib/logo.mjs holds the pixels). */
|
|
369
|
+
function Logo({ pixels = LOGO }) {
|
|
370
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
371
|
+
flexDirection: "column",
|
|
372
|
+
flexShrink: 0,
|
|
373
|
+
children: logoRows(pixels).map((runs, y) => /* @__PURE__ */ jsx(Text, { children: runs.map((r, i) => /* @__PURE__ */ jsx(Text, {
|
|
374
|
+
color: r.fg,
|
|
375
|
+
backgroundColor: r.bg,
|
|
376
|
+
children: r.text
|
|
377
|
+
}, i)) }, y))
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
/** What shows while the harbor loads: the mark, centred, and which harbor. */
|
|
381
|
+
function Splash({ opening }) {
|
|
382
|
+
const { columns, rows } = useWindowSize();
|
|
383
|
+
const mark = rows >= 18 && columns >= 30;
|
|
384
|
+
const height = (mark ? LOGO.length / 2 + 1 : 0) + 2;
|
|
385
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
386
|
+
width: columns,
|
|
387
|
+
height: rows,
|
|
388
|
+
flexDirection: "column",
|
|
389
|
+
alignItems: "center",
|
|
390
|
+
paddingTop: Math.max(0, Math.floor((rows - height) / 2)),
|
|
391
|
+
children: [
|
|
392
|
+
mark ? /* @__PURE__ */ jsx(Box, {
|
|
393
|
+
marginBottom: 1,
|
|
394
|
+
children: /* @__PURE__ */ jsx(Logo, {})
|
|
395
|
+
}) : null,
|
|
396
|
+
/* @__PURE__ */ jsx(Text, {
|
|
397
|
+
bold: true,
|
|
398
|
+
color: theme.accent,
|
|
399
|
+
children: "Harbor"
|
|
400
|
+
}),
|
|
401
|
+
/* @__PURE__ */ jsx(Text, {
|
|
402
|
+
color: theme.muted,
|
|
403
|
+
children: opening ? `Opening ${opening}…` : "Opening your harbor…"
|
|
404
|
+
})
|
|
405
|
+
]
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
213
409
|
//#endregion
|
|
214
410
|
//#region node_modules/date-fns/constants.js
|
|
215
411
|
/**
|
|
@@ -2075,28 +2271,6 @@ function forecastLine(f) {
|
|
|
2075
2271
|
return `At this rate it ends at ${money(Math.round(f.eac))} (${money(gap)} ${side} budget)`;
|
|
2076
2272
|
}
|
|
2077
2273
|
|
|
2078
|
-
//#endregion
|
|
2079
|
-
//#region src/tui/theme.ts
|
|
2080
|
-
const theme = {
|
|
2081
|
-
accent: "#3dbcab",
|
|
2082
|
-
coral: "#e0714f",
|
|
2083
|
-
muted: "gray",
|
|
2084
|
-
selected: "#3dbcab"
|
|
2085
|
-
};
|
|
2086
|
-
const HEALTH_COLOR = {
|
|
2087
|
-
"on-track": "green",
|
|
2088
|
-
"at-risk": "yellow",
|
|
2089
|
-
"off-track": "red",
|
|
2090
|
-
paused: "gray",
|
|
2091
|
-
done: "cyan"
|
|
2092
|
-
};
|
|
2093
|
-
const PRIORITY_COLOR = {
|
|
2094
|
-
urgent: "red",
|
|
2095
|
-
high: "#e0714f",
|
|
2096
|
-
medium: void 0,
|
|
2097
|
-
low: "gray"
|
|
2098
|
-
};
|
|
2099
|
-
|
|
2100
2274
|
//#endregion
|
|
2101
2275
|
//#region src/tui/ui.tsx
|
|
2102
2276
|
/**
|
|
@@ -3457,165 +3631,6 @@ function PeopleView({ active, width, height, go }) {
|
|
|
3457
3631
|
});
|
|
3458
3632
|
}
|
|
3459
3633
|
|
|
3460
|
-
//#endregion
|
|
3461
|
-
//#region cli/lib/logo.mjs
|
|
3462
|
-
/**
|
|
3463
|
-
* Harbor's mark for the terminal: the favicon (public/favicon.svg) as
|
|
3464
|
-
* pixels, a teal tile with the white H and three coral boats. Each
|
|
3465
|
-
* terminal row draws two pixel rows with half blocks (▀ ▄ █), so a square
|
|
3466
|
-
* grid comes out square. `harbor help` prints it; the full-screen app draws
|
|
3467
|
-
* it on its splash and its keys overlay (src/tui/logo.tsx).
|
|
3468
|
-
*
|
|
3469
|
-
* T teal, W white, C coral, P a boat's pointed bow (coral in the middle of
|
|
3470
|
-
* the two pixels, drawn with quarter blocks), . nothing (the rounded corners).
|
|
3471
|
-
*/
|
|
3472
|
-
/** 24 by 24: posts three wide, a pixel of water between each boat and the posts. */
|
|
3473
|
-
const LOGO = [
|
|
3474
|
-
"...TTTTTTTTTTTTTTTTTT...",
|
|
3475
|
-
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
3476
|
-
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
3477
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3478
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3479
|
-
"TTPPTWWWTTTTTTTTWWWTTTTT",
|
|
3480
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3481
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3482
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3483
|
-
"TTCCTWWWTTTTTTTTWWWTTTTT",
|
|
3484
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3485
|
-
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
3486
|
-
"TTTTTWWWWWWWWWWWWWWTTTTT",
|
|
3487
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3488
|
-
"TTTTTWWWTTTTTTTTWWWTPPTT",
|
|
3489
|
-
"TTTTTWWWTTTPPTTTWWWTCCTT",
|
|
3490
|
-
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
3491
|
-
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
3492
|
-
"TTTTTWWWTTTCCTTTWWWTCCTT",
|
|
3493
|
-
"TTTTTWWWTTTCCTTTWWWTTTTT",
|
|
3494
|
-
"TTTTTWWWTTTTTTTTWWWTTTTT",
|
|
3495
|
-
".TTTTTTTTTTTTTTTTTTTTTT.",
|
|
3496
|
-
"..TTTTTTTTTTTTTTTTTTTT..",
|
|
3497
|
-
"...TTTTTTTTTTTTTTTTTT..."
|
|
3498
|
-
];
|
|
3499
|
-
/** 16 by 16, for narrow places. */
|
|
3500
|
-
const LOGO_SMALL = [
|
|
3501
|
-
"..TTTTTTTTTTTT..",
|
|
3502
|
-
".TTTTTTTTTTTTTT.",
|
|
3503
|
-
"TTTTWWTTTTWWTTTT",
|
|
3504
|
-
"TTTTWWTTTTWWTTTT",
|
|
3505
|
-
"TPPTWWTTTTWWTTTT",
|
|
3506
|
-
"TCCTWWTTTTWWTTTT",
|
|
3507
|
-
"TCCTWWTTTTWWTTTT",
|
|
3508
|
-
"TTTTWWWWWWWWTTTT",
|
|
3509
|
-
"TTTTWWWWWWWWTTTT",
|
|
3510
|
-
"TTTTWWTTTTWWTPPT",
|
|
3511
|
-
"TTTTWWTPPTWWTCCT",
|
|
3512
|
-
"TTTTWWTCCTWWTCCT",
|
|
3513
|
-
"TTTTWWTCCTWWTTTT",
|
|
3514
|
-
"TTTTWWTTTTWWTTTT",
|
|
3515
|
-
".TTTTTTTTTTTTTT.",
|
|
3516
|
-
"..TTTTTTTTTTTT.."
|
|
3517
|
-
];
|
|
3518
|
-
/** The brand's fills, as in favicon.svg and src/styles.css. */
|
|
3519
|
-
const LOGO_COLOURS = {
|
|
3520
|
-
T: "#0f6e64",
|
|
3521
|
-
W: "#ffffff",
|
|
3522
|
-
C: "#c44a2c"
|
|
3523
|
-
};
|
|
3524
|
-
/**
|
|
3525
|
-
* @typedef {{ text: string, fg?: string, bg?: string }} Run
|
|
3526
|
-
* One stretch of a terminal row in the same colours.
|
|
3527
|
-
*/
|
|
3528
|
-
/**
|
|
3529
|
-
* The logo as terminal rows, each a list of runs: the top pixel is the
|
|
3530
|
-
* half block's colour, the bottom one its background.
|
|
3531
|
-
* @param {string[]} pixels
|
|
3532
|
-
* @returns {Run[][]}
|
|
3533
|
-
*/
|
|
3534
|
-
function logoRows(pixels = LOGO) {
|
|
3535
|
-
/** @type {Run[][]} */
|
|
3536
|
-
const rows = [];
|
|
3537
|
-
for (let y = 0; y < pixels.length; y += 2) {
|
|
3538
|
-
/** @type {Run[]} */
|
|
3539
|
-
const runs = [];
|
|
3540
|
-
const top = pixels[y] ?? "";
|
|
3541
|
-
const bottom = pixels[y + 1] ?? "";
|
|
3542
|
-
for (let x = 0; x < top.length; x++) {
|
|
3543
|
-
const t = LOGO_COLOURS[top[x]];
|
|
3544
|
-
const b = LOGO_COLOURS[bottom[x]];
|
|
3545
|
-
const bowRow = top[x] === "P" ? top : bottom[x] === "P" ? bottom : null;
|
|
3546
|
-
const left = bowRow ? bowRow[x - 1] !== "P" : false;
|
|
3547
|
-
/** @type {Run} */
|
|
3548
|
-
const cell = top[x] === "P" ? {
|
|
3549
|
-
text: left ? "▟" : "▙",
|
|
3550
|
-
fg: LOGO_COLOURS.C,
|
|
3551
|
-
bg: LOGO_COLOURS.T
|
|
3552
|
-
} : bottom[x] === "P" ? {
|
|
3553
|
-
text: left ? "▗" : "▖",
|
|
3554
|
-
fg: LOGO_COLOURS.C,
|
|
3555
|
-
bg: t
|
|
3556
|
-
} : !t && !b ? { text: " " } : t === b || !b ? {
|
|
3557
|
-
text: t === b ? "█" : "▀",
|
|
3558
|
-
fg: t
|
|
3559
|
-
} : !t ? {
|
|
3560
|
-
text: "▄",
|
|
3561
|
-
fg: b
|
|
3562
|
-
} : {
|
|
3563
|
-
text: "▀",
|
|
3564
|
-
fg: t,
|
|
3565
|
-
bg: b
|
|
3566
|
-
};
|
|
3567
|
-
const last = runs.at(-1);
|
|
3568
|
-
if (last && last.fg === cell.fg && last.bg === cell.bg) last.text += cell.text;
|
|
3569
|
-
else runs.push(cell);
|
|
3570
|
-
}
|
|
3571
|
-
rows.push(runs);
|
|
3572
|
-
}
|
|
3573
|
-
return rows;
|
|
3574
|
-
}
|
|
3575
|
-
|
|
3576
|
-
//#endregion
|
|
3577
|
-
//#region src/tui/logo.tsx
|
|
3578
|
-
/** Harbor's mark in half blocks (cli/lib/logo.mjs holds the pixels). */
|
|
3579
|
-
function Logo({ pixels = LOGO }) {
|
|
3580
|
-
return /* @__PURE__ */ jsx(Box, {
|
|
3581
|
-
flexDirection: "column",
|
|
3582
|
-
flexShrink: 0,
|
|
3583
|
-
children: logoRows(pixels).map((runs, y) => /* @__PURE__ */ jsx(Text, { children: runs.map((r, i) => /* @__PURE__ */ jsx(Text, {
|
|
3584
|
-
color: r.fg,
|
|
3585
|
-
backgroundColor: r.bg,
|
|
3586
|
-
children: r.text
|
|
3587
|
-
}, i)) }, y))
|
|
3588
|
-
});
|
|
3589
|
-
}
|
|
3590
|
-
/** What shows while the harbor loads: the mark, centred, and which harbor. */
|
|
3591
|
-
function Splash({ opening }) {
|
|
3592
|
-
const { columns, rows } = useWindowSize();
|
|
3593
|
-
const mark = rows >= 18 && columns >= 30;
|
|
3594
|
-
const height = (mark ? LOGO.length / 2 + 1 : 0) + 2;
|
|
3595
|
-
return /* @__PURE__ */ jsxs(Box, {
|
|
3596
|
-
width: columns,
|
|
3597
|
-
height: rows,
|
|
3598
|
-
flexDirection: "column",
|
|
3599
|
-
alignItems: "center",
|
|
3600
|
-
paddingTop: Math.max(0, Math.floor((rows - height) / 2)),
|
|
3601
|
-
children: [
|
|
3602
|
-
mark ? /* @__PURE__ */ jsx(Box, {
|
|
3603
|
-
marginBottom: 1,
|
|
3604
|
-
children: /* @__PURE__ */ jsx(Logo, {})
|
|
3605
|
-
}) : null,
|
|
3606
|
-
/* @__PURE__ */ jsx(Text, {
|
|
3607
|
-
bold: true,
|
|
3608
|
-
color: theme.accent,
|
|
3609
|
-
children: "Harbor"
|
|
3610
|
-
}),
|
|
3611
|
-
/* @__PURE__ */ jsx(Text, {
|
|
3612
|
-
color: theme.muted,
|
|
3613
|
-
children: opening ? `Opening ${opening}…` : "Opening your harbor…"
|
|
3614
|
-
})
|
|
3615
|
-
]
|
|
3616
|
-
});
|
|
3617
|
-
}
|
|
3618
|
-
|
|
3619
3634
|
//#endregion
|
|
3620
3635
|
//#region src/tui/moves.ts
|
|
3621
3636
|
/**
|
|
@@ -5811,6 +5826,8 @@ const NAV = [
|
|
|
5811
5826
|
label: "History"
|
|
5812
5827
|
}
|
|
5813
5828
|
];
|
|
5829
|
+
/** The sidebar's lines: the screens, a gap, then Harbors and Anything. */
|
|
5830
|
+
const NAV_LINES = NAV.length + 3;
|
|
5814
5831
|
const SAVE_LABEL = {
|
|
5815
5832
|
saved: ["● saved", "green"],
|
|
5816
5833
|
pending: ["● saving soon", "yellow"],
|
|
@@ -6004,7 +6021,8 @@ function App({ ui }) {
|
|
|
6004
6021
|
if (input === "u" && !store.undoLast()) notify("Nothing to undo.");
|
|
6005
6022
|
}, { isActive: !covered && panel !== null });
|
|
6006
6023
|
const bodyHeight = Math.max(8, rows - 1 - 2);
|
|
6007
|
-
const
|
|
6024
|
+
const mark = columns >= 110 && bodyHeight >= NAV_LINES + 1 + LOGO_SMALL.length / 2 ? LOGO_SMALL : bodyHeight >= NAV_LINES + 1 + LOGO_MINI.length / 2 ? LOGO_MINI : null;
|
|
6025
|
+
const navWidth = (mark?.[0]?.length ?? LOGO_MINI.length) + 3;
|
|
6008
6026
|
const mainWidth = Math.max(30, columns - navWidth - 1);
|
|
6009
6027
|
const mainActive = focus === "main" && !covered && panel === null;
|
|
6010
6028
|
const [saveText, saveColor] = SAVE_LABEL[save];
|
|
@@ -6282,7 +6300,8 @@ function App({ ui }) {
|
|
|
6282
6300
|
/* @__PURE__ */ jsx(Text, {
|
|
6283
6301
|
color: theme.muted,
|
|
6284
6302
|
children: ": Anything"
|
|
6285
|
-
})
|
|
6303
|
+
}),
|
|
6304
|
+
mark ? /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(Box, { flexGrow: 1 }), /* @__PURE__ */ jsx(Logo, { pixels: mark })] }) : null
|
|
6286
6305
|
]
|
|
6287
6306
|
}), /* @__PURE__ */ jsxs(Box, {
|
|
6288
6307
|
flexDirection: "column",
|
|
@@ -8560,8 +8579,62 @@ function retryDelay(attempt) {
|
|
|
8560
8579
|
return Math.min(3e4, 2e3 * 2 ** Math.max(0, attempt));
|
|
8561
8580
|
}
|
|
8562
8581
|
|
|
8582
|
+
//#endregion
|
|
8583
|
+
//#region cli/lib/plain.mjs
|
|
8584
|
+
/**
|
|
8585
|
+
* Text from a harbor, made safe to show in a terminal. A task title can come
|
|
8586
|
+
* from a public intake form, a GitHub pull request or any member, and a
|
|
8587
|
+
* terminal acts on control characters it's sent: an escape sequence in a
|
|
8588
|
+
* title could set the clipboard, retitle the window or hide what's around
|
|
8589
|
+
* it. So they're taken out before anything is printed: the C0 and C1
|
|
8590
|
+
* controls (tab and newline stay), DEL, and the bidirectional overrides that
|
|
8591
|
+
* make text read in a different order than it's stored.
|
|
8592
|
+
*/
|
|
8593
|
+
const UNSAFE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f--]/g;
|
|
8594
|
+
/** @param {string} text */
|
|
8595
|
+
function plain(text) {
|
|
8596
|
+
return text.replace(UNSAFE, "");
|
|
8597
|
+
}
|
|
8598
|
+
/**
|
|
8599
|
+
* Every string in a value made plain: what the full-screen app takes in from
|
|
8600
|
+
* Harbor, before any of it reaches the screen.
|
|
8601
|
+
* @template T
|
|
8602
|
+
* @param {T} value
|
|
8603
|
+
* @returns {T}
|
|
8604
|
+
*/
|
|
8605
|
+
function plainDeep(value) {
|
|
8606
|
+
if (typeof value === "string") return plain(value);
|
|
8607
|
+
if (Array.isArray(value)) return value.map(plainDeep);
|
|
8608
|
+
if (value && typeof value === "object") {
|
|
8609
|
+
/** @type {Record<string, unknown>} */
|
|
8610
|
+
const out = {};
|
|
8611
|
+
for (const [k, v] of Object.entries(value)) out[k] = plainDeep(v);
|
|
8612
|
+
return out;
|
|
8613
|
+
}
|
|
8614
|
+
return value;
|
|
8615
|
+
}
|
|
8616
|
+
|
|
8563
8617
|
//#endregion
|
|
8564
8618
|
//#region src/tui/engine/transport.ts
|
|
8619
|
+
/**
|
|
8620
|
+
* How the terminal app talks to Harbor: POSTs to /api/app with the bearer
|
|
8621
|
+
* token `harbor login` saved (mcp/app.server.ts on the other end). An
|
|
8622
|
+
* expired token is renewed once and the request retried.
|
|
8623
|
+
*/
|
|
8624
|
+
/**
|
|
8625
|
+
* The same transport with every answer made plain (cli/lib/plain.mjs):
|
|
8626
|
+
* titles, names and notes can come from intake forms, GitHub or anyone in
|
|
8627
|
+
* the harbor, and a terminal acts on control characters they carry.
|
|
8628
|
+
*/
|
|
8629
|
+
function plainTransport(t) {
|
|
8630
|
+
return {
|
|
8631
|
+
harbors: async () => plainDeep(await t.harbors()),
|
|
8632
|
+
load: async (workspaceId) => plainDeep(await t.load(workspaceId)),
|
|
8633
|
+
save: async (body) => plainDeep(await t.save(body)),
|
|
8634
|
+
wait: async (workspaceId, since) => plainDeep(await t.wait(workspaceId, since)),
|
|
8635
|
+
history: async (query) => plainDeep(await t.history(query))
|
|
8636
|
+
};
|
|
8637
|
+
}
|
|
8565
8638
|
/** Harbor said no, or couldn't be reached (`offline`). */
|
|
8566
8639
|
var TransportError = class extends Error {
|
|
8567
8640
|
status;
|
|
@@ -8844,30 +8917,35 @@ function createUiStore() {
|
|
|
8844
8917
|
* CLI package's own dependencies.
|
|
8845
8918
|
*/
|
|
8846
8919
|
const today = () => format(/* @__PURE__ */ new Date(), "yyyy-MM-dd");
|
|
8920
|
+
/** How long `harbor` shows its splash at the least, however fast the harbor loads. */
|
|
8921
|
+
const SPLASH_MS = 1200;
|
|
8847
8922
|
/**
|
|
8848
8923
|
* Open the app on a transport that's already signed in: the harbor used
|
|
8849
8924
|
* most, the store and the sync loop, drawn with Ink.
|
|
8850
8925
|
*/
|
|
8851
8926
|
async function startApp(deps, options = {}) {
|
|
8927
|
+
const transport = plainTransport(deps.transport);
|
|
8928
|
+
const account = plainDeep(deps.account);
|
|
8929
|
+
const harbors = plainDeep(deps.harbors);
|
|
8852
8930
|
const ui = createUiStore();
|
|
8853
8931
|
const store = createAppStore((n) => ui.setState({ notice: n.kind === "view-only" ? "You can only view this harbor, so that wasn't changed." : n.kept > 0 ? `Undid ${n.label}. ${n.kept} field${n.kept === 1 ? "" : "s"} someone else changed since stayed theirs.` : `Undid ${n.label}.` }));
|
|
8854
8932
|
const sync = createSync({
|
|
8855
8933
|
store,
|
|
8856
|
-
transport
|
|
8934
|
+
transport,
|
|
8857
8935
|
account: {
|
|
8858
|
-
id:
|
|
8859
|
-
name:
|
|
8936
|
+
id: account.id,
|
|
8937
|
+
name: account.name || account.email
|
|
8860
8938
|
},
|
|
8861
8939
|
onState: (save) => ui.setState({ save }),
|
|
8862
8940
|
onNotice: (notice) => ui.setState({ notice })
|
|
8863
8941
|
});
|
|
8864
|
-
const opening = deps.workspaceId ?
|
|
8942
|
+
const opening = deps.workspaceId ? harbors.find((h) => h.id === deps.workspaceId) : harbors.length === 1 ? harbors[0] : void 0;
|
|
8865
8943
|
const instance = render(/* @__PURE__ */ jsx(Splash, { opening: opening?.name }), {
|
|
8866
8944
|
exitOnCtrlC: false,
|
|
8867
8945
|
...options
|
|
8868
8946
|
});
|
|
8869
8947
|
try {
|
|
8870
|
-
await sync.open(deps.workspaceId);
|
|
8948
|
+
await Promise.all([sync.open(deps.workspaceId), new Promise((r) => setTimeout(r, deps.splashMs ?? 0))]);
|
|
8871
8949
|
} catch (err) {
|
|
8872
8950
|
instance.unmount();
|
|
8873
8951
|
throw err;
|
|
@@ -8876,9 +8954,9 @@ async function startApp(deps, options = {}) {
|
|
|
8876
8954
|
value: {
|
|
8877
8955
|
store,
|
|
8878
8956
|
sync,
|
|
8879
|
-
transport
|
|
8880
|
-
account
|
|
8881
|
-
harbors
|
|
8957
|
+
transport,
|
|
8958
|
+
account,
|
|
8959
|
+
harbors,
|
|
8882
8960
|
today
|
|
8883
8961
|
},
|
|
8884
8962
|
children: /* @__PURE__ */ jsx(App, { ui })
|
|
@@ -8902,10 +8980,11 @@ async function runApp(opts) {
|
|
|
8902
8980
|
transport,
|
|
8903
8981
|
account,
|
|
8904
8982
|
harbors,
|
|
8905
|
-
workspaceId: pick?.id
|
|
8983
|
+
workspaceId: pick?.id,
|
|
8984
|
+
splashMs: SPLASH_MS
|
|
8906
8985
|
}, { alternateScreen: true });
|
|
8907
8986
|
await instance.waitUntilExit();
|
|
8908
8987
|
}
|
|
8909
8988
|
|
|
8910
8989
|
//#endregion
|
|
8911
|
-
export { TransportError, httpTransport, runApp, startApp };
|
|
8990
|
+
export { SPLASH_MS, TransportError, httpTransport, runApp, startApp };
|
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
|
@@ -12,6 +12,7 @@ import { DEFAULT_SERVER, forgetCredentials, readCredentials, writeCredentials }
|
|
|
12
12
|
import { harbors, myWork, taskLine, taskList, todayLocal } from "./format.mjs";
|
|
13
13
|
import { colourDepth, logoLines } from "./logo.mjs";
|
|
14
14
|
import { HarborError, createClient } from "./mcp.mjs";
|
|
15
|
+
import { jsonText, plain } from "./plain.mjs";
|
|
15
16
|
|
|
16
17
|
export const HELP = `harbor: Harbor from the terminal
|
|
17
18
|
|
|
@@ -60,15 +61,31 @@ const spec = (flags = [], options = []) => ({
|
|
|
60
61
|
options: [...COMMON.options, ...options],
|
|
61
62
|
});
|
|
62
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
|
+
|
|
63
84
|
function openBrowser(url) {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
? ["open", [url]]
|
|
67
|
-
: process.platform === "win32"
|
|
68
|
-
? ["cmd", ["/c", "start", "", url]]
|
|
69
|
-
: ["xdg-open", [url]];
|
|
85
|
+
const command = browserCommand(url);
|
|
86
|
+
if (!command) return;
|
|
70
87
|
try {
|
|
71
|
-
spawn(
|
|
88
|
+
spawn(command[0], command[1], { stdio: "ignore", detached: true })
|
|
72
89
|
.on("error", () => {})
|
|
73
90
|
.unref();
|
|
74
91
|
} catch {
|
|
@@ -217,7 +234,7 @@ export async function run(argv, io = {}) {
|
|
|
217
234
|
if (err instanceof HarborError) throw new HarborError(hint(err.message));
|
|
218
235
|
throw err;
|
|
219
236
|
});
|
|
220
|
-
out(asJson ?
|
|
237
|
+
out(asJson ? jsonText(result) : plain(show(result)));
|
|
221
238
|
return 0;
|
|
222
239
|
};
|
|
223
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
CHANGED
|
@@ -57,6 +57,22 @@ export const LOGO_SMALL = [
|
|
|
57
57
|
"..TTTTTTTTTTTT..",
|
|
58
58
|
];
|
|
59
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
|
+
|
|
60
76
|
/** The brand's fills, as in favicon.svg and src/styles.css. */
|
|
61
77
|
export const LOGO_COLOURS = { T: "#0f6e64", W: "#ffffff", C: "#c44a2c" };
|
|
62
78
|
|
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
|
+
}
|