@harborppm/cli 0.41.0 → 0.42.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 +3 -3
- package/dist/app.mjs +73 -22
- package/lib/cli.mjs +69 -22
- package/lib/format.mjs +82 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -83,7 +83,7 @@ waits: [CS-2]
|
|
|
83
83
|
Old endpoints go away on Nov 20.
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
A new file with any name is a new task: `harbor sync` adds it and renames it to its key. A field changed in a file and not on the website goes to Harbor; one changed in both places keeps the website's and says so. Removing a file doesn't delete the task (mark it done instead). On a fresh clone, run `harbor sync` before editing. `harbor init --hook` adds a git post-commit hook that syncs after each commit.
|
|
86
|
+
A new file with any name is a new task: `harbor sync` adds it and renames it to its key. A field changed in a file and not on the website goes to Harbor; one changed in both places keeps the website's and says so. Removing a file doesn't delete the task (mark it done instead). On a fresh clone, run `harbor sync` before editing. `harbor init --hook` adds a git post-commit hook that syncs after each commit, and `--dry-run` on either says what it would do without writing or sending anything.
|
|
87
87
|
|
|
88
88
|
No account? `harbor init --local` makes a plan that lives only in the folder (the rest of it in `.harbor/plan.json`), and `harbor` in that folder opens it full screen, offline and free. Later, `harbor init PROJECT` moves its tasks into a project on harborppm.com.
|
|
89
89
|
|
|
@@ -161,9 +161,9 @@ harbor premortem PROJECT ["WHAT WENT WRONG"] [--start]
|
|
|
161
161
|
harbor voyage PROJECT ["YOUR LINE"]
|
|
162
162
|
harbor note PROJECT ["NOTE"] [--opens DATE|--milestone NAME|--off-track]
|
|
163
163
|
harbor limit PROJECT [COLUMN N|none]
|
|
164
|
-
harbor init [PROJECT] [--hook]
|
|
164
|
+
harbor init [PROJECT] [--hook] [--dry-run] a project's tasks as files in this repository
|
|
165
165
|
harbor init --local [NAME] a plan in this folder alone, no account
|
|
166
|
-
harbor sync [--quiet]
|
|
166
|
+
harbor sync [--quiet] [--dry-run] the files and the website, in step both ways
|
|
167
167
|
harbor handover PERSON [--to NAME] [--until DATE] [--note TEXT] [--keep ID,ID] [--confirm]
|
|
168
168
|
harbor handback PERSON
|
|
169
169
|
harbor agent [--agent NAME]
|
package/dist/app.mjs
CHANGED
|
@@ -3046,6 +3046,20 @@ function chanceText(chance) {
|
|
|
3046
3046
|
if (chance > .95) return "over 95%";
|
|
3047
3047
|
return `${Math.round(chance * 20) * 5}%`;
|
|
3048
3048
|
}
|
|
3049
|
+
/**
|
|
3050
|
+
* The open task due last, when it's due after the likely finish. The pace
|
|
3051
|
+
* knows nothing of due dates, so a task due later means the plan runs
|
|
3052
|
+
* longer than the pace says.
|
|
3053
|
+
*/
|
|
3054
|
+
function dueAfterFinish(tide, tasks) {
|
|
3055
|
+
if (tide.kind !== "forecast" || !tide.likely) return null;
|
|
3056
|
+
let last = null;
|
|
3057
|
+
for (const t of tasks) {
|
|
3058
|
+
if (t.status === "done" || !t.dueDate) continue;
|
|
3059
|
+
if (!last || t.dueDate > last.dueDate) last = t;
|
|
3060
|
+
}
|
|
3061
|
+
return last && last.dueDate.slice(0, 10) > tide.likely ? last : null;
|
|
3062
|
+
}
|
|
3049
3063
|
|
|
3050
3064
|
//#endregion
|
|
3051
3065
|
//#region src/lib/flow.ts
|
|
@@ -3956,11 +3970,13 @@ function filterTasks(tasks, f) {
|
|
|
3956
3970
|
}
|
|
3957
3971
|
/**
|
|
3958
3972
|
* When the project will likely finish at its crew's recent pace, in one
|
|
3959
|
-
* line under its name,
|
|
3973
|
+
* line under its name, whether that misses the end date, and an open task
|
|
3974
|
+
* due after it, which the pace can't see. Empty when
|
|
3960
3975
|
* there's no forecast (all done, paused, or nothing finished lately).
|
|
3961
3976
|
*/
|
|
3962
3977
|
function tideLine(data, project, today) {
|
|
3963
|
-
const
|
|
3978
|
+
const tasks = tasksOf(data.tasks, project.id);
|
|
3979
|
+
const tide = tideForecast(project, tasks, parseIso(`${today}T12:00:00`));
|
|
3964
3980
|
if (tide.kind === "no-pace") return {
|
|
3965
3981
|
text: "No forecast yet: nothing finished in the last few weeks",
|
|
3966
3982
|
late: false
|
|
@@ -3972,8 +3988,10 @@ function tideLine(data, project, today) {
|
|
|
3972
3988
|
};
|
|
3973
3989
|
const very = tide.veryLikely ? `, very likely by ${shortDay(tide.veryLikely, today)}` : "";
|
|
3974
3990
|
const end = project.endDate >= today ? ` · ${chanceText(tide.chanceByEnd)} by its end, ${shortDay(project.endDate, today)}` : "";
|
|
3991
|
+
const later = dueAfterFinish(tide, tasks);
|
|
3992
|
+
const due = later ? ` · ${taskKey(later, project) ?? `“${later.title}”`} is due later, ${shortDay(later.dueDate.slice(0, 10), today)}` : "";
|
|
3975
3993
|
return {
|
|
3976
|
-
text: `Probably done ${shortDay(tide.likely, today)}${very}${end}${tide.rough ? " · rough" : ""}`,
|
|
3994
|
+
text: `Probably done ${shortDay(tide.likely, today)}${very}${end}${due}${tide.rough ? " · rough" : ""}`,
|
|
3977
3995
|
late: tide.chanceByEnd < .5
|
|
3978
3996
|
};
|
|
3979
3997
|
}
|
|
@@ -15529,12 +15547,33 @@ async function saveOrStop(sync) {
|
|
|
15529
15547
|
await sync.flush();
|
|
15530
15548
|
if (sync.state !== "saved") throw new HarborError(sync.state === "offline" ? "Can't reach Harbor, so nothing was sent and the folder was left as it was." : "Harbor didn't take the changes, so the folder was left as it was. Try harbor sync again.");
|
|
15531
15549
|
}
|
|
15532
|
-
/**
|
|
15550
|
+
/**
|
|
15551
|
+
* A --dry-run's folder and transport: everything is read as usual, and
|
|
15552
|
+
* nothing is written to disk or sent to Harbor.
|
|
15553
|
+
*/
|
|
15554
|
+
function dry(folder, transport) {
|
|
15555
|
+
return {
|
|
15556
|
+
folder: {
|
|
15557
|
+
...folder,
|
|
15558
|
+
write: async () => {},
|
|
15559
|
+
remove: async () => {}
|
|
15560
|
+
},
|
|
15561
|
+
transport: {
|
|
15562
|
+
...transport,
|
|
15563
|
+
save: () => new Promise(() => {})
|
|
15564
|
+
}
|
|
15565
|
+
};
|
|
15566
|
+
}
|
|
15567
|
+
/** `harbor sync` on a linked plan; with dryRun, only what it would do. */
|
|
15533
15568
|
async function syncLinked(opts) {
|
|
15534
|
-
const {
|
|
15569
|
+
const { config, say } = opts;
|
|
15570
|
+
const { folder, transport } = opts.dryRun ? dry(opts.folder, opts.transport) : {
|
|
15571
|
+
folder: opts.folder,
|
|
15572
|
+
transport: opts.transport
|
|
15573
|
+
};
|
|
15535
15574
|
if (!config.harbor) throw new HarborError("This plan isn't linked to a harbor.");
|
|
15536
|
-
const { account } = await
|
|
15537
|
-
const { store, sync } = await openHarbor(
|
|
15575
|
+
const { account } = await transport.harbors();
|
|
15576
|
+
const { store, sync } = await openHarbor(transport, account, config.harbor.id, say);
|
|
15538
15577
|
try {
|
|
15539
15578
|
const projectId = config.project.id;
|
|
15540
15579
|
if (!store.getState().projects.some((p) => p.id === projectId)) throw new HarborError(`${config.project.name} isn't in ${config.harbor.name} any more. Run harbor init PROJECT to link another.`);
|
|
@@ -15544,7 +15583,7 @@ async function syncLinked(opts) {
|
|
|
15544
15583
|
say
|
|
15545
15584
|
});
|
|
15546
15585
|
if (done.report.sent.length + done.report.added.length > 0 && readOnlyHarbor(store.getState())) throw new HarborError(store.getState().keepPaused ? "This harbor's free trial has ended, so the folder's changes weren't sent. Its owner can subscribe at harborppm.com/keep." : "You can only view this harbor, so the folder's changes weren't sent. The folder was left as it was.");
|
|
15547
|
-
await saveOrStop(sync);
|
|
15586
|
+
if (!opts.dryRun) await saveOrStop(sync);
|
|
15548
15587
|
const project = store.getState().projects.find((p) => p.id === projectId);
|
|
15549
15588
|
if (project.key && project.key !== config.project.key) await folder.write(CONFIG_FILE, json({
|
|
15550
15589
|
...config,
|
|
@@ -15579,18 +15618,22 @@ function projectNamed(projects, text, opts = {}) {
|
|
|
15579
15618
|
* A local plan here moves its tasks into the project first.
|
|
15580
15619
|
*/
|
|
15581
15620
|
async function initLinked(opts) {
|
|
15582
|
-
const {
|
|
15621
|
+
const { say } = opts;
|
|
15622
|
+
const { folder, transport } = opts.dryRun ? dry(opts.folder, opts.transport) : {
|
|
15623
|
+
folder: opts.folder,
|
|
15624
|
+
transport: opts.transport
|
|
15625
|
+
};
|
|
15583
15626
|
const existing = await readConfig(folder);
|
|
15584
15627
|
if (existing && !existing.local) throw new HarborError(`This folder already follows ${existing.project.name}. Run harbor sync to bring it up to date.`);
|
|
15585
|
-
const answer = await
|
|
15628
|
+
const answer = await transport.harbors();
|
|
15586
15629
|
if (answer.harbors.length === 0) throw new HarborError("You're not in a harbor yet. Run harbor to make one first.");
|
|
15587
15630
|
const harbor = opts.pickHarbor(answer.harbors);
|
|
15588
|
-
const { store, sync } = await openHarbor(
|
|
15631
|
+
const { store, sync } = await openHarbor(transport, answer.account, harbor.id, say);
|
|
15589
15632
|
try {
|
|
15590
15633
|
const project = projectNamed(store.getState().projects, opts.project, { exactOnly: opts.exactOnly });
|
|
15591
15634
|
let moved = 0;
|
|
15592
15635
|
if (existing?.local) moved = await moveLocalTasks(folder, store, project.id, say);
|
|
15593
|
-
await saveOrStop(sync);
|
|
15636
|
+
if (!opts.dryRun) await saveOrStop(sync);
|
|
15594
15637
|
const settled = store.getState().projects.find((p) => p.id === project.id);
|
|
15595
15638
|
const config = {
|
|
15596
15639
|
version: 1,
|
|
@@ -15853,13 +15896,14 @@ var commands_exports = /* @__PURE__ */ __exportAll({
|
|
|
15853
15896
|
syncHere: () => syncHere,
|
|
15854
15897
|
syncLine: () => syncLine
|
|
15855
15898
|
});
|
|
15856
|
-
/** The one line a sync ends with. */
|
|
15857
|
-
function syncLine(report) {
|
|
15899
|
+
/** The one line a sync ends with; a dry run's says what it would have done. */
|
|
15900
|
+
function syncLine(report, dryRun = false) {
|
|
15858
15901
|
const parts = [
|
|
15859
|
-
report.sent.length ?
|
|
15860
|
-
report.added.length ?
|
|
15861
|
-
report.took.length ?
|
|
15902
|
+
report.sent.length ? `${dryRun ? "would send" : "sent"} ${plural(report.sent.length, "change")} to Harbor` : "",
|
|
15903
|
+
report.added.length ? `${dryRun ? "would add" : "added"} ${plural(report.added.length, "task")}` : "",
|
|
15904
|
+
report.took.length ? `${dryRun ? "would bring in" : "brought in"} ${plural(report.took.length, "change")} from Harbor` : ""
|
|
15862
15905
|
].filter(Boolean);
|
|
15906
|
+
if (dryRun) return `⚓ Dry run: ${parts.length ? parts.join(", ") : "already in step with Harbor"}. Nothing was written or sent.`;
|
|
15863
15907
|
return parts.length ? `⚓ ${parts.join(", ")}.` : "⚓ Already in step with Harbor.";
|
|
15864
15908
|
}
|
|
15865
15909
|
/** The local plan above `cwd`, if that's what's there. */
|
|
@@ -15908,22 +15952,28 @@ async function initHere(opts) {
|
|
|
15908
15952
|
project: opts.project?.trim() || basename(home),
|
|
15909
15953
|
exactOnly: !opts.project?.trim(),
|
|
15910
15954
|
pickHarbor: opts.pickHarbor,
|
|
15911
|
-
say: opts.say
|
|
15955
|
+
say: opts.say,
|
|
15956
|
+
dryRun: opts.dryRun
|
|
15912
15957
|
});
|
|
15913
15958
|
const { project, harbor } = made.config;
|
|
15959
|
+
if (opts.dryRun) {
|
|
15960
|
+
opts.say(made.moved ? `Would move this folder's ${plural(made.moved, "task")} into ${project.name} (${project.key}) in ${harbor.name}.` : `Would make ${shownDir(opts.cwd, dir)} for ${project.name} (${project.key}) in ${harbor.name}, with ${plural(made.tasks, "task")}.`);
|
|
15961
|
+
opts.say("Dry run: nothing was written or sent. Run it again without --dry-run to do it.");
|
|
15962
|
+
return made.config;
|
|
15963
|
+
}
|
|
15914
15964
|
opts.say(made.moved ? `Moved this folder's ${plural(made.moved, "task")} into ${project.name} in ${harbor.name}.` : `Made ${shownDir(opts.cwd, dir)} for ${project.name} (${project.key}) in ${harbor.name}, with ${plural(made.tasks, "task")}. Commit it like any code.`);
|
|
15915
15965
|
opts.say("Edit a file, or add one, then run harbor sync. Changes on the website come back the same way.");
|
|
15916
15966
|
if (opts.hook) opts.say(await addHook(home));
|
|
15917
15967
|
return made.config;
|
|
15918
15968
|
}
|
|
15919
|
-
/** `harbor sync`: the plan above `cwd` and its project, both ways. */
|
|
15969
|
+
/** `harbor sync`: the plan above `cwd` and its project, both ways; with dryRun, what it would do. */
|
|
15920
15970
|
async function syncHere(opts) {
|
|
15921
15971
|
const dir = await findPlan(opts.cwd);
|
|
15922
15972
|
if (!dir) throw new HarborError("There's no plan here. Run harbor init PROJECT in your repository to make one.");
|
|
15923
15973
|
const folder = diskFolder(dir);
|
|
15924
15974
|
const config = await readConfig(folder);
|
|
15925
15975
|
if (config.local) {
|
|
15926
|
-
await loadLocal(folder, opts.say);
|
|
15976
|
+
if (!opts.dryRun) await loadLocal(folder, opts.say);
|
|
15927
15977
|
opts.say("⚓ This plan lives only in this folder, so there's nothing to sync with. harbor init PROJECT keeps it in step with a project on harborppm.com.");
|
|
15928
15978
|
return null;
|
|
15929
15979
|
}
|
|
@@ -15931,10 +15981,11 @@ async function syncHere(opts) {
|
|
|
15931
15981
|
transport: await opts.transport(),
|
|
15932
15982
|
folder,
|
|
15933
15983
|
config,
|
|
15934
|
-
say: opts.say
|
|
15984
|
+
say: opts.say,
|
|
15985
|
+
dryRun: opts.dryRun
|
|
15935
15986
|
});
|
|
15936
15987
|
for (const c of report.clashes) opts.say(c);
|
|
15937
|
-
opts.say(syncLine(report));
|
|
15988
|
+
opts.say(syncLine(report, opts.dryRun));
|
|
15938
15989
|
return report;
|
|
15939
15990
|
}
|
|
15940
15991
|
/** A post-commit hook that syncs after each commit; an existing hook is only added to. */
|
package/lib/cli.mjs
CHANGED
|
@@ -22,8 +22,11 @@ import {
|
|
|
22
22
|
forecastList,
|
|
23
23
|
forecastOne,
|
|
24
24
|
STATUS,
|
|
25
|
+
statusWord,
|
|
26
|
+
exportData,
|
|
25
27
|
harbors,
|
|
26
28
|
whoami,
|
|
29
|
+
whoamiData,
|
|
27
30
|
limitsText,
|
|
28
31
|
hillData,
|
|
29
32
|
hillList,
|
|
@@ -94,13 +97,14 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
94
97
|
a project's public roadmap: read,
|
|
95
98
|
publish or take it down
|
|
96
99
|
|
|
97
|
-
harbor init [PROJECT] [--hook]
|
|
100
|
+
harbor init [PROJECT] [--hook] [--dry-run] keep a project's tasks as files in
|
|
98
101
|
this repository (.harbor/), one
|
|
99
102
|
per task, in step both ways
|
|
100
103
|
harbor init --local [NAME] a plan in this folder alone, no
|
|
101
104
|
account; harbor here opens it
|
|
102
|
-
harbor sync [--quiet]
|
|
105
|
+
harbor sync [--quiet] [--dry-run] send the files' edits to Harbor
|
|
103
106
|
and bring its changes back
|
|
107
|
+
(--dry-run: only say what)
|
|
104
108
|
|
|
105
109
|
harbor program "NAME" [--portfolio P] [--lead me|NAME] [--starts DATE]
|
|
106
110
|
[--ends DATE]
|
|
@@ -153,11 +157,12 @@ export const HELP = `harbor: Harbor from the terminal
|
|
|
153
157
|
the crew, in the order to do them
|
|
154
158
|
harbor agent report TASK "WHAT YOU DID" [--start] [--agent NAME]
|
|
155
159
|
the agent's note; moves the task
|
|
156
|
-
to In review (--start:
|
|
160
|
+
to In review (--start: In play)
|
|
157
161
|
|
|
158
162
|
Every command takes --json, and --harbor NAME when you're in more than one
|
|
159
163
|
harbor (or set one with harbor use). harbor help COMMAND shows just that one.
|
|
160
|
-
Statuses: backlog, todo, doing, review, done
|
|
164
|
+
Statuses: backlog, todo, doing, review, done (the board's words work too:
|
|
165
|
+
"to do", "in play"; --json says doing for In play).
|
|
161
166
|
DATE: YYYY-MM-DD, today, tomorrow, or +N for N days from today.
|
|
162
167
|
Changes show in Harbor's History as yours, "via Harbor CLI". These commands
|
|
163
168
|
never delete; the full-screen app can, as the website can, and asks first.
|
|
@@ -417,10 +422,17 @@ const DETAIL = {
|
|
|
417
422
|
" repository, one file per task in",
|
|
418
423
|
" PROJECT (a key like RR or a name;",
|
|
419
424
|
" the folder's name when left out)",
|
|
425
|
+
" harbor init [PROJECT] --dry-run reads: says which project and how",
|
|
426
|
+
" many tasks, and writes nothing",
|
|
420
427
|
" harbor init --local [NAME] writes: a plan that lives only in",
|
|
421
428
|
" this folder, with no account;",
|
|
422
429
|
" harbor here opens it full screen",
|
|
423
430
|
"",
|
|
431
|
+
" .harbor/ goes at the top of the git repository you're in; outside one, in",
|
|
432
|
+
" this folder. Without PROJECT, only a project called exactly what the",
|
|
433
|
+
" folder is will do: when none is, nothing is made and Harbor lists the",
|
|
434
|
+
" projects there are, with their keys, to name one.",
|
|
435
|
+
"",
|
|
424
436
|
" Each file has title, status, owner, due, priority and waits between --- lines,",
|
|
425
437
|
" and the task's notes below. Edit them, add new ones (any name; Harbor gives",
|
|
426
438
|
" them a key), review them in pull requests, then run harbor sync. --hook adds",
|
|
@@ -430,11 +442,14 @@ const DETAIL = {
|
|
|
430
442
|
sync: [
|
|
431
443
|
" harbor sync [--quiet] writes: sends what the files changed to",
|
|
432
444
|
" Harbor and rewrites the files from it",
|
|
445
|
+
" harbor sync --dry-run reads: says what it would send and",
|
|
446
|
+
" bring in, and changes nothing",
|
|
433
447
|
"",
|
|
434
448
|
" A field you changed and nobody changed on the website goes to Harbor; one",
|
|
435
449
|
" changed in both places keeps the website's, and says so. Removing a file",
|
|
436
450
|
" doesn't delete the task (mark it done instead). On a fresh clone, run",
|
|
437
|
-
" harbor sync before editing. --quiet prints only problems.",
|
|
451
|
+
" harbor sync before editing. --quiet prints only problems. It works from",
|
|
452
|
+
" anywhere inside the folder that holds .harbor/.",
|
|
438
453
|
],
|
|
439
454
|
limit: [
|
|
440
455
|
" harbor limit PROJECT reads its board's column limits",
|
|
@@ -443,7 +458,8 @@ const DETAIL = {
|
|
|
443
458
|
" 1-5, 8, 10 and 15)",
|
|
444
459
|
" harbor limit PROJECT COLUMN none writes: no limit on COLUMN",
|
|
445
460
|
"",
|
|
446
|
-
" COLUMN is a status: doing or review usually, or backlog, todo,
|
|
461
|
+
" COLUMN is a status: doing (In play) or review usually, or backlog, todo,",
|
|
462
|
+
" done.",
|
|
447
463
|
" Only the harbor's owner can change a limit. A move past a limit is",
|
|
448
464
|
" refused until you run it again with --over-limit.",
|
|
449
465
|
],
|
|
@@ -467,12 +483,14 @@ const DETAIL = {
|
|
|
467
483
|
" harbor roadmap PROJECT --publish --dates",
|
|
468
484
|
" writes: also shows the day each thing",
|
|
469
485
|
" shipped (--no-dates: titles only, the",
|
|
470
|
-
" default
|
|
486
|
+
" first time's default; without either,",
|
|
487
|
+
" it stays as it is)",
|
|
471
488
|
" harbor roadmap PROJECT --publish --site",
|
|
472
489
|
" writes: also shows it at",
|
|
473
490
|
" harborppm.com/roadmap, in place of any",
|
|
474
491
|
" other (Harbor's own team only;",
|
|
475
|
-
" --no-site
|
|
492
|
+
" --no-site takes it off; without",
|
|
493
|
+
" either, it stays as it is)",
|
|
476
494
|
" harbor roadmap PROJECT --off writes: takes it down at once; the link",
|
|
477
495
|
" then says it isn't published, and",
|
|
478
496
|
" publishing again makes a new link",
|
|
@@ -509,7 +527,9 @@ const DETAIL = {
|
|
|
509
527
|
" harbor export review reads the review as CSV",
|
|
510
528
|
"",
|
|
511
529
|
" The same spreadsheets the website's Export buttons download. They print;",
|
|
512
|
-
" harbor export tasks RR > tasks.csv saves one.",
|
|
530
|
+
" harbor export tasks RR > tasks.csv saves one. A task export starts with",
|
|
531
|
+
" each task's key, so harbor import matches those tasks by key. With",
|
|
532
|
+
" --json, rows is one object per row, keyed by column.",
|
|
513
533
|
],
|
|
514
534
|
import: [
|
|
515
535
|
" harbor import FILE --project P writes: adds the file's tasks",
|
|
@@ -519,7 +539,8 @@ const DETAIL = {
|
|
|
519
539
|
" FILE is CSV or tab-separated: a spreadsheet, or a Jira, Asana or Linear",
|
|
520
540
|
" export. It needs a column of titles; rows naming a Project column go",
|
|
521
541
|
" there. People it names who aren't in the harbor are added unless you say",
|
|
522
|
-
" --no-people. A task already in its project
|
|
542
|
+
" --no-people. A task already in its project is left alone: by its key",
|
|
543
|
+
" (a Key column, as harbor export writes) or by title.",
|
|
523
544
|
],
|
|
524
545
|
note: [
|
|
525
546
|
" harbor note PROJECT reads its notes for later: who left",
|
|
@@ -564,14 +585,17 @@ const DETAIL = {
|
|
|
564
585
|
agent: [
|
|
565
586
|
" harbor agent reads the tasks people gave the crew's",
|
|
566
587
|
" agent (an AI crew member on People),",
|
|
567
|
-
" in
|
|
588
|
+
" in play first, with their notes",
|
|
568
589
|
' harbor agent report TASK "TEXT" writes: the agent\'s note on the task,',
|
|
569
590
|
" and moves it to In review for a person",
|
|
570
591
|
" to check",
|
|
571
592
|
' harbor agent report TASK "TEXT" --start',
|
|
572
593
|
" writes: the note, and moves it to",
|
|
573
|
-
" In
|
|
594
|
+
" In play",
|
|
574
595
|
"",
|
|
596
|
+
" TASK is a task key, like RR-42 (harbor agent lists them), or an id. It",
|
|
597
|
+
" has to be assigned to an agent: for anyone else's task, report refuses",
|
|
598
|
+
" and nothing changes, so assign it to the agent on the website first.",
|
|
575
599
|
" --agent NAME picks the agent when the crew has more than one. An agent",
|
|
576
600
|
" never marks its own work Done; a person does, on the website or in the",
|
|
577
601
|
" terminal app.",
|
|
@@ -976,7 +1000,7 @@ export async function run(argv, io = {}) {
|
|
|
976
1000
|
if (command === "init" || command === "sync") {
|
|
977
1001
|
const init = command === "init";
|
|
978
1002
|
const { values, positionals } = parseArgs(rest, {
|
|
979
|
-
flags: init ? ["local", "hook", "json"] : ["quiet", "json"],
|
|
1003
|
+
flags: init ? ["local", "hook", "json", "dry-run"] : ["quiet", "json", "dry-run"],
|
|
980
1004
|
options: init ? ["harbor"] : [],
|
|
981
1005
|
});
|
|
982
1006
|
const cwd = io.cwd ?? process.cwd();
|
|
@@ -989,7 +1013,12 @@ export async function run(argv, io = {}) {
|
|
|
989
1013
|
}
|
|
990
1014
|
: (line) => out(plain(line));
|
|
991
1015
|
const app = await loadApp();
|
|
1016
|
+
const dryRun = Boolean(values["dry-run"]);
|
|
992
1017
|
if (init && values.local) {
|
|
1018
|
+
if (dryRun)
|
|
1019
|
+
throw new HarborError(
|
|
1020
|
+
"harbor init --local only writes files in this folder, with no account; leave out --dry-run.",
|
|
1021
|
+
);
|
|
993
1022
|
if (positionals.length > 1)
|
|
994
1023
|
throw new HarborError(`harbor init --local takes one name; put it in quotes.`);
|
|
995
1024
|
const config = await app.initLocalHere({ cwd, name: positionals[0], say });
|
|
@@ -1023,6 +1052,7 @@ export async function run(argv, io = {}) {
|
|
|
1023
1052
|
transport: await transport(),
|
|
1024
1053
|
project: positionals[0],
|
|
1025
1054
|
hook: Boolean(values.hook),
|
|
1055
|
+
dryRun,
|
|
1026
1056
|
say,
|
|
1027
1057
|
pickHarbor: (list) => {
|
|
1028
1058
|
if (values.harbor) return app.harborNamed(list, values.harbor);
|
|
@@ -1034,11 +1064,14 @@ export async function run(argv, io = {}) {
|
|
|
1034
1064
|
);
|
|
1035
1065
|
},
|
|
1036
1066
|
});
|
|
1037
|
-
if (values.json) out(jsonText({ plan: config, notes: lines }));
|
|
1067
|
+
if (values.json) out(jsonText({ plan: config, ...(dryRun ? { dryRun } : {}), notes: lines }));
|
|
1038
1068
|
return 0;
|
|
1039
1069
|
}
|
|
1040
|
-
const report = await app.syncHere({ cwd, transport, say });
|
|
1041
|
-
if (values.json)
|
|
1070
|
+
const report = await app.syncHere({ cwd, transport, say, dryRun });
|
|
1071
|
+
if (values.json)
|
|
1072
|
+
out(
|
|
1073
|
+
jsonText({ ...(report ?? { local: true }), ...(dryRun ? { dryRun } : {}), notes: lines }),
|
|
1074
|
+
);
|
|
1042
1075
|
return 0;
|
|
1043
1076
|
}
|
|
1044
1077
|
|
|
@@ -1087,7 +1120,12 @@ export async function run(argv, io = {}) {
|
|
|
1087
1120
|
}
|
|
1088
1121
|
case "whoami": {
|
|
1089
1122
|
none(parse(spec()).positionals);
|
|
1090
|
-
return call(
|
|
1123
|
+
return call(
|
|
1124
|
+
"list_harbors",
|
|
1125
|
+
{},
|
|
1126
|
+
(r) => whoami(r, creds),
|
|
1127
|
+
(r) => whoamiData(r, creds),
|
|
1128
|
+
);
|
|
1091
1129
|
}
|
|
1092
1130
|
case "use": {
|
|
1093
1131
|
const { positionals } = parse({ flags: ["json"], options: [] });
|
|
@@ -1251,6 +1289,7 @@ export async function run(argv, io = {}) {
|
|
|
1251
1289
|
.split(",")
|
|
1252
1290
|
.map((x) => x.trim())
|
|
1253
1291
|
.filter(Boolean)
|
|
1292
|
+
.map(statusWord)
|
|
1254
1293
|
: undefined,
|
|
1255
1294
|
overdue: values.late || undefined,
|
|
1256
1295
|
};
|
|
@@ -1463,7 +1502,7 @@ export async function run(argv, io = {}) {
|
|
|
1463
1502
|
assignee: values.assignee ?? "me",
|
|
1464
1503
|
due: dueDate(values.due),
|
|
1465
1504
|
priority: values.priority,
|
|
1466
|
-
status: values.status,
|
|
1505
|
+
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
1467
1506
|
overLimit: values["over-limit"] || undefined,
|
|
1468
1507
|
},
|
|
1469
1508
|
(r) => `Added: ${taskLine(r.created, opts)}`,
|
|
@@ -1512,7 +1551,7 @@ export async function run(argv, io = {}) {
|
|
|
1512
1551
|
assignee: values.assignee,
|
|
1513
1552
|
due: values.due === undefined ? undefined : nullable(dueDate(values.due)),
|
|
1514
1553
|
priority: values.priority,
|
|
1515
|
-
status: values.status,
|
|
1554
|
+
status: values.status === undefined ? undefined : statusWord(values.status),
|
|
1516
1555
|
description: values.notes,
|
|
1517
1556
|
repeat: values.repeat,
|
|
1518
1557
|
waitsOn:
|
|
@@ -1630,6 +1669,8 @@ export async function run(argv, io = {}) {
|
|
|
1630
1669
|
"export_csv",
|
|
1631
1670
|
{ harbor, what, project: project || undefined, all: Boolean(values.all) },
|
|
1632
1671
|
(r) => r.csv.replace(/\r\n/g, "\n"),
|
|
1672
|
+
// --json: the rows, each keyed by its column, not one CSV string.
|
|
1673
|
+
(r) => (asJson ? exportData(r) : r),
|
|
1633
1674
|
);
|
|
1634
1675
|
}
|
|
1635
1676
|
case "import": {
|
|
@@ -1709,7 +1750,7 @@ export async function run(argv, io = {}) {
|
|
|
1709
1750
|
if (limit !== null && !(Number.isInteger(limit) && limit >= 1 && limit <= 20)) {
|
|
1710
1751
|
throw new HarborError("A limit is a whole number from 1 to 20, or none.");
|
|
1711
1752
|
}
|
|
1712
|
-
return call("set_column_limit", { harbor, project, column, limit }, (r) =>
|
|
1753
|
+
return call("set_column_limit", { harbor, project, column: statusWord(column), limit }, (r) =>
|
|
1713
1754
|
limitsText(r.set.project, r.limits, r.tasks, r.hiddenColumns),
|
|
1714
1755
|
);
|
|
1715
1756
|
}
|
|
@@ -1724,7 +1765,13 @@ export async function run(argv, io = {}) {
|
|
|
1724
1765
|
if (!note) {
|
|
1725
1766
|
if (values.opens || values.milestone || values["off-track"])
|
|
1726
1767
|
throw new HarborError("Write the note too, in quotes after the project.");
|
|
1727
|
-
|
|
1768
|
+
// --json: the project and its notes, an empty list when there are none.
|
|
1769
|
+
return call(
|
|
1770
|
+
"get_project",
|
|
1771
|
+
{ harbor, project },
|
|
1772
|
+
(r) => notesText(r.project, r.notes),
|
|
1773
|
+
(r) => ({ id: r.id, project: r.name, notes: r.notesForLater ?? [] }),
|
|
1774
|
+
);
|
|
1728
1775
|
}
|
|
1729
1776
|
return call(
|
|
1730
1777
|
"leave_note",
|
|
@@ -1805,7 +1852,7 @@ export async function run(argv, io = {}) {
|
|
|
1805
1852
|
"agent_update",
|
|
1806
1853
|
{ harbor, task, note, status: values.start ? "doing" : "review", agent: values.agent },
|
|
1807
1854
|
(r) =>
|
|
1808
|
-
`${r.updated.key ?? r.updated.title} is ${r.updated.status === "review" ? "in review for a person to check" : "in
|
|
1855
|
+
`${r.updated.key ?? r.updated.title} is ${r.updated.status === "review" ? "in review for a person to check" : "in play"}, with ${r.commentedAs}'s note.`,
|
|
1809
1856
|
);
|
|
1810
1857
|
}
|
|
1811
1858
|
case "handback": {
|
package/lib/format.mjs
CHANGED
|
@@ -1,13 +1,30 @@
|
|
|
1
1
|
/** Plain text for a terminal: one task per line, its key (or id) first so it can be copied. */
|
|
2
2
|
|
|
3
|
+
/** Statuses in the board's words, as the website, the app and an export say them. */
|
|
3
4
|
export const STATUS = {
|
|
4
5
|
backlog: "backlog",
|
|
5
6
|
todo: "to do",
|
|
6
|
-
doing: "
|
|
7
|
+
doing: "in play",
|
|
7
8
|
review: "review",
|
|
8
9
|
done: "done",
|
|
9
10
|
};
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* A status as someone typed it: Harbor's own (doing) or the board's (In play,
|
|
14
|
+
* To do), any case, with a space, a hyphen or neither. Anything else comes
|
|
15
|
+
* back as typed, for Harbor to refuse with the list.
|
|
16
|
+
*/
|
|
17
|
+
export function statusWord(typed) {
|
|
18
|
+
const v = String(typed)
|
|
19
|
+
.trim()
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.replace(/[\s_-]+/g, "");
|
|
22
|
+
return (
|
|
23
|
+
{ inplay: "doing", inprogress: "doing", todo: "todo", inreview: "review" }[v] ??
|
|
24
|
+
(["backlog", "doing", "review", "done"].includes(v) ? v : String(typed).trim())
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
11
28
|
/**
|
|
12
29
|
* `mine` leaves out whose each task is: every one is yours. `width` (the
|
|
13
30
|
* terminal's, when printing to one) shortens a long title so the line fits;
|
|
@@ -191,6 +208,25 @@ export function whoami(r, creds) {
|
|
|
191
208
|
return lines.join("\n");
|
|
192
209
|
}
|
|
193
210
|
|
|
211
|
+
/** whoami --json: the account and its harbors, with where it signed in and the harbor commands use. */
|
|
212
|
+
export function whoamiData(r, creds) {
|
|
213
|
+
const list = r.harbors ?? [];
|
|
214
|
+
const set = creds.harbor ? list.find((h) => h.id === creds.harbor) : null;
|
|
215
|
+
const using = set
|
|
216
|
+
? { id: set.id, name: set.name, from: "harbor use" }
|
|
217
|
+
: creds.harbor
|
|
218
|
+
? { id: creds.harbor, name: creds.harborName ?? null, from: "harbor use", left: true }
|
|
219
|
+
: list.length === 1
|
|
220
|
+
? { id: list[0].id, name: list[0].name, from: "your only harbor" }
|
|
221
|
+
: null;
|
|
222
|
+
return {
|
|
223
|
+
...r,
|
|
224
|
+
server: creds.server,
|
|
225
|
+
readOnly: !creds.scope?.split(" ").includes("harbor:write"),
|
|
226
|
+
harbor: using,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
194
230
|
export function harbors(r) {
|
|
195
231
|
const list = r.harbors ?? [];
|
|
196
232
|
if (!list.length) return "You aren't in any harbor yet. Run harbor to make one.";
|
|
@@ -318,13 +354,17 @@ export function forecastOne(p) {
|
|
|
318
354
|
|
|
319
355
|
/** forecast --json: the finish forecast itself, with only what names the project around it. */
|
|
320
356
|
export function forecastData(r) {
|
|
357
|
+
// The same fields every time, so a script needn't test for them; notes are
|
|
358
|
+
// what the pace can't see, as the list shows them.
|
|
321
359
|
const one = (p) => ({
|
|
322
360
|
id: p.id,
|
|
323
361
|
name: p.name,
|
|
324
362
|
...(p.key ? { key: p.key } : {}),
|
|
325
363
|
...(p.program !== undefined ? { program: p.program } : {}),
|
|
326
364
|
ends: p.ends ?? null,
|
|
365
|
+
...(p.finish?.none ? {} : { rough: false, shortHistory: false, lastDue: null }),
|
|
327
366
|
...p.finish,
|
|
367
|
+
notes: p.finish?.none ? [] : finishNotes(p.finish ?? {}),
|
|
328
368
|
});
|
|
329
369
|
if (!r.projects) return one(r);
|
|
330
370
|
return { projects: r.projects.filter((p) => p.health !== "done").map(one) };
|
|
@@ -533,7 +573,7 @@ export function agentWorkText(r) {
|
|
|
533
573
|
const lines = [`Work for ${r.agent}${r.addedBy ? ` (added by ${r.addedBy})` : ""}`];
|
|
534
574
|
if (!r.tasks?.length) lines.push("", "Nothing to do: no open tasks are assigned to it.");
|
|
535
575
|
for (const t of r.tasks ?? []) {
|
|
536
|
-
const bits = [t.status
|
|
576
|
+
const bits = [STATUS[t.status] ?? t.status, t.priority];
|
|
537
577
|
if (t.due) bits.push(`due ${t.due}`);
|
|
538
578
|
lines.push(
|
|
539
579
|
"",
|
|
@@ -555,3 +595,43 @@ export function agentWorkText(r) {
|
|
|
555
595
|
);
|
|
556
596
|
return lines.join("\n");
|
|
557
597
|
}
|
|
598
|
+
|
|
599
|
+
/** CSV text as rows of cells: quoted cells may hold commas, quotes ("") and line breaks. */
|
|
600
|
+
export function csvRows(text) {
|
|
601
|
+
const rows = [];
|
|
602
|
+
let row = [];
|
|
603
|
+
let cell = "";
|
|
604
|
+
let quoted = false;
|
|
605
|
+
for (let i = 0; i < text.length; i++) {
|
|
606
|
+
const c = text[i];
|
|
607
|
+
if (quoted) {
|
|
608
|
+
if (c === '"' && text[i + 1] === '"') {
|
|
609
|
+
cell += '"';
|
|
610
|
+
i++;
|
|
611
|
+
} else if (c === '"') quoted = false;
|
|
612
|
+
else cell += c;
|
|
613
|
+
} else if (c === '"') quoted = true;
|
|
614
|
+
else if (c === ",") {
|
|
615
|
+
row.push(cell);
|
|
616
|
+
cell = "";
|
|
617
|
+
} else if (c === "\n" || c === "\r") {
|
|
618
|
+
if (c === "\r" && text[i + 1] === "\n") i++;
|
|
619
|
+
row.push(cell);
|
|
620
|
+
rows.push(row);
|
|
621
|
+
row = [];
|
|
622
|
+
cell = "";
|
|
623
|
+
} else cell += c;
|
|
624
|
+
}
|
|
625
|
+
if (cell || row.length) rows.push([...row, cell]);
|
|
626
|
+
return rows;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** export --json: each row as an object keyed by the file's headers, not one CSV string. */
|
|
630
|
+
export function exportData(r) {
|
|
631
|
+
const [columns = [], ...body] = csvRows(r.csv ?? "");
|
|
632
|
+
return {
|
|
633
|
+
filename: r.filename,
|
|
634
|
+
columns,
|
|
635
|
+
rows: body.map((cells) => Object.fromEntries(columns.map((c, i) => [c, cells[i] ?? ""]))),
|
|
636
|
+
};
|
|
637
|
+
}
|