@harborppm/cli 0.8.0 → 0.9.1
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 +2 -2
- package/dist/app.mjs +205 -27
- package/lib/cli.mjs +28 -7
- package/lib/format.mjs +31 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ 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
|
|
@@ -83,7 +83,7 @@ harbor issue "TITLE" --project P|--program P [--impact 1-5] [--owner NAME]
|
|
|
83
83
|
|
|
84
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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Built from src/tui by cli/build.mjs. Edit the source, not this file.
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
3
|
import { Box, Text, render, useApp, useInput, useWindowSize } from "ink";
|
|
4
|
-
import React, { createContext, useContext, useEffect, useMemo, useState } from "react";
|
|
4
|
+
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
5
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
|
|
7
7
|
//#region src/lib/clock.ts
|
|
@@ -2157,15 +2157,23 @@ applyTheme("dark");
|
|
|
2157
2157
|
*/
|
|
2158
2158
|
/** One line of text. Enter keeps it, Esc leaves it as it was. */
|
|
2159
2159
|
function TextField({ label, initial = "", placeholder, hint, onSubmit, onCancel }) {
|
|
2160
|
-
const [value,
|
|
2160
|
+
const [value, setShown] = useState(initial);
|
|
2161
|
+
const typed = useRef(initial);
|
|
2162
|
+
const setValue = (next) => {
|
|
2163
|
+
typed.current = next;
|
|
2164
|
+
setShown(next);
|
|
2165
|
+
};
|
|
2161
2166
|
useInput((input, key) => {
|
|
2162
2167
|
if (key.escape) return onCancel();
|
|
2163
|
-
if (key.return) return onSubmit(
|
|
2164
|
-
if (key.backspace || key.delete) return setValue(
|
|
2168
|
+
if (key.return) return onSubmit(typed.current);
|
|
2169
|
+
if (key.backspace || key.delete) return setValue(typed.current.slice(0, -1));
|
|
2165
2170
|
if (key.ctrl && input === "u") return setValue("");
|
|
2166
2171
|
if (key.ctrl || key.meta || key.tab || key.upArrow || key.downArrow) return;
|
|
2167
2172
|
if (key.leftArrow || key.rightArrow) return;
|
|
2168
|
-
if (input)
|
|
2173
|
+
if (!input) return;
|
|
2174
|
+
const ends = /[\r\n]+$/.test(input);
|
|
2175
|
+
setValue(typed.current + input.replace(/[\r\n]+$/, "").replace(/[\r\n]+/g, " "));
|
|
2176
|
+
if (ends) onSubmit(typed.current);
|
|
2169
2177
|
});
|
|
2170
2178
|
return /* @__PURE__ */ jsxs(Box, {
|
|
2171
2179
|
flexDirection: "column",
|
|
@@ -3052,7 +3060,7 @@ function inboxOf(data, personId, now = clockNow()) {
|
|
|
3052
3060
|
});
|
|
3053
3061
|
}
|
|
3054
3062
|
for (const blocker of mine) {
|
|
3055
|
-
const waiting = (waitersByBlocker(data.tasks).get(blocker.id) ?? []).filter((t) => t.status !== "done" && t.id !== blocker.id);
|
|
3063
|
+
const waiting = (waitersByBlocker(data.tasks).get(blocker.id) ?? []).filter((t) => t.status !== "done" && t.id !== blocker.id && t.assigneeId !== blocker.assigneeId);
|
|
3056
3064
|
if (waiting.length === 0) continue;
|
|
3057
3065
|
const project = projectOf(data, blocker);
|
|
3058
3066
|
const names = [...new Set(waiting.map((t) => {
|
|
@@ -3865,24 +3873,21 @@ function HarborSwitch({ harbors, current, width, onPick, onClose }) {
|
|
|
3865
3873
|
});
|
|
3866
3874
|
}
|
|
3867
3875
|
const HELP = [
|
|
3868
|
-
["1–8", "
|
|
3869
|
-
[":", "anything: a screen, something new, a
|
|
3876
|
+
["1–8", "the screens, as the sidebar lists them"],
|
|
3877
|
+
[":", "anything: a screen, something new, a name"],
|
|
3870
3878
|
["/", "find a task by key or words"],
|
|
3871
3879
|
["H", "switch harbor"],
|
|
3872
3880
|
["Tab", "between the sidebar and the screen"],
|
|
3873
|
-
["↑↓ or j k", "move; ←→ or h l across
|
|
3874
|
-
["Enter", "open"],
|
|
3875
|
-
["
|
|
3876
|
-
["
|
|
3877
|
-
["e", "change what's open: its health, lead, dates, budget"],
|
|
3881
|
+
["↑↓ or j k", "move; ←→ or h l across columns and lists"],
|
|
3882
|
+
["Enter / Esc", "open / back"],
|
|
3883
|
+
["n", "new: a task, or what the screen lists"],
|
|
3884
|
+
["e", "change what's open (health, lead, dates)"],
|
|
3878
3885
|
["[ ]", "move a card one column left or right"],
|
|
3879
|
-
["x", "mark done"],
|
|
3880
|
-
["
|
|
3881
|
-
["u", "undo your last change"],
|
|
3886
|
+
["x / d", "mark done / delete (asks first)"],
|
|
3887
|
+
["u / r", "undo your last change / retry saving"],
|
|
3882
3888
|
["v", "on a project: board, list, budget"],
|
|
3883
|
-
["m / R", "on a program
|
|
3889
|
+
["m / R", "new milestone on a program / log a risk"],
|
|
3884
3890
|
["c / a", "on a task: comment / add a checklist step"],
|
|
3885
|
-
["r", "retry saving now"],
|
|
3886
3891
|
["q", "quit (saves first)"]
|
|
3887
3892
|
];
|
|
3888
3893
|
/**
|
|
@@ -3890,7 +3895,7 @@ const HELP = [
|
|
|
3890
3895
|
* the 16-pixel mark, and the box's border and padding. Narrower, the lines
|
|
3891
3896
|
* would wrap and split the mark.
|
|
3892
3897
|
*/
|
|
3893
|
-
const HELP_WITH_MARK =
|
|
3898
|
+
const HELP_WITH_MARK = 13 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
|
|
3894
3899
|
function Help({ width, onClose }) {
|
|
3895
3900
|
useInput((input, key) => {
|
|
3896
3901
|
if (key.escape || key.return || input === "?" || input === "q") onClose();
|
|
@@ -3911,11 +3916,11 @@ function Help({ width, onClose }) {
|
|
|
3911
3916
|
}),
|
|
3912
3917
|
HELP.map(([k, what]) => /* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsx(Text, {
|
|
3913
3918
|
bold: true,
|
|
3914
|
-
children: k.padEnd(
|
|
3919
|
+
children: k.padEnd(13)
|
|
3915
3920
|
}), what] }, k)),
|
|
3916
3921
|
/* @__PURE__ */ jsx(Text, {
|
|
3917
3922
|
color: theme.muted,
|
|
3918
|
-
children: "Everything saves as you go
|
|
3923
|
+
children: "Everything saves as you go and shows on the website."
|
|
3919
3924
|
})
|
|
3920
3925
|
]
|
|
3921
3926
|
}), width >= HELP_WITH_MARK ? /* @__PURE__ */ jsxs(Box, {
|
|
@@ -5921,7 +5926,7 @@ const SAVE_LABEL = {
|
|
|
5921
5926
|
offline: ["● offline, will save", "bad"]
|
|
5922
5927
|
};
|
|
5923
5928
|
function App({ ui }) {
|
|
5924
|
-
const { store, sync, account, harbors, today } = useDeps();
|
|
5929
|
+
const { store, sync, account, harbors, today, remember } = useDeps();
|
|
5925
5930
|
const { exit } = useApp();
|
|
5926
5931
|
const { columns, rows } = useWindowSize();
|
|
5927
5932
|
const harborName = useHarbor((s) => s.harborName);
|
|
@@ -6014,7 +6019,10 @@ function App({ ui }) {
|
|
|
6014
6019
|
const switchHarbor = (id) => {
|
|
6015
6020
|
goScreen("today");
|
|
6016
6021
|
notify("Opening…");
|
|
6017
|
-
sync.flush().then(() => sync.open(id)).then(
|
|
6022
|
+
sync.flush().then(() => sync.open(id)).then(async (h) => {
|
|
6023
|
+
const saved = remember ? await remember(h).then(() => true, () => false) : false;
|
|
6024
|
+
notify(saved ? `Opened ${h.name}. Commands use it too.` : `Opened ${h.name}.`);
|
|
6025
|
+
}).catch((e) => notify(`Couldn't open it: ${e.message}`));
|
|
6018
6026
|
};
|
|
6019
6027
|
const quit = async () => {
|
|
6020
6028
|
setQuitting(true);
|
|
@@ -7022,6 +7030,13 @@ function viewerUpdate(state, partial) {
|
|
|
7022
7030
|
blocked
|
|
7023
7031
|
};
|
|
7024
7032
|
}
|
|
7033
|
+
/**
|
|
7034
|
+
* Whether this saved harbor can't be changed from here: the person is a
|
|
7035
|
+
* viewer, or its free trial has ended with nobody keeping it (keep.ts).
|
|
7036
|
+
*/
|
|
7037
|
+
function readOnlyHarbor(s) {
|
|
7038
|
+
return s.cloud && (s.myRole === "viewer" || Boolean(s.keepPaused));
|
|
7039
|
+
}
|
|
7025
7040
|
|
|
7026
7041
|
//#endregion
|
|
7027
7042
|
//#region src/lib/demo-seed.ts
|
|
@@ -7117,7 +7132,9 @@ function sessionSlice({ set, get, write }) {
|
|
|
7117
7132
|
setKeep: (k) => set({
|
|
7118
7133
|
kept: k.kept,
|
|
7119
7134
|
keepInterval: k.interval,
|
|
7120
|
-
stripeReady: k.stripe
|
|
7135
|
+
stripeReady: k.stripe,
|
|
7136
|
+
trialEndsAt: k.trialEndsAt ?? null,
|
|
7137
|
+
keepPaused: Boolean(k.paused)
|
|
7121
7138
|
}),
|
|
7122
7139
|
ensureAccountPerson: ({ accountId, name, personId }) => {
|
|
7123
7140
|
const hit = get().people.find((p) => p.accountId === accountId);
|
|
@@ -7189,7 +7206,7 @@ function sessionSlice({ set, get, write }) {
|
|
|
7189
7206
|
set({ selectedTaskId: id });
|
|
7190
7207
|
},
|
|
7191
7208
|
log: (text, href) => {
|
|
7192
|
-
if (
|
|
7209
|
+
if (readOnlyHarbor(get())) return;
|
|
7193
7210
|
set({ activity: [{
|
|
7194
7211
|
id: uid("a"),
|
|
7195
7212
|
personId: get().currentUserId,
|
|
@@ -7247,6 +7264,104 @@ function normalizeRules(rules) {
|
|
|
7247
7264
|
};
|
|
7248
7265
|
}
|
|
7249
7266
|
|
|
7267
|
+
//#endregion
|
|
7268
|
+
//#region src/lib/sample-project.ts
|
|
7269
|
+
/**
|
|
7270
|
+
* A small project a new harbor can add to try things on: a few cards in each
|
|
7271
|
+
* column, one that waits on another, a checklist and a risk. Nothing is late,
|
|
7272
|
+
* so it doesn't turn a fresh harbor red, and every card says what to try.
|
|
7273
|
+
*/
|
|
7274
|
+
const SAMPLE_PROJECT_NAME = "Website refresh (sample)";
|
|
7275
|
+
function sampleProject(opts) {
|
|
7276
|
+
const { ownerId, today, now, makeId } = opts;
|
|
7277
|
+
const day = (n) => todayISO(addDays(parseDate$1(today), n));
|
|
7278
|
+
const end = opts.programEnd && opts.programEnd < day(30) ? opts.programEnd : day(30);
|
|
7279
|
+
const projectId = makeId("pj");
|
|
7280
|
+
const project = {
|
|
7281
|
+
id: projectId,
|
|
7282
|
+
programId: opts.programId,
|
|
7283
|
+
name: SAMPLE_PROJECT_NAME,
|
|
7284
|
+
description: "A project to try things on. Move cards, add tasks, change dates. Delete it from Details when you’re done.",
|
|
7285
|
+
swatch: "sea",
|
|
7286
|
+
ownerId,
|
|
7287
|
+
health: "on-track",
|
|
7288
|
+
startDate: today,
|
|
7289
|
+
endDate: end,
|
|
7290
|
+
budget: 0,
|
|
7291
|
+
spent: 0
|
|
7292
|
+
};
|
|
7293
|
+
const task = (title, status, o = {}) => ({
|
|
7294
|
+
id: makeId("t"),
|
|
7295
|
+
projectId,
|
|
7296
|
+
title,
|
|
7297
|
+
description: o.description ?? "",
|
|
7298
|
+
status,
|
|
7299
|
+
priority: o.priority ?? "medium",
|
|
7300
|
+
assigneeId: ownerId,
|
|
7301
|
+
dueDate: o.due == null ? null : day(o.due),
|
|
7302
|
+
createdAt: now,
|
|
7303
|
+
completedAt: status === "done" ? now : null,
|
|
7304
|
+
checklist: (o.checklist ?? []).map(([t, done]) => ({
|
|
7305
|
+
id: makeId("ck"),
|
|
7306
|
+
title: t,
|
|
7307
|
+
done
|
|
7308
|
+
})),
|
|
7309
|
+
blockedById: o.waitsOn ?? null,
|
|
7310
|
+
blockedByIds: o.waitsOn ? [o.waitsOn] : []
|
|
7311
|
+
});
|
|
7312
|
+
const draft = task("Draft the new home page", "doing", {
|
|
7313
|
+
description: "A sample card. Tick the checklist, leave a comment, or drag it to Review on the board.",
|
|
7314
|
+
due: 3,
|
|
7315
|
+
priority: "high",
|
|
7316
|
+
checklist: [
|
|
7317
|
+
["Headline and one line under it", true],
|
|
7318
|
+
["Three reasons to stay", false],
|
|
7319
|
+
["One clear button", false]
|
|
7320
|
+
]
|
|
7321
|
+
});
|
|
7322
|
+
return {
|
|
7323
|
+
project,
|
|
7324
|
+
tasks: [
|
|
7325
|
+
task("Agree what the refresh is for", "done", { description: "Finished. Move it back if it turns out it wasn’t." }),
|
|
7326
|
+
task("List the pages people actually use", "done"),
|
|
7327
|
+
draft,
|
|
7328
|
+
task("Write the pricing page copy", "review", {
|
|
7329
|
+
description: "Waiting for a second pair of eyes. Move it to Done when it reads well.",
|
|
7330
|
+
due: 5
|
|
7331
|
+
}),
|
|
7332
|
+
task("Pick the photos", "todo", {
|
|
7333
|
+
description: "Hand this one to someone else from the card’s Owner field.",
|
|
7334
|
+
due: 7
|
|
7335
|
+
}),
|
|
7336
|
+
task("Check every page on a phone", "todo", {
|
|
7337
|
+
description: "Waits on the home page draft, so it shows as waiting until that card is done.",
|
|
7338
|
+
due: 10,
|
|
7339
|
+
waitsOn: draft.id
|
|
7340
|
+
}),
|
|
7341
|
+
task("Send old links to the new pages", "backlog", { description: "No date yet. Backlog is for what isn’t planned." }),
|
|
7342
|
+
task("Tell customers what changed", "backlog", { priority: "low" })
|
|
7343
|
+
],
|
|
7344
|
+
raidItems: [{
|
|
7345
|
+
id: makeId("r"),
|
|
7346
|
+
kind: "risk",
|
|
7347
|
+
projectId,
|
|
7348
|
+
title: "Photos may not be cleared in time",
|
|
7349
|
+
description: "A sample risk. If the photos aren’t licensed before launch, the home page ships with placeholders.",
|
|
7350
|
+
severity: "medium",
|
|
7351
|
+
status: "open",
|
|
7352
|
+
ownerId,
|
|
7353
|
+
dueDate: day(7),
|
|
7354
|
+
probability: 3,
|
|
7355
|
+
impact: 3,
|
|
7356
|
+
mitigation: "Shortlist stock photos we already own as a fallback.",
|
|
7357
|
+
createdById: ownerId,
|
|
7358
|
+
createdAt: now,
|
|
7359
|
+
updatedAt: now,
|
|
7360
|
+
updates: []
|
|
7361
|
+
}]
|
|
7362
|
+
};
|
|
7363
|
+
}
|
|
7364
|
+
|
|
7250
7365
|
//#endregion
|
|
7251
7366
|
//#region src/lib/slices/hierarchy.ts
|
|
7252
7367
|
/** Portfolios, programs and projects: create, edit, custom fields, automations, health overrides, boards and cascading delete. */
|
|
@@ -7294,6 +7409,31 @@ function hierarchySlice({ get, write }) {
|
|
|
7294
7409
|
get().log(`opened project ${row.name}`, `/projects/${id}`);
|
|
7295
7410
|
return id;
|
|
7296
7411
|
},
|
|
7412
|
+
addSampleProject: () => {
|
|
7413
|
+
const state = get();
|
|
7414
|
+
const program = state.programs[0];
|
|
7415
|
+
if (!program) return null;
|
|
7416
|
+
const rows = sampleProject({
|
|
7417
|
+
programId: program.id,
|
|
7418
|
+
ownerId: state.currentUserId,
|
|
7419
|
+
today: todayISO(),
|
|
7420
|
+
programEnd: program.endDate,
|
|
7421
|
+
now: stamp(),
|
|
7422
|
+
makeId: uid
|
|
7423
|
+
});
|
|
7424
|
+
const id = rows.project.id;
|
|
7425
|
+
write({
|
|
7426
|
+
projects: [...state.projects, rows.project],
|
|
7427
|
+
tasks: [...state.tasks, ...rows.tasks],
|
|
7428
|
+
raidItems: [...state.raidItems, ...rows.raidItems],
|
|
7429
|
+
boardConfigs: {
|
|
7430
|
+
...state.boardConfigs ?? {},
|
|
7431
|
+
[id]: defaultBoardConfig()
|
|
7432
|
+
}
|
|
7433
|
+
});
|
|
7434
|
+
get().log(`added the sample project`, `/projects/${id}`);
|
|
7435
|
+
return id;
|
|
7436
|
+
},
|
|
7297
7437
|
updatePortfolio: (id, patch) => {
|
|
7298
7438
|
write({ portfolios: get().portfolios.map((p) => p.id === id ? {
|
|
7299
7439
|
...p,
|
|
@@ -9115,6 +9255,26 @@ function addFirstProject(store, name, accountId, today) {
|
|
|
9115
9255
|
ownerId: me?.id ?? ""
|
|
9116
9256
|
}, { programId: program.id }, today);
|
|
9117
9257
|
}
|
|
9258
|
+
/**
|
|
9259
|
+
* Someone in several harbors with none picked: ask which, as the commands
|
|
9260
|
+
* would refuse to guess. A number from the list or a name answers; anything
|
|
9261
|
+
* else asks again.
|
|
9262
|
+
*/
|
|
9263
|
+
async function pickHarbor(deps) {
|
|
9264
|
+
deps.say(`You're in ${deps.harbors.length} harbors. Which one should harbor open?`);
|
|
9265
|
+
deps.harbors.forEach((h, i) => deps.say(` ${i + 1}. ${h.name}`));
|
|
9266
|
+
for (;;) {
|
|
9267
|
+
const answer = (await deps.ask(`Number or name (1-${deps.harbors.length}): `)).trim();
|
|
9268
|
+
if (!answer) continue;
|
|
9269
|
+
const n = Number(answer);
|
|
9270
|
+
if (Number.isInteger(n) && n >= 1 && n <= deps.harbors.length) return deps.harbors[n - 1];
|
|
9271
|
+
try {
|
|
9272
|
+
return deps.named(answer);
|
|
9273
|
+
} catch (error) {
|
|
9274
|
+
deps.say(error.message);
|
|
9275
|
+
}
|
|
9276
|
+
}
|
|
9277
|
+
}
|
|
9118
9278
|
|
|
9119
9279
|
//#endregion
|
|
9120
9280
|
//#region src/tui/main.tsx
|
|
@@ -9170,7 +9330,8 @@ async function startApp(deps, options = {}) {
|
|
|
9170
9330
|
transport,
|
|
9171
9331
|
account,
|
|
9172
9332
|
harbors,
|
|
9173
|
-
today
|
|
9333
|
+
today,
|
|
9334
|
+
remember: deps.remember
|
|
9174
9335
|
},
|
|
9175
9336
|
children: /* @__PURE__ */ jsx(App, { ui })
|
|
9176
9337
|
}));
|
|
@@ -9225,6 +9386,22 @@ async function runApp(opts) {
|
|
|
9225
9386
|
terminal.close();
|
|
9226
9387
|
}
|
|
9227
9388
|
} else if (opts.harbor) workspaceId = harborNamed(harbors, opts.harbor).id;
|
|
9389
|
+
else if (opts.saved && harbors.some((h) => h.id === opts.saved)) workspaceId = opts.saved;
|
|
9390
|
+
else if (harbors.length > 1) {
|
|
9391
|
+
const terminal = terminalAsk();
|
|
9392
|
+
try {
|
|
9393
|
+
const picked = await pickHarbor({
|
|
9394
|
+
ask: terminal.ask,
|
|
9395
|
+
say: (line) => process.stdout.write(`${plain(line)}\n`),
|
|
9396
|
+
harbors,
|
|
9397
|
+
named: (key) => harborNamed(harbors, key)
|
|
9398
|
+
});
|
|
9399
|
+
workspaceId = picked.id;
|
|
9400
|
+
await opts.remember?.(picked);
|
|
9401
|
+
} finally {
|
|
9402
|
+
terminal.close();
|
|
9403
|
+
}
|
|
9404
|
+
}
|
|
9228
9405
|
const { instance } = await startApp({
|
|
9229
9406
|
transport,
|
|
9230
9407
|
account,
|
|
@@ -9232,7 +9409,8 @@ async function runApp(opts) {
|
|
|
9232
9409
|
workspaceId,
|
|
9233
9410
|
splashMs: SPLASH_MS,
|
|
9234
9411
|
theme,
|
|
9235
|
-
firstRun
|
|
9412
|
+
firstRun,
|
|
9413
|
+
remember: opts.remember
|
|
9236
9414
|
}, { alternateScreen: true });
|
|
9237
9415
|
await instance.waitUntilExit();
|
|
9238
9416
|
}
|
package/lib/cli.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
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
|
|
31
|
+
harbor use HARBOR|none the harbor to work in
|
|
32
32
|
harbor theme [auto|light|dark] the app's colours
|
|
33
33
|
harbor --version which version this is
|
|
34
34
|
|
|
@@ -174,8 +174,9 @@ export function hint(message) {
|
|
|
174
174
|
// zod's "Invalid option: expected one of "low"|"high"" reads as the choices.
|
|
175
175
|
const said = why
|
|
176
176
|
.join(": ")
|
|
177
|
-
.replace(
|
|
178
|
-
|
|
177
|
+
.replace(
|
|
178
|
+
/^Invalid option: expected one of (.*)$/,
|
|
179
|
+
(_, list) => `pick one of ${list.replace(/"/g, "").split("|").join(", ")}`,
|
|
179
180
|
);
|
|
180
181
|
if (!seen.has(flag)) seen.set(flag, said);
|
|
181
182
|
}
|
|
@@ -326,10 +327,26 @@ export async function run(argv, io = {}) {
|
|
|
326
327
|
}
|
|
327
328
|
}
|
|
328
329
|
const { runApp } = await loadApp();
|
|
330
|
+
// The app and the commands share one harbor choice: what harbor use set,
|
|
331
|
+
// or what's picked or switched to in the app. Each write reads the file
|
|
332
|
+
// first, so a renewed token and a new choice don't undo each other.
|
|
333
|
+
const keepHarbor = async (c) => {
|
|
334
|
+
const now = await readCredentials(env);
|
|
335
|
+
return now ? { ...c, harbor: now.harbor ?? null, harborName: now.harborName ?? null } : c;
|
|
336
|
+
};
|
|
329
337
|
await runApp({
|
|
330
|
-
source: tokenSource({
|
|
338
|
+
source: tokenSource({
|
|
339
|
+
creds,
|
|
340
|
+
save: async (c) => writeCredentials(await keepHarbor(c), env),
|
|
341
|
+
fetcher,
|
|
342
|
+
}),
|
|
331
343
|
fetcher,
|
|
332
|
-
harbor: values.harbor
|
|
344
|
+
harbor: values.harbor,
|
|
345
|
+
saved: creds.harbor ?? null,
|
|
346
|
+
remember: async (h) => {
|
|
347
|
+
const now = (await readCredentials(env)) ?? creds;
|
|
348
|
+
await writeCredentials({ ...now, harbor: h.id, harborName: h.name }, env);
|
|
349
|
+
},
|
|
333
350
|
theme: (await readSettings(env)).theme,
|
|
334
351
|
});
|
|
335
352
|
return 0;
|
|
@@ -420,7 +437,11 @@ export async function run(argv, io = {}) {
|
|
|
420
437
|
if (!creds)
|
|
421
438
|
throw new HarborError("Not signed in. Run harbor login, or harbor signup if you're new.");
|
|
422
439
|
const client = createClient({ creds, save: (c) => writeCredentials(c, env), fetcher });
|
|
423
|
-
|
|
440
|
+
// A terminal's lines fit its width; piped output stays whole for scripts.
|
|
441
|
+
const opts = {
|
|
442
|
+
today: todayLocal(),
|
|
443
|
+
width: process.stdout.isTTY ? process.stdout.columns : undefined,
|
|
444
|
+
};
|
|
424
445
|
|
|
425
446
|
const call = async (name, args, show) => {
|
|
426
447
|
const result = await client.call(name, args).catch((err) => {
|
|
@@ -514,7 +535,7 @@ export async function run(argv, io = {}) {
|
|
|
514
535
|
const lines = [
|
|
515
536
|
taskLine(
|
|
516
537
|
{ ...t, waitingOn: t.waitingOn.filter((w) => w.status !== "done").length },
|
|
517
|
-
opts,
|
|
538
|
+
{ ...opts, width: undefined },
|
|
518
539
|
),
|
|
519
540
|
];
|
|
520
541
|
if (t.description) lines.push("", t.description);
|
package/lib/format.mjs
CHANGED
|
@@ -8,8 +8,12 @@ const STATUS = {
|
|
|
8
8
|
done: "done",
|
|
9
9
|
};
|
|
10
10
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* `mine` leaves out whose each task is: every one is yours. `width` (the
|
|
13
|
+
* terminal's, when printing to one) shortens a long title so the line fits;
|
|
14
|
+
* piped output keeps every title whole.
|
|
15
|
+
*/
|
|
16
|
+
export function taskLine(t, { today, mine = false, width } = {}) {
|
|
13
17
|
const late = t.due && today && t.due < today && t.status !== "done";
|
|
14
18
|
const bits = [
|
|
15
19
|
STATUS[t.status] ?? t.status,
|
|
@@ -19,7 +23,21 @@ export function taskLine(t, { today, mine = false } = {}) {
|
|
|
19
23
|
t.project ?? null,
|
|
20
24
|
t.waitingOn ? `waits on ${t.waitingOn}` : null,
|
|
21
25
|
].filter(Boolean);
|
|
22
|
-
|
|
26
|
+
const key = `${t.key ?? t.id} `;
|
|
27
|
+
const tail = ` (${bits.join(" · ")})`;
|
|
28
|
+
return key + fitTitle(t.title, width ? width - 1 - key.length - tail.length : Infinity) + tail;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The title cut to `room` characters with an ellipsis, but never below 20. */
|
|
32
|
+
function fitTitle(title, room) {
|
|
33
|
+
const chars = [...title];
|
|
34
|
+
if (chars.length <= room) return title;
|
|
35
|
+
return (
|
|
36
|
+
chars
|
|
37
|
+
.slice(0, Math.max(20, room) - 1)
|
|
38
|
+
.join("")
|
|
39
|
+
.trimEnd() + "…"
|
|
40
|
+
);
|
|
23
41
|
}
|
|
24
42
|
|
|
25
43
|
export function myWork(w, opts) {
|
|
@@ -36,7 +54,8 @@ export function myWork(w, opts) {
|
|
|
36
54
|
if (w.raid?.length) {
|
|
37
55
|
out.push("", "Yours in the register:");
|
|
38
56
|
for (const r of w.raid)
|
|
39
|
-
|
|
57
|
+
// No command takes a risk's id, so it isn't shown.
|
|
58
|
+
out.push(` ${r.kind}: ${r.title}${r.due ? ` (review by ${r.due})` : ""}`);
|
|
40
59
|
}
|
|
41
60
|
return out.join("\n");
|
|
42
61
|
}
|
|
@@ -53,7 +72,14 @@ export function taskList(r, opts) {
|
|
|
53
72
|
export function harbors(r) {
|
|
54
73
|
const list = r.harbors ?? [];
|
|
55
74
|
if (!list.length) return "You aren't in any harbor yet. Run harbor to make one.";
|
|
56
|
-
|
|
75
|
+
// The website's words for a role, not the stored ones; names, not ids
|
|
76
|
+
// (harbor use and --harbor take a name).
|
|
77
|
+
const label = (role) =>
|
|
78
|
+
role === "owner" ? "Owner" : role === "viewer" ? "Can view" : role ? "Can edit" : "";
|
|
79
|
+
const width = Math.min(40, Math.max(...list.map((h) => h.name.length)));
|
|
80
|
+
return list
|
|
81
|
+
.map((h) => (h.role ? `${h.name.padEnd(width)} ${label(h.role)}` : h.name))
|
|
82
|
+
.join("\n");
|
|
57
83
|
}
|
|
58
84
|
|
|
59
85
|
export function todayLocal(d = new Date()) {
|