bikeshed-now 0.16.0 → 0.18.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 +40 -1
  2. package/package.json +1 -1
  3. package/src/index.mjs +139 -46
package/README.md CHANGED
@@ -82,7 +82,10 @@ bikeshed publish platform.md --id 01-platform
82
82
  `publish` says where it filed the document and who can read it there.
83
83
  `bikeshed folders` counts who can read each folder, and `bikeshed folder <id>`
84
84
  names them: filing a document in a folder shares it with all of them, and
85
- nobody is emailed.
85
+ nobody is emailed. Under a folder shared by link or made public it is readable
86
+ by anyone holding the link, or anyone, and the output says through which
87
+ folder. Given a folder's link, `bikeshed folder <link>` lists what that link
88
+ opens, to read only.
86
89
 
87
90
  Ids are shared by the whole deployment and never change, so choose one every
88
91
  time: put the date first on something time-bound
@@ -146,6 +149,13 @@ to change visibility, `--public-edit true|false` to change open editing, or
146
149
  Omitted settings stay unchanged. These commands are owner-only, including on
147
150
  expired documents; the returned URL includes an unlisted key when needed.
148
151
 
152
+ `bikeshed visibility folder <id|link>` does the same for a folder, without
153
+ `--public-edit`. A folder's link or public level opens everything beneath it to
154
+ read, without an account: its subfolders, documents filed later, and documents
155
+ that are private on their own. Its expiry ends only that; the people it is
156
+ shared with keep their access. `bikeshed visibility document <id>` names a
157
+ document explicitly, for one whose id is `folder`.
158
+
149
159
  `bikeshed rename-folder <id> <name>` and
150
160
  `bikeshed move-folder <id> <folder|root>` require edit rights.
151
161
  `bikeshed delete-folder <id>` is owner-only: contents move to its parent or the
@@ -166,3 +176,32 @@ reviewer can always tell which feedback came from a person.
166
176
 
167
177
  Apache-2.0. The deployment it talks to is the same repository:
168
178
  [DeputyHumans/bikeshed-now](https://github.com/DeputyHumans/bikeshed-now).
179
+
180
+ ### Previous versions and write guards
181
+
182
+ Use `list_versions({id, limit:20, cursor})` or `bikeshed versions <id|link>
183
+ --all --json` to discover published history. Pages are newest first, default 20,
184
+ maximum 50; pass `nextCursor` as `cursor` until null. Each row has a pinned URL.
185
+ History uses the document's current access rules and expiry. A missing or
186
+ withdrawn version is an error, never a request for the latest version.
187
+
188
+ Read a chosen version with `get_document({id, version:"v2"})` and
189
+ `get_comments({id, version:"v2", includeResolved:true})`, or
190
+ `bikeshed get <id> --version v2` and `bikeshed comments <id> --version v2 --all`.
191
+ Read results name `currentVersion` and `isCurrent`; historical write capabilities
192
+ are false. Existing comments and suggestions remain readable as an archive.
193
+ Older versions cannot be edited or receive, revise, delete, reply to, resolve,
194
+ reopen, accept, reject, claim or withdraw feedback. Fork one to make a new,
195
+ independent document with `fork_document({id, version:"v2"})`.
196
+
197
+ CLI `get`, `comments`, `fork`, and `open` preserve a version in a pasted
198
+ `/d/<id>/<version>?k=...` URL. Feedback commands also preserve it as a write
199
+ guard. `--version` must agree with the URL; conflicts fail. Keys are retained.
200
+ Document-wide sharing, folder and lineage operations still address the document.
201
+
202
+ Before changing feedback, read the latest version and its feedback, then pass
203
+ that `version` to every feedback tool (`--version` in the CLI). Pass
204
+ `baseVersion` to `update_document` and `commit_draft` (`--base` on `bikeshed
205
+ update` and `bikeshed commit`). A versioned URL supplies the CLI base too.
206
+ If the head changes, the server refuses the write. Read the new head and
207
+ 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.18.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>
@@ -83,7 +84,8 @@ const USAGE = `bikeshed ${VERSION} — publish and revise documents
83
84
 
84
85
  Organising
85
86
  bikeshed folders the folder tree, with ids and readers
86
- bikeshed folder <id> who can read a folder, and so what is in it
87
+ bikeshed folder <id|link> who can read a folder, and so what is in it;
88
+ with a folder's link, what the link opens
87
89
  bikeshed mkdir <name> [--parent <id>] create a folder
88
90
  bikeshed mv <id> <folder|root> move a document
89
91
  bikeshed rename <id> <title> change a document's title; its address stays
@@ -95,6 +97,9 @@ Management
95
97
  bikeshed visibility <id> [private|unlisted|public]
96
98
  --public-edit true|false change open editing
97
99
  --expires <ISO timestamp|none> set or clear expiry; omitted settings stay unchanged
100
+ bikeshed visibility folder <id> [private|unlisted|public] [--expires <ISO timestamp|none>]
101
+ a folder's link or public level opens EVERYTHING
102
+ beneath it to read; its expiry ends only that
98
103
  bikeshed rename-folder <id> <name> rename a folder you can edit
99
104
  bikeshed move-folder <id> <folder|root> move a folder and recompute inherited access
100
105
  bikeshed delete-folder <id> owner-only; reparent contents, retain documents
@@ -122,9 +127,9 @@ Options
122
127
  --title <text> title for publish or fork (fork default: the original's)
123
128
  --id <id> with publish: the address, /d/<id> (default: from the title)
124
129
  --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
130
+ --version <v> get/comments/fork/open: the published version to read
131
+ or copy; feedback commands: require that version to
132
+ still be current. On its own: the CLI version
128
133
  --parent <id> parent folder for mkdir
129
134
  --limit <n> with list: rows per page (default 20, max 50)
130
135
  --cursor <id> with list: the page after the one that printed it
@@ -136,7 +141,7 @@ Options
136
141
  60, at least 30)
137
142
  --timeout <s> with changes --wait: give up after this long (default
138
143
  3600), exit 3, and print the cursor to resume from
139
- --base <v> with update: the version you revised; refused if
144
+ --base <v> with update/commit: the version you revised; refused if
140
145
  somebody has published since
141
146
  --all with list: every page; with comments: resolved too
142
147
  --with <text> with suggest or revise: what the passage should say instead
@@ -175,18 +180,31 @@ function out(json, value, human) {
175
180
  * for both — `…/d/<id>[/<version>]?k=<key>` — and the key goes along as the
176
181
  * tools' `k` argument (SPEC §17).
177
182
  */
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}`);
183
+ function docArgs(ref, flags) {
184
+ let id = ref;
185
+ let k;
186
+ let linkedVersion;
187
+ if (/^https?:\/\//i.test(ref)) {
188
+ let url;
189
+ try { url = new URL(ref); } catch { fail(`not a document link: ${ref}`); }
190
+ const match = url.pathname.match(/^\/d\/([^/]+)(?:\/([^/]+))?\/?$/);
191
+ if (!match) fail(`not a document link: ${ref}`);
192
+ id = decodeURIComponent(match[1]);
193
+ linkedVersion = match[2] ? decodeURIComponent(match[2]) : undefined;
194
+ k = url.searchParams.get("k");
185
195
  }
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 } : {}) };
196
+ // Document-wide operations deliberately use only the document id/key.
197
+ if (flags === undefined) return { id, ...(k ? { k } : {}) };
198
+ const explicit = flags.version;
199
+ if (explicit !== undefined && (typeof explicit !== "string" || !explicit || /[\/\\?#\s]/.test(explicit))) {
200
+ fail("invalid version");
201
+ }
202
+ if (explicit && linkedVersion && explicit !== linkedVersion) {
203
+ fail(`conflicting versions: the link names ${linkedVersion}, the flag names ${explicit}`);
204
+ }
205
+ const version = explicit ?? linkedVersion;
206
+ if (version && (version === "." || version === ".." || /[\/\\?#\s]/.test(version))) fail("invalid version");
207
+ return { id, ...(k ? { k } : {}), ...(version ? { version } : {}) };
190
208
  }
191
209
 
192
210
  const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
@@ -496,7 +514,7 @@ async function update(flags, [ref, path]) {
496
514
  if (!ref || !path) fail("usage: bikeshed update <id> <file>");
497
515
  const { origin, token } = resolveAuth(flags);
498
516
  const file = resolve(path);
499
- const doc = docArgs(ref);
517
+ const { version: selectedBase, ...doc } = docArgs(ref, { version: flags.base });
500
518
  const { id } = doc;
501
519
 
502
520
  const once = async () => {
@@ -505,7 +523,7 @@ async function update(flags, [ref, path]) {
505
523
  // text, every byte above 0x7f in it would become U+FFFD on the way.
506
524
  // --base: the version this was revised from, so an update over somebody's
507
525
  // newer save is refused instead of replacing it (SPEC §19).
508
- const base = typeof flags.base === "string" && flags.base ? flags.base : undefined;
526
+ const base = selectedBase;
509
527
  const result = isArchive(file, bytes)
510
528
  ? await uploadFile(origin, token, file, { update: id, k: doc.k, base })
511
529
  : await callTool(origin, token, "update_document", {
@@ -527,10 +545,30 @@ async function update(flags, [ref, path]) {
527
545
  if (flags.watch) await watchFile(file, once);
528
546
  }
529
547
 
548
+ /** The published history, paged by the deployment rather than guessed from ids. */
549
+ async function versions(flags, [ref, ...rest]) {
550
+ if (!ref || rest.length) fail("usage: bikeshed versions <id> [--limit N] [--cursor …] [--all] [--json]");
551
+ const { origin, token } = resolveAuth(flags);
552
+ const limit = flags.limit === undefined ? 20 : Number(flags.limit);
553
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) fail("--limit must be an integer from 1 to 50");
554
+ let cursor = flags.cursor;
555
+ const rows = [];
556
+ let result;
557
+ do {
558
+ result = await callTool(origin, token, "list_versions", { ...docArgs(ref), limit, ...(cursor ? { cursor } : {}) });
559
+ rows.push(...result.versions);
560
+ cursor = result.nextCursor;
561
+ } while (flags.all && cursor);
562
+ out(flags.json, { ...result, versions: rows }, () => {
563
+ for (const row of rows) console.log(`${row.isCurrent ? "*" : " "} ${row.version} ${row.publishedAt ?? ""} ${row.label} ${row.url ?? ""}`);
564
+ if (cursor) console.log(`… more: bikeshed versions ${ref} --cursor ${cursor}`);
565
+ });
566
+ }
567
+
530
568
  async function get(flags, [ref]) {
531
569
  if (!ref) fail("usage: bikeshed get <id>");
532
570
  const { origin, token } = resolveAuth(flags);
533
- const doc = docArgs(ref);
571
+ const doc = docArgs(ref, flags);
534
572
  const result = await callTool(origin, token, "get_document", doc);
535
573
  // The tool cuts a long source at a fixed length. Printed as it came, the cut
536
574
  // copy exited 0 and looked complete, and `update` with an edit of it
@@ -555,9 +593,7 @@ async function comments(flags, [ref]) {
555
593
  if (!ref) fail("usage: bikeshed comments <id>");
556
594
  const { origin, token } = resolveAuth(flags);
557
595
  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.
596
+ ...docArgs(ref, flags),
561
597
  ...(typeof flags.version === "string" ? { version: flags.version } : {}),
562
598
  includeResolved: Boolean(flags.all),
563
599
  });
@@ -603,7 +639,7 @@ async function comment(flags, [ref, ...rest]) {
603
639
  if (typeof flags.body !== "string" || !flags.body.trim()) fail(usage);
604
640
  const { origin, token } = resolveAuth(flags);
605
641
  const result = await callTool(origin, token, "add_comment", {
606
- ...docArgs(ref),
642
+ ...docArgs(ref, flags),
607
643
  exact,
608
644
  body: flags.body,
609
645
  ...(typeof flags.prefix === "string" ? { prefix: flags.prefix } : {}),
@@ -617,7 +653,7 @@ async function reply(flags, [ref, threadId, ...rest]) {
617
653
  if (!ref || !threadId || !body) fail("usage: bikeshed reply <id> <thread> <text>");
618
654
  const { origin, token } = resolveAuth(flags);
619
655
  const result = await callTool(origin, token, "reply_to_comment", {
620
- ...docArgs(ref),
656
+ ...docArgs(ref, flags),
621
657
  threadId,
622
658
  body,
623
659
  });
@@ -641,7 +677,7 @@ async function reopenThread(flags, args) {
641
677
  async function setResolved(flags, [ref, threadId, ...rest], verb) {
642
678
  if (!ref || !threadId || rest.length) fail(`usage: bikeshed ${verb} <id> <thread>`);
643
679
  const { origin, token } = resolveAuth(flags);
644
- const result = await callTool(origin, token, `${verb}_thread`, { ...docArgs(ref), threadId });
680
+ const result = await callTool(origin, token, `${verb}_thread`, { ...docArgs(ref, flags), threadId });
645
681
  out(flags.json, result, () => console.log(`${verb === "resolve" ? "resolved" : "reopened"} ${threadId}`));
646
682
  }
647
683
 
@@ -657,7 +693,7 @@ async function suggest(flags, [ref, ...rest]) {
657
693
  const replacement = replacementFrom(flags, usage);
658
694
  const { origin, token } = resolveAuth(flags);
659
695
  const result = await callTool(origin, token, "suggest_edit", {
660
- ...docArgs(ref),
696
+ ...docArgs(ref, flags),
661
697
  exact,
662
698
  replacement,
663
699
  ...(typeof flags.note === "string" ? { note: flags.note } : {}),
@@ -689,7 +725,7 @@ async function revise(flags, [ref, threadId, ...rest]) {
689
725
  const replacement = replacementFrom(flags, usage);
690
726
  const { origin, token } = resolveAuth(flags);
691
727
  const result = await callTool(origin, token, "revise_suggestion", {
692
- ...docArgs(ref),
728
+ ...docArgs(ref, flags),
693
729
  threadId,
694
730
  replacement,
695
731
  ...(typeof flags.note === "string" ? { note: flags.note } : {}),
@@ -705,8 +741,9 @@ async function revise(flags, [ref, threadId, ...rest]) {
705
741
  async function accept(flags, [ref, threadId, ...rest]) {
706
742
  if (!ref || !threadId || rest.length) fail("usage: bikeshed accept <id> <thread>");
707
743
  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 });
744
+ const { id, version } = docArgs(ref, flags);
745
+ // Deciding still takes a real role; a version is only a precondition.
746
+ const result = await callTool(origin, token, "accept_suggestion", { id, ...(version ? { version } : {}), threadId });
710
747
  out(flags.json, result, () => console.log(`accepted ${threadId}; the change is in the draft — \`bikeshed commit\` publishes it`));
711
748
  }
712
749
 
@@ -714,7 +751,8 @@ async function accept(flags, [ref, threadId, ...rest]) {
714
751
  async function commit(flags, [ref, ...rest]) {
715
752
  if (!ref || rest.length) fail("usage: bikeshed commit <id>");
716
753
  const { origin, token } = resolveAuth(flags);
717
- const result = await callTool(origin, token, "commit_draft", { id: docArgs(ref).id });
754
+ const { id, version } = docArgs(ref, { version: flags.base });
755
+ const result = await callTool(origin, token, "commit_draft", { id, ...(version ? { baseVersion: version } : {}) });
718
756
  out(flags.json, result, () =>
719
757
  console.log(result.deduped ? `nothing new: the draft is ${result.versionId} already` : `published ${result.versionId}`),
720
758
  );
@@ -728,9 +766,10 @@ async function commit(flags, [ref, ...rest]) {
728
766
  async function reject(flags, [ref, threadId, ...rest]) {
729
767
  if (!ref || !threadId || rest.length) fail("usage: bikeshed reject <id> <thread> [--reason <why>]");
730
768
  const { origin, token } = resolveAuth(flags);
731
- // Deciding takes a real role on the document, never a link, so only the id is sent.
769
+ const { id, version } = docArgs(ref, flags);
770
+ // Deciding still takes a real role; a version is only a precondition.
732
771
  const result = await callTool(origin, token, "reject_suggestion", {
733
- id: docArgs(ref).id,
772
+ id, ...(version ? { version } : {}),
734
773
  threadId,
735
774
  ...(typeof flags.reason === "string" ? { reason: flags.reason } : {}),
736
775
  });
@@ -741,7 +780,7 @@ async function reject(flags, [ref, threadId, ...rest]) {
741
780
  async function withdraw(flags, [ref, threadId, ...rest]) {
742
781
  if (!ref || !threadId || rest.length) fail("usage: bikeshed withdraw <id> <thread>");
743
782
  const { origin, token } = resolveAuth(flags);
744
- const result = await callTool(origin, token, "withdraw_suggestion", { ...docArgs(ref), threadId });
783
+ const result = await callTool(origin, token, "withdraw_suggestion", { ...docArgs(ref, flags), threadId });
745
784
  out(flags.json, result, () => console.log(`withdrew ${threadId}`));
746
785
  }
747
786
 
@@ -781,20 +820,51 @@ function describeAccess(result) {
781
820
  const where = result.filedIn ? `filed in ${result.filedIn.path}` : "at the root";
782
821
  const who = access.readers === 1 ? "only you can read it" : `${access.readers} people can read it`;
783
822
  const also = access.alsoReadableBy ? `, and ${access.alsoReadableBy}` : "";
784
- return `${where}: ${who}${also}`;
823
+ // A folder's link or public level reaches what is filed beneath it; say which.
824
+ const through = access.throughFolders?.length
825
+ ? ` (through ${access.throughFolders.map((f) => f.path).join(", ")})`
826
+ : "";
827
+ return `${where}: ${who}${also}${through}`;
828
+ }
829
+
830
+ /**
831
+ * A folder's id, and its key when what was pasted is the folder's link,
832
+ * `…/folder/<id>?k=<key>` (SPEC §17 "A folder's reach").
833
+ */
834
+ function folderArgs(ref) {
835
+ if (!/^https?:\/\//i.test(ref)) return { id: ref };
836
+ let url;
837
+ try { url = new URL(ref); } catch { fail(`not a folder link: ${ref}`); }
838
+ const match = url.pathname.match(/^\/folder\/([^/]+)\/?$/);
839
+ if (!match) fail(`not a folder link: ${ref}`);
840
+ const k = url.searchParams.get("k");
841
+ return { id: decodeURIComponent(match[1]), ...(k ? { k } : {}) };
785
842
  }
786
843
 
787
844
  /** Who can read one folder: and so who can read anything filed in it. */
788
- async function folder(flags, [id]) {
789
- if (!id) fail("usage: bikeshed folder <id>");
845
+ async function folder(flags, [ref]) {
846
+ if (!ref) fail("usage: bikeshed folder <id|link>");
790
847
  const { origin, token } = resolveAuth(flags);
791
- const result = await callTool(origin, token, "get_folder", { id });
848
+ const result = await callTool(origin, token, "get_folder", folderArgs(ref));
849
+ // Opened by its link: what it holds, to read, and nothing about who reads it.
850
+ if (result.viaLink) {
851
+ out(flags.json, result, () => {
852
+ console.log(`${result.path.map((p) => p.name).join("/")} ${result.folderId} (read-only, through its link)`);
853
+ for (const f of result.folders) console.log(` ${f.name}/ ${f.folderId}`);
854
+ for (const d of result.documents) console.log(` ${d.title} ${d.url ?? d.docId}`);
855
+ if (result.truncated) console.log(" (more than can be listed at once)");
856
+ });
857
+ return;
858
+ }
792
859
  out(flags.json, result, () => {
793
860
  console.log(`${result.path} ${result.id}${result.canWrite ? "" : " (read-only)"}`);
794
861
  console.log(`${result.readers} can read it${result.readers === 1 ? " (only you)" : ""}:`);
795
862
  for (const person of result.people) console.log(` ${person}`);
796
863
  if (result.morePeople > 0) console.log(` and ${result.morePeople} more`);
797
864
  if (result.teams.length) console.log(`teams: ${result.teams.join(", ")}`);
865
+ for (const f of result.throughFolders ?? []) {
866
+ console.log(`also ${f.visibility === "public" ? "anyone" : "anyone with the link"}, through ${f.path}${f.expiresAt === null ? "" : ` until ${new Date(f.expiresAt).toISOString()}`}`);
867
+ }
798
868
  console.log(`can edit: ${[...result.editors, ...(result.moreEditors ? [`and ${result.moreEditors} more`] : [])].join(", ")}`);
799
869
  });
800
870
  }
@@ -805,11 +875,18 @@ function printManagedAccess(result) {
805
875
  for (const [role, people] of Object.entries(result.direct)) console.log(`${role}: ${people.join(", ") || "none"}`);
806
876
  for (const grant of result.inherited) console.log(`inherited ${grant.role}: ${grant.principal} via ${grant.path}`);
807
877
  for (const [role, people] of Object.entries(result.effective)) console.log(`${role}: ${people.join(", ") || "none"}`);
878
+ for (const r of result.inheritedReach ?? []) {
879
+ console.log(`readable by ${r.visibility === "public" ? "anyone" : "anyone with the folder's link"} through ${r.path}${r.expiresAt === null ? "" : `, until ${new Date(r.expiresAt).toISOString()}`}`);
880
+ }
808
881
  if (result.visibility !== undefined) printVisibility(result);
809
882
  }
810
883
 
811
884
  function printVisibility(result) {
812
- console.log(`visibility: ${result.visibility}; open editing: ${result.publicEdit ? "on" : "off"}; expires: ${result.expiresAt === null ? "never" : new Date(result.expiresAt).toISOString()}`);
885
+ const expires = result.expiresAt === null ? "never" : new Date(result.expiresAt).toISOString();
886
+ // A folder has no open editing, and its date ends only its link.
887
+ console.log(result.publicEdit === undefined
888
+ ? `visibility: ${result.visibility}; link ends: ${expires}`
889
+ : `visibility: ${result.visibility}; open editing: ${result.publicEdit ? "on" : "off"}; expires: ${expires}`);
813
890
  console.log(result.url);
814
891
  }
815
892
 
@@ -832,9 +909,16 @@ async function access(flags, [kind, ref, ...rest]) {
832
909
  out(flags.json, result, () => printManagedAccess(result));
833
910
  }
834
911
 
835
- async function visibility(flags, [ref, level, ...rest]) {
836
- if (!ref || rest.length || (level !== undefined && !["private", "unlisted", "public"].includes(level))) fail("usage: bikeshed visibility <id> [private|unlisted|public] [--public-edit true|false] [--expires <ISO timestamp|none>]");
837
- const args = { id: docArgs(ref).id };
912
+ async function visibility(flags, positionals) {
913
+ // `visibility folder <id> …` and `visibility document <id> …` name the kind;
914
+ // a bare `visibility <id>` is a document, as it always was.
915
+ const named = ["folder", "document"].includes(positionals[0]) && positionals.length > 1;
916
+ const kind = named ? positionals[0] : "document";
917
+ const [ref, level, ...rest] = named ? positionals.slice(1) : positionals;
918
+ const usage = "usage: bikeshed visibility [folder|document] <id> [private|unlisted|public] [--public-edit true|false] [--expires <ISO timestamp|none>]";
919
+ if (!ref || rest.length || (level !== undefined && !["private", "unlisted", "public"].includes(level))) fail(usage);
920
+ if (kind === "folder" && flags["public-edit"] !== undefined) fail("a folder has no open editing: what its link opens is read-only");
921
+ const args = { id: kind === "folder" ? folderArgs(ref).id : docArgs(ref).id };
838
922
  if (level !== undefined) args.visibility = level;
839
923
  if (flags["public-edit"] !== undefined) {
840
924
  if (!["true", "false"].includes(flags["public-edit"])) fail("--public-edit must be true or false");
@@ -848,7 +932,11 @@ async function visibility(flags, [ref, level, ...rest]) {
848
932
  }
849
933
  }
850
934
  const { origin, token } = resolveAuth(flags);
851
- const result = await callTool(origin, token, Object.keys(args).length > 1 ? "set_document_visibility" : "get_document_access", args);
935
+ const changing = Object.keys(args).length > 1;
936
+ const tool = kind === "folder"
937
+ ? (changing ? "set_folder_visibility" : "get_folder_access")
938
+ : (changing ? "set_document_visibility" : "get_document_access");
939
+ const result = await callTool(origin, token, tool, args);
852
940
  out(flags.json, result, () => printVisibility(result));
853
941
  }
854
942
 
@@ -942,7 +1030,7 @@ function printChanges(flags, result) {
942
1030
  async function fork(flags, [ref]) {
943
1031
  if (!ref) fail("usage: bikeshed fork <id> [--version <v>] [--folder <id>] [--title <text>]");
944
1032
  const { origin, token } = resolveAuth(flags);
945
- const args = docArgs(ref);
1033
+ const args = docArgs(ref, flags);
946
1034
  if (typeof flags.version === "string") args.version = flags.version;
947
1035
  if (typeof flags.folder === "string") args.folder = flags.folder;
948
1036
  if (typeof flags.title === "string") args.title = flags.title;
@@ -1094,8 +1182,8 @@ async function open(flags, [ref]) {
1094
1182
  const { origin } = resolveAuth(flags);
1095
1183
  // An unlisted document opens only with its key, and the link is the one place
1096
1184
  // 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)}` : ""}`;
1185
+ const { id, k, version } = docArgs(ref, flags);
1186
+ const url = `${origin}/d/${encodeURIComponent(id)}${version ? `/${encodeURIComponent(version)}` : ""}${k ? `?k=${encodeURIComponent(k)}` : ""}`;
1099
1187
  // Best effort, and the address is printed either way. This used to spawn
1100
1188
  // `start`, which is a cmd.exe builtin rather than a program, so on Windows
1101
1189
  // the spawn failed and, with nothing listening for the error, crashed.
@@ -1134,6 +1222,7 @@ const COMMANDS = {
1134
1222
  fork,
1135
1223
  lineage,
1136
1224
  get,
1225
+ versions,
1137
1226
  comments,
1138
1227
  changes,
1139
1228
  comment,
@@ -1218,6 +1307,10 @@ async function main() {
1218
1307
  return;
1219
1308
  }
1220
1309
 
1310
+ const versionCommands = new Set(["get", "comments", "fork", "open", "comment", "reply", "resolve", "reopen", "suggest", "revise", "withdraw", "accept", "reject"]);
1311
+ if (values.version !== undefined && !versionCommands.has(name)) fail(`--version is not supported by ${name}; update and commit use --base`);
1312
+ if (values.base !== undefined && !["update", "commit"].includes(name)) fail(`--base is not supported by ${name}`);
1313
+
1221
1314
  const command = COMMANDS[name];
1222
1315
  if (!command) fail(`unknown command: ${name}\n\n${USAGE}`);
1223
1316