@harborppm/cli 0.18.1 → 0.19.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/dist/app.mjs +120 -86
- package/lib/cli.mjs +77 -22
- package/lib/format.mjs +5 -2
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -113,17 +113,16 @@ const MINUTE = 6e4;
|
|
|
113
113
|
/**
|
|
114
114
|
* How long ago (or until) `date` is, in the one unit that fits: "5 minutes
|
|
115
115
|
* ago", "in 2 days", "1 year ago". Rounds to the nearest whole unit, and
|
|
116
|
-
* matches date-fns's formatDistanceToNowStrict with addSuffix
|
|
116
|
+
* matches date-fns's formatDistanceToNowStrict with addSuffix, except that
|
|
117
|
+
* anything under a minute either way is "just now", never "0 seconds ago".
|
|
117
118
|
*/
|
|
118
119
|
function timeAgo(date, now = clockNow()) {
|
|
119
120
|
const diff = date.getTime() - now.getTime();
|
|
120
121
|
const minutes = Math.abs(diff) / MINUTE;
|
|
121
122
|
let n;
|
|
122
123
|
let unit;
|
|
123
|
-
if (minutes < 1)
|
|
124
|
-
|
|
125
|
-
unit = "second";
|
|
126
|
-
} else if (minutes < 60) {
|
|
124
|
+
if (minutes < 1) return "just now";
|
|
125
|
+
else if (minutes < 60) {
|
|
127
126
|
n = Math.round(minutes);
|
|
128
127
|
unit = "minute";
|
|
129
128
|
} else if (minutes < 1440) {
|
|
@@ -826,7 +825,7 @@ function scoreBand(score) {
|
|
|
826
825
|
function normalizeRaidItem(r) {
|
|
827
826
|
const kind = r.kind === "issue" || r.kind === "decision" ? r.kind : "risk";
|
|
828
827
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
829
|
-
const impact = kind === "decision" ? null : clampScale(r.impact) ?? (r.severity ? impactFromSeverity(r.severity) :
|
|
828
|
+
const impact = kind === "decision" ? null : clampScale(r.impact) ?? (r.impact === void 0 && r.severity ? impactFromSeverity(r.severity) : null);
|
|
830
829
|
const probability = kind === "risk" ? clampScale(r.probability) : null;
|
|
831
830
|
const allowed = STATUSES[kind];
|
|
832
831
|
const status = r.status && allowed.includes(r.status) ? r.status : allowed[0];
|
|
@@ -964,7 +963,9 @@ function stepsFor(kind, data, ctx, me) {
|
|
|
964
963
|
{
|
|
965
964
|
key: "endDate",
|
|
966
965
|
label: "Ends",
|
|
967
|
-
kind: "date"
|
|
966
|
+
kind: "date",
|
|
967
|
+
optional: true,
|
|
968
|
+
skipSays: "for 60 days from today"
|
|
968
969
|
},
|
|
969
970
|
{
|
|
970
971
|
key: "budget",
|
|
@@ -2333,15 +2334,22 @@ function TextField({ label, initial = "", placeholder, hint, onSubmit, onCancel
|
|
|
2333
2334
|
bold: true,
|
|
2334
2335
|
children: label
|
|
2335
2336
|
}),
|
|
2336
|
-
/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsxs(Text, { children: [value
|
|
2337
|
-
color: theme.muted,
|
|
2338
|
-
children: placeholder ?? ""
|
|
2339
|
-
}), /* @__PURE__ */ jsx(Text, {
|
|
2337
|
+
/* @__PURE__ */ jsx(Box, { children: value ? /* @__PURE__ */ jsxs(Text, { children: [value, /* @__PURE__ */ jsx(Text, {
|
|
2340
2338
|
inverse: true,
|
|
2341
2339
|
children: " "
|
|
2342
|
-
})] })
|
|
2340
|
+
})] }) : /* @__PURE__ */ jsxs(Text, {
|
|
2341
|
+
wrap: "truncate-end",
|
|
2342
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
2343
|
+
inverse: true,
|
|
2344
|
+
children: " "
|
|
2345
|
+
}), /* @__PURE__ */ jsx(Text, {
|
|
2346
|
+
color: theme.muted,
|
|
2347
|
+
children: placeholder ?? ""
|
|
2348
|
+
})]
|
|
2349
|
+
}) }),
|
|
2343
2350
|
/* @__PURE__ */ jsx(Text, {
|
|
2344
2351
|
color: theme.muted,
|
|
2352
|
+
wrap: "truncate-end",
|
|
2345
2353
|
children: hint ?? "Enter to keep · Esc to leave it"
|
|
2346
2354
|
})
|
|
2347
2355
|
]
|
|
@@ -2654,7 +2662,7 @@ function Stepper({ title, steps, width, onDone, onCancel }) {
|
|
|
2654
2662
|
}, step.key) : /* @__PURE__ */ jsx(TextField, {
|
|
2655
2663
|
label,
|
|
2656
2664
|
placeholder: step.kind === "date" ? "YYYY-MM-DD, today, +30" : step.kind === "text" ? step.placeholder : void 0,
|
|
2657
|
-
hint: error ?? (step.optional ?
|
|
2665
|
+
hint: error ?? (step.optional ? `Enter ${step.kind === "date" && step.skipSays ? step.skipSays : "to skip"} · Esc to leave it` : void 0),
|
|
2658
2666
|
onCancel,
|
|
2659
2667
|
onSubmit: (text) => {
|
|
2660
2668
|
const t = text.trim();
|
|
@@ -5204,37 +5212,45 @@ function ProgramView({ programId, active, width, height, go, onBack }) {
|
|
|
5204
5212
|
children: [
|
|
5205
5213
|
/* @__PURE__ */ jsxs(Box, {
|
|
5206
5214
|
justifyContent: "space-between",
|
|
5207
|
-
|
|
5208
|
-
|
|
5209
|
-
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5223
|
-
|
|
5224
|
-
|
|
5225
|
-
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5231
|
-
|
|
5232
|
-
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5215
|
+
columnGap: 2,
|
|
5216
|
+
children: [/* @__PURE__ */ jsx(Box, {
|
|
5217
|
+
flexShrink: 1,
|
|
5218
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
5219
|
+
wrap: "truncate-end",
|
|
5220
|
+
children: [
|
|
5221
|
+
/* @__PURE__ */ jsx(Text, {
|
|
5222
|
+
bold: true,
|
|
5223
|
+
children: program.name
|
|
5224
|
+
}),
|
|
5225
|
+
" ",
|
|
5226
|
+
/* @__PURE__ */ jsx(HealthMark, {
|
|
5227
|
+
health: program.health,
|
|
5228
|
+
label: true
|
|
5229
|
+
}),
|
|
5230
|
+
program.healthOverride ? /* @__PURE__ */ jsxs(Text, {
|
|
5231
|
+
color: theme.muted,
|
|
5232
|
+
children: [
|
|
5233
|
+
" (by hand: ",
|
|
5234
|
+
program.healthNote || "no note",
|
|
5235
|
+
")"
|
|
5236
|
+
]
|
|
5237
|
+
}) : null
|
|
5238
|
+
]
|
|
5239
|
+
})
|
|
5240
|
+
}), /* @__PURE__ */ jsx(Box, {
|
|
5241
|
+
flexShrink: 2,
|
|
5242
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
5243
|
+
color: theme.muted,
|
|
5244
|
+
wrap: "truncate-end",
|
|
5245
|
+
children: [
|
|
5246
|
+
portfolio?.name,
|
|
5247
|
+
lead ? ` · ${lead.name}` : "",
|
|
5248
|
+
" · ",
|
|
5249
|
+
shortDay(program.startDate, today()),
|
|
5250
|
+
"–",
|
|
5251
|
+
shortDay(program.endDate, today())
|
|
5252
|
+
]
|
|
5253
|
+
})
|
|
5238
5254
|
})]
|
|
5239
5255
|
}),
|
|
5240
5256
|
/* @__PURE__ */ jsxs(Text, {
|
|
@@ -5798,37 +5814,47 @@ function ProjectView({ projectId, active, width, height, go, onBack }) {
|
|
|
5798
5814
|
borderColor: on ? theme.accent : theme.border,
|
|
5799
5815
|
paddingX: 1,
|
|
5800
5816
|
children: [
|
|
5801
|
-
/* @__PURE__ */ jsxs(Box, {
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
|
|
5811
|
-
|
|
5812
|
-
|
|
5813
|
-
|
|
5814
|
-
|
|
5815
|
-
|
|
5816
|
-
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
5825
|
-
|
|
5826
|
-
|
|
5827
|
-
|
|
5828
|
-
|
|
5829
|
-
|
|
5830
|
-
|
|
5831
|
-
|
|
5817
|
+
/* @__PURE__ */ jsxs(Box, {
|
|
5818
|
+
height: 1,
|
|
5819
|
+
overflow: "hidden",
|
|
5820
|
+
children: [
|
|
5821
|
+
i === 0 && firstColumn > 0 ? /* @__PURE__ */ jsx(Box, {
|
|
5822
|
+
flexShrink: 0,
|
|
5823
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
5824
|
+
color: theme.muted,
|
|
5825
|
+
children: "‹ "
|
|
5826
|
+
})
|
|
5827
|
+
}) : null,
|
|
5828
|
+
/* @__PURE__ */ jsx(Box, {
|
|
5829
|
+
flexGrow: 1,
|
|
5830
|
+
flexShrink: 1,
|
|
5831
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
5832
|
+
bold: true,
|
|
5833
|
+
color: on ? theme.accent : void 0,
|
|
5834
|
+
wrap: "truncate-end",
|
|
5835
|
+
children: [
|
|
5836
|
+
c.label.toUpperCase(),
|
|
5837
|
+
" ",
|
|
5838
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
5839
|
+
color: over ? theme.bad : theme.muted,
|
|
5840
|
+
children: [
|
|
5841
|
+
c.tasks.length < c.all ? `${c.tasks.length} of ` : "",
|
|
5842
|
+
c.all,
|
|
5843
|
+
c.limit != null ? `/${c.limit}` : ""
|
|
5844
|
+
]
|
|
5845
|
+
})
|
|
5846
|
+
]
|
|
5847
|
+
})
|
|
5848
|
+
}),
|
|
5849
|
+
i === shownColumns.length - 1 && ci < columns.length - 1 ? /* @__PURE__ */ jsx(Box, {
|
|
5850
|
+
flexShrink: 0,
|
|
5851
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
5852
|
+
color: theme.muted,
|
|
5853
|
+
children: " ›"
|
|
5854
|
+
})
|
|
5855
|
+
}) : null
|
|
5856
|
+
]
|
|
5857
|
+
}),
|
|
5832
5858
|
c.tasks.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
5833
5859
|
color: theme.muted,
|
|
5834
5860
|
children: "—"
|
|
@@ -6743,6 +6769,7 @@ function TodayView({ active, width, height, go }) {
|
|
|
6743
6769
|
else if (key.pageUp) setAt((i) => step(i, -10, mine.length));
|
|
6744
6770
|
else if (key.return && selected) go.openTask(selected.id);
|
|
6745
6771
|
else if (input === "x" && selected) moveTask(store, go, selected, "done");
|
|
6772
|
+
else if (input === "n" && projects.length === 0) go.create("project");
|
|
6746
6773
|
else if (input === "n") go.newTask(selected?.projectId ?? null);
|
|
6747
6774
|
else if (input === "f" && selected) {
|
|
6748
6775
|
if (store.getState().toggleFocus(selected.id) === "full") go.notify(`Today's focus holds ${3}. f on one of them takes it off first.`);
|
|
@@ -6800,7 +6827,7 @@ function TodayView({ active, width, height, go }) {
|
|
|
6800
6827
|
}),
|
|
6801
6828
|
mine.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
6802
6829
|
color: theme.muted,
|
|
6803
|
-
children: "Nothing open is yours. n adds a task."
|
|
6830
|
+
children: projects.length ? "Nothing open is yours. n adds a task." : "Nothing open is yours. n starts your first project."
|
|
6804
6831
|
}) : view.items.map((t, i) => /* @__PURE__ */ jsx(TaskLine, {
|
|
6805
6832
|
task: t,
|
|
6806
6833
|
projects,
|
|
@@ -9090,15 +9117,17 @@ function riskTitle(answer) {
|
|
|
9090
9117
|
title = title.replace(/\.$/, "");
|
|
9091
9118
|
return title;
|
|
9092
9119
|
}
|
|
9093
|
-
/**
|
|
9094
|
-
|
|
9120
|
+
/**
|
|
9121
|
+
* The risk an answer becomes: on its project, open, unrated, with the whole
|
|
9122
|
+
* answer as what could happen. Scoped to the project alone, like a risk
|
|
9123
|
+
* logged on the project page; the program and portfolio roll it up from there.
|
|
9124
|
+
*/
|
|
9125
|
+
function riskFromAnswer(answer, project) {
|
|
9095
9126
|
return {
|
|
9096
9127
|
kind: "risk",
|
|
9097
9128
|
title: riskTitle(answer.body),
|
|
9098
9129
|
description: `${answer.body.trim()}\n\nRaised in the pre-mortem.`,
|
|
9099
9130
|
projectId: project.id,
|
|
9100
|
-
programId: project.programId,
|
|
9101
|
-
portfolioId,
|
|
9102
9131
|
status: "open",
|
|
9103
9132
|
ownerId: null,
|
|
9104
9133
|
dueDate: null
|
|
@@ -9304,8 +9333,7 @@ function milestonesRaidSlice({ set, get, write }) {
|
|
|
9304
9333
|
if (!answer || answer.raidId) return answer?.raidId ?? null;
|
|
9305
9334
|
const project = get().projects.find((p) => p.id === answer.projectId);
|
|
9306
9335
|
if (!project) return null;
|
|
9307
|
-
const
|
|
9308
|
-
const raidId = get().addRaidItem(riskFromAnswer(answer, project, program?.portfolioId ?? null));
|
|
9336
|
+
const raidId = get().addRaidItem(riskFromAnswer(answer, project));
|
|
9309
9337
|
write({ reflections: (get().reflections ?? []).map((r) => r.id === id ? {
|
|
9310
9338
|
...r,
|
|
9311
9339
|
raidId,
|
|
@@ -10328,11 +10356,17 @@ function createUiStore() {
|
|
|
10328
10356
|
|
|
10329
10357
|
//#endregion
|
|
10330
10358
|
//#region src/lib/empty.ts
|
|
10331
|
-
/**
|
|
10332
|
-
|
|
10359
|
+
/**
|
|
10360
|
+
* A name worth offering in the harbor-name field: "Sam's harbor", or "" when
|
|
10361
|
+
* nobody is known yet, so the field stays empty and shows its placeholder.
|
|
10362
|
+
*/
|
|
10363
|
+
function harborNameFor(displayName, email) {
|
|
10333
10364
|
const first = (displayName || email || "").split(/[\s@]/)[0]?.trim();
|
|
10334
|
-
|
|
10335
|
-
|
|
10365
|
+
return first ? `${first}'s harbor` : "";
|
|
10366
|
+
}
|
|
10367
|
+
/** What a new harbor is called until its owner names it: "Sam's harbor", else "My harbor". */
|
|
10368
|
+
function suggestedHarborName(displayName, email) {
|
|
10369
|
+
return harborNameFor(displayName, email) || "My harbor";
|
|
10336
10370
|
}
|
|
10337
10371
|
|
|
10338
10372
|
//#endregion
|
package/lib/cli.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
writeSettings,
|
|
18
18
|
} from "./config.mjs";
|
|
19
19
|
import {
|
|
20
|
+
STATUS,
|
|
20
21
|
harbors,
|
|
21
22
|
myWork,
|
|
22
23
|
premortemText,
|
|
@@ -91,10 +92,11 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
91
92
|
harbor decision "TITLE" --project P|--program P [--owner NAME] [--due DATE]
|
|
92
93
|
harbor risks [words] [--kind risk,issue,decision] [--project P] [--closed]
|
|
93
94
|
the register, worst first
|
|
95
|
+
(--closed adds closed ones)
|
|
94
96
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
95
97
|
a project's pre-mortem: start,
|
|
96
98
|
answer or read it
|
|
97
|
-
harbor voyage PROJECT ["YOUR LINE"]
|
|
99
|
+
harbor voyage PROJECT ["YOUR LINE"] a Done project's voyage log, and
|
|
98
100
|
your line in it
|
|
99
101
|
|
|
100
102
|
Every command takes --json, and --harbor NAME when you're in more than one
|
|
@@ -234,7 +236,9 @@ export function hint(message) {
|
|
|
234
236
|
.replace(
|
|
235
237
|
/^Invalid option: expected one of (.*)$/,
|
|
236
238
|
(_, list) => `pick one of ${list.replace(/"/g, "").split("|").join(", ")}`,
|
|
237
|
-
)
|
|
239
|
+
)
|
|
240
|
+
// The CLI reads today, tomorrow and +N before Harbor sees the date.
|
|
241
|
+
.replace(/^a date as YYYY-MM-DD$/, "a date like 2026-11-30, today, tomorrow or +N days");
|
|
238
242
|
if (!seen.has(flag)) seen.set(flag, said);
|
|
239
243
|
}
|
|
240
244
|
return [...seen].map(([flag, why]) => `${flag}: ${why}`).join("\n");
|
|
@@ -288,13 +292,15 @@ const DETAIL = {
|
|
|
288
292
|
' harbor premortem PROJECT "ANSWER" --start',
|
|
289
293
|
" writes: starts one and adds your answer",
|
|
290
294
|
"",
|
|
291
|
-
" PROJECT is a name or an id. When two projects share a
|
|
295
|
+
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
296
|
+
" name, give the key.",
|
|
292
297
|
],
|
|
293
298
|
voyage: [
|
|
294
299
|
" harbor voyage PROJECT reads a Done project's voyage log",
|
|
295
300
|
' harbor voyage PROJECT "YOUR LINE" writes: adds or changes your line in it',
|
|
296
301
|
"",
|
|
297
|
-
" PROJECT is a name or an id. When two projects share a
|
|
302
|
+
" PROJECT is a name, a key like RR, or an id. When two projects share a",
|
|
303
|
+
" name, give the key.",
|
|
298
304
|
],
|
|
299
305
|
};
|
|
300
306
|
|
|
@@ -307,11 +313,12 @@ export function commandHelp(command) {
|
|
|
307
313
|
const names = words.filter(
|
|
308
314
|
(_, j) => j > 0 && (words[j - 1] === "harbor" || words[j - 1] === "|"),
|
|
309
315
|
);
|
|
310
|
-
|
|
316
|
+
// A command line names one (the footer's "harbor (or set one…" doesn't).
|
|
317
|
+
if (words[0] !== "harbor" || !/^[a-z-]+$/.test(words[1] ?? "") || !names.includes(command))
|
|
318
|
+
continue;
|
|
311
319
|
picked.push(lines[i]);
|
|
312
|
-
// Its options carry on below it, further in.
|
|
313
|
-
while (lines[i + 1]
|
|
314
|
-
picked.push(lines[++i]);
|
|
320
|
+
// Its options and the rest of its description carry on below it, further in.
|
|
321
|
+
while (/^ {6,}\S/.test(lines[i + 1] ?? "")) picked.push(lines[++i]);
|
|
315
322
|
}
|
|
316
323
|
if (DETAIL[command]) picked.splice(0, picked.length, ...DETAIL[command]);
|
|
317
324
|
if (picked.length === 0) return null;
|
|
@@ -321,6 +328,40 @@ export function commandHelp(command) {
|
|
|
321
328
|
return [...picked, ...common].join("\n");
|
|
322
329
|
}
|
|
323
330
|
|
|
331
|
+
/** Every command the help names, for spotting a typo. */
|
|
332
|
+
/** "zsh, bash or fish". */
|
|
333
|
+
const either = (list) => `${list.slice(0, -1).join(", ")} or ${list.at(-1)}`;
|
|
334
|
+
|
|
335
|
+
const COMMANDS = [
|
|
336
|
+
...new Set(
|
|
337
|
+
HELP.split("\n").flatMap((line) =>
|
|
338
|
+
[...line.matchAll(/(?:^\s+harbor|\|) ([a-z][a-z-]+)/g)].map((m) => m[1]),
|
|
339
|
+
),
|
|
340
|
+
),
|
|
341
|
+
];
|
|
342
|
+
|
|
343
|
+
/** How many single-letter changes (or swaps of neighbours) turn a into b. */
|
|
344
|
+
function distance(a, b) {
|
|
345
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
|
|
346
|
+
for (let j = 1; j <= b.length; j++) d[0][j] = j;
|
|
347
|
+
for (let i = 1; i <= a.length; i++)
|
|
348
|
+
for (let j = 1; j <= b.length; j++) {
|
|
349
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
350
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
351
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1])
|
|
352
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
353
|
+
}
|
|
354
|
+
return d[a.length][b.length];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A mistyped command, with the one it was probably meant to be. */
|
|
358
|
+
export function noCommand(name) {
|
|
359
|
+
if (name.startsWith("-")) return `${name} goes after a command, like harbor mine ${name}.`;
|
|
360
|
+
const near = COMMANDS.filter((c) => distance(name, c) <= (c.length > 4 ? 2 : 1));
|
|
361
|
+
const guess = near.length === 1 ? ` Did you mean harbor ${near[0]}?` : "";
|
|
362
|
+
return `No command "${name}".${guess} Run harbor help.`;
|
|
363
|
+
}
|
|
364
|
+
|
|
324
365
|
/** Room for --json and --harbor before the command too: `harbor --json mine`. */
|
|
325
366
|
function commandFirst(argv) {
|
|
326
367
|
const front = [];
|
|
@@ -386,9 +427,9 @@ export async function run(argv, io = {}) {
|
|
|
386
427
|
|
|
387
428
|
if (command === "help" || command === "--help" || command === "-h") {
|
|
388
429
|
// `harbor help mine`: that command's help, as `harbor mine --help` gives it.
|
|
389
|
-
if (rest[0]) {
|
|
430
|
+
if (rest[0] && rest[0] !== "help") {
|
|
390
431
|
const help = commandHelp(rest[0]);
|
|
391
|
-
if (help === null) throw new HarborError(
|
|
432
|
+
if (help === null) throw new HarborError(noCommand(rest[0]));
|
|
392
433
|
out(help);
|
|
393
434
|
return 0;
|
|
394
435
|
}
|
|
@@ -413,7 +454,7 @@ export async function run(argv, io = {}) {
|
|
|
413
454
|
if (command && asksHelp(rest)) {
|
|
414
455
|
const help = commandHelp(command === "app" ? "" : command);
|
|
415
456
|
if (help === null && command !== "app") {
|
|
416
|
-
throw new HarborError(
|
|
457
|
+
throw new HarborError(noCommand(command));
|
|
417
458
|
}
|
|
418
459
|
out(help ?? HELP);
|
|
419
460
|
return 0;
|
|
@@ -515,7 +556,20 @@ export async function run(argv, io = {}) {
|
|
|
515
556
|
await writeCredentials(creds, env);
|
|
516
557
|
const canWrite = creds.scope.split(" ").includes("harbor:write");
|
|
517
558
|
const said = `Signed in to ${server}${canWrite ? "." : ", reading only."}`;
|
|
518
|
-
|
|
559
|
+
// Someone who made their account on the sign-in page has no harbor yet either.
|
|
560
|
+
const fresh =
|
|
561
|
+
signUp ||
|
|
562
|
+
(await createClient({
|
|
563
|
+
creds,
|
|
564
|
+
// A renewed sign-in keeps the harbor set with harbor use.
|
|
565
|
+
save: (c) =>
|
|
566
|
+
writeCredentials({ ...c, harbor: creds.harbor, harborName: creds.harborName }, env),
|
|
567
|
+
fetcher,
|
|
568
|
+
})
|
|
569
|
+
.call("list_harbors", {})
|
|
570
|
+
.then((r) => (r.harbors ?? []).length === 0)
|
|
571
|
+
.catch(() => false));
|
|
572
|
+
const next = fresh ? "Next: run harbor, and it helps you make your first harbor." : null;
|
|
519
573
|
const welcome = banner(io, env, ({ bold, dim }) => [
|
|
520
574
|
bold("Welcome aboard."),
|
|
521
575
|
said,
|
|
@@ -565,7 +619,7 @@ export async function run(argv, io = {}) {
|
|
|
565
619
|
none(positionals);
|
|
566
620
|
if (values.init !== undefined) {
|
|
567
621
|
const snippet = initSnippet(values.init);
|
|
568
|
-
if (!snippet) throw new HarborError(`Pick ${SHELLS
|
|
622
|
+
if (!snippet) throw new HarborError(`Pick ${either(SHELLS)} (not "${values.init}").`);
|
|
569
623
|
out(
|
|
570
624
|
// Comments, so the whole of it can be appended straight to the file.
|
|
571
625
|
`${snippet}\n# Shows what's due today and late, from the prompt after next;\n# nothing when you're clear. harbor prompt --refresh checks now.`,
|
|
@@ -574,7 +628,7 @@ export async function run(argv, io = {}) {
|
|
|
574
628
|
}
|
|
575
629
|
const shell = values.shell;
|
|
576
630
|
if (shell !== undefined && !SHELLS.includes(shell))
|
|
577
|
-
throw new HarborError(`--shell is ${SHELLS
|
|
631
|
+
throw new HarborError(`--shell is ${either(SHELLS)} (not "${shell}").`);
|
|
578
632
|
const creds = await readCredentials(env);
|
|
579
633
|
const harbor = values.harbor ?? creds?.harbor ?? null;
|
|
580
634
|
const today = todayLocal(io.now ? new Date(io.now()) : new Date());
|
|
@@ -615,7 +669,7 @@ export async function run(argv, io = {}) {
|
|
|
615
669
|
|
|
616
670
|
// A mistyped command says so, signed in or not.
|
|
617
671
|
if (commandHelp(command) === null) {
|
|
618
|
-
throw new HarborError(
|
|
672
|
+
throw new HarborError(noCommand(command));
|
|
619
673
|
}
|
|
620
674
|
const creds = await readCredentials(env);
|
|
621
675
|
if (!creds)
|
|
@@ -782,13 +836,14 @@ export async function run(argv, io = {}) {
|
|
|
782
836
|
}
|
|
783
837
|
case "show": {
|
|
784
838
|
const { positionals, harbor } = parse(spec());
|
|
785
|
-
return call("get_task", { harbor, task: one(positionals, "task
|
|
839
|
+
return call("get_task", { harbor, task: one(positionals, "task key, like RR-42") }, (t) => {
|
|
786
840
|
const lines = [
|
|
787
841
|
taskLine(
|
|
788
842
|
{ ...t, waitingOn: t.waitingOn.filter((w) => w.status !== "done").length },
|
|
789
843
|
{ ...opts, width: undefined },
|
|
790
844
|
),
|
|
791
845
|
];
|
|
846
|
+
if (t.repeats) lines.push(`Repeats ${REPEAT_WORD[t.repeats] ?? t.repeats}.`);
|
|
792
847
|
if (t.description) lines.push("", t.description);
|
|
793
848
|
if (t.checklist.length)
|
|
794
849
|
lines.push("", ...t.checklist.map((c) => ` [${c.done ? "x" : " "}] ${c.step}`));
|
|
@@ -796,13 +851,13 @@ export async function run(argv, io = {}) {
|
|
|
796
851
|
lines.push(
|
|
797
852
|
"",
|
|
798
853
|
"Waits on:",
|
|
799
|
-
...t.waitingOn.map((w) => ` ${w.key ?? w.id} ${w.title} (${w.status})`),
|
|
854
|
+
...t.waitingOn.map((w) => ` ${w.key ?? w.id} ${w.title} (${STATUS[w.status] ?? w.status})`),
|
|
800
855
|
);
|
|
801
856
|
if (t.waitedOnBy.length)
|
|
802
857
|
lines.push(
|
|
803
858
|
"",
|
|
804
859
|
"Holds up:",
|
|
805
|
-
...t.waitedOnBy.map((w) => ` ${w.key ?? w.id} ${w.title} (${w.status})`),
|
|
860
|
+
...t.waitedOnBy.map((w) => ` ${w.key ?? w.id} ${w.title} (${STATUS[w.status] ?? w.status})`),
|
|
806
861
|
);
|
|
807
862
|
if (t.comments.length)
|
|
808
863
|
lines.push(
|
|
@@ -857,7 +912,7 @@ export async function run(argv, io = {}) {
|
|
|
857
912
|
out(jsonText(r));
|
|
858
913
|
return 0;
|
|
859
914
|
}
|
|
860
|
-
const as = r.role === "viewer" ? "
|
|
915
|
+
const as = r.role === "viewer" ? "someone who can view (not change)" : "crew who can edit";
|
|
861
916
|
out(
|
|
862
917
|
plain(
|
|
863
918
|
r.sent
|
|
@@ -924,7 +979,7 @@ export async function run(argv, io = {}) {
|
|
|
924
979
|
"update_task",
|
|
925
980
|
{
|
|
926
981
|
harbor,
|
|
927
|
-
task: one(positionals, "task
|
|
982
|
+
task: one(positionals, "task key, like RR-42"),
|
|
928
983
|
status,
|
|
929
984
|
confirmed: values.confirm || undefined,
|
|
930
985
|
overLimit: values["over-limit"] || undefined,
|
|
@@ -950,7 +1005,7 @@ export async function run(argv, io = {}) {
|
|
|
950
1005
|
"update_task",
|
|
951
1006
|
{
|
|
952
1007
|
harbor,
|
|
953
|
-
task: one(positionals, "task
|
|
1008
|
+
task: one(positionals, "task key, like RR-42"),
|
|
954
1009
|
title: values.title,
|
|
955
1010
|
assignee: values.assignee,
|
|
956
1011
|
due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
|
|
@@ -983,7 +1038,7 @@ export async function run(argv, io = {}) {
|
|
|
983
1038
|
case "comment": {
|
|
984
1039
|
const { positionals, harbor } = parse(spec());
|
|
985
1040
|
const [task, ...words] = positionals;
|
|
986
|
-
if (!task) throw new HarborError("Give a task
|
|
1041
|
+
if (!task) throw new HarborError("Give a task key, like RR-42, then the comment.");
|
|
987
1042
|
return call(
|
|
988
1043
|
"add_comment",
|
|
989
1044
|
{ harbor, task, body: text(words, "the comment") },
|
package/lib/format.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Plain text for a terminal: one task per line, its key (or id) first so it can be copied. */
|
|
2
2
|
|
|
3
|
-
const STATUS = {
|
|
3
|
+
export const STATUS = {
|
|
4
4
|
backlog: "backlog",
|
|
5
5
|
todo: "to do",
|
|
6
6
|
doing: "doing",
|
|
@@ -55,7 +55,9 @@ export function myWork(w, opts) {
|
|
|
55
55
|
out.push("", "Yours in the register:");
|
|
56
56
|
for (const r of w.raid)
|
|
57
57
|
// No command takes a risk's id, so it isn't shown.
|
|
58
|
-
out.push(
|
|
58
|
+
out.push(
|
|
59
|
+
` ${r.kind}: ${r.title}${r.due ? ` (${r.kind === "Decision" ? "decide" : "review"} by ${r.due})` : ""}`,
|
|
60
|
+
);
|
|
59
61
|
}
|
|
60
62
|
return out.join("\n");
|
|
61
63
|
}
|
|
@@ -127,6 +129,7 @@ export function projectList(r, { done = false } = {}) {
|
|
|
127
129
|
.map((p) => {
|
|
128
130
|
const late = p.tasks.late ? `, ${p.tasks.late} late` : "";
|
|
129
131
|
const bits = [
|
|
132
|
+
p.key,
|
|
130
133
|
p.program,
|
|
131
134
|
health(p.health),
|
|
132
135
|
`${p.tasks.total - p.tasks.done} open${late}`,
|