@harborppm/cli 0.8.0 → 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 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 commands mean when you're in several
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 one `harbor use` set too.
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
@@ -3052,7 +3052,7 @@ function inboxOf(data, personId, now = clockNow()) {
3052
3052
  });
3053
3053
  }
3054
3054
  for (const blocker of mine) {
3055
- 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);
3056
3056
  if (waiting.length === 0) continue;
3057
3057
  const project = projectOf(data, blocker);
3058
3058
  const names = [...new Set(waiting.map((t) => {
@@ -3865,24 +3865,21 @@ function HarborSwitch({ harbors, current, width, onPick, onClose }) {
3865
3865
  });
3866
3866
  }
3867
3867
  const HELP = [
3868
- ["1–8", "Today, Inbox, Projects, Programs, Risks, People, Roadmap, History"],
3869
- [":", "anything: a screen, something new, a project or program by name"],
3868
+ ["1–8", "the screens, as the sidebar lists them"],
3869
+ [":", "anything: a screen, something new, a name"],
3870
3870
  ["/", "find a task by key or words"],
3871
3871
  ["H", "switch harbor"],
3872
3872
  ["Tab", "between the sidebar and the screen"],
3873
- ["↑↓ or j k", "move; ←→ or h l across board columns and lists"],
3874
- ["Enter", "open"],
3875
- ["Esc", "back"],
3876
- ["n", "new: a task, or what the screen lists (project, program, risk)"],
3877
- ["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)"],
3878
3877
  ["[ ]", "move a card one column left or right"],
3879
- ["x", "mark done"],
3880
- ["d", "delete (asks first)"],
3881
- ["u", "undo your last change"],
3878
+ ["x / d", "mark done / delete (asks first)"],
3879
+ ["u / r", "undo your last change / retry saving"],
3882
3880
  ["v", "on a project: board, list, budget"],
3883
- ["m / R", "on a program: new milestone; anywhere: log a risk"],
3881
+ ["m / R", "new milestone on a program / log a risk"],
3884
3882
  ["c / a", "on a task: comment / add a checklist step"],
3885
- ["r", "retry saving now"],
3886
3883
  ["q", "quit (saves first)"]
3887
3884
  ];
3888
3885
  /**
@@ -3890,7 +3887,7 @@ const HELP = [
3890
3887
  * the 16-pixel mark, and the box's border and padding. Narrower, the lines
3891
3888
  * would wrap and split the mark.
3892
3889
  */
3893
- const HELP_WITH_MARK = 11 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
3890
+ const HELP_WITH_MARK = 13 + Math.max(...HELP.map(([, what]) => what.length)) + 2 + LOGO_SMALL[0].length + 4;
3894
3891
  function Help({ width, onClose }) {
3895
3892
  useInput((input, key) => {
3896
3893
  if (key.escape || key.return || input === "?" || input === "q") onClose();
@@ -3911,11 +3908,11 @@ function Help({ width, onClose }) {
3911
3908
  }),
3912
3909
  HELP.map(([k, what]) => /* @__PURE__ */ jsxs(Text, { children: [/* @__PURE__ */ jsx(Text, {
3913
3910
  bold: true,
3914
- children: k.padEnd(11)
3911
+ children: k.padEnd(13)
3915
3912
  }), what] }, k)),
3916
3913
  /* @__PURE__ */ jsx(Text, {
3917
3914
  color: theme.muted,
3918
- children: "Everything saves as you go, and shows on the website for everyone."
3915
+ children: "Everything saves as you go and shows on the website."
3919
3916
  })
3920
3917
  ]
3921
3918
  }), width >= HELP_WITH_MARK ? /* @__PURE__ */ jsxs(Box, {
@@ -5921,7 +5918,7 @@ const SAVE_LABEL = {
5921
5918
  offline: ["● offline, will save", "bad"]
5922
5919
  };
5923
5920
  function App({ ui }) {
5924
- const { store, sync, account, harbors, today } = useDeps();
5921
+ const { store, sync, account, harbors, today, remember } = useDeps();
5925
5922
  const { exit } = useApp();
5926
5923
  const { columns, rows } = useWindowSize();
5927
5924
  const harborName = useHarbor((s) => s.harborName);
@@ -6014,7 +6011,10 @@ function App({ ui }) {
6014
6011
  const switchHarbor = (id) => {
6015
6012
  goScreen("today");
6016
6013
  notify("Opening…");
6017
- sync.flush().then(() => sync.open(id)).then((h) => notify(`Opened ${h.name}.`)).catch((e) => notify(`Couldn't open it: ${e.message}`));
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}`));
6018
6018
  };
6019
6019
  const quit = async () => {
6020
6020
  setQuitting(true);
@@ -7247,6 +7247,104 @@ function normalizeRules(rules) {
7247
7247
  };
7248
7248
  }
7249
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
+
7250
7348
  //#endregion
7251
7349
  //#region src/lib/slices/hierarchy.ts
7252
7350
  /** Portfolios, programs and projects: create, edit, custom fields, automations, health overrides, boards and cascading delete. */
@@ -7294,6 +7392,31 @@ function hierarchySlice({ get, write }) {
7294
7392
  get().log(`opened project ${row.name}`, `/projects/${id}`);
7295
7393
  return id;
7296
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
+ },
7297
7420
  updatePortfolio: (id, patch) => {
7298
7421
  write({ portfolios: get().portfolios.map((p) => p.id === id ? {
7299
7422
  ...p,
@@ -9115,6 +9238,26 @@ function addFirstProject(store, name, accountId, today) {
9115
9238
  ownerId: me?.id ?? ""
9116
9239
  }, { programId: program.id }, today);
9117
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
+ }
9118
9261
 
9119
9262
  //#endregion
9120
9263
  //#region src/tui/main.tsx
@@ -9170,7 +9313,8 @@ async function startApp(deps, options = {}) {
9170
9313
  transport,
9171
9314
  account,
9172
9315
  harbors,
9173
- today
9316
+ today,
9317
+ remember: deps.remember
9174
9318
  },
9175
9319
  children: /* @__PURE__ */ jsx(App, { ui })
9176
9320
  }));
@@ -9225,6 +9369,22 @@ async function runApp(opts) {
9225
9369
  terminal.close();
9226
9370
  }
9227
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
+ }
9228
9388
  const { instance } = await startApp({
9229
9389
  transport,
9230
9390
  account,
@@ -9232,7 +9392,8 @@ async function runApp(opts) {
9232
9392
  workspaceId,
9233
9393
  splashMs: SPLASH_MS,
9234
9394
  theme,
9235
- firstRun
9395
+ firstRun,
9396
+ remember: opts.remember
9236
9397
  }, { alternateScreen: true });
9237
9398
  await instance.waitUntilExit();
9238
9399
  }
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 which harbor commands use
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
 
@@ -326,10 +326,26 @@ export async function run(argv, io = {}) {
326
326
  }
327
327
  }
328
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
+ };
329
336
  await runApp({
330
- source: tokenSource({ creds, save: (c) => writeCredentials(c, env), fetcher }),
337
+ source: tokenSource({
338
+ creds,
339
+ save: async (c) => writeCredentials(await keepHarbor(c), env),
340
+ fetcher,
341
+ }),
331
342
  fetcher,
332
- harbor: values.harbor ?? creds.harbor ?? undefined,
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
+ },
333
349
  theme: (await readSettings(env)).theme,
334
350
  });
335
351
  return 0;
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
- out.push(` ${r.id} ${r.kind}: ${r.title}${r.due ? ` (review by ${r.due})` : ""}`);
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
- return list.map((h) => `${h.id} ${h.name}${h.role ? ` (${h.role})` : ""}`).join("\n");
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()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Harbor from the terminal: a full-screen app with everything, plus commands for scripts and agents.",
5
5
  "keywords": [
6
6
  "harbor",