@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 +11 -1
- package/bin/harbor.mjs +22 -5
- package/dist/app.mjs +240 -67
- package/lib/app.mjs +11 -3
- package/lib/cli.mjs +57 -9
- package/lib/config.mjs +23 -4
- package/lib/logo.mjs +1 -1
- 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; 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
|
-
|
|
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
|
@@ -2077,25 +2077,62 @@ function forecastLine(f) {
|
|
|
2077
2077
|
|
|
2078
2078
|
//#endregion
|
|
2079
2079
|
//#region src/tui/theme.ts
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
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
|
|
2094
|
-
|
|
2095
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 ?
|
|
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:
|
|
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:
|
|
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
|
|
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.
|
|
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.
|
|
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 >=
|
|
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.
|
|
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 ?
|
|
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 ?
|
|
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 :
|
|
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 ?
|
|
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 ?
|
|
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:
|
|
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" ?
|
|
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 ?
|
|
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.
|
|
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:
|
|
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:
|
|
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" ?
|
|
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", "
|
|
5816
|
-
pending: ["● saving soon", "
|
|
5817
|
-
saving: ["● saving…", "
|
|
5818
|
-
retrying: ["● not saved, retrying", "
|
|
5819
|
-
offline: ["● offline, will save", "
|
|
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
|
|
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,
|
|
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:
|
|
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:
|
|
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 :
|
|
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
|
|
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
|
|
9026
|
+
transport,
|
|
8857
9027
|
account: {
|
|
8858
|
-
id:
|
|
8859
|
-
name:
|
|
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 ?
|
|
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
|
|
8880
|
-
account
|
|
8881
|
-
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
|
-
|
|
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
|
@@ -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 {
|
|
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
|
|
65
|
-
|
|
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(
|
|
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 ?
|
|
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
|
-
|
|
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) {
|
|
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
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
|
+
}
|