@harborppm/cli 0.7.1 → 0.9.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 +6 -6
- package/dist/app.mjs +278 -78
- package/lib/auth.mjs +6 -3
- package/lib/cli.mjs +72 -22
- package/lib/format.mjs +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,12 +63,12 @@ It runs on the same engine as the website: health, task keys, limits and undo wo
|
|
|
63
63
|
|
|
64
64
|
```
|
|
65
65
|
harbor harbors the harbors you belong to
|
|
66
|
-
harbor use HARBOR|none the harbor
|
|
66
|
+
harbor use HARBOR|none the harbor to work in when you're in several
|
|
67
67
|
harbor mine what's on you, late first
|
|
68
68
|
harbor tasks [words] [--project P] [--assignee me|none|NAME] [--late] [--status S,S]
|
|
69
69
|
harbor show TASK one task in full
|
|
70
70
|
|
|
71
|
-
harbor add "TITLE" --project P [--assignee me|NAME] [--due
|
|
71
|
+
harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
|
|
72
72
|
[--priority low|medium|high|urgent] [--status S] [--over-limit]
|
|
73
73
|
harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
|
|
74
74
|
harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
|
|
@@ -76,14 +76,14 @@ harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
|
|
|
76
76
|
harbor comment TASK "TEXT"
|
|
77
77
|
harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
|
|
78
78
|
harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
|
|
79
|
-
[--owner NAME] [--due
|
|
79
|
+
[--owner NAME] [--due DATE]
|
|
80
80
|
harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
81
|
-
[--due
|
|
81
|
+
[--due DATE]
|
|
82
82
|
```
|
|
83
83
|
|
|
84
|
-
Every command takes `--json` for the raw result, and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done.
|
|
84
|
+
Every command takes `--json` for the raw result, and `harbor COMMAND --help` shows just that command. Statuses are backlog, todo, doing, review and done. A `DATE` is YYYY-MM-DD, `today`, `tomorrow`, or `+N` for N days from today.
|
|
85
85
|
|
|
86
|
-
When you belong to more than one harbor, say which: `--harbor NAME` on the command, or `harbor use NAME` once (it's saved with your sign-in; `harbor use none` forgets it). With neither, the commands ask rather than guess, so nothing lands in the wrong harbor. The full-screen app opens the
|
|
86
|
+
When you belong to more than one harbor, say which: `--harbor NAME` on the command, or `harbor use NAME` once (it's saved with your sign-in; `harbor use none` forgets it). With neither, the commands ask rather than guess, so nothing lands in the wrong harbor. The full-screen app shares the same choice: it opens the harbor `harbor use` set, asks which one the first time if none is set, and a harbor you switch to in the app becomes the one the commands use.
|
|
87
87
|
|
|
88
88
|
Harbor holds back what it would hold back in the app: finishing a task that still waits on another, or going past a column's limit. The CLI says so and names the flag that goes ahead (`--confirm`, `--over-limit`).
|
|
89
89
|
|
package/dist/app.mjs
CHANGED
|
@@ -2298,12 +2298,21 @@ function PriorityMark({ priority }) {
|
|
|
2298
2298
|
function Heading({ children, right }) {
|
|
2299
2299
|
return /* @__PURE__ */ jsxs(Box, {
|
|
2300
2300
|
justifyContent: "space-between",
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2301
|
+
columnGap: 2,
|
|
2302
|
+
children: [/* @__PURE__ */ jsx(Box, {
|
|
2303
|
+
flexShrink: 1,
|
|
2304
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
2305
|
+
bold: true,
|
|
2306
|
+
wrap: "truncate-end",
|
|
2307
|
+
children
|
|
2308
|
+
})
|
|
2309
|
+
}), right ? /* @__PURE__ */ jsx(Box, {
|
|
2310
|
+
flexShrink: 2,
|
|
2311
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
2312
|
+
color: theme.muted,
|
|
2313
|
+
wrap: "truncate-end",
|
|
2314
|
+
children: right
|
|
2315
|
+
})
|
|
2307
2316
|
}) : null]
|
|
2308
2317
|
});
|
|
2309
2318
|
}
|
|
@@ -2917,7 +2926,7 @@ function HistoryView({ active, width, height, go }) {
|
|
|
2917
2926
|
on ? "› " : " ",
|
|
2918
2927
|
/* @__PURE__ */ jsx(Text, {
|
|
2919
2928
|
color: theme.muted,
|
|
2920
|
-
children: timeAgo(parseIso(row.at))
|
|
2929
|
+
children: `${timeAgo(parseIso(row.at))} `.padEnd(16)
|
|
2921
2930
|
}),
|
|
2922
2931
|
/* @__PURE__ */ jsx(Text, {
|
|
2923
2932
|
bold: true,
|
|
@@ -2972,7 +2981,9 @@ function constraintIdle(data, constraint) {
|
|
|
2972
2981
|
if (constraint.status !== "active" || !constraint.ownerId) return false;
|
|
2973
2982
|
const tasks = tasksInProgramOf(data, constraint.programId);
|
|
2974
2983
|
if (!tasks.some((t) => t.status !== "done")) return false;
|
|
2975
|
-
|
|
2984
|
+
if (tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing")) return false;
|
|
2985
|
+
const here = new Set(tasks.map((t) => t.id));
|
|
2986
|
+
return data.tasks.some((t) => t.assigneeId === constraint.ownerId && t.status === "doing" && !here.has(t.id));
|
|
2976
2987
|
}
|
|
2977
2988
|
|
|
2978
2989
|
//#endregion
|
|
@@ -3041,7 +3052,7 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
3041
3052
|
});
|
|
3042
3053
|
}
|
|
3043
3054
|
for (const blocker of mine) {
|
|
3044
|
-
const waiting = (waitersByBlocker(data.tasks).get(blocker.id) ?? []).filter((t) => t.status !== "done" && t.id !== blocker.id);
|
|
3055
|
+
const waiting = (waitersByBlocker(data.tasks).get(blocker.id) ?? []).filter((t) => t.status !== "done" && t.id !== blocker.id && t.assigneeId !== blocker.assigneeId);
|
|
3045
3056
|
if (waiting.length === 0) continue;
|
|
3046
3057
|
const project = projectOf(data, blocker);
|
|
3047
3058
|
const names = [...new Set(waiting.map((t) => {
|
|
@@ -3267,6 +3278,7 @@ function weeklyLoad(tasks, personId, now, weeks = 6) {
|
|
|
3267
3278
|
|
|
3268
3279
|
//#endregion
|
|
3269
3280
|
//#region src/tui/views/lists.tsx
|
|
3281
|
+
const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
3270
3282
|
function RowList({ title, right, rows, empty, active, width, height, onOpen, keys = {} }) {
|
|
3271
3283
|
const [at, setAt] = useState(0);
|
|
3272
3284
|
const row = rows[Math.min(at, rows.length - 1)];
|
|
@@ -3368,7 +3380,7 @@ function ProjectsView({ active, width, height, go }) {
|
|
|
3368
3380
|
]);
|
|
3369
3381
|
return /* @__PURE__ */ jsx(RowList, {
|
|
3370
3382
|
title: "Portfolios, programs and projects",
|
|
3371
|
-
right: `${projects.length}
|
|
3383
|
+
right: `${count(projects.length, "project")} · n new project · e edit`,
|
|
3372
3384
|
rows,
|
|
3373
3385
|
empty: "No projects yet. n adds one.",
|
|
3374
3386
|
active,
|
|
@@ -3413,7 +3425,7 @@ function ProgramsView({ active, width, height, go }) {
|
|
|
3413
3425
|
]);
|
|
3414
3426
|
return /* @__PURE__ */ jsx(RowList, {
|
|
3415
3427
|
title: "Programs",
|
|
3416
|
-
right: `${programs.length}
|
|
3428
|
+
right: `${count(programs.length, "program")} · n new · e edit`,
|
|
3417
3429
|
rows,
|
|
3418
3430
|
empty: "No programs yet. n adds one.",
|
|
3419
3431
|
active,
|
|
@@ -3502,7 +3514,7 @@ function PeopleView({ active, width, height, go }) {
|
|
|
3502
3514
|
]);
|
|
3503
3515
|
return /* @__PURE__ */ jsx(RowList, {
|
|
3504
3516
|
title: "People",
|
|
3505
|
-
right: `${people.length} people · Enter to change`,
|
|
3517
|
+
right: `${people.length === 1 ? "1 person" : `${people.length} people`} · Enter to change`,
|
|
3506
3518
|
rows,
|
|
3507
3519
|
empty: "Nobody here yet. Invite people from the website.",
|
|
3508
3520
|
active,
|
|
@@ -3801,7 +3813,7 @@ function NewTask({ projectId, status, width, onDone, onFull }) {
|
|
|
3801
3813
|
if (!chosen) return /* @__PURE__ */ jsx(Box, {
|
|
3802
3814
|
width,
|
|
3803
3815
|
children: /* @__PURE__ */ jsx(Picker, {
|
|
3804
|
-
title: "New task in which project?",
|
|
3816
|
+
title: projects.length ? "New task in which project?" : "No projects yet. Esc, then 3 for Projects and n to start one.",
|
|
3805
3817
|
choices: projects.map((p) => ({
|
|
3806
3818
|
label: p.name,
|
|
3807
3819
|
value: p.id,
|
|
@@ -3816,7 +3828,7 @@ function NewTask({ projectId, status, width, onDone, onFull }) {
|
|
|
3816
3828
|
children: /* @__PURE__ */ jsx(TextField, {
|
|
3817
3829
|
label: `New task in ${chosen.name}${status ? ` · ${STATUS_LABEL[status]}` : ""}`,
|
|
3818
3830
|
placeholder: "What needs doing?",
|
|
3819
|
-
hint:
|
|
3831
|
+
hint: `Enter adds it (yours, ${STATUS_LABEL[status ?? "todo"].toLowerCase()}) · Esc leaves it`,
|
|
3820
3832
|
onCancel: () => onDone(null),
|
|
3821
3833
|
onSubmit: (title) => {
|
|
3822
3834
|
if (!title.trim()) return onDone(null);
|
|
@@ -3853,24 +3865,21 @@ function HarborSwitch({ harbors, current, width, onPick, onClose }) {
|
|
|
3853
3865
|
});
|
|
3854
3866
|
}
|
|
3855
3867
|
const HELP = [
|
|
3856
|
-
["1–8", "
|
|
3857
|
-
[":", "anything: a screen, something new, a
|
|
3868
|
+
["1–8", "the screens, as the sidebar lists them"],
|
|
3869
|
+
[":", "anything: a screen, something new, a name"],
|
|
3858
3870
|
["/", "find a task by key or words"],
|
|
3859
3871
|
["H", "switch harbor"],
|
|
3860
3872
|
["Tab", "between the sidebar and the screen"],
|
|
3861
|
-
["↑↓ or j k", "move; ←→ or h l across
|
|
3862
|
-
["Enter", "open"],
|
|
3863
|
-
["
|
|
3864
|
-
["
|
|
3865
|
-
["e", "change what's open: its health, lead, dates, budget"],
|
|
3873
|
+
["↑↓ or j k", "move; ←→ or h l across columns and lists"],
|
|
3874
|
+
["Enter / Esc", "open / back"],
|
|
3875
|
+
["n", "new: a task, or what the screen lists"],
|
|
3876
|
+
["e", "change what's open (health, lead, dates)"],
|
|
3866
3877
|
["[ ]", "move a card one column left or right"],
|
|
3867
|
-
["x", "mark done"],
|
|
3868
|
-
["
|
|
3869
|
-
["u", "undo your last change"],
|
|
3878
|
+
["x / d", "mark done / delete (asks first)"],
|
|
3879
|
+
["u / r", "undo your last change / retry saving"],
|
|
3870
3880
|
["v", "on a project: board, list, budget"],
|
|
3871
|
-
["m / R", "on a program
|
|
3881
|
+
["m / R", "new milestone on a program / log a risk"],
|
|
3872
3882
|
["c / a", "on a task: comment / add a checklist step"],
|
|
3873
|
-
["r", "retry saving now"],
|
|
3874
3883
|
["q", "quit (saves first)"]
|
|
3875
3884
|
];
|
|
3876
3885
|
/**
|
|
@@ -3878,7 +3887,7 @@ const HELP = [
|
|
|
3878
3887
|
* the 16-pixel mark, and the box's border and padding. Narrower, the lines
|
|
3879
3888
|
* would wrap and split the mark.
|
|
3880
3889
|
*/
|
|
3881
|
-
const HELP_WITH_MARK =
|
|
3890
|
+
const HELP_WITH_MARK = 13 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
|
|
3882
3891
|
function Help({ width, onClose }) {
|
|
3883
3892
|
useInput((input, key) => {
|
|
3884
3893
|
if (key.escape || key.return || input === "?" || input === "q") onClose();
|
|
@@ -3899,11 +3908,11 @@ function Help({ width, onClose }) {
|
|
|
3899
3908
|
}),
|
|
3900
3909
|
HELP.map(([k, what]) => /* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsx(Text, {
|
|
3901
3910
|
bold: true,
|
|
3902
|
-
children: k.padEnd(
|
|
3911
|
+
children: k.padEnd(13)
|
|
3903
3912
|
}), what] }, k)),
|
|
3904
3913
|
/* @__PURE__ */ jsx(Text, {
|
|
3905
3914
|
color: theme.muted,
|
|
3906
|
-
children: "Everything saves as you go
|
|
3915
|
+
children: "Everything saves as you go and shows on the website."
|
|
3907
3916
|
})
|
|
3908
3917
|
]
|
|
3909
3918
|
}), width >= HELP_WITH_MARK ? /* @__PURE__ */ jsxs(Box, {
|
|
@@ -4911,7 +4920,7 @@ function PortfolioView({ portfolioId, active, width, height, go, onBack }) {
|
|
|
4911
4920
|
}),
|
|
4912
4921
|
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
4913
4922
|
/* @__PURE__ */ jsx(Heading, {
|
|
4914
|
-
right: `${list.length}
|
|
4923
|
+
right: `${list.length} program${list.length === 1 ? "" : "s"}`,
|
|
4915
4924
|
children: "Programs"
|
|
4916
4925
|
}),
|
|
4917
4926
|
list.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
@@ -5047,6 +5056,9 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5047
5056
|
const { done, total } = projectSummary(data, project);
|
|
5048
5057
|
const lead = people.find((p) => p.id === project.ownerId);
|
|
5049
5058
|
const body = height - 3;
|
|
5059
|
+
const fit = Math.max(1, Math.floor((width - 2) / 14));
|
|
5060
|
+
const firstColumn = Math.min(Math.max(0, col - Math.floor(fit / 2)), Math.max(0, columns.length - fit));
|
|
5061
|
+
const shownColumns = columns.slice(firstColumn, firstColumn + fit);
|
|
5050
5062
|
return /* @__PURE__ */ jsxs(Box, {
|
|
5051
5063
|
flexDirection: "column",
|
|
5052
5064
|
width,
|
|
@@ -5055,36 +5067,44 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5055
5067
|
children: [
|
|
5056
5068
|
/* @__PURE__ */ jsxs(Box, {
|
|
5057
5069
|
justifyContent: "space-between",
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
5087
|
-
|
|
5070
|
+
columnGap: 2,
|
|
5071
|
+
children: [/* @__PURE__ */ jsx(Box, {
|
|
5072
|
+
flexShrink: 1,
|
|
5073
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
5074
|
+
wrap: "truncate-end",
|
|
5075
|
+
children: [
|
|
5076
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5077
|
+
bold: true,
|
|
5078
|
+
children: project.name
|
|
5079
|
+
}),
|
|
5080
|
+
" ",
|
|
5081
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5082
|
+
color: theme.muted,
|
|
5083
|
+
children: project.key
|
|
5084
|
+
}),
|
|
5085
|
+
" ",
|
|
5086
|
+
/* @__PURE__ */ jsx(HealthMark, {
|
|
5087
|
+
health: project.health,
|
|
5088
|
+
label: true
|
|
5089
|
+
})
|
|
5090
|
+
]
|
|
5091
|
+
})
|
|
5092
|
+
}), /* @__PURE__ */ jsx(Box, {
|
|
5093
|
+
flexShrink: 2,
|
|
5094
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
5095
|
+
color: theme.muted,
|
|
5096
|
+
wrap: "truncate-end",
|
|
5097
|
+
children: [
|
|
5098
|
+
done,
|
|
5099
|
+
" of ",
|
|
5100
|
+
total,
|
|
5101
|
+
" done",
|
|
5102
|
+
lead ? ` · ${lead.name}` : "",
|
|
5103
|
+
" · ",
|
|
5104
|
+
mode,
|
|
5105
|
+
" · v next · e details"
|
|
5106
|
+
]
|
|
5107
|
+
})
|
|
5088
5108
|
})]
|
|
5089
5109
|
}),
|
|
5090
5110
|
/* @__PURE__ */ jsx(Text, {
|
|
@@ -5100,8 +5120,9 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5100
5120
|
today: day
|
|
5101
5121
|
}) : mode === "board" ? /* @__PURE__ */ jsx(Box, {
|
|
5102
5122
|
height: body,
|
|
5103
|
-
children:
|
|
5104
|
-
const
|
|
5123
|
+
children: shownColumns.map((c, i) => {
|
|
5124
|
+
const ci = firstColumn + i;
|
|
5125
|
+
const w = Math.floor((width - 2) / shownColumns.length);
|
|
5105
5126
|
const on = active && ci === col;
|
|
5106
5127
|
const r = Math.min(rowByCol[ci] ?? 0, Math.max(0, c.tasks.length - 1));
|
|
5107
5128
|
const view = windowOf(c.tasks, r, Math.max(1, Math.floor((body - 3) / 3)));
|
|
@@ -5113,19 +5134,33 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5113
5134
|
borderColor: on ? theme.accent : theme.border,
|
|
5114
5135
|
paddingX: 1,
|
|
5115
5136
|
children: [
|
|
5116
|
-
/* @__PURE__ */ jsxs(
|
|
5117
|
-
|
|
5118
|
-
|
|
5119
|
-
|
|
5120
|
-
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5137
|
+
/* @__PURE__ */ jsxs(Box, { children: [
|
|
5138
|
+
i === 0 && firstColumn > 0 ? /* @__PURE__ */ jsx(Text, {
|
|
5139
|
+
color: theme.muted,
|
|
5140
|
+
children: "‹ "
|
|
5141
|
+
}) : null,
|
|
5142
|
+
/* @__PURE__ */ jsx(Box, {
|
|
5143
|
+
flexGrow: 1,
|
|
5144
|
+
flexShrink: 1,
|
|
5145
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
5146
|
+
bold: true,
|
|
5147
|
+
color: on ? theme.accent : void 0,
|
|
5148
|
+
wrap: "truncate-end",
|
|
5149
|
+
children: [
|
|
5150
|
+
c.label.toUpperCase(),
|
|
5151
|
+
" ",
|
|
5152
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
5153
|
+
color: over ? theme.bad : theme.muted,
|
|
5154
|
+
children: [c.tasks.length, c.limit != null ? `/${c.limit}` : ""]
|
|
5155
|
+
})
|
|
5156
|
+
]
|
|
5126
5157
|
})
|
|
5127
|
-
|
|
5128
|
-
|
|
5158
|
+
}),
|
|
5159
|
+
i === shownColumns.length - 1 && ci < columns.length - 1 ? /* @__PURE__ */ jsx(Text, {
|
|
5160
|
+
color: theme.muted,
|
|
5161
|
+
children: " ›"
|
|
5162
|
+
}) : null
|
|
5163
|
+
] }),
|
|
5129
5164
|
c.tasks.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
5130
5165
|
color: theme.muted,
|
|
5131
5166
|
children: "—"
|
|
@@ -5883,7 +5918,7 @@ const SAVE_LABEL = {
|
|
|
5883
5918
|
offline: ["● offline, will save", "bad"]
|
|
5884
5919
|
};
|
|
5885
5920
|
function App({ ui }) {
|
|
5886
|
-
const { store, sync, account, harbors, today } = useDeps();
|
|
5921
|
+
const { store, sync, account, harbors, today, remember } = useDeps();
|
|
5887
5922
|
const { exit } = useApp();
|
|
5888
5923
|
const { columns, rows } = useWindowSize();
|
|
5889
5924
|
const harborName = useHarbor((s) => s.harborName);
|
|
@@ -5976,7 +6011,10 @@ function App({ ui }) {
|
|
|
5976
6011
|
const switchHarbor = (id) => {
|
|
5977
6012
|
goScreen("today");
|
|
5978
6013
|
notify("Opening…");
|
|
5979
|
-
sync.flush().then(() => sync.open(id)).then(
|
|
6014
|
+
sync.flush().then(() => sync.open(id)).then(async (h) => {
|
|
6015
|
+
const saved = remember ? await remember(h).then(() => true, () => false) : false;
|
|
6016
|
+
notify(saved ? `Opened ${h.name}. Commands use it too.` : `Opened ${h.name}.`);
|
|
6017
|
+
}).catch((e) => notify(`Couldn't open it: ${e.message}`));
|
|
5980
6018
|
};
|
|
5981
6019
|
const quit = async () => {
|
|
5982
6020
|
setQuitting(true);
|
|
@@ -7209,6 +7247,104 @@ function normalizeRules(rules) {
|
|
|
7209
7247
|
};
|
|
7210
7248
|
}
|
|
7211
7249
|
|
|
7250
|
+
//#endregion
|
|
7251
|
+
//#region src/lib/sample-project.ts
|
|
7252
|
+
/**
|
|
7253
|
+
* A small project a new harbor can add to try things on: a few cards in each
|
|
7254
|
+
* column, one that waits on another, a checklist and a risk. Nothing is late,
|
|
7255
|
+
* so it doesn't turn a fresh harbor red, and every card says what to try.
|
|
7256
|
+
*/
|
|
7257
|
+
const SAMPLE_PROJECT_NAME = "Website refresh (sample)";
|
|
7258
|
+
function sampleProject(opts) {
|
|
7259
|
+
const { ownerId, today, now, makeId } = opts;
|
|
7260
|
+
const day = (n) => todayISO(addDays(parseDate$1(today), n));
|
|
7261
|
+
const end = opts.programEnd && opts.programEnd < day(30) ? opts.programEnd : day(30);
|
|
7262
|
+
const projectId = makeId("pj");
|
|
7263
|
+
const project = {
|
|
7264
|
+
id: projectId,
|
|
7265
|
+
programId: opts.programId,
|
|
7266
|
+
name: SAMPLE_PROJECT_NAME,
|
|
7267
|
+
description: "A project to try things on. Move cards, add tasks, change dates. Delete it from Details when you’re done.",
|
|
7268
|
+
swatch: "sea",
|
|
7269
|
+
ownerId,
|
|
7270
|
+
health: "on-track",
|
|
7271
|
+
startDate: today,
|
|
7272
|
+
endDate: end,
|
|
7273
|
+
budget: 0,
|
|
7274
|
+
spent: 0
|
|
7275
|
+
};
|
|
7276
|
+
const task = (title, status, o = {}) => ({
|
|
7277
|
+
id: makeId("t"),
|
|
7278
|
+
projectId,
|
|
7279
|
+
title,
|
|
7280
|
+
description: o.description ?? "",
|
|
7281
|
+
status,
|
|
7282
|
+
priority: o.priority ?? "medium",
|
|
7283
|
+
assigneeId: ownerId,
|
|
7284
|
+
dueDate: o.due == null ? null : day(o.due),
|
|
7285
|
+
createdAt: now,
|
|
7286
|
+
completedAt: status === "done" ? now : null,
|
|
7287
|
+
checklist: (o.checklist ?? []).map(([t, done]) => ({
|
|
7288
|
+
id: makeId("ck"),
|
|
7289
|
+
title: t,
|
|
7290
|
+
done
|
|
7291
|
+
})),
|
|
7292
|
+
blockedById: o.waitsOn ?? null,
|
|
7293
|
+
blockedByIds: o.waitsOn ? [o.waitsOn] : []
|
|
7294
|
+
});
|
|
7295
|
+
const draft = task("Draft the new home page", "doing", {
|
|
7296
|
+
description: "A sample card. Tick the checklist, leave a comment, or drag it to Review on the board.",
|
|
7297
|
+
due: 3,
|
|
7298
|
+
priority: "high",
|
|
7299
|
+
checklist: [
|
|
7300
|
+
["Headline and one line under it", true],
|
|
7301
|
+
["Three reasons to stay", false],
|
|
7302
|
+
["One clear button", false]
|
|
7303
|
+
]
|
|
7304
|
+
});
|
|
7305
|
+
return {
|
|
7306
|
+
project,
|
|
7307
|
+
tasks: [
|
|
7308
|
+
task("Agree what the refresh is for", "done", { description: "Finished. Move it back if it turns out it wasn’t." }),
|
|
7309
|
+
task("List the pages people actually use", "done"),
|
|
7310
|
+
draft,
|
|
7311
|
+
task("Write the pricing page copy", "review", {
|
|
7312
|
+
description: "Waiting for a second pair of eyes. Move it to Done when it reads well.",
|
|
7313
|
+
due: 5
|
|
7314
|
+
}),
|
|
7315
|
+
task("Pick the photos", "todo", {
|
|
7316
|
+
description: "Hand this one to someone else from the card’s Owner field.",
|
|
7317
|
+
due: 7
|
|
7318
|
+
}),
|
|
7319
|
+
task("Check every page on a phone", "todo", {
|
|
7320
|
+
description: "Waits on the home page draft, so it shows as waiting until that card is done.",
|
|
7321
|
+
due: 10,
|
|
7322
|
+
waitsOn: draft.id
|
|
7323
|
+
}),
|
|
7324
|
+
task("Send old links to the new pages", "backlog", { description: "No date yet. Backlog is for what isn’t planned." }),
|
|
7325
|
+
task("Tell customers what changed", "backlog", { priority: "low" })
|
|
7326
|
+
],
|
|
7327
|
+
raidItems: [{
|
|
7328
|
+
id: makeId("r"),
|
|
7329
|
+
kind: "risk",
|
|
7330
|
+
projectId,
|
|
7331
|
+
title: "Photos may not be cleared in time",
|
|
7332
|
+
description: "A sample risk. If the photos aren’t licensed before launch, the home page ships with placeholders.",
|
|
7333
|
+
severity: "medium",
|
|
7334
|
+
status: "open",
|
|
7335
|
+
ownerId,
|
|
7336
|
+
dueDate: day(7),
|
|
7337
|
+
probability: 3,
|
|
7338
|
+
impact: 3,
|
|
7339
|
+
mitigation: "Shortlist stock photos we already own as a fallback.",
|
|
7340
|
+
createdById: ownerId,
|
|
7341
|
+
createdAt: now,
|
|
7342
|
+
updatedAt: now,
|
|
7343
|
+
updates: []
|
|
7344
|
+
}]
|
|
7345
|
+
};
|
|
7346
|
+
}
|
|
7347
|
+
|
|
7212
7348
|
//#endregion
|
|
7213
7349
|
//#region src/lib/slices/hierarchy.ts
|
|
7214
7350
|
/** Portfolios, programs and projects: create, edit, custom fields, automations, health overrides, boards and cascading delete. */
|
|
@@ -7256,6 +7392,31 @@ function hierarchySlice({ get, write }) {
|
|
|
7256
7392
|
get().log(`opened project ${row.name}`, `/projects/${id}`);
|
|
7257
7393
|
return id;
|
|
7258
7394
|
},
|
|
7395
|
+
addSampleProject: () => {
|
|
7396
|
+
const state = get();
|
|
7397
|
+
const program = state.programs[0];
|
|
7398
|
+
if (!program) return null;
|
|
7399
|
+
const rows = sampleProject({
|
|
7400
|
+
programId: program.id,
|
|
7401
|
+
ownerId: state.currentUserId,
|
|
7402
|
+
today: todayISO(),
|
|
7403
|
+
programEnd: program.endDate,
|
|
7404
|
+
now: stamp(),
|
|
7405
|
+
makeId: uid
|
|
7406
|
+
});
|
|
7407
|
+
const id = rows.project.id;
|
|
7408
|
+
write({
|
|
7409
|
+
projects: [...state.projects, rows.project],
|
|
7410
|
+
tasks: [...state.tasks, ...rows.tasks],
|
|
7411
|
+
raidItems: [...state.raidItems, ...rows.raidItems],
|
|
7412
|
+
boardConfigs: {
|
|
7413
|
+
...state.boardConfigs ?? {},
|
|
7414
|
+
[id]: defaultBoardConfig()
|
|
7415
|
+
}
|
|
7416
|
+
});
|
|
7417
|
+
get().log(`added the sample project`, `/projects/${id}`);
|
|
7418
|
+
return id;
|
|
7419
|
+
},
|
|
7259
7420
|
updatePortfolio: (id, patch) => {
|
|
7260
7421
|
write({ portfolios: get().portfolios.map((p) => p.id === id ? {
|
|
7261
7422
|
...p,
|
|
@@ -8729,6 +8890,7 @@ function httpTransport(source, fetcher = fetch) {
|
|
|
8729
8890
|
}
|
|
8730
8891
|
} catch (error) {
|
|
8731
8892
|
if (error instanceof TransportError) throw error;
|
|
8893
|
+
if (error?.signedOut) throw error;
|
|
8732
8894
|
throw new TransportError("Can't reach Harbor.", 0, true);
|
|
8733
8895
|
}
|
|
8734
8896
|
const answer = await res.json().catch(() => ({}));
|
|
@@ -9076,6 +9238,26 @@ function addFirstProject(store, name, accountId, today) {
|
|
|
9076
9238
|
ownerId: me?.id ?? ""
|
|
9077
9239
|
}, { programId: program.id }, today);
|
|
9078
9240
|
}
|
|
9241
|
+
/**
|
|
9242
|
+
* Someone in several harbors with none picked: ask which, as the commands
|
|
9243
|
+
* would refuse to guess. A number from the list or a name answers; anything
|
|
9244
|
+
* else asks again.
|
|
9245
|
+
*/
|
|
9246
|
+
async function pickHarbor(deps) {
|
|
9247
|
+
deps.say(`You're in ${deps.harbors.length} harbors. Which one should harbor open?`);
|
|
9248
|
+
deps.harbors.forEach((h, i) => deps.say(` ${i + 1}. ${h.name}`));
|
|
9249
|
+
for (;;) {
|
|
9250
|
+
const answer = (await deps.ask(`Number or name (1-${deps.harbors.length}): `)).trim();
|
|
9251
|
+
if (!answer) continue;
|
|
9252
|
+
const n = Number(answer);
|
|
9253
|
+
if (Number.isInteger(n) && n >= 1 && n <= deps.harbors.length) return deps.harbors[n - 1];
|
|
9254
|
+
try {
|
|
9255
|
+
return deps.named(answer);
|
|
9256
|
+
} catch (error) {
|
|
9257
|
+
deps.say(error.message);
|
|
9258
|
+
}
|
|
9259
|
+
}
|
|
9260
|
+
}
|
|
9079
9261
|
|
|
9080
9262
|
//#endregion
|
|
9081
9263
|
//#region src/tui/main.tsx
|
|
@@ -9131,7 +9313,8 @@ async function startApp(deps, options = {}) {
|
|
|
9131
9313
|
transport,
|
|
9132
9314
|
account,
|
|
9133
9315
|
harbors,
|
|
9134
|
-
today
|
|
9316
|
+
today,
|
|
9317
|
+
remember: deps.remember
|
|
9135
9318
|
},
|
|
9136
9319
|
children: /* @__PURE__ */ jsx(App, { ui })
|
|
9137
9320
|
}));
|
|
@@ -9186,6 +9369,22 @@ async function runApp(opts) {
|
|
|
9186
9369
|
terminal.close();
|
|
9187
9370
|
}
|
|
9188
9371
|
} else if (opts.harbor) workspaceId = harborNamed(harbors, opts.harbor).id;
|
|
9372
|
+
else if (opts.saved && harbors.some((h) => h.id === opts.saved)) workspaceId = opts.saved;
|
|
9373
|
+
else if (harbors.length > 1) {
|
|
9374
|
+
const terminal = terminalAsk();
|
|
9375
|
+
try {
|
|
9376
|
+
const picked = await pickHarbor({
|
|
9377
|
+
ask: terminal.ask,
|
|
9378
|
+
say: (line) => process.stdout.write(`${plain(line)}\n`),
|
|
9379
|
+
harbors,
|
|
9380
|
+
named: (key) => harborNamed(harbors, key)
|
|
9381
|
+
});
|
|
9382
|
+
workspaceId = picked.id;
|
|
9383
|
+
await opts.remember?.(picked);
|
|
9384
|
+
} finally {
|
|
9385
|
+
terminal.close();
|
|
9386
|
+
}
|
|
9387
|
+
}
|
|
9189
9388
|
const { instance } = await startApp({
|
|
9190
9389
|
transport,
|
|
9191
9390
|
account,
|
|
@@ -9193,7 +9392,8 @@ async function runApp(opts) {
|
|
|
9193
9392
|
workspaceId,
|
|
9194
9393
|
splashMs: SPLASH_MS,
|
|
9195
9394
|
theme,
|
|
9196
|
-
firstRun
|
|
9395
|
+
firstRun,
|
|
9396
|
+
remember: opts.remember
|
|
9197
9397
|
}, { alternateScreen: true });
|
|
9198
9398
|
await instance.waitUntilExit();
|
|
9199
9399
|
}
|
package/lib/auth.mjs
CHANGED
|
@@ -67,7 +67,7 @@ export async function listenForCode(state, { timeoutMs = 5 * 60_000 } = {}) {
|
|
|
67
67
|
if (ok) settle.resolve(url.searchParams.get("code"));
|
|
68
68
|
else
|
|
69
69
|
settle.reject(
|
|
70
|
-
new
|
|
70
|
+
new HarborError(
|
|
71
71
|
error === "access_denied"
|
|
72
72
|
? "You declined in the browser."
|
|
73
73
|
: "The sign-in didn't come back as expected.",
|
|
@@ -77,7 +77,7 @@ export async function listenForCode(state, { timeoutMs = 5 * 60_000 } = {}) {
|
|
|
77
77
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
78
78
|
const { port } = server.address();
|
|
79
79
|
const timer = setTimeout(
|
|
80
|
-
() => settle.reject(new
|
|
80
|
+
() => settle.reject(new HarborError("No answer from the browser in 5 minutes. Run harbor login to try again.")),
|
|
81
81
|
timeoutMs,
|
|
82
82
|
);
|
|
83
83
|
const done = code.finally(() => {
|
|
@@ -178,7 +178,10 @@ export async function refresh(creds, { fetcher = fetch, now = Date.now } = {}) {
|
|
|
178
178
|
// Refused: the sign-in was disconnected in Harbor, or renewed by another
|
|
179
179
|
// copy of these credentials (a refresh token is good once).
|
|
180
180
|
if (res.status === 400 || res.status === 401) {
|
|
181
|
-
|
|
181
|
+
const ended = new HarborError("Your sign-in on this machine has ended. Run harbor login.");
|
|
182
|
+
// The full-screen app says this rather than "Can't reach Harbor".
|
|
183
|
+
Object.assign(ended, { signedOut: true });
|
|
184
|
+
throw ended;
|
|
182
185
|
}
|
|
183
186
|
const tokens = await json(res, "Refreshing your sign-in");
|
|
184
187
|
return toCredentials(creds.server, creds.tokenEndpoint, creds.clientId, tokens, now());
|
package/lib/cli.mjs
CHANGED
|
@@ -23,36 +23,39 @@ import { jsonText, plain } from "./plain.mjs";
|
|
|
23
23
|
|
|
24
24
|
export const HELP = `harbor: Harbor from the terminal
|
|
25
25
|
|
|
26
|
-
harbor the full-screen app
|
|
27
|
-
harbor signup [--server URL] new to Harbor: make an account
|
|
26
|
+
harbor the full-screen app, by keyboard
|
|
27
|
+
harbor signup [--server URL] new to Harbor: make an account
|
|
28
28
|
harbor login [--server URL] [--read-only] sign in (opens your browser once)
|
|
29
29
|
harbor logout forget the sign-in on this machine
|
|
30
30
|
harbor harbors the harbors you belong to
|
|
31
|
-
harbor use HARBOR|none the harbor
|
|
32
|
-
harbor theme [auto|light|dark] the app's colours
|
|
31
|
+
harbor use HARBOR|none the harbor to work in
|
|
32
|
+
harbor theme [auto|light|dark] the app's colours
|
|
33
33
|
harbor --version which version this is
|
|
34
34
|
|
|
35
35
|
harbor mine what's on you, late first
|
|
36
|
-
harbor tasks [words] [--project P] [--assignee me|none|NAME]
|
|
37
|
-
|
|
36
|
+
harbor tasks [words] [--project P] [--assignee me|none|NAME]
|
|
37
|
+
[--late] [--status S,S]
|
|
38
|
+
harbor show TASK one task (TASK: a key like RR-42)
|
|
38
39
|
|
|
39
|
-
harbor add "TITLE" --project P [--assignee me|NAME] [--due
|
|
40
|
-
[--priority low|medium|high|urgent] [--status S]
|
|
40
|
+
harbor add "TITLE" --project P [--assignee me|NAME] [--due DATE]
|
|
41
|
+
[--priority low|medium|high|urgent] [--status S]
|
|
42
|
+
[--over-limit]
|
|
41
43
|
harbor start TASK | review TASK | done TASK [--confirm] [--over-limit]
|
|
42
44
|
harbor edit TASK [--title T] [--assignee me|none|NAME] [--due DATE|none]
|
|
43
45
|
[--priority P] [--status S] [--confirm] [--over-limit]
|
|
44
46
|
harbor comment TASK "TEXT"
|
|
45
47
|
harbor checkin "WHAT YOU'RE DOING" [--on-blocker]
|
|
46
48
|
harbor risk "TITLE" --project P|--program P [--impact 1-5] [--probability 1-5]
|
|
47
|
-
[--owner NAME] [--due
|
|
49
|
+
[--owner NAME] [--due DATE]
|
|
48
50
|
harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
49
|
-
[--due
|
|
51
|
+
[--due DATE]
|
|
50
52
|
|
|
51
|
-
Every command takes --json, and --harbor NAME when you're in more than one
|
|
52
|
-
(or set one with harbor use). harbor COMMAND
|
|
53
|
+
Every command takes --json, and --harbor NAME when you're in more than one
|
|
54
|
+
harbor (or set one with harbor use). harbor help COMMAND shows just that one.
|
|
53
55
|
Statuses: backlog, todo, doing, review, done.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
DATE: YYYY-MM-DD, today, tomorrow, or +N for N days from today.
|
|
57
|
+
Changes show in Harbor's History as yours, "via Harbor CLI". These commands
|
|
58
|
+
never delete; the full-screen app can, as the website can, and asks first.
|
|
56
59
|
`;
|
|
57
60
|
|
|
58
61
|
export const VERSION = JSON.parse(
|
|
@@ -125,6 +128,16 @@ const level = (v, name) => {
|
|
|
125
128
|
};
|
|
126
129
|
const nullable = (v) => (v === "none" ? null : v);
|
|
127
130
|
|
|
131
|
+
/** --due as people say it: today, tomorrow, +3, or a date. Anything else goes to Harbor to judge. */
|
|
132
|
+
export function dueDate(v, today = new Date()) {
|
|
133
|
+
if (v === undefined || v === null) return v;
|
|
134
|
+
const words = { today: 0, tomorrow: 1 };
|
|
135
|
+
const days = v in words ? words[v] : /^\+\d{1,3}$/.test(v) ? Number(v.slice(1)) : null;
|
|
136
|
+
if (days === null) return v;
|
|
137
|
+
const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() + days);
|
|
138
|
+
return todayLocal(d);
|
|
139
|
+
}
|
|
140
|
+
|
|
128
141
|
/** Harbor's tool fields, as the CLI's flags. */
|
|
129
142
|
const FLAG = {
|
|
130
143
|
due: "--due",
|
|
@@ -158,7 +171,13 @@ export function hint(message) {
|
|
|
158
171
|
const [path, ...why] = issue.split(": ");
|
|
159
172
|
const field = path.replace(/\.\d+$/, "");
|
|
160
173
|
const flag = FLAG[field] ?? `--${field}`;
|
|
161
|
-
|
|
174
|
+
// zod's "Invalid option: expected one of "low"|"high"" reads as the choices.
|
|
175
|
+
const said = why
|
|
176
|
+
.join(": ")
|
|
177
|
+
.replace(/^Invalid option: expected one of (.*)$/, (_, list) =>
|
|
178
|
+
`pick one of ${list.replace(/"/g, "").split("|").join(", ")}`,
|
|
179
|
+
);
|
|
180
|
+
if (!seen.has(flag)) seen.set(flag, said);
|
|
162
181
|
}
|
|
163
182
|
return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
|
|
164
183
|
}
|
|
@@ -168,6 +187,10 @@ export function hint(message) {
|
|
|
168
187
|
"Say which with --harbor NAME, or set one with harbor use NAME.",
|
|
169
188
|
)
|
|
170
189
|
.replace(/Find it with search_tasks\.$/, "Find it with harbor tasks.")
|
|
190
|
+
.replace(
|
|
191
|
+
/^Nothing to change: pass a field to update\.$/,
|
|
192
|
+
"Nothing to change. Say what with --title, --assignee, --due, --priority or --status.",
|
|
193
|
+
)
|
|
171
194
|
.replace(
|
|
172
195
|
/^This connection was allowed to read only\..*$/s,
|
|
173
196
|
"This sign-in can only read. To make changes, run harbor login again.",
|
|
@@ -233,6 +256,13 @@ export async function run(argv, io = {}) {
|
|
|
233
256
|
const [command, ...rest] = commandFirst(argv);
|
|
234
257
|
|
|
235
258
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
259
|
+
// `harbor help mine`: that command's help, as `harbor mine --help` gives it.
|
|
260
|
+
if (rest[0]) {
|
|
261
|
+
const help = commandHelp(rest[0]);
|
|
262
|
+
if (help === null) throw new HarborError(`No command "${rest[0]}". Run harbor help.`);
|
|
263
|
+
out(help);
|
|
264
|
+
return 0;
|
|
265
|
+
}
|
|
236
266
|
const top = banner(io, env, ({ bold, dim }) => [
|
|
237
267
|
bold("Harbor"),
|
|
238
268
|
"Portfolios, programs and projects,",
|
|
@@ -296,10 +326,26 @@ export async function run(argv, io = {}) {
|
|
|
296
326
|
}
|
|
297
327
|
}
|
|
298
328
|
const { runApp } = await loadApp();
|
|
329
|
+
// The app and the commands share one harbor choice: what harbor use set,
|
|
330
|
+
// or what's picked or switched to in the app. Each write reads the file
|
|
331
|
+
// first, so a renewed token and a new choice don't undo each other.
|
|
332
|
+
const keepHarbor = async (c) => {
|
|
333
|
+
const now = await readCredentials(env);
|
|
334
|
+
return now ? { ...c, harbor: now.harbor ?? null, harborName: now.harborName ?? null } : c;
|
|
335
|
+
};
|
|
299
336
|
await runApp({
|
|
300
|
-
source: tokenSource({
|
|
337
|
+
source: tokenSource({
|
|
338
|
+
creds,
|
|
339
|
+
save: async (c) => writeCredentials(await keepHarbor(c), env),
|
|
340
|
+
fetcher,
|
|
341
|
+
}),
|
|
301
342
|
fetcher,
|
|
302
|
-
harbor: values.harbor
|
|
343
|
+
harbor: values.harbor,
|
|
344
|
+
saved: creds.harbor ?? null,
|
|
345
|
+
remember: async (h) => {
|
|
346
|
+
const now = (await readCredentials(env)) ?? creds;
|
|
347
|
+
await writeCredentials({ ...now, harbor: h.id, harborName: h.name }, env);
|
|
348
|
+
},
|
|
303
349
|
theme: (await readSettings(env)).theme,
|
|
304
350
|
});
|
|
305
351
|
return 0;
|
|
@@ -382,6 +428,10 @@ export async function run(argv, io = {}) {
|
|
|
382
428
|
return 0;
|
|
383
429
|
}
|
|
384
430
|
|
|
431
|
+
// A mistyped command says so, signed in or not.
|
|
432
|
+
if (commandHelp(command) === null) {
|
|
433
|
+
throw new HarborError(`No command "${command}". Run harbor help.`);
|
|
434
|
+
}
|
|
385
435
|
const creds = await readCredentials(env);
|
|
386
436
|
if (!creds)
|
|
387
437
|
throw new HarborError("Not signed in. Run harbor login, or harbor signup if you're new.");
|
|
@@ -419,7 +469,7 @@ export async function run(argv, io = {}) {
|
|
|
419
469
|
out(
|
|
420
470
|
creds.harbor
|
|
421
471
|
? plain(`Commands use ${creds.harborName ?? creds.harbor}.`)
|
|
422
|
-
: "No harbor set
|
|
472
|
+
: "No harbor set. In one harbor, commands use it; in several, set one with harbor use NAME or say --harbor NAME.",
|
|
423
473
|
);
|
|
424
474
|
return 0;
|
|
425
475
|
}
|
|
@@ -518,7 +568,7 @@ export async function run(argv, io = {}) {
|
|
|
518
568
|
project: values.project,
|
|
519
569
|
title: text(positionals, "the task"),
|
|
520
570
|
assignee: values.assignee,
|
|
521
|
-
due: values.due,
|
|
571
|
+
due: dueDate(values.due),
|
|
522
572
|
priority: values.priority,
|
|
523
573
|
status: values.status,
|
|
524
574
|
overLimit: values["over-limit"] || undefined,
|
|
@@ -560,7 +610,7 @@ export async function run(argv, io = {}) {
|
|
|
560
610
|
task: one(positionals, "task id"),
|
|
561
611
|
title: values.title,
|
|
562
612
|
assignee: values.assignee,
|
|
563
|
-
due: values.due === undefined ? undefined : nullable(values.due),
|
|
613
|
+
due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
|
|
564
614
|
priority: values.priority,
|
|
565
615
|
status: values.status,
|
|
566
616
|
confirmed: values.confirm || undefined,
|
|
@@ -616,9 +666,9 @@ export async function run(argv, io = {}) {
|
|
|
616
666
|
owner: values.owner,
|
|
617
667
|
impact: level(values.impact, "impact"),
|
|
618
668
|
probability: level(values.probability, "probability"),
|
|
619
|
-
due: values.due,
|
|
669
|
+
due: dueDate(values.due),
|
|
620
670
|
},
|
|
621
|
-
(r) => `Logged the ${command}
|
|
671
|
+
(r) => `Logged the ${command} “${r.raised.title}”.`,
|
|
622
672
|
);
|
|
623
673
|
}
|
|
624
674
|
default:
|
package/lib/format.mjs
CHANGED
|
@@ -36,7 +36,8 @@ export function myWork(w, opts) {
|
|
|
36
36
|
if (w.raid?.length) {
|
|
37
37
|
out.push("", "Yours in the register:");
|
|
38
38
|
for (const r of w.raid)
|
|
39
|
-
|
|
39
|
+
// No command takes a risk's id, so it isn't shown.
|
|
40
|
+
out.push(` ${r.kind}: ${r.title}${r.due ? ` (review by ${r.due})` : ""}`);
|
|
40
41
|
}
|
|
41
42
|
return out.join("\n");
|
|
42
43
|
}
|
|
@@ -53,7 +54,14 @@ export function taskList(r, opts) {
|
|
|
53
54
|
export function harbors(r) {
|
|
54
55
|
const list = r.harbors ?? [];
|
|
55
56
|
if (!list.length) return "You aren't in any harbor yet. Run harbor to make one.";
|
|
56
|
-
|
|
57
|
+
// The website's words for a role, not the stored ones; names, not ids
|
|
58
|
+
// (harbor use and --harbor take a name).
|
|
59
|
+
const label = (role) =>
|
|
60
|
+
role === "owner" ? "Owner" : role === "viewer" ? "Can view" : role ? "Can edit" : "";
|
|
61
|
+
const width = Math.min(40, Math.max(...list.map((h) => h.name.length)));
|
|
62
|
+
return list
|
|
63
|
+
.map((h) => (h.role ? `${h.name.padEnd(width)} ${label(h.role)}` : h.name))
|
|
64
|
+
.join("\n");
|
|
57
65
|
}
|
|
58
66
|
|
|
59
67
|
export function todayLocal(d = new Date()) {
|