@harborppm/cli 0.74.0 → 0.76.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 +177 -57
- package/lib/cli.mjs +22 -4
- package/package.json +1 -1
package/dist/app.mjs
CHANGED
|
@@ -18722,6 +18722,146 @@ function toCsv(headers, rows) {
|
|
|
18722
18722
|
return [headers, ...rows].map((r) => r.map(cell).join(",")).join("\r\n");
|
|
18723
18723
|
}
|
|
18724
18724
|
|
|
18725
|
+
// src/lib/payload.ts
|
|
18726
|
+
/**
|
|
18727
|
+
* The document fields of a store state, by reference, with stable empties for
|
|
18728
|
+
* anything missing. Cheap enough to run on every render; feeds `useHarborData`.
|
|
18729
|
+
*/
|
|
18730
|
+
function pickData(s) {
|
|
18731
|
+
return {
|
|
18732
|
+
people: s.people ?? NONE,
|
|
18733
|
+
portfolios: s.portfolios ?? NONE,
|
|
18734
|
+
programs: s.programs ?? NONE,
|
|
18735
|
+
projects: s.projects ?? NONE,
|
|
18736
|
+
milestones: s.milestones ?? NONE,
|
|
18737
|
+
raidItems: s.raidItems ?? NONE,
|
|
18738
|
+
constraints: s.constraints ?? NONE,
|
|
18739
|
+
tasks: s.tasks ?? NONE,
|
|
18740
|
+
comments: s.comments ?? NONE,
|
|
18741
|
+
attachments: s.attachments ?? NONE,
|
|
18742
|
+
activity: s.activity ?? NONE,
|
|
18743
|
+
currentUserId: s.currentUserId,
|
|
18744
|
+
boardConfigs: s.boardConfigs ?? NO_CONFIGS,
|
|
18745
|
+
readPingIds: s.readPingIds ?? NONE,
|
|
18746
|
+
ropes: s.ropes ?? NONE,
|
|
18747
|
+
checkIns: s.checkIns ?? NONE,
|
|
18748
|
+
spendEntries: s.spendEntries ?? NONE,
|
|
18749
|
+
reflections: s.reflections ?? NONE,
|
|
18750
|
+
priorityRules: s.priorityRules ?? null,
|
|
18751
|
+
workCalendar: s.workCalendar ?? null
|
|
18752
|
+
};
|
|
18753
|
+
}
|
|
18754
|
+
/** The document as sent to the server: normalized and without inline file data. */
|
|
18755
|
+
function payloadOf(s) {
|
|
18756
|
+
const data = pickData(s);
|
|
18757
|
+
return {
|
|
18758
|
+
...data,
|
|
18759
|
+
people: data.people.map((p) => ({
|
|
18760
|
+
...p,
|
|
18761
|
+
accountId: p.accountId ?? null
|
|
18762
|
+
})),
|
|
18763
|
+
attachments: data.attachments.map((a) => a.url?.startsWith("data:") ? {
|
|
18764
|
+
...a,
|
|
18765
|
+
url: null
|
|
18766
|
+
} : a)
|
|
18767
|
+
};
|
|
18768
|
+
}
|
|
18769
|
+
var NONE, NO_CONFIGS;
|
|
18770
|
+
var init_payload = __esmMin((() => {
|
|
18771
|
+
NONE = [];
|
|
18772
|
+
NO_CONFIGS = {};
|
|
18773
|
+
}));
|
|
18774
|
+
|
|
18775
|
+
// src/lib/harbor-export.ts
|
|
18776
|
+
/**
|
|
18777
|
+
* A copy of a whole harbor for its owner to keep: everything in the document
|
|
18778
|
+
* as one JSON file, and every task in one spreadsheet. The website downloads
|
|
18779
|
+
* them (components/export-dialog.tsx), agents and the terminal get the same
|
|
18780
|
+
* through the export_harbor tool (`harbor export harbor`). Owners only: the
|
|
18781
|
+
* file holds everyone's work, comments and time off.
|
|
18782
|
+
*/
|
|
18783
|
+
init_blockers();
|
|
18784
|
+
init_indexes();
|
|
18785
|
+
init_payload();
|
|
18786
|
+
init_swatches();
|
|
18787
|
+
init_task_keys();
|
|
18788
|
+
/** Bumped when the file's shape changes in a way a reader would notice. */
|
|
18789
|
+
const EXPORT_VERSION = 1;
|
|
18790
|
+
/**
|
|
18791
|
+
* The whole document, as the server keeps it. Left out: who is looking
|
|
18792
|
+
* (`currentUserId`) and which notifications they've read, which belong to the
|
|
18793
|
+
* person exporting, not the harbor. Files uploaded to a task are listed with
|
|
18794
|
+
* their links; images pasted straight into this browser aren't on the server
|
|
18795
|
+
* and come without one.
|
|
18796
|
+
*/
|
|
18797
|
+
function harborExport(data, harbor, now) {
|
|
18798
|
+
const { currentUserId: _me, readPingIds: _read, ...doc } = payloadOf(data);
|
|
18799
|
+
const counts = {};
|
|
18800
|
+
for (const [k, v] of Object.entries(doc)) if (Array.isArray(v)) counts[k] = v.length;
|
|
18801
|
+
return {
|
|
18802
|
+
format: "harbor-export",
|
|
18803
|
+
version: 1,
|
|
18804
|
+
exportedAt: now.toISOString(),
|
|
18805
|
+
harbor,
|
|
18806
|
+
counts,
|
|
18807
|
+
data: doc
|
|
18808
|
+
};
|
|
18809
|
+
}
|
|
18810
|
+
/**
|
|
18811
|
+
* Every task in the harbor, one row each, with its project and program. Key
|
|
18812
|
+
* first, as in a project's own export, so a project's rows can go back in
|
|
18813
|
+
* through Import.
|
|
18814
|
+
*/
|
|
18815
|
+
function allTasksCsv(data) {
|
|
18816
|
+
return tasksSheetCsv(data, data.tasks);
|
|
18817
|
+
}
|
|
18818
|
+
/**
|
|
18819
|
+
* These tasks, one row each, as `allTasksCsv` lays them out: the whole
|
|
18820
|
+
* harbor's, or a program's for a sheet link (src/lib/sheet-feed.ts).
|
|
18821
|
+
*/
|
|
18822
|
+
function tasksSheetCsv(data, rows) {
|
|
18823
|
+
const projects = byId(data.projects);
|
|
18824
|
+
const programs = byId(data.programs);
|
|
18825
|
+
const people = byId(data.people);
|
|
18826
|
+
const tasks = byId(rows);
|
|
18827
|
+
const keyOf = (t) => taskKey(t, projects.get(t.projectId)) ?? t.title;
|
|
18828
|
+
return toCsv([
|
|
18829
|
+
"Key",
|
|
18830
|
+
"Title",
|
|
18831
|
+
"Project",
|
|
18832
|
+
"Program",
|
|
18833
|
+
"Status",
|
|
18834
|
+
"Priority",
|
|
18835
|
+
"Owner",
|
|
18836
|
+
"Due",
|
|
18837
|
+
"Done",
|
|
18838
|
+
"Blocked by",
|
|
18839
|
+
"Created",
|
|
18840
|
+
"Description"
|
|
18841
|
+
], rows.map((t) => {
|
|
18842
|
+
const project = projects.get(t.projectId);
|
|
18843
|
+
const program = project ? programs.get(project.programId) : void 0;
|
|
18844
|
+
return [
|
|
18845
|
+
taskKey(t, project) ?? "",
|
|
18846
|
+
t.title,
|
|
18847
|
+
project?.name ?? "",
|
|
18848
|
+
program?.name ?? "",
|
|
18849
|
+
TASK_STATUS_LABEL[t.status],
|
|
18850
|
+
PRIORITY_LABEL[t.priority],
|
|
18851
|
+
t.assigneeId && people.get(t.assigneeId)?.name || "",
|
|
18852
|
+
t.dueDate ?? "",
|
|
18853
|
+
t.status === "done" ? "yes" : "no",
|
|
18854
|
+
blockerIds(t).map((id) => tasks.get(id)).filter((b) => Boolean(b)).map(keyOf).join("; "),
|
|
18855
|
+
t.createdAt?.slice(0, 10) ?? "",
|
|
18856
|
+
t.description
|
|
18857
|
+
];
|
|
18858
|
+
}));
|
|
18859
|
+
}
|
|
18860
|
+
/** "cedar-and-co-2026-11-01": a file name for the harbor on a day. */
|
|
18861
|
+
function exportName(harborName, now) {
|
|
18862
|
+
return `${harborName.normalize("NFKD").replace(/\p{M}/gu, "").replace(/&/g, " and ").replace(/[^\w]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "harbor"}-${now.toISOString().slice(0, 10)}`;
|
|
18863
|
+
}
|
|
18864
|
+
|
|
18725
18865
|
// src/lib/exports.ts
|
|
18726
18866
|
/**
|
|
18727
18867
|
* The spreadsheets Harbor hands out: a project's tasks, the risk register and
|
|
@@ -20408,6 +20548,32 @@ const HARBOR_TOOLS = [
|
|
|
20408
20548
|
};
|
|
20409
20549
|
}
|
|
20410
20550
|
}),
|
|
20551
|
+
tool({
|
|
20552
|
+
name: "export_harbor",
|
|
20553
|
+
title: "Download the whole harbor",
|
|
20554
|
+
description: "A copy of the whole harbor for its owner to keep: as json, one file with every portfolio, program, project, task, comment, milestone, risk, decision, person and day off; as csv, every task in one spreadsheet with its project and program. Owners only, since it holds everyone's work; anyone can use export_csv for one project or the register." + DATA_NOTE,
|
|
20555
|
+
input: object({
|
|
20556
|
+
harbor: harborArg,
|
|
20557
|
+
as: _enum(["json", "csv"]).default("json")
|
|
20558
|
+
}),
|
|
20559
|
+
run: (ctx, input) => {
|
|
20560
|
+
if (ctx.harbor.role !== "owner") throw new ToolError(`Only an owner can download the whole of ${ctx.harbor.name}. export_csv gives a project's tasks or the register.`);
|
|
20561
|
+
const base = exportName(ctx.harbor.name, ctx.now);
|
|
20562
|
+
if (input.as === "csv") return {
|
|
20563
|
+
filename: `${base}-tasks.csv`,
|
|
20564
|
+
csv: allTasksCsv(ctx.data)
|
|
20565
|
+
};
|
|
20566
|
+
const file = harborExport(ctx.data, {
|
|
20567
|
+
id: ctx.harbor.id,
|
|
20568
|
+
name: ctx.harbor.name
|
|
20569
|
+
}, ctx.now);
|
|
20570
|
+
return {
|
|
20571
|
+
filename: `${base}.json`,
|
|
20572
|
+
counts: file.counts,
|
|
20573
|
+
json: JSON.stringify(file, null, 2)
|
|
20574
|
+
};
|
|
20575
|
+
}
|
|
20576
|
+
}),
|
|
20411
20577
|
tool({
|
|
20412
20578
|
name: "list_risks",
|
|
20413
20579
|
title: "Risks, issues and decisions",
|
|
@@ -21154,56 +21320,6 @@ const LIST_HARBORS = {
|
|
|
21154
21320
|
input: object({})
|
|
21155
21321
|
};
|
|
21156
21322
|
|
|
21157
|
-
// src/lib/payload.ts
|
|
21158
|
-
/**
|
|
21159
|
-
* The document fields of a store state, by reference, with stable empties for
|
|
21160
|
-
* anything missing. Cheap enough to run on every render; feeds `useHarborData`.
|
|
21161
|
-
*/
|
|
21162
|
-
function pickData(s) {
|
|
21163
|
-
return {
|
|
21164
|
-
people: s.people ?? NONE,
|
|
21165
|
-
portfolios: s.portfolios ?? NONE,
|
|
21166
|
-
programs: s.programs ?? NONE,
|
|
21167
|
-
projects: s.projects ?? NONE,
|
|
21168
|
-
milestones: s.milestones ?? NONE,
|
|
21169
|
-
raidItems: s.raidItems ?? NONE,
|
|
21170
|
-
constraints: s.constraints ?? NONE,
|
|
21171
|
-
tasks: s.tasks ?? NONE,
|
|
21172
|
-
comments: s.comments ?? NONE,
|
|
21173
|
-
attachments: s.attachments ?? NONE,
|
|
21174
|
-
activity: s.activity ?? NONE,
|
|
21175
|
-
currentUserId: s.currentUserId,
|
|
21176
|
-
boardConfigs: s.boardConfigs ?? NO_CONFIGS,
|
|
21177
|
-
readPingIds: s.readPingIds ?? NONE,
|
|
21178
|
-
ropes: s.ropes ?? NONE,
|
|
21179
|
-
checkIns: s.checkIns ?? NONE,
|
|
21180
|
-
spendEntries: s.spendEntries ?? NONE,
|
|
21181
|
-
reflections: s.reflections ?? NONE,
|
|
21182
|
-
priorityRules: s.priorityRules ?? null,
|
|
21183
|
-
workCalendar: s.workCalendar ?? null
|
|
21184
|
-
};
|
|
21185
|
-
}
|
|
21186
|
-
/** The document as sent to the server: normalized and without inline file data. */
|
|
21187
|
-
function payloadOf(s) {
|
|
21188
|
-
const data = pickData(s);
|
|
21189
|
-
return {
|
|
21190
|
-
...data,
|
|
21191
|
-
people: data.people.map((p) => ({
|
|
21192
|
-
...p,
|
|
21193
|
-
accountId: p.accountId ?? null
|
|
21194
|
-
})),
|
|
21195
|
-
attachments: data.attachments.map((a) => a.url?.startsWith("data:") ? {
|
|
21196
|
-
...a,
|
|
21197
|
-
url: null
|
|
21198
|
-
} : a)
|
|
21199
|
-
};
|
|
21200
|
-
}
|
|
21201
|
-
var NONE, NO_CONFIGS;
|
|
21202
|
-
var init_payload = __esmMin((() => {
|
|
21203
|
-
NONE = [];
|
|
21204
|
-
NO_CONFIGS = {};
|
|
21205
|
-
}));
|
|
21206
|
-
|
|
21207
21323
|
// src/lib/view-only.ts
|
|
21208
21324
|
function isDocumentKey(key, state) {
|
|
21209
21325
|
documentKeys ??= new Set(Object.keys(pickData(state)));
|
|
@@ -21231,10 +21347,11 @@ function viewerUpdate(state, partial) {
|
|
|
21231
21347
|
}
|
|
21232
21348
|
/**
|
|
21233
21349
|
* Whether this saved harbor can't be changed from here: the person is a
|
|
21234
|
-
* viewer,
|
|
21350
|
+
* viewer, its free trial has ended with nobody keeping it (keep.ts), or it
|
|
21351
|
+
* was opened offline from this device's copy (`offlineCopy`).
|
|
21235
21352
|
*/
|
|
21236
21353
|
function readOnlyHarbor(s) {
|
|
21237
|
-
return s.cloud && (s.myRole === "viewer" || Boolean(s.keepPaused));
|
|
21354
|
+
return s.cloud && (s.myRole === "viewer" || Boolean(s.keepPaused) || Boolean(s.offlineCopy));
|
|
21238
21355
|
}
|
|
21239
21356
|
var VIEWER_LOCAL, documentKeys;
|
|
21240
21357
|
var init_view_only = __esmMin((() => {
|
|
@@ -21689,6 +21806,7 @@ function sessionSlice({ set, get, write }) {
|
|
|
21689
21806
|
}),
|
|
21690
21807
|
setWorkspaceVersion: (workspaceVersion) => set({ workspaceVersion }),
|
|
21691
21808
|
setPhase: (phase) => set({ phase }),
|
|
21809
|
+
setOfflineCopy: (offlineCopy) => set({ offlineCopy }),
|
|
21692
21810
|
setMyRole: (myRole) => set({ myRole }),
|
|
21693
21811
|
setHarborName: (harborName) => set({ harborName }),
|
|
21694
21812
|
setKeep: (k) => set({
|
|
@@ -24719,6 +24837,7 @@ function shoreService(raw) {
|
|
|
24719
24837
|
if ((host === "discord.com" || host === "discordapp.com" || host === "ptb.discord.com" || host === "canary.discord.com") && /^\/api\/webhooks\/\d+\/[\w-]+$/.test(path)) return "discord";
|
|
24720
24838
|
if (/^[a-z0-9-]+\.webhook\.office\.com$/.test(host) && path.startsWith("/webhookb2/")) return "teams";
|
|
24721
24839
|
if ((/^[a-z0-9.-]+\.logic\.azure\.com$/.test(host) || /^[a-z0-9.-]+\.environment\.api\.powerplatform\.com$/.test(host)) && path.includes("/workflows/")) return "teams-workflow";
|
|
24840
|
+
if (host === "chat.googleapis.com" && /^\/v1\/spaces\/[\w-]+\/messages$/.test(path) && url.searchParams.get("key") && url.searchParams.get("token")) return "google-chat";
|
|
24722
24841
|
return null;
|
|
24723
24842
|
}
|
|
24724
24843
|
/** "hooks.slack.com/…8f2Q": enough to recognise an address without showing the secret in it. */
|
|
@@ -24737,7 +24856,8 @@ var init_shore = __esmMin((() => {
|
|
|
24737
24856
|
slack: "Slack",
|
|
24738
24857
|
discord: "Discord",
|
|
24739
24858
|
teams: "Teams",
|
|
24740
|
-
"teams-workflow": "Teams"
|
|
24859
|
+
"teams-workflow": "Teams",
|
|
24860
|
+
"google-chat": "Google Chat"
|
|
24741
24861
|
};
|
|
24742
24862
|
SHORE_KINDS = [
|
|
24743
24863
|
{
|
|
@@ -24829,7 +24949,7 @@ function memoryHarbor(start, opts) {
|
|
|
24829
24949
|
}
|
|
24830
24950
|
let row = hooks.get(program.id);
|
|
24831
24951
|
if (ask.url || ask.on?.length || ask.off?.length) {
|
|
24832
|
-
if (ask.url && !shoreService(ask.url)) throw new TransportError("That isn’t a Slack, Discord or
|
|
24952
|
+
if (ask.url && !shoreService(ask.url)) throw new TransportError("That isn’t a Slack, Discord, Teams or Google Chat incoming-webhook address.", 400);
|
|
24833
24953
|
const url = ask.url || row?.url;
|
|
24834
24954
|
if (!url) throw new TransportError("Paste the channel’s address first.", 400);
|
|
24835
24955
|
row = {
|
|
@@ -26446,7 +26566,7 @@ function LifeboatsPanel({ id, width, height, go, active, onClose }) {
|
|
|
26446
26566
|
|
|
26447
26567
|
// src/tui/views/shore.tsx
|
|
26448
26568
|
/**
|
|
26449
|
-
* Ship to shore in the terminal: a program's Slack, Discord or
|
|
26569
|
+
* Ship to shore in the terminal: a program's Slack, Discord, Teams or Google Chat channel
|
|
26450
26570
|
* and which posts go there, as the website's dialog sets it (shore.ts). The
|
|
26451
26571
|
* address is a secret, so once saved only a hint of it shows. Asks /api/app's
|
|
26452
26572
|
* `shore` (the in-memory server keeps a pretend one).
|
|
@@ -26499,7 +26619,7 @@ function ShorePanel({ id, width, height, go, active, onClose }) {
|
|
|
26499
26619
|
if (input === "p") return go.ask("The channel's incoming-webhook address", (text) => {
|
|
26500
26620
|
const url = text.trim();
|
|
26501
26621
|
if (!url) return;
|
|
26502
|
-
if (!shoreService(url)) return go.notify("That isn't a Slack, Discord or
|
|
26622
|
+
if (!shoreService(url)) return go.notify("That isn't a Slack, Discord, Teams or Google Chat incoming-webhook address. It starts https://hooks.slack.com/, https://discord.com/api/webhooks/, https://chat.googleapis.com/ or a Teams workflow address.");
|
|
26503
26623
|
send({ url }, (a) => `Connected to ${a.hook?.service ?? "the channel"}. Turn on what it posts.`);
|
|
26504
26624
|
});
|
|
26505
26625
|
if (!hook) return;
|
|
@@ -26545,7 +26665,7 @@ function ShorePanel({ id, width, height, go, active, onClose }) {
|
|
|
26545
26665
|
/* @__PURE__ */ jsx(Text, {
|
|
26546
26666
|
color: theme.muted,
|
|
26547
26667
|
wrap: "wrap",
|
|
26548
|
-
children: "Harbor posts into one Slack, Discord or
|
|
26668
|
+
children: "Harbor posts into one Slack, Discord, Teams or Google Chat channel for this program instead of sending mail. Each post links back here."
|
|
26549
26669
|
}),
|
|
26550
26670
|
/* @__PURE__ */ jsx(Text, { children: " " }),
|
|
26551
26671
|
failed ? /* @__PURE__ */ jsx(Text, {
|
package/lib/cli.mjs
CHANGED
|
@@ -233,6 +233,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
233
233
|
harbor report PROGRAM a program's status report
|
|
234
234
|
harbor export tasks PROJECT|risks|review [--all]
|
|
235
235
|
a spreadsheet (CSV) to stdout
|
|
236
|
+
harbor export harbor [--csv] the whole harbor as JSON (owners)
|
|
236
237
|
harbor import FILE [--project P] [--portfolio P] [--no-people]
|
|
237
238
|
add tasks from a CSV file
|
|
238
239
|
harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
@@ -768,8 +769,8 @@ const DETAIL = {
|
|
|
768
769
|
shore: [
|
|
769
770
|
" harbor shore PROGRAM reads the program's chat channel and",
|
|
770
771
|
" which posts go there",
|
|
771
|
-
" harbor shore PROGRAM --url URL writes: posts go to this Slack, Discord",
|
|
772
|
-
" or
|
|
772
|
+
" harbor shore PROGRAM --url URL writes: posts go to this Slack, Discord,",
|
|
773
|
+
" Teams or Google Chat webhook address",
|
|
773
774
|
" harbor shore PROGRAM --on KINDS writes: turns posts on; KINDS are",
|
|
774
775
|
" forecast, health and notes, with commas",
|
|
775
776
|
" harbor shore PROGRAM --off KINDS writes: turns them off",
|
|
@@ -870,11 +871,16 @@ const DETAIL = {
|
|
|
870
871
|
" harbor export risks [--all] reads the risk register as CSV",
|
|
871
872
|
" (--all adds closed ones)",
|
|
872
873
|
" harbor export review reads the review as CSV",
|
|
874
|
+
" harbor export harbor reads the whole harbor as JSON:",
|
|
875
|
+
" every project, task, comment, risk,",
|
|
876
|
+
" person and day off (owners only)",
|
|
877
|
+
" --csv every task instead, one row each with its project",
|
|
873
878
|
"",
|
|
874
879
|
" The same spreadsheets the website's Export buttons download. They print;",
|
|
875
880
|
" harbor export tasks RR > tasks.csv saves one. A task export starts with",
|
|
876
881
|
" each task's key, so harbor import matches those tasks by key. With",
|
|
877
882
|
" --json, rows is one object per row, keyed by column.",
|
|
883
|
+
" harbor export harbor > cedar.json keeps a copy of everything.",
|
|
878
884
|
],
|
|
879
885
|
import: [
|
|
880
886
|
" harbor import FILE --project P writes: adds the file's tasks",
|
|
@@ -2821,10 +2827,22 @@ export async function run(argv, io = {}) {
|
|
|
2821
2827
|
return call("status_report", { harbor, program }, (r) => r.markdown);
|
|
2822
2828
|
}
|
|
2823
2829
|
case "export": {
|
|
2824
|
-
const { positionals, values, harbor } = parse(spec(["all"]));
|
|
2830
|
+
const { positionals, values, harbor } = parse(spec(["all", "csv"]));
|
|
2825
2831
|
const [what, ...name] = positionals;
|
|
2832
|
+
if (what === "harbor") {
|
|
2833
|
+
none(name);
|
|
2834
|
+
// The whole harbor, owners only: JSON by default, every task with --csv.
|
|
2835
|
+
return call(
|
|
2836
|
+
"export_harbor",
|
|
2837
|
+
{ harbor, as: values.csv ? "csv" : "json" },
|
|
2838
|
+
(r) => (r.csv ? r.csv.replace(/\r\n/g, "\n") : r.json),
|
|
2839
|
+
(r) => (asJson && r.json ? JSON.parse(r.json) : r),
|
|
2840
|
+
);
|
|
2841
|
+
}
|
|
2826
2842
|
if (!["tasks", "risks", "review"].includes(what))
|
|
2827
|
-
throw new HarborError(
|
|
2843
|
+
throw new HarborError(
|
|
2844
|
+
"Say what to export: harbor export tasks PROJECT, risks, review or harbor.",
|
|
2845
|
+
);
|
|
2828
2846
|
const project = name.join(" ").trim();
|
|
2829
2847
|
if (what === "tasks" && !project)
|
|
2830
2848
|
throw new HarborError('Give the project: harbor export tasks "NAME".');
|