bikeshed-now 0.12.0 → 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 +34 -4
- package/package.json +1 -1
- package/src/client.mjs +2 -1
- package/src/index.mjs +293 -27
package/README.md
CHANGED
|
@@ -38,18 +38,28 @@ 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
|
-
bikeshed
|
|
49
|
+
bikeshed resolve storage-migration <thread> # closes it, with a note saying you did
|
|
50
|
+
bikeshed update storage-migration proposal.md --base v1 # refused if somebody published since
|
|
48
51
|
|
|
49
52
|
# Given "Can suggest" rather than "Can edit"? Propose the change instead, for a
|
|
50
53
|
# person to accept or reject.
|
|
51
54
|
bikeshed suggest storage-migration "a single weekend" --with "two weekends"
|
|
52
55
|
|
|
56
|
+
# Own the document, or can approve on it? Decide somebody's suggestions: an
|
|
57
|
+
# accept goes into the draft, a rejection carries the reason its author is sent,
|
|
58
|
+
# and commit publishes the draft as one version.
|
|
59
|
+
bikeshed accept storage-migration <thread>
|
|
60
|
+
bikeshed reject storage-migration <thread> --reason "Already in section 3."
|
|
61
|
+
bikeshed commit storage-migration
|
|
62
|
+
|
|
53
63
|
# Something nobody has raised yet? Start a thread on the passage it is about.
|
|
54
64
|
bikeshed comment storage-migration "a single weekend" --body "Is the dual-write fortnight in this?"
|
|
55
65
|
```
|
|
@@ -69,8 +79,28 @@ bikeshed publish platform.md --id 01-platform
|
|
|
69
79
|
# published 01-platform v1 — https://bikeshed.now/d/01-platform
|
|
70
80
|
```
|
|
71
81
|
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
97
|
+
|
|
98
|
+
A rename changes the title and leaves the address alone, so links already sent
|
|
99
|
+
keep working:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
bikeshed rename 01-platform Platform review, second pass
|
|
103
|
+
```
|
|
74
104
|
|
|
75
105
|
Wherever a command takes an id, it also takes the link you were sent. An
|
|
76
106
|
unlisted document you are not a member of needs the link, because its key is in
|
package/package.json
CHANGED
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
|
@@ -53,22 +53,40 @@ 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)
|
|
57
|
+
bikeshed changes <id> --since <cursor|last|all>
|
|
58
|
+
what happened since a cursor; --wait to
|
|
59
|
+
keep checking until something does
|
|
56
60
|
bikeshed comment <id> <text> --body <comment>
|
|
57
61
|
start a thread on a passage
|
|
58
62
|
bikeshed reply <id> <thread> <text> reply in a thread
|
|
63
|
+
bikeshed resolve <id> <thread> close a thread once its point is dealt with
|
|
64
|
+
bikeshed reopen <id> <thread> open a resolved thread again
|
|
59
65
|
bikeshed suggest <id> <text> --with <new text>
|
|
60
66
|
propose new text for a passage, for a
|
|
61
67
|
person to accept or reject (--delete to
|
|
62
68
|
propose removing it)
|
|
69
|
+
bikeshed revise <id> <thread> --with <new text>
|
|
70
|
+
change your suggestion before it is
|
|
71
|
+
decided; what it said before is kept
|
|
72
|
+
bikeshed withdraw <id> <thread> take back your suggestion before it is
|
|
73
|
+
decided; its thread stays
|
|
74
|
+
bikeshed accept <id> <thread> accept a suggestion, as its owner or an
|
|
75
|
+
approver: the change goes into the draft
|
|
76
|
+
bikeshed reject <id> <thread> decline a suggestion, as its owner or an
|
|
77
|
+
approver (--reason <why>)
|
|
78
|
+
bikeshed commit <id> publish the draft as the next version
|
|
63
79
|
bikeshed open <id> open it in a browser
|
|
64
80
|
|
|
65
81
|
Where a command takes <id> it also takes the link you were sent. An unlisted
|
|
66
82
|
document you are not a member of needs the link: its key is in it.
|
|
67
83
|
|
|
68
84
|
Organising
|
|
69
|
-
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
|
|
70
87
|
bikeshed mkdir <name> [--parent <id>] create a folder
|
|
71
88
|
bikeshed mv <id> <folder|root> move a document
|
|
89
|
+
bikeshed rename <id> <title> change a document's title; its address stays
|
|
72
90
|
|
|
73
91
|
Skills
|
|
74
92
|
bikeshed skills skills this deployment publishes
|
|
@@ -93,15 +111,27 @@ Options
|
|
|
93
111
|
--title <text> title for publish or fork (fork default: the original's)
|
|
94
112
|
--id <id> with publish: the address, /d/<id> (default: from the title)
|
|
95
113
|
--folder <id> destination folder for publish or fork
|
|
96
|
-
--version <v> with fork: the version to copy
|
|
97
|
-
|
|
114
|
+
--version <v> with fork: the version to copy; with comments: the
|
|
115
|
+
version to read (default: current); on its own,
|
|
116
|
+
which version of bikeshed this is
|
|
98
117
|
--parent <id> parent folder for mkdir
|
|
99
118
|
--limit <n> with list: rows per page (default 20, max 50)
|
|
100
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
|
|
101
130
|
--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
|
|
131
|
+
--with <text> with suggest or revise: what the passage should say instead
|
|
132
|
+
--delete with suggest or revise: propose deleting the passage
|
|
133
|
+
--note <text> with suggest or revise: why, for whoever decides
|
|
134
|
+
--reason <text> with reject: why, for the suggestion's author
|
|
105
135
|
--prefix <text> with suggest: words just before a passage that repeats
|
|
106
136
|
--suffix <text> with suggest: words just after it
|
|
107
137
|
--watch with publish/update: republish on every save
|
|
@@ -433,9 +463,11 @@ async function publish(flags, [path]) {
|
|
|
433
463
|
}
|
|
434
464
|
// The address is in the human line already; `--json` carried only the id, so
|
|
435
465
|
// anything reading it had to know how to build a URL out of a docId.
|
|
436
|
-
out(flags.json, { ...result, url: result.url ?? `${origin}/d/${result.docId}` }, () =>
|
|
437
|
-
console.log(`published ${result.docId} ${result.versionId} — ${origin}/d/${result.docId}`)
|
|
438
|
-
|
|
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
|
+
});
|
|
439
471
|
return result;
|
|
440
472
|
};
|
|
441
473
|
|
|
@@ -460,9 +492,16 @@ async function update(flags, [ref, path]) {
|
|
|
460
492
|
const bytes = readFileSync(file);
|
|
461
493
|
// An archive goes up as bytes, to the one endpoint that takes them. Read as
|
|
462
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;
|
|
463
498
|
const result = isArchive(file, bytes)
|
|
464
|
-
? await uploadFile(origin, token, file, { update: id, k: doc.k })
|
|
465
|
-
: await callTool(origin, token, "update_document", {
|
|
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
|
+
});
|
|
466
505
|
out(flags.json, { ...result, url: result.url ?? `${origin}/d/${id}` }, () =>
|
|
467
506
|
console.log(
|
|
468
507
|
result.deduped
|
|
@@ -506,6 +545,9 @@ async function comments(flags, [ref]) {
|
|
|
506
545
|
const { origin, token } = resolveAuth(flags);
|
|
507
546
|
const result = await callTool(origin, token, "get_comments", {
|
|
508
547
|
...docArgs(ref),
|
|
548
|
+
// Threads belong to the version they were opened on; a version in a link
|
|
549
|
+
// is not taken as a request for one, only the flag is.
|
|
550
|
+
...(typeof flags.version === "string" ? { version: flags.version } : {}),
|
|
509
551
|
includeResolved: Boolean(flags.all),
|
|
510
552
|
});
|
|
511
553
|
out(flags.json, result, () => {
|
|
@@ -518,11 +560,14 @@ async function comments(flags, [ref]) {
|
|
|
518
560
|
// which passage to change without opening the document.
|
|
519
561
|
console.log(`\n── ${thread.threadId}${thread.section ? ` (${thread.section})` : ""}`);
|
|
520
562
|
if (thread.quote) console.log(` “${thread.quote}”`);
|
|
521
|
-
// A suggested edit says what the passage would become,
|
|
522
|
-
//
|
|
563
|
+
// A suggested edit says what the passage would become, whether it is
|
|
564
|
+
// still open, and what its author proposed before changing it (SPEC §22).
|
|
523
565
|
if (thread.suggestion) {
|
|
524
|
-
const
|
|
525
|
-
console.log(` → ${
|
|
566
|
+
const said = (text) => (text === "" ? "(delete it)" : `“${text}”`);
|
|
567
|
+
console.log(` → ${said(thread.suggestion.replacement)} [${thread.suggestion.state}]`);
|
|
568
|
+
for (const earlier of thread.suggestion.revisions ?? []) {
|
|
569
|
+
console.log(` was ${said(earlier.replacement)}${earlier.note ? ` (${earlier.note})` : ""}`);
|
|
570
|
+
}
|
|
526
571
|
}
|
|
527
572
|
for (const comment of thread.comments) {
|
|
528
573
|
const via = comment.via && comment.via !== "web" ? ` [via ${comment.via}]` : "";
|
|
@@ -568,6 +613,27 @@ async function reply(flags, [ref, threadId, ...rest]) {
|
|
|
568
613
|
out(flags.json, result, () => console.log(`replied in ${threadId}`));
|
|
569
614
|
}
|
|
570
615
|
|
|
616
|
+
/**
|
|
617
|
+
* Close a thread on the current version, or open it again. Either leaves a
|
|
618
|
+
* note in the thread saying who did it and through what, which is how the
|
|
619
|
+
* person who raised the point hears (SPEC §19).
|
|
620
|
+
*/
|
|
621
|
+
// `resolve` alone is node:path's, imported above.
|
|
622
|
+
async function resolveThread(flags, args) {
|
|
623
|
+
await setResolved(flags, args, "resolve");
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function reopenThread(flags, args) {
|
|
627
|
+
await setResolved(flags, args, "reopen");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async function setResolved(flags, [ref, threadId, ...rest], verb) {
|
|
631
|
+
if (!ref || !threadId || rest.length) fail(`usage: bikeshed ${verb} <id> <thread>`);
|
|
632
|
+
const { origin, token } = resolveAuth(flags);
|
|
633
|
+
const result = await callTool(origin, token, `${verb}_thread`, { ...docArgs(ref), threadId });
|
|
634
|
+
out(flags.json, result, () => console.log(`${verb === "resolve" ? "resolved" : "reopened"} ${threadId}`));
|
|
635
|
+
}
|
|
636
|
+
|
|
571
637
|
/**
|
|
572
638
|
* Propose new text for one passage of a document's current version. The
|
|
573
639
|
* passage is quoted as a reader sees it, which is how `comments` prints it, so
|
|
@@ -577,16 +643,12 @@ async function suggest(flags, [ref, ...rest]) {
|
|
|
577
643
|
const exact = rest.join(" ");
|
|
578
644
|
const usage = "usage: bikeshed suggest <id> <text> (--with <new text> | --delete) [--note <why>]";
|
|
579
645
|
if (!ref || !exact) fail(usage);
|
|
580
|
-
const
|
|
581
|
-
if (replacing === Boolean(flags.delete)) fail(usage);
|
|
582
|
-
// An empty --with is what an unset shell variable sends; deleting a passage
|
|
583
|
-
// has to be asked for by name.
|
|
584
|
-
if (replacing && flags.with === "") fail("an empty --with would delete the passage; say --delete if you mean that");
|
|
646
|
+
const replacement = replacementFrom(flags, usage);
|
|
585
647
|
const { origin, token } = resolveAuth(flags);
|
|
586
648
|
const result = await callTool(origin, token, "suggest_edit", {
|
|
587
649
|
...docArgs(ref),
|
|
588
650
|
exact,
|
|
589
|
-
replacement
|
|
651
|
+
replacement,
|
|
590
652
|
...(typeof flags.note === "string" ? { note: flags.note } : {}),
|
|
591
653
|
...(typeof flags.prefix === "string" ? { prefix: flags.prefix } : {}),
|
|
592
654
|
...(typeof flags.suffix === "string" ? { suffix: flags.suffix } : {}),
|
|
@@ -596,6 +658,82 @@ async function suggest(flags, [ref, ...rest]) {
|
|
|
596
658
|
);
|
|
597
659
|
}
|
|
598
660
|
|
|
661
|
+
/** The replacement `suggest` and `revise` were given: --with, or --delete by name. */
|
|
662
|
+
function replacementFrom(flags, usage) {
|
|
663
|
+
const replacing = typeof flags.with === "string";
|
|
664
|
+
if (replacing === Boolean(flags.delete)) fail(usage);
|
|
665
|
+
// An empty --with is what an unset shell variable sends; deleting a passage
|
|
666
|
+
// has to be asked for by name.
|
|
667
|
+
if (replacing && flags.with === "") fail("an empty --with would delete the passage; say --delete if you mean that");
|
|
668
|
+
return replacing ? flags.with : "";
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Change the text of a suggestion of yours that nobody has decided yet. What
|
|
673
|
+
* it said before is kept, for whoever decides (SPEC §22).
|
|
674
|
+
*/
|
|
675
|
+
async function revise(flags, [ref, threadId, ...rest]) {
|
|
676
|
+
const usage = "usage: bikeshed revise <id> <thread> (--with <new text> | --delete) [--note <why>]";
|
|
677
|
+
if (!ref || !threadId || rest.length) fail(usage);
|
|
678
|
+
const replacement = replacementFrom(flags, usage);
|
|
679
|
+
const { origin, token } = resolveAuth(flags);
|
|
680
|
+
const result = await callTool(origin, token, "revise_suggestion", {
|
|
681
|
+
...docArgs(ref),
|
|
682
|
+
threadId,
|
|
683
|
+
replacement,
|
|
684
|
+
...(typeof flags.note === "string" ? { note: flags.note } : {}),
|
|
685
|
+
});
|
|
686
|
+
out(flags.json, result, () => console.log(`changed ${threadId}; what it said before is kept`));
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Accept a suggestion, as the document's owner or an approver. The change is
|
|
691
|
+
* made in the draft, where the decisions of a review gather until `commit`
|
|
692
|
+
* publishes them as one version (SPEC §22).
|
|
693
|
+
*/
|
|
694
|
+
async function accept(flags, [ref, threadId, ...rest]) {
|
|
695
|
+
if (!ref || !threadId || rest.length) fail("usage: bikeshed accept <id> <thread>");
|
|
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, "accept_suggestion", { id: docArgs(ref).id, threadId });
|
|
699
|
+
out(flags.json, result, () => console.log(`accepted ${threadId}; the change is in the draft — \`bikeshed commit\` publishes it`));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** Publish the draft as the document's next version, as "Save as new version" does in the app. */
|
|
703
|
+
async function commit(flags, [ref, ...rest]) {
|
|
704
|
+
if (!ref || rest.length) fail("usage: bikeshed commit <id>");
|
|
705
|
+
const { origin, token } = resolveAuth(flags);
|
|
706
|
+
const result = await callTool(origin, token, "commit_draft", { id: docArgs(ref).id });
|
|
707
|
+
out(flags.json, result, () =>
|
|
708
|
+
console.log(result.deduped ? `nothing new: the draft is ${result.versionId} already` : `published ${result.versionId}`),
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Decline a suggestion, as the document's owner or an approver. The passage
|
|
714
|
+
* stays as it is and the thread closes with a note giving the reason, which
|
|
715
|
+
* its author is sent (SPEC §22).
|
|
716
|
+
*/
|
|
717
|
+
async function reject(flags, [ref, threadId, ...rest]) {
|
|
718
|
+
if (!ref || !threadId || rest.length) fail("usage: bikeshed reject <id> <thread> [--reason <why>]");
|
|
719
|
+
const { origin, token } = resolveAuth(flags);
|
|
720
|
+
// Deciding takes a real role on the document, never a link, so only the id is sent.
|
|
721
|
+
const result = await callTool(origin, token, "reject_suggestion", {
|
|
722
|
+
id: docArgs(ref).id,
|
|
723
|
+
threadId,
|
|
724
|
+
...(typeof flags.reason === "string" ? { reason: flags.reason } : {}),
|
|
725
|
+
});
|
|
726
|
+
out(flags.json, result, () => console.log(`rejected ${threadId}`));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Take back a suggestion of yours that nobody has decided yet; its thread stays. */
|
|
730
|
+
async function withdraw(flags, [ref, threadId, ...rest]) {
|
|
731
|
+
if (!ref || !threadId || rest.length) fail("usage: bikeshed withdraw <id> <thread>");
|
|
732
|
+
const { origin, token } = resolveAuth(flags);
|
|
733
|
+
const result = await callTool(origin, token, "withdraw_suggestion", { ...docArgs(ref), threadId });
|
|
734
|
+
out(flags.json, result, () => console.log(`withdrew ${threadId}`));
|
|
735
|
+
}
|
|
736
|
+
|
|
599
737
|
async function folders(flags) {
|
|
600
738
|
const { origin, token } = resolveAuth(flags);
|
|
601
739
|
const result = await callTool(origin, token, "list_folders", {});
|
|
@@ -611,7 +749,9 @@ async function folders(flags) {
|
|
|
611
749
|
const depth = folder.path.split("/").length - 1;
|
|
612
750
|
const label = `${" ".repeat(depth)}${folder.name}`;
|
|
613
751
|
const note = folder.canWrite ? "" : " (read-only)";
|
|
614
|
-
|
|
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}`);
|
|
615
755
|
}
|
|
616
756
|
if (result.truncated) {
|
|
617
757
|
console.log("\nThere are more folders than this listing shows.");
|
|
@@ -619,6 +759,96 @@ async function folders(flags) {
|
|
|
619
759
|
});
|
|
620
760
|
}
|
|
621
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
|
+
|
|
622
852
|
/**
|
|
623
853
|
* Your own copy of a document you can read. The original's author sees that it
|
|
624
854
|
* was forked — a count, never who — and the fork records where it came from,
|
|
@@ -706,13 +936,30 @@ async function mv(flags, [id, dest]) {
|
|
|
706
936
|
*/
|
|
707
937
|
if (!id || !dest) fail("usage: bikeshed mv <id> <folder|root>");
|
|
708
938
|
const { origin, token } = resolveAuth(flags);
|
|
709
|
-
|
|
939
|
+
// Moving is an editor's act, and a link's key only lets somebody read, so
|
|
940
|
+
// only the id goes along.
|
|
941
|
+
const doc = docArgs(id).id;
|
|
942
|
+
const args = dest === "root" ? { id: doc } : { id: doc, folder: dest };
|
|
710
943
|
const result = await callTool(origin, token, "move_document", args);
|
|
711
944
|
out(flags.json, result, () =>
|
|
712
|
-
console.log(result.folder ? `moved ${
|
|
945
|
+
console.log(result.folder ? `moved ${doc} into ${result.folder}` : `moved ${doc} to the root`),
|
|
713
946
|
);
|
|
714
947
|
}
|
|
715
948
|
|
|
949
|
+
/**
|
|
950
|
+
* Give a document a new title. Its address is its id, which never changes, so
|
|
951
|
+
* every link already sent keeps working (SPEC §4). The title is the rest of the
|
|
952
|
+
* line, so it needs no quotes.
|
|
953
|
+
*/
|
|
954
|
+
async function rename(flags, [ref, ...rest]) {
|
|
955
|
+
const title = rest.join(" ").trim();
|
|
956
|
+
if (!ref || !title) fail("usage: bikeshed rename <id> <title>");
|
|
957
|
+
const { origin, token } = resolveAuth(flags);
|
|
958
|
+
// Renaming takes no link key, as in the workspace list: only the id is sent.
|
|
959
|
+
const result = await callTool(origin, token, "rename_document", { id: docArgs(ref).id, title });
|
|
960
|
+
out(flags.json, result, () => console.log(`renamed ${result.id} to “${result.title}”`));
|
|
961
|
+
}
|
|
962
|
+
|
|
716
963
|
/**
|
|
717
964
|
* Skills are instructions, not code, and they are fetched from the DEPLOYMENT
|
|
718
965
|
* rather than bundled with this CLI on purpose: a deployment running an older
|
|
@@ -757,10 +1004,13 @@ async function skillsCommand(flags, [sub, dir]) {
|
|
|
757
1004
|
});
|
|
758
1005
|
}
|
|
759
1006
|
|
|
760
|
-
async function open(flags, [
|
|
761
|
-
if (!
|
|
1007
|
+
async function open(flags, [ref]) {
|
|
1008
|
+
if (!ref) fail("usage: bikeshed open <id>");
|
|
762
1009
|
const { origin } = resolveAuth(flags);
|
|
763
|
-
|
|
1010
|
+
// An unlisted document opens only with its key, and the link is the one place
|
|
1011
|
+
// the key lives, so a link keeps it.
|
|
1012
|
+
const { id, k } = docArgs(ref);
|
|
1013
|
+
const url = `${origin}/d/${encodeURIComponent(id)}${k ? `?k=${encodeURIComponent(k)}` : ""}`;
|
|
764
1014
|
// Best effort, and the address is printed either way. This used to spawn
|
|
765
1015
|
// `start`, which is a cmd.exe builtin rather than a program, so on Windows
|
|
766
1016
|
// the spawn failed and, with nothing listening for the error, crashed.
|
|
@@ -800,13 +1050,23 @@ const COMMANDS = {
|
|
|
800
1050
|
lineage,
|
|
801
1051
|
get,
|
|
802
1052
|
comments,
|
|
1053
|
+
changes,
|
|
803
1054
|
comment,
|
|
804
1055
|
reply,
|
|
1056
|
+
resolve: resolveThread,
|
|
1057
|
+
reopen: reopenThread,
|
|
805
1058
|
suggest,
|
|
1059
|
+
revise,
|
|
1060
|
+
withdraw,
|
|
1061
|
+
accept,
|
|
1062
|
+
reject,
|
|
1063
|
+
commit,
|
|
806
1064
|
open,
|
|
807
1065
|
folders,
|
|
1066
|
+
folder,
|
|
808
1067
|
mkdir,
|
|
809
1068
|
mv,
|
|
1069
|
+
rename,
|
|
810
1070
|
skills: skillsCommand,
|
|
811
1071
|
};
|
|
812
1072
|
|
|
@@ -837,9 +1097,15 @@ async function main() {
|
|
|
837
1097
|
paste: { type: "boolean", default: false },
|
|
838
1098
|
"no-browser": { type: "boolean", default: false },
|
|
839
1099
|
note: { type: "string" },
|
|
1100
|
+
reason: { type: "string" },
|
|
840
1101
|
body: { type: "string" },
|
|
841
1102
|
prefix: { type: "string" },
|
|
842
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" },
|
|
843
1109
|
},
|
|
844
1110
|
});
|
|
845
1111
|
|