bikeshed-now 0.14.1 → 0.15.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 CHANGED
@@ -38,14 +38,16 @@ be using the same one: revoke it at `/account` once nothing needs it.
38
38
  bikeshed publish proposal.md --title "Storage migration"
39
39
  # published storage-migration v1 — https://bikeshed.now/d/storage-migration
40
40
 
41
- # Somebody highlights a sentence and says what is wrong with it.
41
+ # Somebody highlights a sentence and says what is wrong with it. Wait for it:
42
+ # the cursor comes from `publish --json`, and every `changes` prints the next one.
43
+ bikeshed changes storage-migration --since <cursor> --wait
42
44
  bikeshed comments storage-migration
43
45
 
44
46
  # Fix that sentence, say what you changed in its thread, then publish the
45
47
  # revision. Reply first: a thread belongs to the version it was written on.
46
48
  bikeshed reply storage-migration <thread> "Rewritten to describe the dual-write period."
47
49
  bikeshed resolve storage-migration <thread> # closes it, with a note saying you did
48
- bikeshed update storage-migration proposal.md
50
+ bikeshed update storage-migration proposal.md --base v1 # refused if somebody published since
49
51
 
50
52
  # Given "Can suggest" rather than "Can edit"? Propose the change instead, for a
51
53
  # person to accept or reject.
@@ -77,8 +79,21 @@ bikeshed publish platform.md --id 01-platform
77
79
  # published 01-platform v1 — https://bikeshed.now/d/01-platform
78
80
  ```
79
81
 
80
- If the id is taken, the document is still published, at the id plus a few
81
- random characters, and the CLI says so on stderr.
82
+ `publish` says where it filed the document and who can read it there.
83
+ `bikeshed folders` counts who can read each folder, and `bikeshed folder <id>`
84
+ names them: filing a document in a folder shares it with all of them, and
85
+ nobody is emailed.
86
+
87
+ Ids are shared by the whole deployment and never change, so choose one every
88
+ time: put the date first on something time-bound
89
+ (`2026-10-09-storage-migration-review`), and the project on something that
90
+ lives on (`billing-storage-migration`, not `migration`). If the id is taken, the
91
+ document is still published, at the id plus a few random characters, and the
92
+ CLI says so on stderr.
93
+
94
+ `bikeshed open <link>` opens a document in your browser, an unlisted one's key
95
+ included. It opens the browser on the machine that runs it, so an agent in CI
96
+ or a container should hand you the link instead.
82
97
 
83
98
  A rename changes the title and leaves the address alone, so links already sent
84
99
  keep working:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bikeshed-now",
3
- "version": "0.14.1",
3
+ "version": "0.15.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/client.mjs CHANGED
@@ -95,7 +95,7 @@ export async function callTool(origin, token, name, args = {}) {
95
95
  * with `update`, as a new version of that document, with `k` for an unlisted
96
96
  * one.
97
97
  */
98
- export async function uploadFile(origin, token, path, { title, folder, id, update, k } = {}) {
98
+ export async function uploadFile(origin, token, path, { title, folder, id, update, k, base } = {}) {
99
99
  const bytes = readFileSync(path);
100
100
  const params = new URLSearchParams({ filename: basename(path) });
101
101
  if (title) params.set("title", title);
@@ -103,6 +103,7 @@ export async function uploadFile(origin, token, path, { title, folder, id, updat
103
103
  if (id) params.set("id", id);
104
104
  if (update) params.set("update", update);
105
105
  if (k) params.set("k", k);
106
+ if (base) params.set("baseVersion", base);
106
107
 
107
108
  const res = await fetch(`${origin}/publish?${params}`, {
108
109
  method: "POST",
package/src/index.mjs CHANGED
@@ -54,6 +54,9 @@ const USAGE = `bikeshed ${VERSION} — publish and revise documents
54
54
  bikeshed get <id> print a document's source
55
55
  bikeshed comments <id> unresolved feedback, with its anchored text
56
56
  (--version <v> for an earlier version's)
57
+ bikeshed changes <id> --since <cursor|last|all>
58
+ what happened since a cursor; --wait to
59
+ keep checking until something does
57
60
  bikeshed comment <id> <text> --body <comment>
58
61
  start a thread on a passage
59
62
  bikeshed reply <id> <thread> <text> reply in a thread
@@ -79,7 +82,8 @@ const USAGE = `bikeshed ${VERSION} — publish and revise documents
79
82
  document you are not a member of needs the link: its key is in it.
80
83
 
81
84
  Organising
82
- bikeshed folders the folder tree, with ids
85
+ bikeshed folders the folder tree, with ids and readers
86
+ bikeshed folder <id> who can read a folder, and so what is in it
83
87
  bikeshed mkdir <name> [--parent <id>] create a folder
84
88
  bikeshed mv <id> <folder|root> move a document
85
89
  bikeshed rename <id> <title> change a document's title; its address stays
@@ -113,6 +117,16 @@ Options
113
117
  --parent <id> parent folder for mkdir
114
118
  --limit <n> with list: rows per page (default 20, max 50)
115
119
  --cursor <id> with list: the page after the one that printed it
120
+ --since <cursor> with changes: a cursor publish, fork, get --json or
121
+ changes printed; "last" where this sign-in left off;
122
+ or "all"
123
+ --wait with changes: keep checking until something happens
124
+ --interval <s> with changes --wait: seconds between checks (default
125
+ 60, at least 30)
126
+ --timeout <s> with changes --wait: give up after this long (default
127
+ 3600), exit 3, and print the cursor to resume from
128
+ --base <v> with update: the version you revised; refused if
129
+ somebody has published since
116
130
  --all with list: every page; with comments: resolved too
117
131
  --with <text> with suggest or revise: what the passage should say instead
118
132
  --delete with suggest or revise: propose deleting the passage
@@ -449,9 +463,11 @@ async function publish(flags, [path]) {
449
463
  }
450
464
  // The address is in the human line already; `--json` carried only the id, so
451
465
  // anything reading it had to know how to build a URL out of a docId.
452
- out(flags.json, { ...result, url: result.url ?? `${origin}/d/${result.docId}` }, () =>
453
- console.log(`published ${result.docId} ${result.versionId} — ${origin}/d/${result.docId}`),
454
- );
466
+ out(flags.json, { ...result, url: result.url ?? `${origin}/d/${result.docId}` }, () => {
467
+ console.log(`published ${result.docId} ${result.versionId} — ${origin}/d/${result.docId}`);
468
+ const where = describeAccess(result);
469
+ if (where) console.log(where);
470
+ });
455
471
  return result;
456
472
  };
457
473
 
@@ -476,9 +492,16 @@ async function update(flags, [ref, path]) {
476
492
  const bytes = readFileSync(file);
477
493
  // An archive goes up as bytes, to the one endpoint that takes them. Read as
478
494
  // text, every byte above 0x7f in it would become U+FFFD on the way.
495
+ // --base: the version this was revised from, so an update over somebody's
496
+ // newer save is refused instead of replacing it (SPEC §19).
497
+ const base = typeof flags.base === "string" && flags.base ? flags.base : undefined;
479
498
  const result = isArchive(file, bytes)
480
- ? await uploadFile(origin, token, file, { update: id, k: doc.k })
481
- : await callTool(origin, token, "update_document", { ...doc, content: bytes.toString("utf8") });
499
+ ? await uploadFile(origin, token, file, { update: id, k: doc.k, base })
500
+ : await callTool(origin, token, "update_document", {
501
+ ...doc,
502
+ content: bytes.toString("utf8"),
503
+ ...(base ? { baseVersion: base } : {}),
504
+ });
482
505
  out(flags.json, { ...result, url: result.url ?? `${origin}/d/${id}` }, () =>
483
506
  console.log(
484
507
  result.deduped
@@ -726,7 +749,9 @@ async function folders(flags) {
726
749
  const depth = folder.path.split("/").length - 1;
727
750
  const label = `${" ".repeat(depth)}${folder.name}`;
728
751
  const note = folder.canWrite ? "" : " (read-only)";
729
- console.log(`${label.padEnd(40)} ${folder.id}${note}`);
752
+ // Filing a document here shares it with everyone counted, and emails nobody.
753
+ const readers = typeof folder.readers === "number" ? ` ${folder.readers} can read` : "";
754
+ console.log(`${label.padEnd(40)} ${folder.id}${readers}${note}`);
730
755
  }
731
756
  if (result.truncated) {
732
757
  console.log("\nThere are more folders than this listing shows.");
@@ -734,6 +759,96 @@ async function folders(flags) {
734
759
  });
735
760
  }
736
761
 
762
+ /**
763
+ * Where a published document is filed and who can read it there, from what the
764
+ * server read after the write, for a person to be told. Empty for a deployment
765
+ * too old to say.
766
+ */
767
+ function describeAccess(result) {
768
+ const access = result.access;
769
+ if (!access) return "";
770
+ const where = result.filedIn ? `filed in ${result.filedIn.path}` : "at the root";
771
+ const who = access.readers === 1 ? "only you can read it" : `${access.readers} people can read it`;
772
+ const also = access.alsoReadableBy ? `, and ${access.alsoReadableBy}` : "";
773
+ return `${where}: ${who}${also}`;
774
+ }
775
+
776
+ /** Who can read one folder: and so who can read anything filed in it. */
777
+ async function folder(flags, [id]) {
778
+ if (!id) fail("usage: bikeshed folder <id>");
779
+ const { origin, token } = resolveAuth(flags);
780
+ const result = await callTool(origin, token, "get_folder", { id });
781
+ out(flags.json, result, () => {
782
+ console.log(`${result.path} ${result.id}${result.canWrite ? "" : " (read-only)"}`);
783
+ console.log(`${result.readers} can read it${result.readers === 1 ? " (only you)" : ""}:`);
784
+ for (const person of result.people) console.log(` ${person}`);
785
+ if (result.morePeople > 0) console.log(` and ${result.morePeople} more`);
786
+ if (result.teams.length) console.log(`teams: ${result.teams.join(", ")}`);
787
+ console.log(`can edit: ${[...result.editors, ...(result.moreEditors ? [`and ${result.moreEditors} more`] : [])].join(", ")}`);
788
+ });
789
+ }
790
+
791
+ /** Exit status when `changes --wait` gives up: distinct from 1, an error. */
792
+ const WAIT_TIMED_OUT = 3;
793
+ /** The shortest wait between checks: each one reads the log and the document. */
794
+ const MIN_INTERVAL_S = 30;
795
+
796
+ /**
797
+ * What happened on a document since a cursor (SPEC §19, ADR 0016).
798
+ *
799
+ * `--since` is required: a check that starts from nowhere in particular
800
+ * reports nothing, and nothing reads as nothing happened. Every page is read
801
+ * before anything is printed, and the cursor printed last is the one to pass
802
+ * next time: replace whatever you held with it.
803
+ *
804
+ * `--wait` keeps checking until something happens, then prints it and exits 0.
805
+ * A harness that wakes an agent when a background command ends (Claude Code
806
+ * does) gets watching from one command. On timeout it exits 3 and still prints
807
+ * the cursor, so the next wait resumes rather than restarts.
808
+ */
809
+ async function changes(flags, [ref]) {
810
+ const usage = "usage: bikeshed changes <id> --since <cursor|last|all> [--wait] [--interval <s>] [--timeout <s>]";
811
+ if (!ref) fail(usage);
812
+ if (typeof flags.since !== "string" || !flags.since) {
813
+ fail(`${usage}\n--since is required: a cursor that publish, fork, get --json or changes printed; "last", where this sign-in left off; or "all"`);
814
+ }
815
+ const interval = Math.max(MIN_INTERVAL_S, flags.interval === undefined ? 60 : Number(flags.interval) || 0);
816
+ const timeout = flags.timeout === undefined ? 3600 : Number(flags.timeout);
817
+ if (!Number.isFinite(timeout) || timeout < 0) fail(usage);
818
+ const { origin, token } = resolveAuth(flags);
819
+ const doc = docArgs(ref);
820
+ const started = Date.now();
821
+ let since = flags.since;
822
+ const events = [];
823
+ for (;;) {
824
+ const result = await callTool(origin, token, "get_changes", { ...doc, since });
825
+ since = result.cursor;
826
+ events.push(...result.events);
827
+ if (result.more) continue;
828
+ const waitFor = Math.max(interval, Number(result.retryAfter) || 0);
829
+ if (!flags.wait || events.length > 0) return printChanges(flags, { ...result, events });
830
+ if ((Date.now() - started) / 1000 + waitFor > timeout) {
831
+ process.exitCode = WAIT_TIMED_OUT;
832
+ return printChanges(flags, { ...result, events, timedOut: true });
833
+ }
834
+ await new Promise((r) => setTimeout(r, waitFor * 1000));
835
+ }
836
+ }
837
+
838
+ function printChanges(flags, result) {
839
+ out(flags.json, result, () => {
840
+ if (result.timedOut) console.log("nothing new before the timeout");
841
+ for (const e of result.events) {
842
+ const quote = e.quote ? ` “${e.quote}”` : "";
843
+ const words = e.body ? `: ${e.body}` : e.replacement !== undefined ? ` → “${e.replacement}”` : "";
844
+ const via = e.channel && e.channel !== "web" ? ` [via ${e.channel}]` : "";
845
+ console.log(`${e.at} ${e.type} ${e.by ?? ""}${via}${e.threadId ? ` (${e.threadId})` : ""}${quote}${words}`);
846
+ }
847
+ if (result.needsReply?.length) console.log(`waiting on a reply: ${result.needsReply.join(", ")}`);
848
+ console.log(`next: --since ${result.cursor}`);
849
+ });
850
+ }
851
+
737
852
  /**
738
853
  * Your own copy of a document you can read. The original's author sees that it
739
854
  * was forked — a count, never who — and the fork records where it came from,
@@ -935,6 +1050,7 @@ const COMMANDS = {
935
1050
  lineage,
936
1051
  get,
937
1052
  comments,
1053
+ changes,
938
1054
  comment,
939
1055
  reply,
940
1056
  resolve: resolveThread,
@@ -947,6 +1063,7 @@ const COMMANDS = {
947
1063
  commit,
948
1064
  open,
949
1065
  folders,
1066
+ folder,
950
1067
  mkdir,
951
1068
  mv,
952
1069
  rename,
@@ -984,6 +1101,11 @@ async function main() {
984
1101
  body: { type: "string" },
985
1102
  prefix: { type: "string" },
986
1103
  suffix: { type: "string" },
1104
+ since: { type: "string" },
1105
+ wait: { type: "boolean", default: false },
1106
+ interval: { type: "string" },
1107
+ timeout: { type: "string" },
1108
+ base: { type: "string" },
987
1109
  },
988
1110
  });
989
1111