@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 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, then:
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
- run(process.argv.slice(2)).then(
6
- (code) => process.exit(code),
7
- (err) => {
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 navWidth = 14;
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: deps.transport,
8934
+ transport,
8857
8935
  account: {
8858
- id: deps.account.id,
8859
- name: deps.account.name || deps.account.email
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 ? deps.harbors.find((h) => h.id === deps.workspaceId) : deps.harbors.length === 1 ? deps.harbors[0] : void 0;
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: deps.transport,
8880
- account: deps.account,
8881
- harbors: deps.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
- 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
@@ -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 [cmd, args] =
65
- process.platform === "darwin"
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(cmd, args, { stdio: "ignore", detached: true })
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 ? JSON.stringify(result, null, 2) : show(result));
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
- 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 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.3.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": {