bikeshed-now 0.3.0 → 0.4.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
@@ -34,6 +34,10 @@ Publishing identical bytes is a no-op: the bundle is hashed, and a matching hash
34
34
  mints no new version. So `update` is safe in a loop, in CI, or after a change you
35
35
  are not sure you made.
36
36
 
37
+ A document of several files is a `.zip` with `index.html` or `index.md` at its
38
+ root. `update` with a `.zip` replaces every file; `update` with one `.md` or
39
+ `.html` replaces the entry and keeps the images and stylesheets beside it.
40
+
37
41
  The address comes from the title. To choose it, pass `--id`:
38
42
 
39
43
  ```bash
@@ -44,6 +48,14 @@ bikeshed publish platform.md --id 01-platform
44
48
  If the id is taken, the document is still published, at the id plus a few
45
49
  random characters, and the CLI says so on stderr.
46
50
 
51
+ Wherever a command takes an id, it also takes the link you were sent. An
52
+ unlisted document you are not a member of needs the link, because its key is in
53
+ it; without the key the document answers as an id that does not exist.
54
+
55
+ ```bash
56
+ bikeshed comments 'https://<deployment>/d/build-estimate?k=…'
57
+ ```
58
+
47
59
  `bikeshed --help` lists the rest — listing documents, folders, and installing the
48
60
  agent skills a deployment serves.
49
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bikeshed-now",
3
- "version": "0.3.0",
3
+ "version": "0.4.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
@@ -4,23 +4,27 @@
4
4
  import { readFileSync } from "node:fs";
5
5
  import { basename } from "node:path";
6
6
 
7
- export const CLIENT_NAME = "bikeshed-cli/0.3.0";
7
+ export const CLIENT_NAME = "bikeshed-cli/0.4.0";
8
8
 
9
9
  /**
10
- * Two surfaces, and the split is deliberate rather than untidy.
10
+ * Three surfaces, and the split is deliberate rather than untidy.
11
11
  *
12
12
  * POST /publish takes RAW BYTES, so it is the only way to send a .zip
13
13
  * bundle without base64-inflating it through JSON. It is
14
- * also the exact endpoint the browser uploader calls.
14
+ * also the exact endpoint the browser uploader calls. With
15
+ * `update=<id>` it revises that document instead, which is
16
+ * how `update` sends a .zip.
15
17
  * POST /mcp is the API for everything else — list, read, update,
16
18
  * comments, reply — because those tools already exist and
17
19
  * adding a parallel REST surface would mean two
18
20
  * implementations of the same six operations.
21
+ * GET /content the byte path a browser reads a document through. Only
22
+ * `get` uses it, and only for a source get_document cut.
19
23
  *
20
- * Both carry the same Bearer token, and both stamp provenance server-side. The
21
- * `X-Bikeshed-Client` and `X-Bikeshed-Agent` headers are self-reported and are
22
- * recorded as such: they are for a human reading an activity feed, not for
23
- * anything that makes a decision.
24
+ * All three carry the same Bearer token, and provenance is stamped
25
+ * server-side. The `X-Bikeshed-Client` and `X-Bikeshed-Agent` headers are
26
+ * self-reported and are recorded as such: they are for a human reading an
27
+ * activity feed, not for anything that makes a decision.
24
28
  */
25
29
 
26
30
  function headers(token, extra = {}) {
@@ -79,13 +83,19 @@ export async function callTool(origin, token, name, args = {}) {
79
83
  return result?.structuredContent ?? result;
80
84
  }
81
85
 
82
- /** Upload a file as a NEW document, exactly as the browser uploader does. */
83
- export async function uploadFile(origin, token, path, { title, folder, id } = {}) {
86
+ /**
87
+ * Upload a file as a NEW document, exactly as the browser uploader does — or,
88
+ * with `update`, as a new version of that document, with `k` for an unlisted
89
+ * one.
90
+ */
91
+ export async function uploadFile(origin, token, path, { title, folder, id, update, k } = {}) {
84
92
  const bytes = readFileSync(path);
85
93
  const params = new URLSearchParams({ filename: basename(path) });
86
94
  if (title) params.set("title", title);
87
95
  if (folder) params.set("folderId", folder);
88
96
  if (id) params.set("id", id);
97
+ if (update) params.set("update", update);
98
+ if (k) params.set("k", k);
89
99
 
90
100
  const res = await fetch(`${origin}/publish?${params}`, {
91
101
  method: "POST",
@@ -102,6 +112,27 @@ export async function uploadFile(origin, token, path, { title, folder, id } = {}
102
112
  return res.json();
103
113
  }
104
114
 
115
+ /**
116
+ * A document's whole source file, as bytes.
117
+ *
118
+ * get_document cuts a long source, because its result lands in an agent's
119
+ * context. A command's output lands in a file or a pipe, and a `get` that
120
+ * prints part of a file is a broken `cat`. So the cut source is read again
121
+ * here, through the path a browser uses: the same object, behind the same
122
+ * read, key and expiry checks.
123
+ */
124
+ export async function fetchSource(origin, token, { id, version, sourceFile, k }) {
125
+ const path = [id, version, ...String(sourceFile).split("/")].map(encodeURIComponent).join("/");
126
+ const query = k ? `?${new URLSearchParams({ k })}` : "";
127
+ const res = await fetch(`${origin}/content/${path}${query}`, { headers: headers(token) });
128
+ if (!res.ok) {
129
+ throw new CliError(
130
+ `${id} is longer than one response, and reading the rest failed (${res.status}). Nothing was printed.`,
131
+ );
132
+ }
133
+ return Buffer.from(await res.arrayBuffer());
134
+ }
135
+
105
136
  /**
106
137
  * The skills a deployment publishes, and their text.
107
138
  *
package/src/index.mjs CHANGED
@@ -8,7 +8,15 @@ import { basename, join, resolve } from "node:path";
8
8
  import { createInterface } from "node:readline/promises";
9
9
 
10
10
  import { CONFIG_PATH, readConfig, resolveAuth, writeConfig } from "./config.mjs";
11
- import { CliError, callTool, fetchSkill, listSkills, uploadFile, whoami } from "./client.mjs";
11
+ import {
12
+ CliError,
13
+ callTool,
14
+ fetchSkill,
15
+ fetchSource,
16
+ listSkills,
17
+ uploadFile,
18
+ whoami,
19
+ } from "./client.mjs";
12
20
 
13
21
  /**
14
22
  * This is the END-USER tool, and it is deliberately not the same thing as the
@@ -36,6 +44,9 @@ const USAGE = `bikeshed — publish and revise documents
36
44
  bikeshed reply <id> <thread> <text> reply in a thread
37
45
  bikeshed open <id> open it in a browser
38
46
 
47
+ Where a command takes <id> it also takes the link you were sent. An unlisted
48
+ document you are not a member of needs the link: its key is in it.
49
+
39
50
  Organising
40
51
  bikeshed folders the folder tree, with ids
41
52
  bikeshed mkdir <name> [--parent <id>] create a folder
@@ -46,6 +57,19 @@ Skills
46
57
  bikeshed skills install <dir> write them into an agent's skills directory
47
58
  (Claude Code: ~/.claude/skills)
48
59
 
60
+ What you can publish
61
+ .md .markdown rendered to HTML on the server; get returns the Markdown
62
+ .html .htm stored as written
63
+ .zip several files, index.html or index.md at the root
64
+ Up to 25 MB. An update keeps the format: a .zip replaces every file, one .md
65
+ or .html replaces the entry and keeps the files beside it.
66
+ Scripts run once a person trusts the document in the app: its owner for
67
+ everyone, any reader for themselves. Each update sandboxes it again. Images,
68
+ stylesheets and fonts from other sites load either way. Markdown is
69
+ CommonMark with tables, strikethrough and task lists: Mermaid, math,
70
+ footnotes and front matter show as typed. The deployment's /llms.txt has the
71
+ full list.
72
+
49
73
  Options
50
74
  --json machine-readable output
51
75
  --title <text> title for publish
@@ -81,6 +105,33 @@ function out(json, value, human) {
81
105
  human();
82
106
  }
83
107
 
108
+ /**
109
+ * A document as a person holds it: its id, or the link they were sent. The link
110
+ * is the only place an unlisted document's key exists, so a pasted one is read
111
+ * for both — `…/d/<id>[/<version>]?k=<key>` — and the key goes along as the
112
+ * tools' `k` argument (SPEC §17).
113
+ */
114
+ function docArgs(ref) {
115
+ if (!/^https?:\/\//i.test(ref)) return { id: ref };
116
+ let url;
117
+ try {
118
+ url = new URL(ref);
119
+ } catch {
120
+ fail(`not a document link: ${ref}`);
121
+ }
122
+ const match = url.pathname.match(/^\/d\/([^/]+)(?:\/[^/]+)?\/?$/);
123
+ if (!match) fail(`not a document link: ${ref}`);
124
+ const k = url.searchParams.get("k");
125
+ return { id: decodeURIComponent(match[1]), ...(k ? { k } : {}) };
126
+ }
127
+
128
+ const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]);
129
+
130
+ /** A .zip by name, or by the bytes the deployment itself sniffs for. */
131
+ function isArchive(path, bytes) {
132
+ return /\.zip$/i.test(path) || bytes.subarray(0, 4).equals(ZIP_MAGIC);
133
+ }
134
+
84
135
  // --- Commands --------------------------------------------------------------
85
136
 
86
137
  async function login(flags) {
@@ -179,14 +230,20 @@ async function publish(flags, [path]) {
179
230
  }
180
231
  }
181
232
 
182
- async function update(flags, [id, path]) {
183
- if (!id || !path) fail("usage: bikeshed update <id> <file>");
233
+ async function update(flags, [ref, path]) {
234
+ if (!ref || !path) fail("usage: bikeshed update <id> <file>");
184
235
  const { origin, token } = resolveAuth(flags);
185
236
  const file = resolve(path);
237
+ const doc = docArgs(ref);
238
+ const { id } = doc;
186
239
 
187
240
  const once = async () => {
188
- const content = readFileSync(file, "utf8");
189
- const result = await callTool(origin, token, "update_document", { id, content });
241
+ const bytes = readFileSync(file);
242
+ // An archive goes up as bytes, to the one endpoint that takes them. Read as
243
+ // text, every byte above 0x7f in it would become U+FFFD on the way.
244
+ const result = isArchive(file, bytes)
245
+ ? await uploadFile(origin, token, file, { update: id, k: doc.k })
246
+ : await callTool(origin, token, "update_document", { ...doc, content: bytes.toString("utf8") });
190
247
  out(flags.json, { ...result, url: result.url ?? `${origin}/d/${id}` }, () =>
191
248
  console.log(
192
249
  result.deduped
@@ -201,18 +258,35 @@ async function update(flags, [id, path]) {
201
258
  if (flags.watch) await watchFile(file, once);
202
259
  }
203
260
 
204
- async function get(flags, [id]) {
205
- if (!id) fail("usage: bikeshed get <id>");
261
+ async function get(flags, [ref]) {
262
+ if (!ref) fail("usage: bikeshed get <id>");
206
263
  const { origin, token } = resolveAuth(flags);
207
- const result = await callTool(origin, token, "get_document", { id });
208
- out(flags.json, result, () => process.stdout.write(result.source));
264
+ const doc = docArgs(ref);
265
+ const result = await callTool(origin, token, "get_document", doc);
266
+ // The tool cuts a long source at a fixed length. Printed as it came, the cut
267
+ // copy exited 0 and looked complete, and `update` with an edit of it
268
+ // published a version missing everything past the cut. So a cut source is
269
+ // read whole, or nothing is printed.
270
+ const whole = result.truncated
271
+ ? await fetchSource(origin, token, {
272
+ id: doc.id,
273
+ version: result.version,
274
+ sourceFile: result.sourceFile,
275
+ k: doc.k,
276
+ })
277
+ : null;
278
+ out(
279
+ flags.json,
280
+ whole ? { ...result, truncated: false, source: whole.toString("utf8") } : result,
281
+ () => process.stdout.write(whole ?? result.source),
282
+ );
209
283
  }
210
284
 
211
- async function comments(flags, [id]) {
212
- if (!id) fail("usage: bikeshed comments <id>");
285
+ async function comments(flags, [ref]) {
286
+ if (!ref) fail("usage: bikeshed comments <id>");
213
287
  const { origin, token } = resolveAuth(flags);
214
288
  const result = await callTool(origin, token, "get_comments", {
215
- id,
289
+ ...docArgs(ref),
216
290
  includeResolved: Boolean(flags.all),
217
291
  });
218
292
  out(flags.json, result, () => {
@@ -234,11 +308,15 @@ async function comments(flags, [id]) {
234
308
  });
235
309
  }
236
310
 
237
- async function reply(flags, [id, threadId, ...rest]) {
311
+ async function reply(flags, [ref, threadId, ...rest]) {
238
312
  const body = rest.join(" ");
239
- if (!id || !threadId || !body) fail("usage: bikeshed reply <id> <thread> <text>");
313
+ if (!ref || !threadId || !body) fail("usage: bikeshed reply <id> <thread> <text>");
240
314
  const { origin, token } = resolveAuth(flags);
241
- const result = await callTool(origin, token, "reply_to_comment", { id, threadId, body });
315
+ const result = await callTool(origin, token, "reply_to_comment", {
316
+ ...docArgs(ref),
317
+ threadId,
318
+ body,
319
+ });
242
320
  out(flags.json, result, () => console.log(`replied in ${threadId}`));
243
321
  }
244
322