@harborppm/cli 0.3.0 → 0.5.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; after that the sidebar holds just the screens. 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.
@@ -27,8 +27,18 @@ A task changes in place too: title, status, owner, due date (`2026-10-14`, `toda
27
27
 
28
28
  Keys: `↑↓` (or `j k`) and `←→` (or `h l`) move, Enter opens, Esc goes back, `:` finds anything (a screen, "new project", a project or program by name), `/` finds a task by its key or words, `n` makes something new, `e` changes what's open, `[` `]` move a card a column, `x` marks it done, `d` deletes (it asks first), `u` undoes your last change, `H` switches harbor, `?` lists them all.
29
29
 
30
+ It has colours for dark terminals and for light ones, and picks by asking the terminal for its background (or reading `COLORFGBG`); a terminal that doesn't say gets the dark ones. `harbor theme light` or `harbor theme dark` settles it, `harbor theme auto` goes back to asking, and `HARBOR_THEME` overrides it for one run.
31
+
30
32
  `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
33
 
34
+ ## Staying up to date
35
+
36
+ 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.
37
+
38
+ ## Text from others
39
+
40
+ 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.
41
+
32
42
  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
43
 
34
44
  ## 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
@@ -2077,25 +2077,62 @@ function forecastLine(f) {
2077
2077
 
2078
2078
  //#endregion
2079
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"
2080
+ /**
2081
+ * Dark: Harbor's teal and coral lifted so they read on black, with the plain
2082
+ * colours every terminal draws for status. Light: the brand's own darker teal
2083
+ * and coral, and status in hues dark enough for text on white (4.5:1 or more).
2084
+ */
2085
+ const PALETTES = {
2086
+ dark: {
2087
+ accent: "#3dbcab",
2088
+ coral: "#e0714f",
2089
+ muted: "gray",
2090
+ selected: "#3dbcab",
2091
+ border: "gray",
2092
+ good: "green",
2093
+ warn: "yellow",
2094
+ bad: "red",
2095
+ done: "cyan"
2096
+ },
2097
+ light: {
2098
+ accent: "#0f6e64",
2099
+ coral: "#b8432c",
2100
+ muted: "#5f5e58",
2101
+ selected: "#0f6e64",
2102
+ border: "#a8a69e",
2103
+ good: "#1f7a3a",
2104
+ warn: "#8a5a0a",
2105
+ bad: "#b8432c",
2106
+ done: "#146c6b"
2107
+ }
2092
2108
  };
2093
- const PRIORITY_COLOR = {
2094
- urgent: "red",
2095
- high: "#e0714f",
2096
- medium: void 0,
2097
- low: "gray"
2109
+ const theme = {
2110
+ name: "dark",
2111
+ ...PALETTES.dark
2098
2112
  };
2113
+ const HEALTH_COLOR = {};
2114
+ const PRIORITY_COLOR = {};
2115
+ /** Switch every colour to one set. Call before the app draws. */
2116
+ function applyTheme(name) {
2117
+ Object.assign(theme, {
2118
+ name,
2119
+ ...PALETTES[name]
2120
+ });
2121
+ Object.assign(HEALTH_COLOR, {
2122
+ "on-track": theme.good,
2123
+ "at-risk": theme.warn,
2124
+ "off-track": theme.bad,
2125
+ paused: theme.muted,
2126
+ done: theme.done
2127
+ });
2128
+ Object.assign(PRIORITY_COLOR, {
2129
+ urgent: theme.bad,
2130
+ high: theme.coral,
2131
+ medium: void 0,
2132
+ low: theme.muted
2133
+ });
2134
+ }
2135
+ applyTheme("dark");
2099
2136
 
2100
2137
  //#endregion
2101
2138
  //#region src/tui/ui.tsx
@@ -2118,7 +2155,7 @@ function TextField({ label, initial = "", placeholder, hint, onSubmit, onCancel
2118
2155
  return /* @__PURE__ */ jsxs(Box, {
2119
2156
  flexDirection: "column",
2120
2157
  borderStyle: "round",
2121
- borderColor: theme.accent,
2158
+ borderColor: theme.border,
2122
2159
  paddingX: 1,
2123
2160
  children: [
2124
2161
  /* @__PURE__ */ jsx(Text, {
@@ -2152,7 +2189,7 @@ function Picker({ title, choices, initial = 0, rows = 10, onPick, onCancel }) {
2152
2189
  return /* @__PURE__ */ jsxs(Box, {
2153
2190
  flexDirection: "column",
2154
2191
  borderStyle: "round",
2155
- borderColor: theme.accent,
2192
+ borderColor: theme.border,
2156
2193
  paddingX: 1,
2157
2194
  children: [
2158
2195
  /* @__PURE__ */ jsx(Text, {
@@ -2221,7 +2258,6 @@ function Hints({ keys }) {
2221
2258
  children: keys.map(([k, label], i) => /* @__PURE__ */ jsxs(Text, { children: [
2222
2259
  i > 0 ? " " : "",
2223
2260
  /* @__PURE__ */ jsx(Text, {
2224
- color: theme.accent,
2225
2261
  bold: true,
2226
2262
  children: k
2227
2263
  }),
@@ -2249,7 +2285,6 @@ function Heading({ children, right }) {
2249
2285
  justifyContent: "space-between",
2250
2286
  children: [/* @__PURE__ */ jsx(Text, {
2251
2287
  bold: true,
2252
- color: theme.accent,
2253
2288
  children
2254
2289
  }), right ? /* @__PURE__ */ jsx(Text, {
2255
2290
  color: theme.muted,
@@ -2344,7 +2379,7 @@ function DetailPanel({ title, subtitle, fields, width, height, keys = {}, hints,
2344
2379
  width,
2345
2380
  height,
2346
2381
  borderStyle: "round",
2347
- borderColor: theme.accent,
2382
+ borderColor: theme.border,
2348
2383
  paddingX: 1,
2349
2384
  children: [
2350
2385
  /* @__PURE__ */ jsx(Text, {
@@ -2687,7 +2722,7 @@ function TaskLine({ task, projects, people, today, selected, width, showProject
2687
2722
  " ",
2688
2723
  /* @__PURE__ */ jsx(PriorityMark, { priority: task.priority }),
2689
2724
  task.dueDate ? /* @__PURE__ */ jsxs(Text, {
2690
- color: late ? "red" : theme.muted,
2725
+ color: late ? theme.bad : theme.muted,
2691
2726
  children: [
2692
2727
  " ",
2693
2728
  late ? "late · " : "",
@@ -2695,7 +2730,7 @@ function TaskLine({ task, projects, people, today, selected, width, showProject
2695
2730
  ]
2696
2731
  }) : null,
2697
2732
  blocked ? /* @__PURE__ */ jsx(Text, {
2698
- color: "yellow",
2733
+ color: theme.warn,
2699
2734
  children: " waits"
2700
2735
  }) : null,
2701
2736
  who ? /* @__PURE__ */ jsxs(Text, {
@@ -2849,7 +2884,7 @@ function HistoryView({ active, width, height, go }) {
2849
2884
  children: "Loading…"
2850
2885
  }) : null,
2851
2886
  state === "failed" ? /* @__PURE__ */ jsx(Text, {
2852
- color: "red",
2887
+ color: theme.bad,
2853
2888
  children: "Couldn’t load History. It tries again after the next save."
2854
2889
  }) : null,
2855
2890
  state === "ready" && rows.length === 0 ? /* @__PURE__ */ jsx(Text, {
@@ -3496,7 +3531,7 @@ const LOGO = [
3496
3531
  "..TTTTTTTTTTTTTTTTTTTT..",
3497
3532
  "...TTTTTTTTTTTTTTTTTT..."
3498
3533
  ];
3499
- /** 16 by 16, for narrow places. */
3534
+ /** 16 by 16, for the keys overlay. */
3500
3535
  const LOGO_SMALL = [
3501
3536
  "..TTTTTTTTTTTT..",
3502
3537
  ".TTTTTTTTTTTTTT.",
@@ -3681,7 +3716,7 @@ function SearchBox({ width, onOpen, onClose }) {
3681
3716
  flexDirection: "column",
3682
3717
  width,
3683
3718
  borderStyle: "round",
3684
- borderColor: theme.accent,
3719
+ borderColor: theme.border,
3685
3720
  paddingX: 1,
3686
3721
  children: [
3687
3722
  /* @__PURE__ */ jsxs(Text, { children: [
@@ -3818,6 +3853,12 @@ const HELP = [
3818
3853
  ["r", "retry saving now"],
3819
3854
  ["q", "quit (saves first)"]
3820
3855
  ];
3856
+ /**
3857
+ * The mark sits beside the keys only when both fit: the longest line, a gap,
3858
+ * the 16-pixel mark, and the box's border and padding. Narrower, the lines
3859
+ * would wrap and split the mark.
3860
+ */
3861
+ const HELP_WITH_MARK = 11 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
3821
3862
  function Help({ width, onClose }) {
3822
3863
  useInput((input, key) => {
3823
3864
  if (key.escape || key.return || input === "?" || input === "q") onClose();
@@ -3825,7 +3866,7 @@ function Help({ width, onClose }) {
3825
3866
  return /* @__PURE__ */ jsxs(Box, {
3826
3867
  width,
3827
3868
  borderStyle: "round",
3828
- borderColor: theme.accent,
3869
+ borderColor: theme.border,
3829
3870
  paddingX: 1,
3830
3871
  children: [/* @__PURE__ */ jsxs(Box, {
3831
3872
  flexDirection: "column",
@@ -3845,7 +3886,7 @@ function Help({ width, onClose }) {
3845
3886
  children: "Everything saves as you go, and shows on the website for everyone."
3846
3887
  })
3847
3888
  ]
3848
- }), width >= 90 ? /* @__PURE__ */ jsxs(Box, {
3889
+ }), width >= HELP_WITH_MARK ? /* @__PURE__ */ jsxs(Box, {
3849
3890
  flexDirection: "column",
3850
3891
  alignItems: "center",
3851
3892
  marginLeft: 2,
@@ -3927,7 +3968,7 @@ function Palette({ width, base, open, onClose }) {
3927
3968
  flexDirection: "column",
3928
3969
  width,
3929
3970
  borderStyle: "round",
3930
- borderColor: theme.accent,
3971
+ borderColor: theme.border,
3931
3972
  paddingX: 1,
3932
3973
  children: [
3933
3974
  /* @__PURE__ */ jsxs(Text, { children: [
@@ -4317,7 +4358,7 @@ function MilestonePanel({ id, width, height, go, onClose }) {
4317
4358
  {
4318
4359
  label: "Due",
4319
4360
  value: `${shortDay(milestone.dueDate, today())}${late ? " (late)" : ""}`,
4320
- color: late ? "red" : void 0,
4361
+ color: late ? theme.bad : void 0,
4321
4362
  edit: {
4322
4363
  kind: "date",
4323
4364
  initial: milestone.dueDate,
@@ -4780,7 +4821,7 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
4780
4821
  }) : m.name,
4781
4822
  " ",
4782
4823
  /* @__PURE__ */ jsxs(Text, {
4783
- color: late ? "red" : theme.muted,
4824
+ color: late ? theme.bad : theme.muted,
4784
4825
  children: [late ? "late · " : "", shortDay(m.dueDate, today())]
4785
4826
  }),
4786
4827
  m.approverId ? /* @__PURE__ */ jsx(Text, {
@@ -5049,7 +5090,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5049
5090
  flexDirection: "column",
5050
5091
  width: w,
5051
5092
  borderStyle: "round",
5052
- borderColor: on ? theme.accent : "gray",
5093
+ borderColor: on ? theme.accent : theme.border,
5053
5094
  paddingX: 1,
5054
5095
  children: [
5055
5096
  /* @__PURE__ */ jsxs(Text, {
@@ -5060,7 +5101,7 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
5060
5101
  c.label.toUpperCase(),
5061
5102
  " ",
5062
5103
  /* @__PURE__ */ jsxs(Text, {
5063
- color: over ? "red" : theme.muted,
5104
+ color: over ? theme.bad : theme.muted,
5064
5105
  children: [c.tasks.length, c.limit != null ? `/${c.limit}` : ""]
5065
5106
  })
5066
5107
  ]
@@ -5146,11 +5187,11 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
5146
5187
  children: [" ", owner]
5147
5188
  }) : null,
5148
5189
  due ? /* @__PURE__ */ jsxs(Text, {
5149
- color: late ? "red" : selected ? void 0 : theme.muted,
5190
+ color: late ? theme.bad : selected ? void 0 : theme.muted,
5150
5191
  children: [" ", due]
5151
5192
  }) : null,
5152
5193
  waits ? /* @__PURE__ */ jsx(Text, {
5153
- color: "yellow",
5194
+ color: theme.warn,
5154
5195
  children: " waits"
5155
5196
  }) : null
5156
5197
  ]
@@ -5168,7 +5209,7 @@ function Card({ taskKey, title, owner, due, late, waits, priority, selected }) {
5168
5209
  /** A project's money: budget, spent, what's left and the forecast, then the ledger. */
5169
5210
  function BudgetPane({ project, spendEntries, at, active, height, today }) {
5170
5211
  const b = budgetOf({ spendEntries }, project, /* @__PURE__ */ new Date(`${today}T12:00:00`));
5171
- const tone = b.forecast?.tone === "over" ? "red" : b.forecast?.tone === "tight" ? "yellow" : "green";
5212
+ const tone = b.forecast?.tone === "over" ? theme.bad : b.forecast?.tone === "tight" ? theme.warn : theme.good;
5172
5213
  return /* @__PURE__ */ jsxs(Box, {
5173
5214
  flexDirection: "column",
5174
5215
  height,
@@ -5196,7 +5237,7 @@ function BudgetPane({ project, spendEntries, at, active, height, today }) {
5196
5237
  " ",
5197
5238
  /* @__PURE__ */ jsxs(Text, {
5198
5239
  bold: true,
5199
- color: b.left < 0 ? "red" : void 0,
5240
+ color: b.left < 0 ? theme.bad : void 0,
5200
5241
  children: [money(Math.abs(b.left)), b.left < 0 ? " over" : ""]
5201
5242
  })
5202
5243
  ] }),
@@ -5536,7 +5577,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5536
5577
  width,
5537
5578
  height,
5538
5579
  borderStyle: "round",
5539
- borderColor: theme.accent,
5580
+ borderColor: theme.border,
5540
5581
  paddingX: 1,
5541
5582
  children: [
5542
5583
  /* @__PURE__ */ jsxs(Text, {
@@ -5553,7 +5594,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5553
5594
  children: task.title
5554
5595
  }),
5555
5596
  viewOnly ? /* @__PURE__ */ jsx(Text, {
5556
- color: "yellow",
5597
+ color: theme.warn,
5557
5598
  children: " view only"
5558
5599
  }) : null
5559
5600
  ]
@@ -5573,7 +5614,7 @@ function TaskPanel({ taskId, width, height, go, onClose }) {
5573
5614
  ]
5574
5615
  }, f.field)),
5575
5616
  waits.length > 0 ? /* @__PURE__ */ jsxs(Text, {
5576
- color: "yellow",
5617
+ color: theme.warn,
5577
5618
  wrap: "truncate-end",
5578
5619
  children: [
5579
5620
  " ",
@@ -5757,7 +5798,7 @@ function TodayView({ active, width, height, go }) {
5757
5798
  children: [
5758
5799
  " ",
5759
5800
  /* @__PURE__ */ jsx(Text, {
5760
- color: r.kind === "risk" ? "yellow" : r.kind === "issue" ? "red" : theme.muted,
5801
+ color: r.kind === "risk" ? theme.warn : r.kind === "issue" ? theme.bad : theme.muted,
5761
5802
  children: r.kind
5762
5803
  }),
5763
5804
  " ",
@@ -5811,12 +5852,15 @@ const NAV = [
5811
5852
  label: "History"
5812
5853
  }
5813
5854
  ];
5855
+ /** The sidebar's width: its longest line ("8 History", ": Anything") with room either side. */
5856
+ const NAV_WIDTH = 15;
5857
+ /** What the corner says about saving, and in which of the theme's colours. */
5814
5858
  const SAVE_LABEL = {
5815
- saved: ["● saved", "green"],
5816
- pending: ["● saving soon", "yellow"],
5817
- saving: ["● saving…", "yellow"],
5818
- retrying: ["● not saved, retrying", "red"],
5819
- offline: ["● offline, will save", "red"]
5859
+ saved: ["● saved", "good"],
5860
+ pending: ["● saving soon", "warn"],
5861
+ saving: ["● saving…", "warn"],
5862
+ retrying: ["● not saved, retrying", "bad"],
5863
+ offline: ["● offline, will save", "bad"]
5820
5864
  };
5821
5865
  function App({ ui }) {
5822
5866
  const { store, sync, account, harbors, today } = useDeps();
@@ -6004,10 +6048,10 @@ function App({ ui }) {
6004
6048
  if (input === "u" && !store.undoLast()) notify("Nothing to undo.");
6005
6049
  }, { isActive: !covered && panel !== null });
6006
6050
  const bodyHeight = Math.max(8, rows - 1 - 2);
6007
- const navWidth = 14;
6008
- const mainWidth = Math.max(30, columns - navWidth - 1);
6051
+ const mainWidth = Math.max(30, columns - NAV_WIDTH - 1);
6009
6052
  const mainActive = focus === "main" && !covered && panel === null;
6010
- const [saveText, saveColor] = SAVE_LABEL[save];
6053
+ const [saveText, saveTone] = SAVE_LABEL[save];
6054
+ const saveColor = theme[saveTone];
6011
6055
  const viewProps = {
6012
6056
  active: mainActive,
6013
6057
  width: mainWidth,
@@ -6238,7 +6282,7 @@ function App({ ui }) {
6238
6282
  children: harborName ?? "…"
6239
6283
  }),
6240
6284
  viewOnly ? /* @__PURE__ */ jsx(Text, {
6241
- color: "yellow",
6285
+ color: theme.warn,
6242
6286
  children: " · view only"
6243
6287
  }) : null
6244
6288
  ]
@@ -6254,13 +6298,13 @@ function App({ ui }) {
6254
6298
  height: bodyHeight,
6255
6299
  children: [/* @__PURE__ */ jsxs(Box, {
6256
6300
  flexDirection: "column",
6257
- width: navWidth,
6301
+ width: NAV_WIDTH,
6258
6302
  paddingX: 1,
6259
6303
  borderStyle: "single",
6260
6304
  borderTop: false,
6261
6305
  borderBottom: false,
6262
6306
  borderLeft: false,
6263
- borderColor: focus === "nav" ? theme.accent : "gray",
6307
+ borderColor: focus === "nav" ? theme.accent : theme.border,
6264
6308
  children: [
6265
6309
  NAV.map((x, i) => {
6266
6310
  const on = x.screen === screen && stack.length === 0;
@@ -7017,12 +7061,24 @@ function sessionSlice({ set, get, write }) {
7017
7061
  keepInterval: k.interval,
7018
7062
  stripeReady: k.stripe
7019
7063
  }),
7020
- ensureAccountPerson: ({ accountId, name }) => {
7064
+ ensureAccountPerson: ({ accountId, name, personId }) => {
7021
7065
  const hit = get().people.find((p) => p.accountId === accountId);
7022
7066
  if (hit) {
7023
7067
  if (get().currentUserId !== hit.id) set({ currentUserId: hit.id });
7024
7068
  return hit.id;
7025
7069
  }
7070
+ const named = personId ? get().people.find((p) => p.id === personId && !p.accountId) : null;
7071
+ if (named) {
7072
+ set({
7073
+ people: get().people.map((p) => p.id === named.id ? {
7074
+ ...p,
7075
+ accountId
7076
+ } : p),
7077
+ currentUserId: named.id
7078
+ });
7079
+ get().log("came aboard", "/people");
7080
+ return named.id;
7081
+ }
7026
7082
  const used = new Set(get().people.map((p) => p.swatch));
7027
7083
  const swatch = SWATCHES.find((s) => !used.has(s)) ?? "pine";
7028
7084
  const id = uid("p");
@@ -7383,7 +7439,8 @@ function hierarchySlice({ get, write }) {
7383
7439
  write({
7384
7440
  programs: add(state.programs, rows.programs),
7385
7441
  projects: add(state.projects, rows.projects),
7386
- tasks: add(state.tasks, rows.tasks)
7442
+ tasks: add(state.tasks, rows.tasks),
7443
+ people: add(state.people, rows.people)
7387
7444
  });
7388
7445
  const n = rows.tasks?.length ?? 0;
7389
7446
  get().log(`imported ${n} task${n === 1 ? "" : "s"} from a spreadsheet`);
@@ -7393,12 +7450,18 @@ function hierarchySlice({ get, write }) {
7393
7450
  const gone = {
7394
7451
  programs: new Set(ids.programs ?? []),
7395
7452
  projects: new Set(ids.projects ?? []),
7396
- tasks: new Set(ids.tasks ?? [])
7453
+ tasks: new Set(ids.tasks ?? []),
7454
+ people: new Set(ids.people ?? [])
7397
7455
  };
7456
+ const tasks = state.tasks.filter((r) => !gone.tasks.has(r.id));
7457
+ if (gone.people.size) {
7458
+ for (const t of tasks) if (t.assigneeId) gone.people.delete(t.assigneeId);
7459
+ }
7398
7460
  write({
7399
7461
  programs: state.programs.filter((r) => !gone.programs.has(r.id)),
7400
7462
  projects: state.projects.filter((r) => !gone.projects.has(r.id)),
7401
- tasks: state.tasks.filter((r) => !gone.tasks.has(r.id)),
7463
+ tasks,
7464
+ people: gone.people.size ? state.people.filter((r) => !gone.people.has(r.id)) : state.people,
7402
7465
  selectedTaskId: gone.tasks.has(state.selectedTaskId ?? "") ? null : state.selectedTaskId
7403
7466
  });
7404
7467
  },
@@ -8560,8 +8623,62 @@ function retryDelay(attempt) {
8560
8623
  return Math.min(3e4, 2e3 * 2 ** Math.max(0, attempt));
8561
8624
  }
8562
8625
 
8626
+ //#endregion
8627
+ //#region cli/lib/plain.mjs
8628
+ /**
8629
+ * Text from a harbor, made safe to show in a terminal. A task title can come
8630
+ * from a public intake form, a GitHub pull request or any member, and a
8631
+ * terminal acts on control characters it's sent: an escape sequence in a
8632
+ * title could set the clipboard, retitle the window or hide what's around
8633
+ * it. So they're taken out before anything is printed: the C0 and C1
8634
+ * controls (tab and newline stay), DEL, and the bidirectional overrides that
8635
+ * make text read in a different order than it's stored.
8636
+ */
8637
+ const UNSAFE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f‪-‮⁦-⁩]/g;
8638
+ /** @param {string} text */
8639
+ function plain(text) {
8640
+ return text.replace(UNSAFE, "");
8641
+ }
8642
+ /**
8643
+ * Every string in a value made plain: what the full-screen app takes in from
8644
+ * Harbor, before any of it reaches the screen.
8645
+ * @template T
8646
+ * @param {T} value
8647
+ * @returns {T}
8648
+ */
8649
+ function plainDeep(value) {
8650
+ if (typeof value === "string") return plain(value);
8651
+ if (Array.isArray(value)) return value.map(plainDeep);
8652
+ if (value && typeof value === "object") {
8653
+ /** @type {Record<string, unknown>} */
8654
+ const out = {};
8655
+ for (const [k, v] of Object.entries(value)) out[k] = plainDeep(v);
8656
+ return out;
8657
+ }
8658
+ return value;
8659
+ }
8660
+
8563
8661
  //#endregion
8564
8662
  //#region src/tui/engine/transport.ts
8663
+ /**
8664
+ * How the terminal app talks to Harbor: POSTs to /api/app with the bearer
8665
+ * token `harbor login` saved (mcp/app.server.ts on the other end). An
8666
+ * expired token is renewed once and the request retried.
8667
+ */
8668
+ /**
8669
+ * The same transport with every answer made plain (cli/lib/plain.mjs):
8670
+ * titles, names and notes can come from intake forms, GitHub or anyone in
8671
+ * the harbor, and a terminal acts on control characters they carry.
8672
+ */
8673
+ function plainTransport(t) {
8674
+ return {
8675
+ harbors: async () => plainDeep(await t.harbors()),
8676
+ load: async (workspaceId) => plainDeep(await t.load(workspaceId)),
8677
+ save: async (body) => plainDeep(await t.save(body)),
8678
+ wait: async (workspaceId, since) => plainDeep(await t.wait(workspaceId, since)),
8679
+ history: async (query) => plainDeep(await t.history(query))
8680
+ };
8681
+ }
8565
8682
  /** Harbor said no, or couldn't be reached (`offline`). */
8566
8683
  var TransportError = class extends Error {
8567
8684
  status;
@@ -8826,6 +8943,53 @@ function createSync(opts) {
8826
8943
  };
8827
8944
  }
8828
8945
 
8946
+ //#endregion
8947
+ //#region src/tui/theme-detect.ts
8948
+ const isThemeSetting = (v) => v === "auto" || v === "light" || v === "dark";
8949
+ /** COLORFGBG is "fg;bg" (sometimes "fg;x;bg"): background 7 or 15 is a light terminal. */
8950
+ function themeFromColorFgBg(value) {
8951
+ const bg = value?.split(";").at(-1);
8952
+ if (!bg || !/^\d+$/.test(bg)) return null;
8953
+ return bg === "7" || bg === "15" ? "light" : "dark";
8954
+ }
8955
+ /** A terminal's answer to OSC 11, like "\x1b]11;rgb:ffff/ffff/ffff\x07": light when its luminance is over half. */
8956
+ function themeFromReply(reply) {
8957
+ const m = /rgba?:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i.exec(reply);
8958
+ if (!m) return null;
8959
+ const [r, g, b] = m.slice(1, 4).map((hex) => parseInt(hex, 16) / (16 ** hex.length - 1));
8960
+ return .2126 * r + .7152 * g + .0722 * b > .5 ? "light" : "dark";
8961
+ }
8962
+ /** Ask the terminal for its background colour; null when it doesn't say in time. */
8963
+ function askBackground(stdin, stdout, ms = 300) {
8964
+ if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") return Promise.resolve(null);
8965
+ return new Promise((resolve) => {
8966
+ let reply = "";
8967
+ const done = (theme) => {
8968
+ clearTimeout(timer);
8969
+ stdin.off("data", onData);
8970
+ stdin.setRawMode(false);
8971
+ stdin.pause();
8972
+ resolve(theme);
8973
+ };
8974
+ const onData = (chunk) => {
8975
+ reply += chunk.toString();
8976
+ if (/\x07|\x1b\\/.test(reply)) done(themeFromReply(reply));
8977
+ };
8978
+ const timer = setTimeout(() => done(null), ms);
8979
+ stdin.setRawMode(true);
8980
+ stdin.resume();
8981
+ stdin.on("data", onData);
8982
+ stdout.write("\x1B]11;?\x1B\\");
8983
+ });
8984
+ }
8985
+ /** The set to draw with, from the saved setting, the environment, then the terminal itself. */
8986
+ async function pickTheme(setting, env = process.env, ask = () => askBackground(process.stdin, process.stdout)) {
8987
+ const fromEnv = env.HARBOR_THEME?.toLowerCase();
8988
+ const chosen = isThemeSetting(fromEnv) ? fromEnv : setting;
8989
+ if (chosen === "light" || chosen === "dark") return chosen;
8990
+ return themeFromColorFgBg(env.COLORFGBG) ?? await ask() ?? "dark";
8991
+ }
8992
+
8829
8993
  //#endregion
8830
8994
  //#region src/tui/ui-state.ts
8831
8995
  /** What the shell shows beside the harbor: the save state and the latest notice. */
@@ -8844,30 +9008,36 @@ function createUiStore() {
8844
9008
  * CLI package's own dependencies.
8845
9009
  */
8846
9010
  const today = () => format(/* @__PURE__ */ new Date(), "yyyy-MM-dd");
9011
+ /** How long `harbor` shows its splash at the least, however fast the harbor loads. */
9012
+ const SPLASH_MS = 1200;
8847
9013
  /**
8848
9014
  * Open the app on a transport that's already signed in: the harbor used
8849
9015
  * most, the store and the sync loop, drawn with Ink.
8850
9016
  */
8851
9017
  async function startApp(deps, options = {}) {
9018
+ applyTheme(deps.theme ?? "dark");
9019
+ const transport = plainTransport(deps.transport);
9020
+ const account = plainDeep(deps.account);
9021
+ const harbors = plainDeep(deps.harbors);
8852
9022
  const ui = createUiStore();
8853
9023
  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
9024
  const sync = createSync({
8855
9025
  store,
8856
- transport: deps.transport,
9026
+ transport,
8857
9027
  account: {
8858
- id: deps.account.id,
8859
- name: deps.account.name || deps.account.email
9028
+ id: account.id,
9029
+ name: account.name || account.email
8860
9030
  },
8861
9031
  onState: (save) => ui.setState({ save }),
8862
9032
  onNotice: (notice) => ui.setState({ notice })
8863
9033
  });
8864
- const opening = deps.workspaceId ? deps.harbors.find((h) => h.id === deps.workspaceId) : deps.harbors.length === 1 ? deps.harbors[0] : void 0;
9034
+ const opening = deps.workspaceId ? harbors.find((h) => h.id === deps.workspaceId) : harbors.length === 1 ? harbors[0] : void 0;
8865
9035
  const instance = render(/* @__PURE__ */ jsx(Splash, { opening: opening?.name }), {
8866
9036
  exitOnCtrlC: false,
8867
9037
  ...options
8868
9038
  });
8869
9039
  try {
8870
- await sync.open(deps.workspaceId);
9040
+ await Promise.all([sync.open(deps.workspaceId), new Promise((r) => setTimeout(r, deps.splashMs ?? 0))]);
8871
9041
  } catch (err) {
8872
9042
  instance.unmount();
8873
9043
  throw err;
@@ -8876,9 +9046,9 @@ async function startApp(deps, options = {}) {
8876
9046
  value: {
8877
9047
  store,
8878
9048
  sync,
8879
- transport: deps.transport,
8880
- account: deps.account,
8881
- harbors: deps.harbors,
9049
+ transport,
9050
+ account,
9051
+ harbors,
8882
9052
  today
8883
9053
  },
8884
9054
  children: /* @__PURE__ */ jsx(App, { ui })
@@ -8892,6 +9062,7 @@ async function startApp(deps, options = {}) {
8892
9062
  }
8893
9063
  /** `harbor`: sign-in already done, take over the terminal until q. */
8894
9064
  async function runApp(opts) {
9065
+ const theme = await pickTheme(opts.theme);
8895
9066
  const transport = httpTransport(opts.source, opts.fetcher);
8896
9067
  const { account, harbors } = await transport.harbors();
8897
9068
  if (harbors.length === 0) throw new Error("You don't belong to a harbor yet. Make one at harborppm.com first.");
@@ -8902,10 +9073,12 @@ async function runApp(opts) {
8902
9073
  transport,
8903
9074
  account,
8904
9075
  harbors,
8905
- workspaceId: pick?.id
9076
+ workspaceId: pick?.id,
9077
+ splashMs: SPLASH_MS,
9078
+ theme
8906
9079
  }, { alternateScreen: true });
8907
9080
  await instance.waitUntilExit();
8908
9081
  }
8909
9082
 
8910
9083
  //#endregion
8911
- export { TransportError, httpTransport, runApp, startApp };
9084
+ 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
@@ -8,10 +8,18 @@ import { readFileSync } from "node:fs";
8
8
  import { parseArgs } from "./args.mjs";
9
9
  import { hasFullAccess, loadApp, tokenSource } from "./app.mjs";
10
10
  import { login } from "./auth.mjs";
11
- import { DEFAULT_SERVER, forgetCredentials, readCredentials, writeCredentials } from "./config.mjs";
11
+ import {
12
+ DEFAULT_SERVER,
13
+ forgetCredentials,
14
+ readCredentials,
15
+ readSettings,
16
+ writeCredentials,
17
+ writeSettings,
18
+ } from "./config.mjs";
12
19
  import { harbors, myWork, taskLine, taskList, todayLocal } from "./format.mjs";
13
20
  import { colourDepth, logoLines } from "./logo.mjs";
14
21
  import { HarborError, createClient } from "./mcp.mjs";
22
+ import { jsonText, plain } from "./plain.mjs";
15
23
 
16
24
  export const HELP = `harbor: Harbor from the terminal
17
25
 
@@ -19,6 +27,7 @@ export const HELP = `harbor: Harbor from the terminal
19
27
  harbor login [--server URL] [--read-only] sign in (opens your browser once)
20
28
  harbor logout forget the sign-in on this machine
21
29
  harbor harbors the harbors you belong to
30
+ harbor theme [auto|light|dark] the app's colours (auto asks the terminal)
22
31
  harbor --version which version this is
23
32
 
24
33
  harbor mine what's on you, late first
@@ -54,21 +63,39 @@ function banner(io, env, words) {
54
63
  return `\n${logoLines(depth, words({ bold, dim })).join("\n")}\n`;
55
64
  }
56
65
 
66
+ const THEMES = ["auto", "light", "dark"];
67
+
57
68
  const COMMON = { flags: ["json", "help"], options: ["harbor"] };
58
69
  const spec = (flags = [], options = []) => ({
59
70
  flags: [...COMMON.flags, ...flags],
60
71
  options: [...COMMON.options, ...options],
61
72
  });
62
73
 
74
+ /**
75
+ * How to open a web address on this platform, as a program and its
76
+ * arguments (never through a shell). Windows' `cmd /c start` would read
77
+ * each & in the address as the end of a command: the sign-in link has
78
+ * several. Only http(s) addresses are opened.
79
+ */
80
+ export function browserCommand(url, platform = process.platform) {
81
+ let parsed;
82
+ try {
83
+ parsed = new URL(url);
84
+ } catch {
85
+ return null;
86
+ }
87
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return null;
88
+ const href = parsed.toString();
89
+ if (platform === "darwin") return ["open", [href]];
90
+ if (platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", href]];
91
+ return ["xdg-open", [href]];
92
+ }
93
+
63
94
  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]];
95
+ const command = browserCommand(url);
96
+ if (!command) return;
70
97
  try {
71
- spawn(cmd, args, { stdio: "ignore", detached: true })
98
+ spawn(command[0], command[1], { stdio: "ignore", detached: true })
72
99
  .on("error", () => {})
73
100
  .unref();
74
101
  } catch {
@@ -167,6 +194,7 @@ export async function run(argv, io = {}) {
167
194
  source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
168
195
  fetcher,
169
196
  harbor: values.harbor,
197
+ theme: (await readSettings(env)).theme,
170
198
  });
171
199
  return 0;
172
200
  }
@@ -199,6 +227,26 @@ export async function run(argv, io = {}) {
199
227
  return 0;
200
228
  }
201
229
 
230
+ if (command === "theme") {
231
+ const { positionals } = parseArgs(rest, { flags: [], options: [] });
232
+ const settings = await readSettings(env);
233
+ const [choice] = positionals;
234
+ if (!choice) {
235
+ out(`The app's colours: ${settings.theme ?? "auto"}.`);
236
+ return 0;
237
+ }
238
+ if (!THEMES.includes(choice)) {
239
+ throw new HarborError(`Pick auto, light or dark (not "${choice}").`);
240
+ }
241
+ await writeSettings({ ...settings, theme: choice }, env);
242
+ out(
243
+ choice === "auto"
244
+ ? "The app will ask the terminal for its background and match it."
245
+ : `The app will use its ${choice} colours.`,
246
+ );
247
+ return 0;
248
+ }
249
+
202
250
  if (command === "logout") {
203
251
  await forgetCredentials(env);
204
252
  out(
@@ -217,7 +265,7 @@ export async function run(argv, io = {}) {
217
265
  if (err instanceof HarborError) throw new HarborError(hint(err.message));
218
266
  throw err;
219
267
  });
220
- out(asJson ? JSON.stringify(result, null, 2) : show(result));
268
+ out(asJson ? jsonText(result) : plain(show(result)));
221
269
  return 0;
222
270
  };
223
271
  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,11 +27,30 @@ 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) {
36
39
  await rm(file(env), { force: true });
37
40
  }
41
+
42
+ const settingsFile = (env) => join(configDir(env), "settings.json");
43
+
44
+ /** Choices that aren't a sign-in (the app's colours), or {} when none are saved. */
45
+ export async function readSettings(env = process.env) {
46
+ try {
47
+ return JSON.parse(await readFile(settingsFile(env), "utf8"));
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ export async function writeSettings(settings, env = process.env) {
54
+ await mkdir(configDir(env), { recursive: true, mode: 0o700 });
55
+ await writeFile(settingsFile(env), JSON.stringify(settings, null, 2));
56
+ }
package/lib/logo.mjs CHANGED
@@ -37,7 +37,7 @@ export const LOGO = [
37
37
  "...TTTTTTTTTTTTTTTTTT...",
38
38
  ];
39
39
 
40
- /** 16 by 16, for narrow places. */
40
+ /** 16 by 16, for the keys overlay. */
41
41
  export const LOGO_SMALL = [
42
42
  "..TTTTTTTTTTTT..",
43
43
  ".TTTTTTTTTTTTTT.",
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.5.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": {