@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.
Files changed (3) hide show
  1. package/dist/app.mjs +177 -57
  2. package/lib/cli.mjs +22 -4
  3. 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, or its free trial has ended with nobody keeping it (keep.ts).
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 Teams incoming-webhook address.", 400);
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 Teams channel
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 Teams incoming-webhook address. It starts https://hooks.slack.com/, https://discord.com/api/webhooks/ or a Teams workflow address.");
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 Teams channel for this program instead of sending mail. Each post links back here."
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 Teams incoming-webhook address",
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("Say what to export: harbor export tasks PROJECT, risks or review.");
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".');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@harborppm/cli",
3
- "version": "0.74.0",
3
+ "version": "0.76.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",