bikeshed-now 0.11.0 → 0.14.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.
Files changed (3) hide show
  1. package/README.md +15 -0
  2. package/package.json +1 -1
  3. package/src/index.mjs +174 -35
package/README.md CHANGED
@@ -44,12 +44,20 @@ bikeshed comments storage-migration
44
44
  # Fix that sentence, say what you changed in its thread, then publish the
45
45
  # revision. Reply first: a thread belongs to the version it was written on.
46
46
  bikeshed reply storage-migration <thread> "Rewritten to describe the dual-write period."
47
+ bikeshed resolve storage-migration <thread> # closes it, with a note saying you did
47
48
  bikeshed update storage-migration proposal.md
48
49
 
49
50
  # Given "Can suggest" rather than "Can edit"? Propose the change instead, for a
50
51
  # person to accept or reject.
51
52
  bikeshed suggest storage-migration "a single weekend" --with "two weekends"
52
53
 
54
+ # Own the document, or can approve on it? Decide somebody's suggestions: an
55
+ # accept goes into the draft, a rejection carries the reason its author is sent,
56
+ # and commit publishes the draft as one version.
57
+ bikeshed accept storage-migration <thread>
58
+ bikeshed reject storage-migration <thread> --reason "Already in section 3."
59
+ bikeshed commit storage-migration
60
+
53
61
  # Something nobody has raised yet? Start a thread on the passage it is about.
54
62
  bikeshed comment storage-migration "a single weekend" --body "Is the dual-write fortnight in this?"
55
63
  ```
@@ -72,6 +80,13 @@ bikeshed publish platform.md --id 01-platform
72
80
  If the id is taken, the document is still published, at the id plus a few
73
81
  random characters, and the CLI says so on stderr.
74
82
 
83
+ A rename changes the title and leaves the address alone, so links already sent
84
+ keep working:
85
+
86
+ ```bash
87
+ bikeshed rename 01-platform Platform review, second pass
88
+ ```
89
+
75
90
  Wherever a command takes an id, it also takes the link you were sent. An
76
91
  unlisted document you are not a member of needs the link, because its key is in
77
92
  it; without the key the document answers as an id that does not exist.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bikeshed-now",
3
- "version": "0.11.0",
3
+ "version": "0.14.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "Publish and revise documents on a Bikeshed deployment from the terminal.",
package/src/index.mjs CHANGED
@@ -53,13 +53,26 @@ const USAGE = `bikeshed ${VERSION} — publish and revise documents
53
53
  bikeshed lineage <id> what it was forked from; for its owner, its forks
54
54
  bikeshed get <id> print a document's source
55
55
  bikeshed comments <id> unresolved feedback, with its anchored text
56
+ (--version <v> for an earlier version's)
56
57
  bikeshed comment <id> <text> --body <comment>
57
58
  start a thread on a passage
58
59
  bikeshed reply <id> <thread> <text> reply in a thread
60
+ bikeshed resolve <id> <thread> close a thread once its point is dealt with
61
+ bikeshed reopen <id> <thread> open a resolved thread again
59
62
  bikeshed suggest <id> <text> --with <new text>
60
63
  propose new text for a passage, for a
61
64
  person to accept or reject (--delete to
62
65
  propose removing it)
66
+ bikeshed revise <id> <thread> --with <new text>
67
+ change your suggestion before it is
68
+ decided; what it said before is kept
69
+ bikeshed withdraw <id> <thread> take back your suggestion before it is
70
+ decided; its thread stays
71
+ bikeshed accept <id> <thread> accept a suggestion, as its owner or an
72
+ approver: the change goes into the draft
73
+ bikeshed reject <id> <thread> decline a suggestion, as its owner or an
74
+ approver (--reason <why>)
75
+ bikeshed commit <id> publish the draft as the next version
63
76
  bikeshed open <id> open it in a browser
64
77
 
65
78
  Where a command takes <id> it also takes the link you were sent. An unlisted
@@ -69,6 +82,7 @@ Organising
69
82
  bikeshed folders the folder tree, with ids
70
83
  bikeshed mkdir <name> [--parent <id>] create a folder
71
84
  bikeshed mv <id> <folder|root> move a document
85
+ bikeshed rename <id> <title> change a document's title; its address stays
72
86
 
73
87
  Skills
74
88
  bikeshed skills skills this deployment publishes
@@ -93,15 +107,17 @@ Options
93
107
  --title <text> title for publish or fork (fork default: the original's)
94
108
  --id <id> with publish: the address, /d/<id> (default: from the title)
95
109
  --folder <id> destination folder for publish or fork
96
- --version <v> with fork: the version to copy (default: current);
97
- on its own, which version of bikeshed this is
110
+ --version <v> with fork: the version to copy; with comments: the
111
+ version to read (default: current); on its own,
112
+ which version of bikeshed this is
98
113
  --parent <id> parent folder for mkdir
99
114
  --limit <n> with list: rows per page (default 20, max 50)
100
115
  --cursor <id> with list: the page after the one that printed it
101
116
  --all with list: every page; with comments: resolved too
102
- --with <text> with suggest: what the passage should say instead
103
- --delete with suggest: propose deleting the passage
104
- --note <text> with suggest: why, for whoever decides
117
+ --with <text> with suggest or revise: what the passage should say instead
118
+ --delete with suggest or revise: propose deleting the passage
119
+ --note <text> with suggest or revise: why, for whoever decides
120
+ --reason <text> with reject: why, for the suggestion's author
105
121
  --prefix <text> with suggest: words just before a passage that repeats
106
122
  --suffix <text> with suggest: words just after it
107
123
  --watch with publish/update: republish on every save
@@ -183,15 +199,9 @@ function isCi(value) {
183
199
 
184
200
  async function browserLogin(flags) {
185
201
  const interactive = Boolean(process.stdin.isTTY);
202
+ // Never asked: --origin, BIKESHED_ORIGIN, the last sign-in, then the default,
203
+ // the same order as every other command. The line below names it.
186
204
  let where = resolveAuth(flags).origin;
187
- if (interactive && !flags.origin) {
188
- const rl = createInterface({ input: process.stdin, output: process.stderr });
189
- try {
190
- where = (await rl.question(`Deployment [${where}]: `)).trim() || where;
191
- } finally {
192
- rl.close();
193
- }
194
- }
195
205
  try {
196
206
  const url = new URL(where);
197
207
  if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error();
@@ -291,16 +301,17 @@ async function browserLogin(flags) {
291
301
  );
292
302
  }
293
303
 
294
- /** Paste a token made at /account. The flow from before browser sign-in. */
304
+ /**
305
+ * Paste a token made at /account. The flow from before browser sign-in.
306
+ *
307
+ * Only the token is read from stdin. Asking for the deployment first read a
308
+ * piped token as the address, and printed it back inside the /account link.
309
+ */
295
310
  async function pasteLogin(flags) {
296
- const { origin } = resolveAuth(flags);
311
+ const { origin: where } = resolveAuth(flags);
297
312
  const rl = createInterface({ input: process.stdin, output: process.stdout });
298
313
  try {
299
- const where =
300
- flags.origin ||
301
- (await rl.question(`Deployment [${origin}]: `)).trim() ||
302
- origin;
303
- console.log(`\nCreate a token at ${where}/account — choose "Command line".`);
314
+ console.log(`Create a token at ${where}/account — choose "Command line".`);
304
315
  console.log("It is shown once, so copy it now.\n");
305
316
  const token = (await rl.question("Token: ")).trim();
306
317
  if (!token) fail("no token given");
@@ -511,6 +522,9 @@ async function comments(flags, [ref]) {
511
522
  const { origin, token } = resolveAuth(flags);
512
523
  const result = await callTool(origin, token, "get_comments", {
513
524
  ...docArgs(ref),
525
+ // Threads belong to the version they were opened on; a version in a link
526
+ // is not taken as a request for one, only the flag is.
527
+ ...(typeof flags.version === "string" ? { version: flags.version } : {}),
514
528
  includeResolved: Boolean(flags.all),
515
529
  });
516
530
  out(flags.json, result, () => {
@@ -523,11 +537,14 @@ async function comments(flags, [ref]) {
523
537
  // which passage to change without opening the document.
524
538
  console.log(`\n── ${thread.threadId}${thread.section ? ` (${thread.section})` : ""}`);
525
539
  if (thread.quote) console.log(` “${thread.quote}”`);
526
- // A suggested edit says what the passage would become, and whether a
527
- // person has decided it yet (SPEC §22).
540
+ // A suggested edit says what the passage would become, whether it is
541
+ // still open, and what its author proposed before changing it (SPEC §22).
528
542
  if (thread.suggestion) {
529
- const change = thread.suggestion.replacement === "" ? "(delete it)" : `“${thread.suggestion.replacement}”`;
530
- console.log(` → ${change} [${thread.suggestion.state}]`);
543
+ const said = (text) => (text === "" ? "(delete it)" : `“${text}”`);
544
+ console.log(` → ${said(thread.suggestion.replacement)} [${thread.suggestion.state}]`);
545
+ for (const earlier of thread.suggestion.revisions ?? []) {
546
+ console.log(` was ${said(earlier.replacement)}${earlier.note ? ` (${earlier.note})` : ""}`);
547
+ }
531
548
  }
532
549
  for (const comment of thread.comments) {
533
550
  const via = comment.via && comment.via !== "web" ? ` [via ${comment.via}]` : "";
@@ -573,6 +590,27 @@ async function reply(flags, [ref, threadId, ...rest]) {
573
590
  out(flags.json, result, () => console.log(`replied in ${threadId}`));
574
591
  }
575
592
 
593
+ /**
594
+ * Close a thread on the current version, or open it again. Either leaves a
595
+ * note in the thread saying who did it and through what, which is how the
596
+ * person who raised the point hears (SPEC §19).
597
+ */
598
+ // `resolve` alone is node:path's, imported above.
599
+ async function resolveThread(flags, args) {
600
+ await setResolved(flags, args, "resolve");
601
+ }
602
+
603
+ async function reopenThread(flags, args) {
604
+ await setResolved(flags, args, "reopen");
605
+ }
606
+
607
+ async function setResolved(flags, [ref, threadId, ...rest], verb) {
608
+ if (!ref || !threadId || rest.length) fail(`usage: bikeshed ${verb} <id> <thread>`);
609
+ const { origin, token } = resolveAuth(flags);
610
+ const result = await callTool(origin, token, `${verb}_thread`, { ...docArgs(ref), threadId });
611
+ out(flags.json, result, () => console.log(`${verb === "resolve" ? "resolved" : "reopened"} ${threadId}`));
612
+ }
613
+
576
614
  /**
577
615
  * Propose new text for one passage of a document's current version. The
578
616
  * passage is quoted as a reader sees it, which is how `comments` prints it, so
@@ -582,16 +620,12 @@ async function suggest(flags, [ref, ...rest]) {
582
620
  const exact = rest.join(" ");
583
621
  const usage = "usage: bikeshed suggest <id> <text> (--with <new text> | --delete) [--note <why>]";
584
622
  if (!ref || !exact) fail(usage);
585
- const replacing = typeof flags.with === "string";
586
- if (replacing === Boolean(flags.delete)) fail(usage);
587
- // An empty --with is what an unset shell variable sends; deleting a passage
588
- // has to be asked for by name.
589
- if (replacing && flags.with === "") fail("an empty --with would delete the passage; say --delete if you mean that");
623
+ const replacement = replacementFrom(flags, usage);
590
624
  const { origin, token } = resolveAuth(flags);
591
625
  const result = await callTool(origin, token, "suggest_edit", {
592
626
  ...docArgs(ref),
593
627
  exact,
594
- replacement: replacing ? flags.with : "",
628
+ replacement,
595
629
  ...(typeof flags.note === "string" ? { note: flags.note } : {}),
596
630
  ...(typeof flags.prefix === "string" ? { prefix: flags.prefix } : {}),
597
631
  ...(typeof flags.suffix === "string" ? { suffix: flags.suffix } : {}),
@@ -601,6 +635,82 @@ async function suggest(flags, [ref, ...rest]) {
601
635
  );
602
636
  }
603
637
 
638
+ /** The replacement `suggest` and `revise` were given: --with, or --delete by name. */
639
+ function replacementFrom(flags, usage) {
640
+ const replacing = typeof flags.with === "string";
641
+ if (replacing === Boolean(flags.delete)) fail(usage);
642
+ // An empty --with is what an unset shell variable sends; deleting a passage
643
+ // has to be asked for by name.
644
+ if (replacing && flags.with === "") fail("an empty --with would delete the passage; say --delete if you mean that");
645
+ return replacing ? flags.with : "";
646
+ }
647
+
648
+ /**
649
+ * Change the text of a suggestion of yours that nobody has decided yet. What
650
+ * it said before is kept, for whoever decides (SPEC §22).
651
+ */
652
+ async function revise(flags, [ref, threadId, ...rest]) {
653
+ const usage = "usage: bikeshed revise <id> <thread> (--with <new text> | --delete) [--note <why>]";
654
+ if (!ref || !threadId || rest.length) fail(usage);
655
+ const replacement = replacementFrom(flags, usage);
656
+ const { origin, token } = resolveAuth(flags);
657
+ const result = await callTool(origin, token, "revise_suggestion", {
658
+ ...docArgs(ref),
659
+ threadId,
660
+ replacement,
661
+ ...(typeof flags.note === "string" ? { note: flags.note } : {}),
662
+ });
663
+ out(flags.json, result, () => console.log(`changed ${threadId}; what it said before is kept`));
664
+ }
665
+
666
+ /**
667
+ * Accept a suggestion, as the document's owner or an approver. The change is
668
+ * made in the draft, where the decisions of a review gather until `commit`
669
+ * publishes them as one version (SPEC §22).
670
+ */
671
+ async function accept(flags, [ref, threadId, ...rest]) {
672
+ if (!ref || !threadId || rest.length) fail("usage: bikeshed accept <id> <thread>");
673
+ const { origin, token } = resolveAuth(flags);
674
+ // Deciding takes a real role on the document, never a link, so only the id is sent.
675
+ const result = await callTool(origin, token, "accept_suggestion", { id: docArgs(ref).id, threadId });
676
+ out(flags.json, result, () => console.log(`accepted ${threadId}; the change is in the draft — \`bikeshed commit\` publishes it`));
677
+ }
678
+
679
+ /** Publish the draft as the document's next version, as "Save as new version" does in the app. */
680
+ async function commit(flags, [ref, ...rest]) {
681
+ if (!ref || rest.length) fail("usage: bikeshed commit <id>");
682
+ const { origin, token } = resolveAuth(flags);
683
+ const result = await callTool(origin, token, "commit_draft", { id: docArgs(ref).id });
684
+ out(flags.json, result, () =>
685
+ console.log(result.deduped ? `nothing new: the draft is ${result.versionId} already` : `published ${result.versionId}`),
686
+ );
687
+ }
688
+
689
+ /**
690
+ * Decline a suggestion, as the document's owner or an approver. The passage
691
+ * stays as it is and the thread closes with a note giving the reason, which
692
+ * its author is sent (SPEC §22).
693
+ */
694
+ async function reject(flags, [ref, threadId, ...rest]) {
695
+ if (!ref || !threadId || rest.length) fail("usage: bikeshed reject <id> <thread> [--reason <why>]");
696
+ const { origin, token } = resolveAuth(flags);
697
+ // Deciding takes a real role on the document, never a link, so only the id is sent.
698
+ const result = await callTool(origin, token, "reject_suggestion", {
699
+ id: docArgs(ref).id,
700
+ threadId,
701
+ ...(typeof flags.reason === "string" ? { reason: flags.reason } : {}),
702
+ });
703
+ out(flags.json, result, () => console.log(`rejected ${threadId}`));
704
+ }
705
+
706
+ /** Take back a suggestion of yours that nobody has decided yet; its thread stays. */
707
+ async function withdraw(flags, [ref, threadId, ...rest]) {
708
+ if (!ref || !threadId || rest.length) fail("usage: bikeshed withdraw <id> <thread>");
709
+ const { origin, token } = resolveAuth(flags);
710
+ const result = await callTool(origin, token, "withdraw_suggestion", { ...docArgs(ref), threadId });
711
+ out(flags.json, result, () => console.log(`withdrew ${threadId}`));
712
+ }
713
+
604
714
  async function folders(flags) {
605
715
  const { origin, token } = resolveAuth(flags);
606
716
  const result = await callTool(origin, token, "list_folders", {});
@@ -711,13 +821,30 @@ async function mv(flags, [id, dest]) {
711
821
  */
712
822
  if (!id || !dest) fail("usage: bikeshed mv <id> <folder|root>");
713
823
  const { origin, token } = resolveAuth(flags);
714
- const args = dest === "root" ? { id } : { id, folder: dest };
824
+ // Moving is an editor's act, and a link's key only lets somebody read, so
825
+ // only the id goes along.
826
+ const doc = docArgs(id).id;
827
+ const args = dest === "root" ? { id: doc } : { id: doc, folder: dest };
715
828
  const result = await callTool(origin, token, "move_document", args);
716
829
  out(flags.json, result, () =>
717
- console.log(result.folder ? `moved ${id} into ${result.folder}` : `moved ${id} to the root`),
830
+ console.log(result.folder ? `moved ${doc} into ${result.folder}` : `moved ${doc} to the root`),
718
831
  );
719
832
  }
720
833
 
834
+ /**
835
+ * Give a document a new title. Its address is its id, which never changes, so
836
+ * every link already sent keeps working (SPEC §4). The title is the rest of the
837
+ * line, so it needs no quotes.
838
+ */
839
+ async function rename(flags, [ref, ...rest]) {
840
+ const title = rest.join(" ").trim();
841
+ if (!ref || !title) fail("usage: bikeshed rename <id> <title>");
842
+ const { origin, token } = resolveAuth(flags);
843
+ // Renaming takes no link key, as in the workspace list: only the id is sent.
844
+ const result = await callTool(origin, token, "rename_document", { id: docArgs(ref).id, title });
845
+ out(flags.json, result, () => console.log(`renamed ${result.id} to “${result.title}”`));
846
+ }
847
+
721
848
  /**
722
849
  * Skills are instructions, not code, and they are fetched from the DEPLOYMENT
723
850
  * rather than bundled with this CLI on purpose: a deployment running an older
@@ -762,10 +889,13 @@ async function skillsCommand(flags, [sub, dir]) {
762
889
  });
763
890
  }
764
891
 
765
- async function open(flags, [id]) {
766
- if (!id) fail("usage: bikeshed open <id>");
892
+ async function open(flags, [ref]) {
893
+ if (!ref) fail("usage: bikeshed open <id>");
767
894
  const { origin } = resolveAuth(flags);
768
- const url = `${origin}/d/${id}`;
895
+ // An unlisted document opens only with its key, and the link is the one place
896
+ // the key lives, so a link keeps it.
897
+ const { id, k } = docArgs(ref);
898
+ const url = `${origin}/d/${encodeURIComponent(id)}${k ? `?k=${encodeURIComponent(k)}` : ""}`;
769
899
  // Best effort, and the address is printed either way. This used to spawn
770
900
  // `start`, which is a cmd.exe builtin rather than a program, so on Windows
771
901
  // the spawn failed and, with nothing listening for the error, crashed.
@@ -807,11 +937,19 @@ const COMMANDS = {
807
937
  comments,
808
938
  comment,
809
939
  reply,
940
+ resolve: resolveThread,
941
+ reopen: reopenThread,
810
942
  suggest,
943
+ revise,
944
+ withdraw,
945
+ accept,
946
+ reject,
947
+ commit,
811
948
  open,
812
949
  folders,
813
950
  mkdir,
814
951
  mv,
952
+ rename,
815
953
  skills: skillsCommand,
816
954
  };
817
955
 
@@ -842,6 +980,7 @@ async function main() {
842
980
  paste: { type: "boolean", default: false },
843
981
  "no-browser": { type: "boolean", default: false },
844
982
  note: { type: "string" },
983
+ reason: { type: "string" },
845
984
  body: { type: "string" },
846
985
  prefix: { type: "string" },
847
986
  suffix: { type: "string" },