bikeshed-now 0.16.0 → 0.17.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/README.md +29 -0
  2. package/package.json +1 -1
  3. package/src/index.mjs +76 -36
package/README.md CHANGED
@@ -166,3 +166,32 @@ reviewer can always tell which feedback came from a person.
166
166
 
167
167
  Apache-2.0. The deployment it talks to is the same repository:
168
168
  [DeputyHumans/bikeshed-now](https://github.com/DeputyHumans/bikeshed-now).
169
+
170
+ ### Previous versions and write guards
171
+
172
+ Use `list_versions({id, limit:20, cursor})` or `bikeshed versions <id|link>
173
+ --all --json` to discover published history. Pages are newest first, default 20,
174
+ maximum 50; pass `nextCursor` as `cursor` until null. Each row has a pinned URL.
175
+ History uses the document's current access rules and expiry. A missing or
176
+ withdrawn version is an error, never a request for the latest version.
177
+
178
+ Read a chosen version with `get_document({id, version:"v2"})` and
179
+ `get_comments({id, version:"v2", includeResolved:true})`, or
180
+ `bikeshed get <id> --version v2` and `bikeshed comments <id> --version v2 --all`.
181
+ Read results name `currentVersion` and `isCurrent`; historical write capabilities
182
+ are false. Existing comments and suggestions remain readable as an archive.
183
+ Older versions cannot be edited or receive, revise, delete, reply to, resolve,
184
+ reopen, accept, reject, claim or withdraw feedback. Fork one to make a new,
185
+ independent document with `fork_document({id, version:"v2"})`.
186
+
187
+ CLI `get`, `comments`, `fork`, and `open` preserve a version in a pasted
188
+ `/d/<id>/<version>?k=...` URL. Feedback commands also preserve it as a write
189
+ guard. `--version` must agree with the URL; conflicts fail. Keys are retained.
190
+ Document-wide sharing, folder and lineage operations still address the document.
191
+
192
+ Before changing feedback, read the latest version and its feedback, then pass
193
+ that `version` to every feedback tool (`--version` in the CLI). Pass
194
+ `baseVersion` to `update_document` and `commit_draft` (`--base` on `bikeshed
195
+ update` and `bikeshed commit`). A versioned URL supplies the CLI base too.
196
+ If the head changes, the server refuses the write. Read the new head and
197
+ reassess the request; never silently redirect a historical request to it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bikeshed-now",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
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
@@ -51,7 +51,8 @@ const USAGE = `bikeshed ${VERSION} — publish and revise documents
51
51
  bikeshed update <id> <file> publish a new version
52
52
  bikeshed fork <id> your own copy, which remembers its original
53
53
  bikeshed lineage <id> what it was forked from; for its owner, its forks
54
- bikeshed get <id> print a document's source
54
+ bikeshed get <id> [--version <v>] print a published version's source
55
+ bikeshed versions <id> list published history (--all for every page)
55
56
  bikeshed comments <id> unresolved feedback, with its anchored text
56
57
  (--version <v> for an earlier version's)
57
58
  bikeshed changes <id> --since <cursor|last|all>
@@ -122,9 +123,9 @@ Options
122
123
  --title <text> title for publish or fork (fork default: the original's)
123
124
  --id <id> with publish: the address, /d/<id> (default: from the title)
124
125
  --folder <id> destination folder for publish or fork
125
- --version <v> with fork: the version to copy; with comments: the
126
- version to read (default: current); on its own,
127
- which version of bikeshed this is
126
+ --version <v> get/comments/fork/open: the published version to read
127
+ or copy; feedback commands: require that version to
128
+ still be current. On its own: the CLI version
128
129
  --parent <id> parent folder for mkdir
129
130
  --limit <n> with list: rows per page (default 20, max 50)
130
131
  --cursor <id> with list: the page after the one that printed it
@@ -136,7 +137,7 @@ Options
136
137
  60, at least 30)
137
138
  --timeout <s> with changes --wait: give up after this long (default
138
139
  3600), exit 3, and print the cursor to resume from
139
- --base <v> with update: the version you revised; refused if
140
+ --base <v> with update/commit: the version you revised; refused if
140
141
  somebody has published since
141
142
  --all with list: every page; with comments: resolved too
142
143
  --with <text> with suggest or revise: what the passage should say instead
@@ -175,18 +176,31 @@ function out(json, value, human) {
175
176
  * for both — `…/d/<id>[/<version>]?k=<key>` — and the key goes along as the
176
177
  * tools' `k` argument (SPEC §17).
177
178
  */
178
- function docArgs(ref) {
179
- if (!/^https?:\/\//i.test(ref)) return { id: ref };
180
- let url;
181
- try {
182
- url = new URL(ref);
183
- } catch {
184
- fail(`not a document link: ${ref}`);
179
+ function docArgs(ref, flags) {
180
+ let id = ref;
181
+ let k;
182
+ let linkedVersion;
183
+ if (/^https?:\/\//i.test(ref)) {
184
+ let url;
185
+ try { url = new URL(ref); } catch { fail(`not a document link: ${ref}`); }
186
+ const match = url.pathname.match(/^\/d\/([^/]+)(?:\/([^/]+))?\/?$/);
187
+ if (!match) fail(`not a document link: ${ref}`);
188
+ id = decodeURIComponent(match[1]);
189
+ linkedVersion = match[2] ? decodeURIComponent(match[2]) : undefined;
190
+ k = url.searchParams.get("k");
191
+ }
192
+ // Document-wide operations deliberately use only the document id/key.
193
+ if (flags === undefined) return { id, ...(k ? { k } : {}) };
194
+ const explicit = flags.version;
195
+ if (explicit !== undefined && (typeof explicit !== "string" || !explicit || /[\/\\?#\s]/.test(explicit))) {
196
+ fail("invalid version");
185
197
  }
186
- const match = url.pathname.match(/^\/d\/([^/]+)(?:\/[^/]+)?\/?$/);
187
- if (!match) fail(`not a document link: ${ref}`);
188
- const k = url.searchParams.get("k");
189
- return { id: decodeURIComponent(match[1]), ...(k ? { k } : {}) };
198
+ if (explicit && linkedVersion && explicit !== linkedVersion) {
199
+ fail(`conflicting versions: the link names ${linkedVersion}, the flag names ${explicit}`);
200
+ }
201
+ const version = explicit ?? linkedVersion;
202
+ if (version && (version === "." || version === ".." || /[\/\\?#\s]/.test(version))) fail("invalid version");
203
+ return { id, ...(k ? { k } : {}), ...(version ? { version } : {}) };
190
204
  }
191
205
 
192
206
  const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
@@ -496,7 +510,7 @@ async function update(flags, [ref, path]) {
496
510
  if (!ref || !path) fail("usage: bikeshed update <id> <file>");
497
511
  const { origin, token } = resolveAuth(flags);
498
512
  const file = resolve(path);
499
- const doc = docArgs(ref);
513
+ const { version: selectedBase, ...doc } = docArgs(ref, { version: flags.base });
500
514
  const { id } = doc;
501
515
 
502
516
  const once = async () => {
@@ -505,7 +519,7 @@ async function update(flags, [ref, path]) {
505
519
  // text, every byte above 0x7f in it would become U+FFFD on the way.
506
520
  // --base: the version this was revised from, so an update over somebody's
507
521
  // newer save is refused instead of replacing it (SPEC §19).
508
- const base = typeof flags.base === "string" && flags.base ? flags.base : undefined;
522
+ const base = selectedBase;
509
523
  const result = isArchive(file, bytes)
510
524
  ? await uploadFile(origin, token, file, { update: id, k: doc.k, base })
511
525
  : await callTool(origin, token, "update_document", {
@@ -527,10 +541,30 @@ async function update(flags, [ref, path]) {
527
541
  if (flags.watch) await watchFile(file, once);
528
542
  }
529
543
 
544
+ /** The published history, paged by the deployment rather than guessed from ids. */
545
+ async function versions(flags, [ref, ...rest]) {
546
+ if (!ref || rest.length) fail("usage: bikeshed versions <id> [--limit N] [--cursor …] [--all] [--json]");
547
+ const { origin, token } = resolveAuth(flags);
548
+ const limit = flags.limit === undefined ? 20 : Number(flags.limit);
549
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) fail("--limit must be an integer from 1 to 50");
550
+ let cursor = flags.cursor;
551
+ const rows = [];
552
+ let result;
553
+ do {
554
+ result = await callTool(origin, token, "list_versions", { ...docArgs(ref), limit, ...(cursor ? { cursor } : {}) });
555
+ rows.push(...result.versions);
556
+ cursor = result.nextCursor;
557
+ } while (flags.all && cursor);
558
+ out(flags.json, { ...result, versions: rows }, () => {
559
+ for (const row of rows) console.log(`${row.isCurrent ? "*" : " "} ${row.version} ${row.publishedAt ?? ""} ${row.label} ${row.url ?? ""}`);
560
+ if (cursor) console.log(`… more: bikeshed versions ${ref} --cursor ${cursor}`);
561
+ });
562
+ }
563
+
530
564
  async function get(flags, [ref]) {
531
565
  if (!ref) fail("usage: bikeshed get <id>");
532
566
  const { origin, token } = resolveAuth(flags);
533
- const doc = docArgs(ref);
567
+ const doc = docArgs(ref, flags);
534
568
  const result = await callTool(origin, token, "get_document", doc);
535
569
  // The tool cuts a long source at a fixed length. Printed as it came, the cut
536
570
  // copy exited 0 and looked complete, and `update` with an edit of it
@@ -555,9 +589,7 @@ async function comments(flags, [ref]) {
555
589
  if (!ref) fail("usage: bikeshed comments <id>");
556
590
  const { origin, token } = resolveAuth(flags);
557
591
  const result = await callTool(origin, token, "get_comments", {
558
- ...docArgs(ref),
559
- // Threads belong to the version they were opened on; a version in a link
560
- // is not taken as a request for one, only the flag is.
592
+ ...docArgs(ref, flags),
561
593
  ...(typeof flags.version === "string" ? { version: flags.version } : {}),
562
594
  includeResolved: Boolean(flags.all),
563
595
  });
@@ -603,7 +635,7 @@ async function comment(flags, [ref, ...rest]) {
603
635
  if (typeof flags.body !== "string" || !flags.body.trim()) fail(usage);
604
636
  const { origin, token } = resolveAuth(flags);
605
637
  const result = await callTool(origin, token, "add_comment", {
606
- ...docArgs(ref),
638
+ ...docArgs(ref, flags),
607
639
  exact,
608
640
  body: flags.body,
609
641
  ...(typeof flags.prefix === "string" ? { prefix: flags.prefix } : {}),
@@ -617,7 +649,7 @@ async function reply(flags, [ref, threadId, ...rest]) {
617
649
  if (!ref || !threadId || !body) fail("usage: bikeshed reply <id> <thread> <text>");
618
650
  const { origin, token } = resolveAuth(flags);
619
651
  const result = await callTool(origin, token, "reply_to_comment", {
620
- ...docArgs(ref),
652
+ ...docArgs(ref, flags),
621
653
  threadId,
622
654
  body,
623
655
  });
@@ -641,7 +673,7 @@ async function reopenThread(flags, args) {
641
673
  async function setResolved(flags, [ref, threadId, ...rest], verb) {
642
674
  if (!ref || !threadId || rest.length) fail(`usage: bikeshed ${verb} <id> <thread>`);
643
675
  const { origin, token } = resolveAuth(flags);
644
- const result = await callTool(origin, token, `${verb}_thread`, { ...docArgs(ref), threadId });
676
+ const result = await callTool(origin, token, `${verb}_thread`, { ...docArgs(ref, flags), threadId });
645
677
  out(flags.json, result, () => console.log(`${verb === "resolve" ? "resolved" : "reopened"} ${threadId}`));
646
678
  }
647
679
 
@@ -657,7 +689,7 @@ async function suggest(flags, [ref, ...rest]) {
657
689
  const replacement = replacementFrom(flags, usage);
658
690
  const { origin, token } = resolveAuth(flags);
659
691
  const result = await callTool(origin, token, "suggest_edit", {
660
- ...docArgs(ref),
692
+ ...docArgs(ref, flags),
661
693
  exact,
662
694
  replacement,
663
695
  ...(typeof flags.note === "string" ? { note: flags.note } : {}),
@@ -689,7 +721,7 @@ async function revise(flags, [ref, threadId, ...rest]) {
689
721
  const replacement = replacementFrom(flags, usage);
690
722
  const { origin, token } = resolveAuth(flags);
691
723
  const result = await callTool(origin, token, "revise_suggestion", {
692
- ...docArgs(ref),
724
+ ...docArgs(ref, flags),
693
725
  threadId,
694
726
  replacement,
695
727
  ...(typeof flags.note === "string" ? { note: flags.note } : {}),
@@ -705,8 +737,9 @@ async function revise(flags, [ref, threadId, ...rest]) {
705
737
  async function accept(flags, [ref, threadId, ...rest]) {
706
738
  if (!ref || !threadId || rest.length) fail("usage: bikeshed accept <id> <thread>");
707
739
  const { origin, token } = resolveAuth(flags);
708
- // Deciding takes a real role on the document, never a link, so only the id is sent.
709
- const result = await callTool(origin, token, "accept_suggestion", { id: docArgs(ref).id, threadId });
740
+ const { id, version } = docArgs(ref, flags);
741
+ // Deciding still takes a real role; a version is only a precondition.
742
+ const result = await callTool(origin, token, "accept_suggestion", { id, ...(version ? { version } : {}), threadId });
710
743
  out(flags.json, result, () => console.log(`accepted ${threadId}; the change is in the draft — \`bikeshed commit\` publishes it`));
711
744
  }
712
745
 
@@ -714,7 +747,8 @@ async function accept(flags, [ref, threadId, ...rest]) {
714
747
  async function commit(flags, [ref, ...rest]) {
715
748
  if (!ref || rest.length) fail("usage: bikeshed commit <id>");
716
749
  const { origin, token } = resolveAuth(flags);
717
- const result = await callTool(origin, token, "commit_draft", { id: docArgs(ref).id });
750
+ const { id, version } = docArgs(ref, { version: flags.base });
751
+ const result = await callTool(origin, token, "commit_draft", { id, ...(version ? { baseVersion: version } : {}) });
718
752
  out(flags.json, result, () =>
719
753
  console.log(result.deduped ? `nothing new: the draft is ${result.versionId} already` : `published ${result.versionId}`),
720
754
  );
@@ -728,9 +762,10 @@ async function commit(flags, [ref, ...rest]) {
728
762
  async function reject(flags, [ref, threadId, ...rest]) {
729
763
  if (!ref || !threadId || rest.length) fail("usage: bikeshed reject <id> <thread> [--reason <why>]");
730
764
  const { origin, token } = resolveAuth(flags);
731
- // Deciding takes a real role on the document, never a link, so only the id is sent.
765
+ const { id, version } = docArgs(ref, flags);
766
+ // Deciding still takes a real role; a version is only a precondition.
732
767
  const result = await callTool(origin, token, "reject_suggestion", {
733
- id: docArgs(ref).id,
768
+ id, ...(version ? { version } : {}),
734
769
  threadId,
735
770
  ...(typeof flags.reason === "string" ? { reason: flags.reason } : {}),
736
771
  });
@@ -741,7 +776,7 @@ async function reject(flags, [ref, threadId, ...rest]) {
741
776
  async function withdraw(flags, [ref, threadId, ...rest]) {
742
777
  if (!ref || !threadId || rest.length) fail("usage: bikeshed withdraw <id> <thread>");
743
778
  const { origin, token } = resolveAuth(flags);
744
- const result = await callTool(origin, token, "withdraw_suggestion", { ...docArgs(ref), threadId });
779
+ const result = await callTool(origin, token, "withdraw_suggestion", { ...docArgs(ref, flags), threadId });
745
780
  out(flags.json, result, () => console.log(`withdrew ${threadId}`));
746
781
  }
747
782
 
@@ -942,7 +977,7 @@ function printChanges(flags, result) {
942
977
  async function fork(flags, [ref]) {
943
978
  if (!ref) fail("usage: bikeshed fork <id> [--version <v>] [--folder <id>] [--title <text>]");
944
979
  const { origin, token } = resolveAuth(flags);
945
- const args = docArgs(ref);
980
+ const args = docArgs(ref, flags);
946
981
  if (typeof flags.version === "string") args.version = flags.version;
947
982
  if (typeof flags.folder === "string") args.folder = flags.folder;
948
983
  if (typeof flags.title === "string") args.title = flags.title;
@@ -1094,8 +1129,8 @@ async function open(flags, [ref]) {
1094
1129
  const { origin } = resolveAuth(flags);
1095
1130
  // An unlisted document opens only with its key, and the link is the one place
1096
1131
  // the key lives, so a link keeps it.
1097
- const { id, k } = docArgs(ref);
1098
- const url = `${origin}/d/${encodeURIComponent(id)}${k ? `?k=${encodeURIComponent(k)}` : ""}`;
1132
+ const { id, k, version } = docArgs(ref, flags);
1133
+ const url = `${origin}/d/${encodeURIComponent(id)}${version ? `/${encodeURIComponent(version)}` : ""}${k ? `?k=${encodeURIComponent(k)}` : ""}`;
1099
1134
  // Best effort, and the address is printed either way. This used to spawn
1100
1135
  // `start`, which is a cmd.exe builtin rather than a program, so on Windows
1101
1136
  // the spawn failed and, with nothing listening for the error, crashed.
@@ -1134,6 +1169,7 @@ const COMMANDS = {
1134
1169
  fork,
1135
1170
  lineage,
1136
1171
  get,
1172
+ versions,
1137
1173
  comments,
1138
1174
  changes,
1139
1175
  comment,
@@ -1218,6 +1254,10 @@ async function main() {
1218
1254
  return;
1219
1255
  }
1220
1256
 
1257
+ const versionCommands = new Set(["get", "comments", "fork", "open", "comment", "reply", "resolve", "reopen", "suggest", "revise", "withdraw", "accept", "reject"]);
1258
+ if (values.version !== undefined && !versionCommands.has(name)) fail(`--version is not supported by ${name}; update and commit use --base`);
1259
+ if (values.base !== undefined && !["update", "commit"].includes(name)) fail(`--base is not supported by ${name}`);
1260
+
1221
1261
  const command = COMMANDS[name];
1222
1262
  if (!command) fail(`unknown command: ${name}\n\n${USAGE}`);
1223
1263