@coderook/cli 0.42.6 → 0.43.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/.claude-plugin/plugin.json +19 -19
- package/dist/cbx/src/core/upload.js +55 -1
- package/dist/cli/src/api.js +30 -0
- package/dist/cli/src/cli.js +7 -3
- package/dist/cli/src/workspace_command.js +13 -0
- package/dist/cli/src/workspace_machine.js +16 -22
- package/dist/cli/src/workspace_put.js +150 -0
- package/dist/cli/src/workspace_starter.js +164 -0
- package/dist/cli/src/workspace_threads.js +56 -1
- package/dist/cli/src/workspace_watch.js +159 -0
- package/package.json +68 -68
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "coderook",
|
|
3
|
-
"displayName": "CodeRook",
|
|
4
|
-
"description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, with cbx or plain git, from Claude Code.",
|
|
5
|
-
"version": "0.
|
|
6
|
-
"author": {
|
|
7
|
-
"name": "ACCA Gaming Productions",
|
|
8
|
-
"url": "https://coderook.com"
|
|
9
|
-
},
|
|
10
|
-
"homepage": "https://coderook.com/docs#assistants",
|
|
11
|
-
"license": "Apache-2.0",
|
|
12
|
-
"keywords": [
|
|
13
|
-
"coderook",
|
|
14
|
-
"versioning",
|
|
15
|
-
"snapshots",
|
|
16
|
-
"backup",
|
|
17
|
-
"git"
|
|
18
|
-
]
|
|
19
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "coderook",
|
|
3
|
+
"displayName": "CodeRook",
|
|
4
|
+
"description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, with cbx or plain git, from Claude Code.",
|
|
5
|
+
"version": "0.43.0",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "ACCA Gaming Productions",
|
|
8
|
+
"url": "https://coderook.com"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://coderook.com/docs#assistants",
|
|
11
|
+
"license": "Apache-2.0",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"coderook",
|
|
14
|
+
"versioning",
|
|
15
|
+
"snapshots",
|
|
16
|
+
"backup",
|
|
17
|
+
"git"
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -1938,9 +1938,63 @@ class Uploader {
|
|
|
1938
1938
|
return priorByDigest.get(declaration.sha256) ?? null;
|
|
1939
1939
|
};
|
|
1940
1940
|
const keptAsItWas = (declaration) => storedAs(declaration) !== null;
|
|
1941
|
-
|
|
1941
|
+
/*
|
|
1942
|
+
A copy left stale by somebody else's save is not an edit.
|
|
1943
|
+
|
|
1944
|
+
Both callers select everything, so a file nobody touched here is still
|
|
1945
|
+
"selected". When the version this save starts from holds a newer copy,
|
|
1946
|
+
merged in from somebody else, the untouched old bytes on this disk
|
|
1947
|
+
differ from it, and they were sent as though they were a change: the
|
|
1948
|
+
second unrelated save after a colleague's edit put their file back to
|
|
1949
|
+
what it was (state-matrix R1, reproduced 2026-11-06: v4 reverted v2).
|
|
1950
|
+
|
|
1951
|
+
What the file IS decides it, not where it sits: bytes identical to what
|
|
1952
|
+
this folder last received (request.known) are not this person's work.
|
|
1953
|
+
The version's own copy is kept, and the folder's record still says it
|
|
1954
|
+
received the old bytes, so the file shows as behind until fetched. A
|
|
1955
|
+
file the version no longer holds at all was removed by somebody else,
|
|
1956
|
+
and an untouched copy here does not bring it back.
|
|
1957
|
+
*/
|
|
1958
|
+
const received = request.known ?? null;
|
|
1959
|
+
const staleHere = new Set();
|
|
1960
|
+
if (received && prior.size) {
|
|
1961
|
+
for (const one of hashed.declarations) {
|
|
1962
|
+
if (received[one.path] !== one.sha256)
|
|
1963
|
+
continue;
|
|
1964
|
+
const now = prior.get(one.path);
|
|
1965
|
+
if (now && now.sha256 === one.sha256)
|
|
1966
|
+
continue;
|
|
1967
|
+
staleHere.add(one.path);
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
const declarations = hashed.declarations.filter((one) => !staleHere.has(one.path) && !keptAsItWas(one));
|
|
1942
1971
|
let totalBytes = hashed.totalBytes;
|
|
1943
1972
|
for (const one of hashed.declarations) {
|
|
1973
|
+
if (!staleHere.has(one.path))
|
|
1974
|
+
continue;
|
|
1975
|
+
totalBytes -= one.size;
|
|
1976
|
+
const now = prior.get(one.path);
|
|
1977
|
+
if (!now) {
|
|
1978
|
+
ticked.delete(one.path);
|
|
1979
|
+
continue;
|
|
1980
|
+
}
|
|
1981
|
+
reused.push({
|
|
1982
|
+
path: one.path,
|
|
1983
|
+
sourceSize: now.sourceSize,
|
|
1984
|
+
storedSize: now.storedSize,
|
|
1985
|
+
mediaType: now.mediaType,
|
|
1986
|
+
added: 0,
|
|
1987
|
+
removed: 0,
|
|
1988
|
+
included: true,
|
|
1989
|
+
deleted: false,
|
|
1990
|
+
binary: false,
|
|
1991
|
+
lines: 0,
|
|
1992
|
+
});
|
|
1993
|
+
}
|
|
1994
|
+
(0, profile_js_1.counted)("stale copies kept as the version has them", staleHere.size);
|
|
1995
|
+
for (const one of hashed.declarations) {
|
|
1996
|
+
if (staleHere.has(one.path))
|
|
1997
|
+
continue;
|
|
1944
1998
|
const held = storedAs(one);
|
|
1945
1999
|
if (!held)
|
|
1946
2000
|
continue;
|
package/dist/cli/src/api.js
CHANGED
|
@@ -52,6 +52,7 @@ exports.tokens = tokens;
|
|
|
52
52
|
exports.getPrivatePaths = getPrivatePaths;
|
|
53
53
|
exports.putPrivatePaths = putPrivatePaths;
|
|
54
54
|
exports.callApi = callApi;
|
|
55
|
+
exports.callApiBytes = callApiBytes;
|
|
55
56
|
exports.revokeToken = revokeToken;
|
|
56
57
|
exports.deleteProject = deleteProject;
|
|
57
58
|
exports.projectLabels = projectLabels;
|
|
@@ -851,6 +852,35 @@ async function putPrivatePaths(repositoryId, patterns) {
|
|
|
851
852
|
async function callApi(route, options = {}) {
|
|
852
853
|
return call(route, options);
|
|
853
854
|
}
|
|
855
|
+
/** A GET whose answer is bytes, not JSON (a draft's raw file). Errors still arrive as JSON. */
|
|
856
|
+
async function callApiBytes(route) {
|
|
857
|
+
const token = await (0, config_js_1.loadToken)();
|
|
858
|
+
if (!token)
|
|
859
|
+
throw new Error("Not signed in. Run: cbx sign-in");
|
|
860
|
+
const response = await fetch(`${(0, config_js_1.apiOrigin)()}${route}`, {
|
|
861
|
+
headers: {
|
|
862
|
+
accept: "application/octet-stream, application/json",
|
|
863
|
+
authorization: `Bearer ${token}`,
|
|
864
|
+
"user-agent": "CodeRook-CLI/0.1",
|
|
865
|
+
...(0, identify_js_1.clientHeaders)(),
|
|
866
|
+
},
|
|
867
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
868
|
+
});
|
|
869
|
+
if (!response.ok) {
|
|
870
|
+
const text = await response.text();
|
|
871
|
+
let body = {};
|
|
872
|
+
try {
|
|
873
|
+
body = text ? JSON.parse(text) : {};
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
/* not JSON */
|
|
877
|
+
}
|
|
878
|
+
if (response.status === 401)
|
|
879
|
+
throw new Error("That token was not accepted. Run: cbx sign-in");
|
|
880
|
+
throw new ServiceError(body.error?.message ?? `${route} failed (${response.status})`, response.status, typeof body.error?.code === "string" ? body.error.code : null);
|
|
881
|
+
}
|
|
882
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
883
|
+
}
|
|
854
884
|
async function revokeToken(tokenId) {
|
|
855
885
|
await call(`/v1/account/tokens/${encodeURIComponent(tokenId)}`, {
|
|
856
886
|
method: "DELETE",
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -2786,9 +2786,9 @@ const SPECS = [
|
|
|
2786
2786
|
name: "workspace",
|
|
2787
2787
|
group: "Workspaces",
|
|
2788
2788
|
summary: "edit, save and run a private project remotely without a checkout",
|
|
2789
|
-
usage: "workspace | workspace <usage|open|list|show|read|files|search|diff|patch|save|run|logs|cancel|deploy|preview|handoff|close|recipe|assign|assignments|unassign|checkout|say|thread|mute|unmute> <project> [workspace] [job] | workspace push [folder]",
|
|
2789
|
+
usage: "workspace | workspace <usage|open|list|show|read|files|search|diff|patch|put|starter|save|run|logs|cancel|deploy|preview|handoff|close|recipe|assign|assignments|unassign|checkout|say|thread|mute|unmute> <project> [workspace] [job] | workspace put <project> <workspace> <path…> | workspace push [folder] | workspace watch [project] [workspace]",
|
|
2790
2790
|
brief: "workspace <action> <project>",
|
|
2791
|
-
detail: "Works on CodeRook's durable remote draft, never the current local folder. Use --data-file for structured edits or recipe configuration. Mutations need the reviewed --revision and a unique --key; reuse the same key and request when retrying. Recipes reference existing project workflows. Usage reads the shared workspace credits and separate PR allowance without a draft ID. Saving and deploying are separate operations. Run on its own at a terminal, it lists your open workspaces with their unread messages: choose one to see its changes and what is being discussed, and reply, save, run checks, hand it over or close it with one key. say and thread post to and read a workspace's discussion as JSON; a machine signed in to your account can name itself with --as (or CBX_NAME) so its messages and unread count are its own.",
|
|
2791
|
+
detail: "Works on CodeRook's durable remote draft, never the current local folder. Use --data-file for structured edits or recipe configuration. Mutations need the reviewed --revision and a unique --key; reuse the same key and request when retrying. Recipes reference existing project workflows. Usage reads the shared workspace credits and separate PR allowance without a draft ID. put copies files from this folder into a draft: text as text, images, fonts and other binary files as bytes (up to 1 MiB each), a file that is gone here is removed there, and each file must still be the version the draft holds, so a teammate's edit is never overwritten. starter reads this folder (package.json scripts, Cargo.toml, go.mod, Python) and suggests one check recipe per package so drafts have something to run; --apply adds them, never replacing a recipe that exists. Saving and deploying are separate operations. Run on its own at a terminal, it lists your open workspaces with their unread messages: choose one to see its changes and what is being discussed, and reply, save, run checks, hand it over or close it with one key. say and thread post to and read a workspace's discussion as JSON; a machine signed in to your account can name itself with --as (or CBX_NAME) so its messages and unread count are its own. watch prints each new message, each message addressed to you (@your name, or the name given with --as) and each new saved version as it happens, until stopped; --json gives one JSON object per line for a program.",
|
|
2792
2792
|
options: [
|
|
2793
2793
|
{ flags: "--data-file <path>", description: "read an operation's JSON from this local file" },
|
|
2794
2794
|
{ flags: "--name <name>", description: "name a new workspace" },
|
|
@@ -2813,8 +2813,12 @@ const SPECS = [
|
|
|
2813
2813
|
{ flags: "--unread", description: "thread: only messages this reader has not read" },
|
|
2814
2814
|
{ flags: "--peek", description: "thread: read without marking anything read" },
|
|
2815
2815
|
{ flags: "--after <time>", description: "thread: continue from the next page's time" },
|
|
2816
|
+
{ flags: "--root <folder>", description: "put/starter: the project folder (default: here)" },
|
|
2817
|
+
{ flags: "--apply", description: "starter: create the suggested workflows and recipes" },
|
|
2818
|
+
{ flags: "--json", description: "watch: one JSON object per line; starter: the plan as JSON" },
|
|
2819
|
+
{ flags: "--interval <seconds>", description: "watch: how often to look (default 20, at least 5)" },
|
|
2816
2820
|
],
|
|
2817
|
-
examples: ["cbx workspace open my-project --name \"Laptop work\" --key laptop-open-001", "cbx workspace list my-project", "cbx workspace assign my-project <draft> --name Laptop", "cbx workspace checkout my-project <draft>", "cbx workspace push --save -m \"Fix the export dialog\"", "cbx workspace", "cbx workspace say my-project <draft> -m \"Tests updated\" --as GrokBot", "cbx workspace thread my-project <draft> --unread"],
|
|
2821
|
+
examples: ["cbx workspace open my-project --name \"Laptop work\" --key laptop-open-001", "cbx workspace list my-project", "cbx workspace assign my-project <draft> --name Laptop", "cbx workspace checkout my-project <draft>", "cbx workspace push --save -m \"Fix the export dialog\"", "cbx workspace", "cbx workspace say my-project <draft> -m \"Tests updated\" --as GrokBot", "cbx workspace thread my-project <draft> --unread", "cbx workspace watch my-project --as GrokBot --json", "cbx workspace put my-project <draft> website/app/page.tsx website/public/og.png", "cbx workspace starter my-project --apply"],
|
|
2818
2822
|
run: workspace_command_js_1.commandWorkspace,
|
|
2819
2823
|
},
|
|
2820
2824
|
{
|
|
@@ -9,6 +9,9 @@ const api_js_1 = require("./api.js");
|
|
|
9
9
|
const config_js_1 = require("./config.js");
|
|
10
10
|
const workspace_machine_js_1 = require("./workspace_machine.js");
|
|
11
11
|
const workspace_threads_js_1 = require("./workspace_threads.js");
|
|
12
|
+
const workspace_put_js_1 = require("./workspace_put.js");
|
|
13
|
+
const workspace_starter_js_1 = require("./workspace_starter.js");
|
|
14
|
+
const workspace_watch_js_1 = require("./workspace_watch.js");
|
|
12
15
|
async function commandWorkspace(parsed) {
|
|
13
16
|
const [operation, projectRef, workspace, runId] = parsed.positional;
|
|
14
17
|
if (operation && workspace_machine_js_1.MACHINE_OPERATIONS.has(operation))
|
|
@@ -16,12 +19,22 @@ async function commandWorkspace(parsed) {
|
|
|
16
19
|
/* On its own at a terminal: choose a workspace and see what is being discussed. Scripts get the usage line as before. */
|
|
17
20
|
if (!operation && process.stdin.isTTY && process.stdout.isTTY)
|
|
18
21
|
return (0, workspace_threads_js_1.interactiveWorkspaces)(parsed);
|
|
22
|
+
/* Every draft you can read when no project is named. */
|
|
23
|
+
if (operation === "watch")
|
|
24
|
+
return (0, workspace_watch_js_1.commandWorkspaceWatch)(parsed);
|
|
19
25
|
if (!operation || !projectRef)
|
|
20
26
|
throw new Error("Use cbx workspace <operation> <project> [workspace]. See cbx help workspace.");
|
|
21
27
|
const project = await (0, api_js_1.findProject)(projectRef);
|
|
22
28
|
if (!project)
|
|
23
29
|
throw new Error(`No accessible project ${projectRef}`);
|
|
24
30
|
const root = `/v1/repositories/${encodeURIComponent(project.id)}`;
|
|
31
|
+
if (operation === "starter")
|
|
32
|
+
return (0, workspace_starter_js_1.commandWorkspaceStarter)(parsed, root);
|
|
33
|
+
if (operation === "put") {
|
|
34
|
+
if (!workspace)
|
|
35
|
+
throw new Error("Supply the workspace ID after the project");
|
|
36
|
+
return (0, workspace_put_js_1.commandWorkspacePut)(parsed, root, workspace);
|
|
37
|
+
}
|
|
25
38
|
if (workspace_threads_js_1.THREAD_OPERATIONS.has(operation)) {
|
|
26
39
|
if (!workspace)
|
|
27
40
|
throw new Error("Supply the workspace ID after the project");
|
|
@@ -36,6 +36,7 @@ const api_js_1 = require("./api.js");
|
|
|
36
36
|
const config_js_1 = require("./config.js");
|
|
37
37
|
const machine_js_1 = require("./machine.js");
|
|
38
38
|
const colour_js_1 = require("./colour.js");
|
|
39
|
+
const workspace_put_js_1 = require("./workspace_put.js");
|
|
39
40
|
const RECORD = node_path_1.default.join(".coderook", "workspace.json");
|
|
40
41
|
/* The draft's own limit per text file (backend remote_workspaces.ts MAX_EDIT_BYTES). */
|
|
41
42
|
const MAX_FILE = 1024 * 1024;
|
|
@@ -45,7 +46,6 @@ const MAX_FILE = 1024 * 1024;
|
|
|
45
46
|
well under the cap.
|
|
46
47
|
*/
|
|
47
48
|
const MAX_REQUEST = 2 * 1024 * 1024;
|
|
48
|
-
const MAX_BATCH = 1536 * 1024;
|
|
49
49
|
const encodedSize = (change) => Buffer.byteLength(JSON.stringify(change));
|
|
50
50
|
/** One draft text file in full, from the line-paged read (2,000 lines a request). */
|
|
51
51
|
async function readWhole(draft, filePath, read = (route) => (0, api_js_1.callApi)(route)) {
|
|
@@ -179,14 +179,14 @@ async function checkout(found, draft, workspaceId, into) {
|
|
|
179
179
|
if (change.after.content === null && change.after.reason?.startsWith("page byte limit")) {
|
|
180
180
|
change.after.content = await readWhole(draft, change.path);
|
|
181
181
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (sha(
|
|
182
|
+
/* Not text (an image, a font, an archive): its exact bytes, from the draft's raw read. */
|
|
183
|
+
const body = change.after.content ??
|
|
184
|
+
(await (0, api_js_1.callApiBytes)(`${draft}/raw?path=${encodeURIComponent(change.path)}`));
|
|
185
|
+
if (sha(body) !== change.after.sha256) {
|
|
186
186
|
throw new Error(`The draft's copy of ${change.path} did not arrive exactly; nothing was overwritten past it. Try again.`);
|
|
187
187
|
}
|
|
188
188
|
await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
|
|
189
|
-
await (0, promises_1.writeFile)(target,
|
|
189
|
+
await (0, promises_1.writeFile)(target, body);
|
|
190
190
|
manifest[change.path] = change.after.sha256;
|
|
191
191
|
}
|
|
192
192
|
edited += 1;
|
|
@@ -216,6 +216,8 @@ async function push(parsed, where) {
|
|
|
216
216
|
const rules = await (0, worktree_js_1.readRules)(folder);
|
|
217
217
|
const found = await (0, worktree_js_1.changedFiles)(folder, rules, new Map(Object.entries(record.files)), "synchronize");
|
|
218
218
|
const changes = [];
|
|
219
|
+
const sent = new Map();
|
|
220
|
+
const sizes = new Map();
|
|
219
221
|
const refused = [];
|
|
220
222
|
for (const file of found) {
|
|
221
223
|
if (file.path === RECORD.replaceAll("\\", "/") || file.path.startsWith(".coderook/"))
|
|
@@ -225,11 +227,14 @@ async function push(parsed, where) {
|
|
|
225
227
|
continue;
|
|
226
228
|
}
|
|
227
229
|
const bytes = await (0, promises_1.readFile)(node_path_1.default.join(folder, ...file.path.split("/")));
|
|
228
|
-
if (bytes.length > MAX_FILE
|
|
230
|
+
if (bytes.length > MAX_FILE) {
|
|
229
231
|
refused.push(file.path);
|
|
230
232
|
continue;
|
|
231
233
|
}
|
|
232
|
-
|
|
234
|
+
/* Text as text; images, fonts and other binary files as base64. */
|
|
235
|
+
const change = { path: file.path, ...(0, workspace_put_js_1.encodeFile)(bytes), expectedSha256: record.files[file.path] ?? null };
|
|
236
|
+
sent.set(file.path, sha(bytes));
|
|
237
|
+
sizes.set(file.path, bytes.length);
|
|
233
238
|
/* Under 1 MiB but so full of escapes that it cannot travel in one request. */
|
|
234
239
|
if (encodedSize(change) > MAX_REQUEST - 64 * 1024) {
|
|
235
240
|
refused.push(file.path);
|
|
@@ -238,7 +243,7 @@ async function push(parsed, where) {
|
|
|
238
243
|
changes.push(change);
|
|
239
244
|
}
|
|
240
245
|
if (refused.length) {
|
|
241
|
-
console.error((0, colour_js_1.red)(`${refused.length} file${refused.length === 1 ? " is" : "s are"}
|
|
246
|
+
console.error((0, colour_js_1.red)(`${refused.length} file${refused.length === 1 ? " is" : "s are"} over 1 MiB, which a draft cannot take:`));
|
|
242
247
|
for (const one of refused.slice(0, 20))
|
|
243
248
|
console.error(` ${one}`);
|
|
244
249
|
console.error((0, colour_js_1.dim)("Nothing was sent. Leave them out, or save them with a normal upload from a linked folder."));
|
|
@@ -256,18 +261,7 @@ async function push(parsed, where) {
|
|
|
256
261
|
}
|
|
257
262
|
// In batches the draft accepts, each against the revision the last one made.
|
|
258
263
|
let revision = record.revision;
|
|
259
|
-
for (
|
|
260
|
-
const batch = [];
|
|
261
|
-
let bytes = 0;
|
|
262
|
-
while (start < changes.length) {
|
|
263
|
-
const next = changes[start];
|
|
264
|
-
const size = encodedSize(next);
|
|
265
|
-
if (batch.length && bytes + size > MAX_BATCH)
|
|
266
|
-
break;
|
|
267
|
-
batch.push(next);
|
|
268
|
-
bytes += size;
|
|
269
|
-
start += 1;
|
|
270
|
-
}
|
|
264
|
+
for (const batch of (0, workspace_put_js_1.batches)(changes, sizes)) {
|
|
271
265
|
const result = await (0, api_js_1.callApi)(`${draft}/changes`, {
|
|
272
266
|
method: "POST",
|
|
273
267
|
body: { expectedRevision: revision, operationKey: key("push"), changes: batch },
|
|
@@ -277,7 +271,7 @@ async function push(parsed, where) {
|
|
|
277
271
|
if (one.remove)
|
|
278
272
|
delete record.files[one.path];
|
|
279
273
|
else
|
|
280
|
-
record.files[one.path] =
|
|
274
|
+
record.files[one.path] = sent.get(one.path);
|
|
281
275
|
}
|
|
282
276
|
record.revision = revision;
|
|
283
277
|
await writeRecord(folder, record);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.encodeFile = encodeFile;
|
|
4
|
+
exports.projectPath = projectPath;
|
|
5
|
+
exports.batches = batches;
|
|
6
|
+
exports.editKey = editKey;
|
|
7
|
+
exports.commandWorkspacePut = commandWorkspacePut;
|
|
8
|
+
/**
|
|
9
|
+
* cbx workspace put <project> <workspace> <path…>
|
|
10
|
+
*
|
|
11
|
+
* Copies files from this computer into a draft: the step every teammate and
|
|
12
|
+
* assistant otherwise scripts by hand (read the file, look up the hash the
|
|
13
|
+
* draft holds, build the JSON, keep under the request limits).
|
|
14
|
+
*
|
|
15
|
+
* - Text goes as content; anything else (images, fonts, archives, anything
|
|
16
|
+
* with a NUL byte or invalid UTF-8) goes as contentBase64.
|
|
17
|
+
* - A path that no longer exists here is removed from the draft.
|
|
18
|
+
* - Each change carries the hash the draft holds for that path, so a file a
|
|
19
|
+
* teammate changed meanwhile is refused instead of overwritten.
|
|
20
|
+
* - Large sets are split into several edits (32 files or 1 MiB each), and
|
|
21
|
+
* each edit's key is derived from what it sends, so running the same
|
|
22
|
+
* command again after a dropped connection does not apply anything twice.
|
|
23
|
+
*/
|
|
24
|
+
const node_crypto_1 = require("node:crypto");
|
|
25
|
+
const promises_1 = require("node:fs/promises");
|
|
26
|
+
const node_path_1 = require("node:path");
|
|
27
|
+
const api_js_1 = require("./api.js");
|
|
28
|
+
const MAX_FILES = 32;
|
|
29
|
+
const MAX_BYTES = 1024 * 1024;
|
|
30
|
+
/* A request may be 2 MiB; escaping and base64 make the JSON larger than the bytes, so keep well under. */
|
|
31
|
+
const MAX_JSON = 1536 * 1024;
|
|
32
|
+
/** UTF-8 text with no NUL byte travels as text; every other file as base64. */
|
|
33
|
+
function encodeFile(bytes) {
|
|
34
|
+
if (!bytes.includes(0)) {
|
|
35
|
+
try {
|
|
36
|
+
return { content: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes) };
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* not UTF-8: fall through to base64 */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return { contentBase64: Buffer.from(bytes).toString("base64") };
|
|
43
|
+
}
|
|
44
|
+
/** A project-relative path with forward slashes, refusing anything outside the root. */
|
|
45
|
+
function projectPath(root, given) {
|
|
46
|
+
const full = (0, node_path_1.isAbsolute)(given) ? given : (0, node_path_1.resolve)(root, given);
|
|
47
|
+
const rel = (0, node_path_1.relative)(root, full);
|
|
48
|
+
if (!rel || rel.startsWith("..") || (0, node_path_1.isAbsolute)(rel))
|
|
49
|
+
throw new Error(`${given} is outside ${root}; run from the project folder or pass --root`);
|
|
50
|
+
return rel.split(node_path_1.sep).join("/");
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Groups changes into edits the service accepts: at most 32 files, 1 MiB of
|
|
54
|
+
* file content and 1.5 MiB of JSON each. `sizes` gives each path's bytes on disk.
|
|
55
|
+
*/
|
|
56
|
+
function batches(changes, sizes) {
|
|
57
|
+
const out = [];
|
|
58
|
+
let current = [];
|
|
59
|
+
let bytes = 0;
|
|
60
|
+
let json = 0;
|
|
61
|
+
for (const change of changes) {
|
|
62
|
+
const size = sizes.get(change.path) ?? 0;
|
|
63
|
+
const encoded = Buffer.byteLength(JSON.stringify(change));
|
|
64
|
+
if (current.length && (current.length >= MAX_FILES || bytes + size > MAX_BYTES || json + encoded > MAX_JSON)) {
|
|
65
|
+
out.push(current);
|
|
66
|
+
current = [];
|
|
67
|
+
bytes = 0;
|
|
68
|
+
json = 0;
|
|
69
|
+
}
|
|
70
|
+
current.push(change);
|
|
71
|
+
bytes += size;
|
|
72
|
+
json += encoded;
|
|
73
|
+
}
|
|
74
|
+
if (current.length)
|
|
75
|
+
out.push(current);
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** A key that is the same whenever the same edit is sent to the same revision. */
|
|
79
|
+
function editKey(revision, changes) {
|
|
80
|
+
const hash = (0, node_crypto_1.createHash)("sha256").update(JSON.stringify([revision, changes])).digest("hex");
|
|
81
|
+
return `put-${revision}-${hash.slice(0, 40)}`;
|
|
82
|
+
}
|
|
83
|
+
async function commandWorkspacePut(parsed, root, workspace) {
|
|
84
|
+
const paths = parsed.positional.slice(3);
|
|
85
|
+
if (!paths.length)
|
|
86
|
+
throw new Error("Name the files to put: cbx workspace put <project> <workspace> <path…>");
|
|
87
|
+
const folder = (0, node_path_1.resolve)(String(parsed.flags.get("root") ?? process.cwd()));
|
|
88
|
+
const draft = `${root}/workspaces/${encodeURIComponent(workspace)}`;
|
|
89
|
+
const wanted = [...new Set(paths.map((p) => projectPath(folder, p)))];
|
|
90
|
+
/* The hashes the draft holds now; the listing pages 500 at a time, so stop once every wanted path is found. */
|
|
91
|
+
const held = new Map();
|
|
92
|
+
for (let offset = 0; offset !== null && held.size < wanted.length;) {
|
|
93
|
+
const page = await (0, api_js_1.callApi)(`${draft}/files?offset=${offset}`);
|
|
94
|
+
for (const f of page.files)
|
|
95
|
+
if (wanted.includes(f.path))
|
|
96
|
+
held.set(f.path, f.sha256);
|
|
97
|
+
offset = page.nextOffset;
|
|
98
|
+
}
|
|
99
|
+
const changes = [];
|
|
100
|
+
const unchanged = [];
|
|
101
|
+
const sizes = new Map();
|
|
102
|
+
for (const path of wanted) {
|
|
103
|
+
const local = (0, node_path_1.join)(folder, ...path.split("/"));
|
|
104
|
+
const exists = await (0, promises_1.stat)(local).then((s) => s.isFile(), () => false);
|
|
105
|
+
if (!exists) {
|
|
106
|
+
const sha = held.get(path);
|
|
107
|
+
if (!sha)
|
|
108
|
+
throw new Error(`${path} is neither here nor in the draft`);
|
|
109
|
+
changes.push({ path, remove: true, expectedSha256: sha });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const bytes = await (0, promises_1.readFile)(local);
|
|
113
|
+
if (bytes.length > MAX_BYTES)
|
|
114
|
+
throw new Error(`${path} is ${bytes.length} bytes; a draft takes files up to 1 MiB. Save larger files with cbx submit.`);
|
|
115
|
+
/* Already what the draft holds: nothing to send, so running put twice changes nothing. */
|
|
116
|
+
if ((0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex") === held.get(path)) {
|
|
117
|
+
unchanged.push(path);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
sizes.set(path, bytes.length);
|
|
121
|
+
changes.push({ path, ...encodeFile(bytes), expectedSha256: held.get(path) ?? null });
|
|
122
|
+
}
|
|
123
|
+
if (!changes.length) {
|
|
124
|
+
console.log(JSON.stringify({ workspace, written: [], removed: [], binary: [], unchanged }, null, 2));
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
let revision = parsed.flags.has("revision")
|
|
128
|
+
? Number(parsed.flags.get("revision"))
|
|
129
|
+
: (await (0, api_js_1.callApi)(draft)).workspace.revision;
|
|
130
|
+
const written = [];
|
|
131
|
+
const removed = [];
|
|
132
|
+
const binary = [];
|
|
133
|
+
for (const batch of batches(changes, sizes)) {
|
|
134
|
+
const out = await (0, api_js_1.callApi)(`${draft}/changes`, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
body: { expectedRevision: revision, operationKey: editKey(revision, batch), changes: batch },
|
|
137
|
+
});
|
|
138
|
+
revision = out.workspace.revision;
|
|
139
|
+
for (const c of batch) {
|
|
140
|
+
if ("remove" in c)
|
|
141
|
+
removed.push(c.path);
|
|
142
|
+
else
|
|
143
|
+
written.push(c.path);
|
|
144
|
+
if ("contentBase64" in c)
|
|
145
|
+
binary.push(c.path);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
console.log(JSON.stringify({ workspace, revision, written, removed, binary, unchanged }, null, 2));
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectStarters = detectStarters;
|
|
4
|
+
exports.commandWorkspaceStarter = commandWorkspaceStarter;
|
|
5
|
+
/**
|
|
6
|
+
* cbx workspace starter <project> [--apply] [--root folder]
|
|
7
|
+
*
|
|
8
|
+
* A draft can run checks only through recipes, and a recipe needs a workflow
|
|
9
|
+
* with a command behind it. A new project has neither, so the first person or
|
|
10
|
+
* assistant to open a draft finds "An administrator must configure this named
|
|
11
|
+
* workspace recipe first" and nothing to run.
|
|
12
|
+
*
|
|
13
|
+
* This reads the project folder and proposes one check recipe per package it
|
|
14
|
+
* recognises (Node by its lockfile, Python, Rust, Go), shows the plan, and with
|
|
15
|
+
* --apply creates the workflows and recipes. It never replaces a recipe or a
|
|
16
|
+
* workflow that already exists, and it never makes deploy recipes: those carry
|
|
17
|
+
* credentials and targets, which only the project's owner can choose.
|
|
18
|
+
*/
|
|
19
|
+
const node_fs_1 = require("node:fs");
|
|
20
|
+
const promises_1 = require("node:fs/promises");
|
|
21
|
+
const node_path_1 = require("node:path");
|
|
22
|
+
const api_js_1 = require("./api.js");
|
|
23
|
+
const colour_js_1 = require("./colour.js");
|
|
24
|
+
const SKIP = new Set(["node_modules", ".git", ".coderook", ".rook", "dist", "build", "target", ".venv", "venv", "vendor", ".next"]);
|
|
25
|
+
const slug = (directory) => (directory.split("/").pop() || "project").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "project";
|
|
26
|
+
function nodeStarter(directory, seen) {
|
|
27
|
+
const at = (name) => (directory ? `${directory}/${name}` : name);
|
|
28
|
+
let scripts = {};
|
|
29
|
+
try {
|
|
30
|
+
scripts = JSON.parse(seen.get(at("package.json")) ?? "{}").scripts ?? {};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
const has = (name) => typeof scripts[name] === "string" && !/no test specified/.test(String(scripts[name]));
|
|
36
|
+
/* Fastest first, so a draft hears about a type error before waiting on the tests. */
|
|
37
|
+
const steps = ["check", "typecheck", "lint", "build", "test"].filter(has);
|
|
38
|
+
if (!steps.length)
|
|
39
|
+
return null;
|
|
40
|
+
const [install, run] = seen.has(at("pnpm-lock.yaml"))
|
|
41
|
+
? ["corepack enable && pnpm install --frozen-lockfile", "pnpm run"]
|
|
42
|
+
: seen.has(at("yarn.lock"))
|
|
43
|
+
? ["corepack enable && yarn install --immutable", "yarn run"]
|
|
44
|
+
: seen.has(at("package-lock.json"))
|
|
45
|
+
? ["npm ci", "npm run"]
|
|
46
|
+
: ["npm install", "npm run"];
|
|
47
|
+
return {
|
|
48
|
+
recipe: `${slug(directory)}-check`,
|
|
49
|
+
workflow: `Draft check · ${directory || "project"}`,
|
|
50
|
+
directory,
|
|
51
|
+
command: [install, ...steps.map((s) => `${run} ${s}`)].join(" && "),
|
|
52
|
+
because: `package.json scripts: ${steps.join(", ")}`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** The starters a folder's files suggest, one per recognised package. */
|
|
56
|
+
function detectStarters(seen) {
|
|
57
|
+
const directories = new Set();
|
|
58
|
+
for (const path of seen.keys()) {
|
|
59
|
+
const cut = path.lastIndexOf("/");
|
|
60
|
+
directories.add(cut === -1 ? "" : path.slice(0, cut));
|
|
61
|
+
}
|
|
62
|
+
const out = [];
|
|
63
|
+
for (const directory of [...directories].sort()) {
|
|
64
|
+
const at = (name) => (directory ? `${directory}/${name}` : name);
|
|
65
|
+
const node = seen.has(at("package.json")) ? nodeStarter(directory, seen) : null;
|
|
66
|
+
if (node)
|
|
67
|
+
out.push(node);
|
|
68
|
+
else if (seen.has(at("Cargo.toml")))
|
|
69
|
+
out.push({ recipe: `${slug(directory)}-check`, workflow: `Draft check · ${directory || "project"}`, directory, command: "cargo test", because: "Cargo.toml" });
|
|
70
|
+
else if (seen.has(at("go.mod")))
|
|
71
|
+
out.push({ recipe: `${slug(directory)}-check`, workflow: `Draft check · ${directory || "project"}`, directory, command: "go vet ./... && go test ./...", because: "go.mod" });
|
|
72
|
+
else if (seen.has(at("pyproject.toml")) || seen.has(at("requirements.txt"))) {
|
|
73
|
+
const install = seen.has(at("requirements.txt")) ? "python -m pip install -r requirements.txt" : "python -m pip install -e .";
|
|
74
|
+
out.push({ recipe: `${slug(directory)}-check`, workflow: `Draft check · ${directory || "project"}`, directory, command: `${install} && python -m pytest`, because: seen.has(at("pyproject.toml")) ? "pyproject.toml" : "requirements.txt" });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/* Two folders with the same last name ("app/web", "site/web") keep apart by their full path. */
|
|
78
|
+
const names = new Map();
|
|
79
|
+
for (const s of out)
|
|
80
|
+
names.set(s.recipe, (names.get(s.recipe) ?? 0) + 1);
|
|
81
|
+
for (const s of out)
|
|
82
|
+
if ((names.get(s.recipe) ?? 0) > 1)
|
|
83
|
+
s.recipe = `${s.directory.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "project"}-check`;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
const MARKERS = new Set(["package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "Cargo.toml", "go.mod", "pyproject.toml", "requirements.txt"]);
|
|
87
|
+
/** The marker files in a folder, two levels deep, skipping dependencies and build output. */
|
|
88
|
+
async function scan(root) {
|
|
89
|
+
const seen = new Map();
|
|
90
|
+
const walk = async (rel, depth) => {
|
|
91
|
+
const entries = await (0, promises_1.readdir)((0, node_path_1.join)(root, ...rel.split("/").filter(Boolean)), { withFileTypes: true }).catch(() => []);
|
|
92
|
+
for (const entry of entries) {
|
|
93
|
+
const path = rel ? `${rel}/${entry.name}` : entry.name;
|
|
94
|
+
if (entry.isDirectory()) {
|
|
95
|
+
if (depth < 2 && !SKIP.has(entry.name) && !entry.name.startsWith("."))
|
|
96
|
+
await walk(path, depth + 1);
|
|
97
|
+
}
|
|
98
|
+
else if (MARKERS.has(entry.name)) {
|
|
99
|
+
seen.set(path, entry.name === "package.json" ? await (0, promises_1.readFile)((0, node_path_1.join)(root, ...path.split("/")), "utf8") : "");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
await walk("", 0);
|
|
104
|
+
return seen;
|
|
105
|
+
}
|
|
106
|
+
async function commandWorkspaceStarter(parsed, root) {
|
|
107
|
+
const folder = (0, node_path_1.resolve)(String(parsed.flags.get("root") ?? process.cwd()));
|
|
108
|
+
if (!(0, node_fs_1.existsSync)(folder))
|
|
109
|
+
throw new Error(`${folder} does not exist`);
|
|
110
|
+
const starters = detectStarters(await scan(folder));
|
|
111
|
+
const existing = await (0, api_js_1.callApi)(`${root}/workspace-recipes`);
|
|
112
|
+
const taken = new Set((existing.recipes ?? []).map((r) => r.name));
|
|
113
|
+
const fresh = starters.filter((s) => !taken.has(s.recipe));
|
|
114
|
+
const json = parsed.flags.has("json");
|
|
115
|
+
if (!starters.length) {
|
|
116
|
+
if (json)
|
|
117
|
+
console.log(JSON.stringify({ starters: [] }));
|
|
118
|
+
else
|
|
119
|
+
console.log("No package.json scripts, Cargo.toml, go.mod or Python project found here. Pass --root with the project folder.");
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
if (!parsed.flags.has("apply")) {
|
|
123
|
+
if (json)
|
|
124
|
+
console.log(JSON.stringify({ starters, existing: [...taken] }, null, 2));
|
|
125
|
+
else {
|
|
126
|
+
for (const s of starters) {
|
|
127
|
+
const kept = taken.has(s.recipe);
|
|
128
|
+
console.log(`${kept ? (0, colour_js_1.dim)("exists ") : (0, colour_js_1.green)("new ")} ${(0, colour_js_1.bold)(s.recipe)} ${(0, colour_js_1.dim)(`in ${s.directory || "."}`)}`);
|
|
129
|
+
if (!kept)
|
|
130
|
+
console.log(` ${s.command}\n ${(0, colour_js_1.dim)(s.because)}`);
|
|
131
|
+
}
|
|
132
|
+
console.log(fresh.length
|
|
133
|
+
? (0, colour_js_1.dim)(`\nNothing was created. Add ${fresh.length === 1 ? "it" : "them"} with `) + (0, colour_js_1.accent)("--apply") + (0, colour_js_1.dim)("; drafts then run them with cbx workspace run <project> <draft> --recipe <name>.")
|
|
134
|
+
: (0, colour_js_1.dim)("\nEvery suggested recipe already exists."));
|
|
135
|
+
}
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
const project = root.split("/").pop();
|
|
139
|
+
const known = new Map((await (0, api_js_1.workflows)(decodeURIComponent(project))).map((w) => [w.name, w.id]));
|
|
140
|
+
const made = [];
|
|
141
|
+
for (const s of fresh) {
|
|
142
|
+
/* A workflow of the same name is reused as it is, never rewritten. */
|
|
143
|
+
let workflowId = known.get(s.workflow);
|
|
144
|
+
if (!workflowId) {
|
|
145
|
+
const created = await (0, api_js_1.callApi)(`${root}/workflows`, {
|
|
146
|
+
method: "POST",
|
|
147
|
+
body: { name: s.workflow, trigger: "manual", command: s.command, workingDirectory: s.directory || null, runsOn: "hosted" },
|
|
148
|
+
});
|
|
149
|
+
workflowId = created.id;
|
|
150
|
+
}
|
|
151
|
+
await (0, api_js_1.callApi)(`${root}/workspace-recipes/${encodeURIComponent(s.recipe)}`, {
|
|
152
|
+
method: "PUT",
|
|
153
|
+
body: { kind: "check", workflowId, includePaths: s.directory ? [`${s.directory}/`] : [] },
|
|
154
|
+
});
|
|
155
|
+
made.push(s.recipe);
|
|
156
|
+
}
|
|
157
|
+
if (json)
|
|
158
|
+
console.log(JSON.stringify({ created: made, existing: [...taken] }, null, 2));
|
|
159
|
+
else if (made.length)
|
|
160
|
+
console.log(`${(0, colour_js_1.green)("Added")} ${made.join(", ")}. Run one on a draft with ${(0, colour_js_1.accent)("cbx workspace run <project> <draft> --recipe <name>")}.`);
|
|
161
|
+
else
|
|
162
|
+
console.log("Every suggested recipe already exists; nothing was changed.");
|
|
163
|
+
return 0;
|
|
164
|
+
}
|
|
@@ -31,6 +31,7 @@ const node_readline_1 = __importDefault(require("node:readline"));
|
|
|
31
31
|
const api_js_1 = require("./api.js");
|
|
32
32
|
const colour_js_1 = require("./colour.js");
|
|
33
33
|
const config_js_1 = require("./config.js");
|
|
34
|
+
const workspace_watch_js_1 = require("./workspace_watch.js");
|
|
34
35
|
exports.THREAD_OPERATIONS = new Set(["say", "thread", "mute", "unmute"]);
|
|
35
36
|
/** The name this machine posts and reads as, if it gave one. */
|
|
36
37
|
function authorName(parsed) {
|
|
@@ -100,14 +101,68 @@ async function unreadReminder(command, parsed) {
|
|
|
100
101
|
const waiting = found?.workspaces.filter((w) => w.unread > 0) ?? [];
|
|
101
102
|
if (!waiting.length)
|
|
102
103
|
return;
|
|
104
|
+
/*
|
|
105
|
+
Messages addressed to this reader come first, with who sent them and
|
|
106
|
+
how to answer: a teammate or an assistant asked by name should not have
|
|
107
|
+
to find that in a count. Each is shown once; the count follows.
|
|
108
|
+
*/
|
|
109
|
+
const addressed = await Promise.race([
|
|
110
|
+
addressedTo(parsed, waiting.slice(0, 3)),
|
|
111
|
+
new Promise((resolve) => setTimeout(() => resolve([]), 1500).unref()),
|
|
112
|
+
]);
|
|
113
|
+
for (const one of addressed.slice(0, 2)) {
|
|
114
|
+
console.log(`\n${(0, colour_js_1.accent)("●")} ${(0, colour_js_1.bold)("For you")} ${(0, colour_js_1.dim)("in")} ${one.workspace.name} ${(0, colour_js_1.dim)("from")} ${one.from}: ${one.snippet}` +
|
|
115
|
+
`\n ${(0, colour_js_1.dim)(`cbx workspace thread ${one.workspace.project} ${one.workspace.id} --unread`)}`);
|
|
116
|
+
}
|
|
103
117
|
const total = waiting.reduce((n, w) => n + w.unread, 0);
|
|
104
118
|
const where = waiting.length === 1 ? `in ${waiting[0].name}` : `in ${waiting.length} workspaces`;
|
|
105
|
-
console.log(
|
|
119
|
+
console.log(`${addressed.length ? "" : "\n"}${(0, colour_js_1.accent)("●")} ${(0, colour_js_1.bold)(`${total} new message${total === 1 ? "" : "s"}`)} ${(0, colour_js_1.dim)(where)} ${(0, colour_js_1.dim)("· cbx workspace")}`);
|
|
106
120
|
}
|
|
107
121
|
catch {
|
|
108
122
|
/* A reminder is never worth an error. */
|
|
109
123
|
}
|
|
110
124
|
}
|
|
125
|
+
/**
|
|
126
|
+
* Unread messages that name this reader (@name), not yet shown on this
|
|
127
|
+
* machine. A machine answers to the name it runs as; a person to their
|
|
128
|
+
* username and display name, read once a day and kept beside the config.
|
|
129
|
+
*/
|
|
130
|
+
async function addressedTo(parsed, waiting) {
|
|
131
|
+
const as = authorName(parsed);
|
|
132
|
+
const names = as ? [as] : await cachedReaderNames();
|
|
133
|
+
if (!names.length)
|
|
134
|
+
return [];
|
|
135
|
+
const shownFile = node_path_1.default.join((0, config_js_1.configDirectory)(), "mentions-shown.json");
|
|
136
|
+
const shown = new Set(await (0, promises_1.readFile)(shownFile, "utf8").then((t) => JSON.parse(t)).catch(() => []));
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const workspace of waiting) {
|
|
139
|
+
const thread = await (0, api_js_1.callApi)(`/v1/repositories/${encodeURIComponent(workspace.projectId)}/workspaces/${encodeURIComponent(workspace.id)}/messages${join(asQuery(as), "unread=true", "peek=true")}`);
|
|
140
|
+
for (const message of thread.messages) {
|
|
141
|
+
if (message.kind !== "message" || message.author.mine || shown.has(message.id) || !(0, workspace_watch_js_1.mentions)(message.body, names))
|
|
142
|
+
continue;
|
|
143
|
+
shown.add(message.id);
|
|
144
|
+
const text = message.body.replace(/\s+/g, " ").trim();
|
|
145
|
+
out.push({ workspace, from: (0, workspace_watch_js_1.authorLabel)(message.author), snippet: text.length > 140 ? `${text.slice(0, 139)}…` : text });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (out.length)
|
|
149
|
+
await (0, promises_1.writeFile)(shownFile, JSON.stringify([...shown].slice(-500))).catch(() => undefined);
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
async function cachedReaderNames() {
|
|
153
|
+
const file = node_path_1.default.join((0, config_js_1.configDirectory)(), "reader-names.json");
|
|
154
|
+
try {
|
|
155
|
+
const kept = JSON.parse(await (0, promises_1.readFile)(file, "utf8"));
|
|
156
|
+
if (Date.now() - kept.at < 86_400_000)
|
|
157
|
+
return kept.names;
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
/* Not read yet on this machine. */
|
|
161
|
+
}
|
|
162
|
+
const names = await (0, workspace_watch_js_1.readerNames)();
|
|
163
|
+
await (0, promises_1.writeFile)(file, JSON.stringify({ at: Date.now(), names })).catch(() => undefined);
|
|
164
|
+
return names;
|
|
165
|
+
}
|
|
111
166
|
/* ----------------------------------------------------------- the picker */
|
|
112
167
|
const when = (iso) => {
|
|
113
168
|
const date = new Date(iso);
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mentions = mentions;
|
|
4
|
+
exports.freshEvents = freshEvents;
|
|
5
|
+
exports.freshSaves = freshSaves;
|
|
6
|
+
exports.authorLabel = authorLabel;
|
|
7
|
+
exports.describe = describe;
|
|
8
|
+
exports.readerNames = readerNames;
|
|
9
|
+
exports.commandWorkspaceWatch = commandWorkspaceWatch;
|
|
10
|
+
/**
|
|
11
|
+
* cbx workspace watch [project] [workspace]
|
|
12
|
+
*
|
|
13
|
+
* What a person or an assistant working on drafts needs to hear about, as it
|
|
14
|
+
* happens: new messages in the threads it can read, messages addressed to it
|
|
15
|
+
* (@mentions), and new saved versions. One line per event until stopped,
|
|
16
|
+
* plain text at a terminal or JSON lines with --json for a program to read.
|
|
17
|
+
*
|
|
18
|
+
* Built so neither a teammate nor an assistant has to write its own poller.
|
|
19
|
+
* It reads with peek, so watching never marks anything read: the reader still
|
|
20
|
+
* sees the thread as unread when it opens it.
|
|
21
|
+
*/
|
|
22
|
+
const api_js_1 = require("./api.js");
|
|
23
|
+
const colour_js_1 = require("./colour.js");
|
|
24
|
+
const workspace_threads_js_1 = require("./workspace_threads.js");
|
|
25
|
+
const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
26
|
+
/**
|
|
27
|
+
* Whether a message names this reader as @name, as a whole word (the same
|
|
28
|
+
* rule the bell uses). A reader can answer to several names: the one a
|
|
29
|
+
* machine runs as, or a person's username and display name.
|
|
30
|
+
*/
|
|
31
|
+
function mentions(body, names) {
|
|
32
|
+
const list = (typeof names === "string" ? [names] : names ?? [])
|
|
33
|
+
.map((name) => name?.trim())
|
|
34
|
+
.filter((name) => Boolean(name));
|
|
35
|
+
return list.some((name) => new RegExp(`(^|[^\\p{L}\\p{N}_])@${escapeRegExp(name)}(?![\\p{L}\\p{N}_-])`, "iu").test(body));
|
|
36
|
+
}
|
|
37
|
+
/** The events in one look that have not been reported, oldest first; updates state. */
|
|
38
|
+
function freshEvents(state, draft, messages, names) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const m of [...messages].sort((a, b) => a.createdAt.localeCompare(b.createdAt))) {
|
|
41
|
+
if (state.seen.has(m.id))
|
|
42
|
+
continue;
|
|
43
|
+
state.seen.add(m.id);
|
|
44
|
+
if (Date.parse(m.createdAt) < state.since || m.author.mine)
|
|
45
|
+
continue;
|
|
46
|
+
const where = { project: draft.project, projectId: draft.projectId, workspace: draft.id, workspaceName: draft.name, id: m.id, at: m.createdAt };
|
|
47
|
+
if (m.kind === "event")
|
|
48
|
+
out.push({ type: "event", ...where, body: m.body });
|
|
49
|
+
else
|
|
50
|
+
out.push({ type: mentions(m.body, names) ? "mention" : "message", ...where, author: m.author, body: m.body });
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
/** New saved versions since the last look; the first look only records where things stand. */
|
|
55
|
+
function freshSaves(state, project, versions) {
|
|
56
|
+
const numbers = versions
|
|
57
|
+
.map((v) => ({ sequence: Number(v.sequence), message: v.message ?? "" }))
|
|
58
|
+
.filter((v) => Number.isFinite(v.sequence));
|
|
59
|
+
const top = numbers.reduce((max, v) => Math.max(max, v.sequence), 0);
|
|
60
|
+
const before = state.newest.get(project.id);
|
|
61
|
+
state.newest.set(project.id, Math.max(before ?? 0, top));
|
|
62
|
+
if (before === undefined)
|
|
63
|
+
return [];
|
|
64
|
+
return numbers
|
|
65
|
+
.filter((v) => v.sequence > before)
|
|
66
|
+
.sort((a, b) => a.sequence - b.sequence)
|
|
67
|
+
.map((v) => ({ type: "save", project: project.name, projectId: project.id, sequence: v.sequence, message: v.message }));
|
|
68
|
+
}
|
|
69
|
+
const flat = (text, most) => {
|
|
70
|
+
const one = text.replace(/\s+/g, " ").trim();
|
|
71
|
+
return one.length > most ? `${one.slice(0, most - 1)}…` : one;
|
|
72
|
+
};
|
|
73
|
+
/** Who wrote it, and how sure the service is: a person on the site, a machine on its own token, or a typed name. */
|
|
74
|
+
function authorLabel(author) {
|
|
75
|
+
if (author.kind === "person")
|
|
76
|
+
return `${author.label} (site)`;
|
|
77
|
+
if (author.kind === "token")
|
|
78
|
+
return `${author.label} (own token)`;
|
|
79
|
+
return `${author.label} (named, not verified)`;
|
|
80
|
+
}
|
|
81
|
+
function describe(event) {
|
|
82
|
+
if (event.type === "save")
|
|
83
|
+
return `${(0, colour_js_1.accent)("save")} ${event.project} v${event.sequence} ${flat(event.message, 160)}`;
|
|
84
|
+
if (event.type === "event")
|
|
85
|
+
return `${(0, colour_js_1.dim)("event")} ${event.workspaceName} ${flat(event.body, 160)}`;
|
|
86
|
+
const label = event.type === "mention" ? (0, colour_js_1.bold)("for you ") : "message ";
|
|
87
|
+
return `${label} ${event.workspaceName} ${authorLabel(event.author)}: ${flat(event.body, 200)} ${(0, colour_js_1.dim)(event.workspace)}`;
|
|
88
|
+
}
|
|
89
|
+
/** The names a person signed in here answers to; empty when that cannot be read. */
|
|
90
|
+
async function readerNames() {
|
|
91
|
+
try {
|
|
92
|
+
const me = (await (0, api_js_1.whoami)());
|
|
93
|
+
return [me.username, me.displayName].filter((name) => Boolean(name?.trim()));
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
100
|
+
async function commandWorkspaceWatch(parsed) {
|
|
101
|
+
const [, projectRef, workspaceId] = parsed.positional;
|
|
102
|
+
const name = (0, workspace_threads_js_1.authorName)(parsed);
|
|
103
|
+
const json = parsed.flags.has("json");
|
|
104
|
+
const every = Math.max(5, Number(parsed.flags.get("interval") ?? 20) || 20) * 1000;
|
|
105
|
+
const project = projectRef ? await (0, api_js_1.findProject)(projectRef) : null;
|
|
106
|
+
if (projectRef && !project)
|
|
107
|
+
throw new Error(`No accessible project ${projectRef}`);
|
|
108
|
+
const as = name ? `as=${encodeURIComponent(name)}` : "";
|
|
109
|
+
/* A machine answers to the name it runs as; a person to their own username and display name. */
|
|
110
|
+
const names = name ? [name] : await readerNames();
|
|
111
|
+
const state = {
|
|
112
|
+
seen: new Set(),
|
|
113
|
+
newest: new Map(),
|
|
114
|
+
since: parsed.flags.has("unread") ? 0 : Date.now() - 60_000,
|
|
115
|
+
};
|
|
116
|
+
const emit = (event) => console.log(json ? JSON.stringify(event) : describe(event));
|
|
117
|
+
if (!json && process.stdout.isTTY)
|
|
118
|
+
console.error((0, colour_js_1.dim)(`Watching ${workspaceId ? "one draft" : project ? project.name : "every draft you can read"}` +
|
|
119
|
+
`${names.length ? `, messages for @${names[0]} first` : ""}. Stop with Ctrl+C.`));
|
|
120
|
+
let stopped = false;
|
|
121
|
+
process.once("SIGINT", () => {
|
|
122
|
+
stopped = true;
|
|
123
|
+
});
|
|
124
|
+
let failures = 0;
|
|
125
|
+
while (!stopped) {
|
|
126
|
+
try {
|
|
127
|
+
const listed = await (0, api_js_1.callApi)(`/v1/account/workspace-threads${as ? `?${as}` : ""}`);
|
|
128
|
+
const drafts = (listed.workspaces ?? []).filter((w) => (!project || w.projectId === project.id) && (!workspaceId || w.id === workspaceId || w.id.startsWith(workspaceId)));
|
|
129
|
+
for (const draft of drafts) {
|
|
130
|
+
if (!draft.unread)
|
|
131
|
+
continue;
|
|
132
|
+
const query = [as, "unread=true", "peek=true"].filter(Boolean).join("&");
|
|
133
|
+
const thread = await (0, api_js_1.callApi)(`/v1/repositories/${encodeURIComponent(draft.projectId)}/workspaces/${encodeURIComponent(draft.id)}/messages?${query}`);
|
|
134
|
+
const events = freshEvents(state, draft, thread.messages ?? [], names);
|
|
135
|
+
/* Messages addressed to this reader first: they are the ones it is being asked to act on. */
|
|
136
|
+
for (const event of [...events.filter((e) => e.type === "mention"), ...events.filter((e) => e.type !== "mention")])
|
|
137
|
+
emit(event);
|
|
138
|
+
}
|
|
139
|
+
const projects = project
|
|
140
|
+
? [{ id: project.id, name: project.name }]
|
|
141
|
+
: [...new Map(drafts.map((d) => [d.projectId, { id: d.projectId, name: d.project }])).values()];
|
|
142
|
+
for (const one of projects) {
|
|
143
|
+
const versions = await (0, api_js_1.callApi)(`/v1/repositories/${encodeURIComponent(one.id)}/versions`);
|
|
144
|
+
for (const event of freshSaves(state, one, versions.versions ?? []))
|
|
145
|
+
emit(event);
|
|
146
|
+
}
|
|
147
|
+
failures = 0;
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
failures += 1;
|
|
151
|
+
console.error(`watch: ${error instanceof Error ? error.message : String(error)}${failures > 1 ? ` (${failures} in a row)` : ""}`);
|
|
152
|
+
}
|
|
153
|
+
/* Backs off while the service is unreachable, up to five minutes, and returns to the interval on success. */
|
|
154
|
+
const pause = Math.min(300_000, every * 2 ** Math.min(failures, 4));
|
|
155
|
+
for (let left = pause; left > 0 && !stopped; left -= 500)
|
|
156
|
+
await wait(Math.min(500, left));
|
|
157
|
+
}
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,68 +1,68 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@coderook/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "The command line for CodeRook (coderook.com): version control and project hosting for projects of any size. Every save is a compressed snapshot, private or public, never used to train AI.",
|
|
5
|
-
"license": "Apache-2.0",
|
|
6
|
-
"homepage": "https://coderook.com",
|
|
7
|
-
"bugs": {
|
|
8
|
-
"url": "https://github.com/AlexC1991/cbx/issues"
|
|
9
|
-
},
|
|
10
|
-
"keywords": [
|
|
11
|
-
"coderook",
|
|
12
|
-
"cbx",
|
|
13
|
-
"version-control",
|
|
14
|
-
"vcs",
|
|
15
|
-
"versioning",
|
|
16
|
-
"project-hosting",
|
|
17
|
-
"backup",
|
|
18
|
-
"snapshots",
|
|
19
|
-
"large-files",
|
|
20
|
-
"gamedev"
|
|
21
|
-
],
|
|
22
|
-
"engines": {
|
|
23
|
-
"node": ">=22.15.0"
|
|
24
|
-
},
|
|
25
|
-
"bin": {
|
|
26
|
-
"cbx": "dist/cli/src/cli.js",
|
|
27
|
-
"coderook": "dist/cli/src/cli.js",
|
|
28
|
-
"git-remote-coderook": "dist/cli/src/git_remote_bin.js"
|
|
29
|
-
},
|
|
30
|
-
"files": [
|
|
31
|
-
".claude-plugin",
|
|
32
|
-
"dist",
|
|
33
|
-
"skills",
|
|
34
|
-
"NOTICE"
|
|
35
|
-
],
|
|
36
|
-
"scripts": {
|
|
37
|
-
"build": "tsc -p tsconfig.json && node scripts/copy-cbx-licence.mjs",
|
|
38
|
-
"check": "tsc -p tsconfig.json --noEmit",
|
|
39
|
-
"test": "tsc -p tsconfig.json && node --test --experimental-strip-types test/*.test.ts",
|
|
40
|
-
"start": "node dist/cli/src/cli.js",
|
|
41
|
-
"prepublishOnly": "npm run sync:plugin && npm run build",
|
|
42
|
-
"test:e2e": "node test/e2e.mjs",
|
|
43
|
-
"test:matrix": "node test/state-matrix.mjs",
|
|
44
|
-
"test:attempt": "node test/attempt-identity.mjs",
|
|
45
|
-
"test:get": "node test/get-safety.mjs",
|
|
46
|
-
"test:live": "node test/e2e.mjs --allow-production && node test/state-matrix.mjs --allow-production && node test/get-safety.mjs --allow-production && node test/get-track.mjs --allow-production && node test/get-protects-edits.mjs --allow-production && node test/upgrade-migration.mjs --allow-production && node test/fault-get.mjs --allow-production && node test/version-floor.mjs --allow-production && node test/fault-submit.mjs --allow-production && node test/race-attempts.mjs --allow-production && node test/attempt-identity.mjs --allow-production",
|
|
47
|
-
"test:getedits": "node test/get-protects-edits.mjs",
|
|
48
|
-
"test:upgrade": "node test/upgrade-migration.mjs",
|
|
49
|
-
"test:faultget": "node test/fault-get.mjs",
|
|
50
|
-
"test:floor": "node test/version-floor.mjs",
|
|
51
|
-
"test:faultsubmit": "node test/fault-submit.mjs",
|
|
52
|
-
"test:race": "node test/race-attempts.mjs",
|
|
53
|
-
"test:runner": "node test/runner-live.mjs",
|
|
54
|
-
"sync:plugin": "node scripts/sync-plugin-version.mjs",
|
|
55
|
-
"test:track": "node test/get-track.mjs",
|
|
56
|
-
"package:remote": "node ../scripts/package-cli-release.mjs",
|
|
57
|
-
"publish:remote": "node ../scripts/publish-cli-release.mjs"
|
|
58
|
-
},
|
|
59
|
-
"devDependencies": {
|
|
60
|
-
"@types/node": "24.10.1",
|
|
61
|
-
"typescript": "5.9.3"
|
|
62
|
-
},
|
|
63
|
-
"repository": {
|
|
64
|
-
"type": "git",
|
|
65
|
-
"url": "git+https://github.com/AlexC1991/cbx.git",
|
|
66
|
-
"directory": "cli"
|
|
67
|
-
}
|
|
68
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@coderook/cli",
|
|
3
|
+
"version": "0.43.0",
|
|
4
|
+
"description": "The command line for CodeRook (coderook.com): version control and project hosting for projects of any size. Every save is a compressed snapshot, private or public, never used to train AI.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"homepage": "https://coderook.com",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/AlexC1991/cbx/issues"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"coderook",
|
|
12
|
+
"cbx",
|
|
13
|
+
"version-control",
|
|
14
|
+
"vcs",
|
|
15
|
+
"versioning",
|
|
16
|
+
"project-hosting",
|
|
17
|
+
"backup",
|
|
18
|
+
"snapshots",
|
|
19
|
+
"large-files",
|
|
20
|
+
"gamedev"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=22.15.0"
|
|
24
|
+
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"cbx": "dist/cli/src/cli.js",
|
|
27
|
+
"coderook": "dist/cli/src/cli.js",
|
|
28
|
+
"git-remote-coderook": "dist/cli/src/git_remote_bin.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
".claude-plugin",
|
|
32
|
+
"dist",
|
|
33
|
+
"skills",
|
|
34
|
+
"NOTICE"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsc -p tsconfig.json && node scripts/copy-cbx-licence.mjs",
|
|
38
|
+
"check": "tsc -p tsconfig.json --noEmit",
|
|
39
|
+
"test": "tsc -p tsconfig.json && node --test --experimental-strip-types test/*.test.ts",
|
|
40
|
+
"start": "node dist/cli/src/cli.js",
|
|
41
|
+
"prepublishOnly": "npm run sync:plugin && npm run build",
|
|
42
|
+
"test:e2e": "node test/e2e.mjs",
|
|
43
|
+
"test:matrix": "node test/state-matrix.mjs",
|
|
44
|
+
"test:attempt": "node test/attempt-identity.mjs",
|
|
45
|
+
"test:get": "node test/get-safety.mjs",
|
|
46
|
+
"test:live": "node test/e2e.mjs --allow-production && node test/state-matrix.mjs --allow-production && node test/get-safety.mjs --allow-production && node test/get-track.mjs --allow-production && node test/get-protects-edits.mjs --allow-production && node test/upgrade-migration.mjs --allow-production && node test/fault-get.mjs --allow-production && node test/version-floor.mjs --allow-production && node test/fault-submit.mjs --allow-production && node test/race-attempts.mjs --allow-production && node test/attempt-identity.mjs --allow-production",
|
|
47
|
+
"test:getedits": "node test/get-protects-edits.mjs",
|
|
48
|
+
"test:upgrade": "node test/upgrade-migration.mjs",
|
|
49
|
+
"test:faultget": "node test/fault-get.mjs",
|
|
50
|
+
"test:floor": "node test/version-floor.mjs",
|
|
51
|
+
"test:faultsubmit": "node test/fault-submit.mjs",
|
|
52
|
+
"test:race": "node test/race-attempts.mjs",
|
|
53
|
+
"test:runner": "node test/runner-live.mjs",
|
|
54
|
+
"sync:plugin": "node scripts/sync-plugin-version.mjs",
|
|
55
|
+
"test:track": "node test/get-track.mjs",
|
|
56
|
+
"package:remote": "node ../scripts/package-cli-release.mjs",
|
|
57
|
+
"publish:remote": "node ../scripts/publish-cli-release.mjs"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/node": "24.10.1",
|
|
61
|
+
"typescript": "5.9.3"
|
|
62
|
+
},
|
|
63
|
+
"repository": {
|
|
64
|
+
"type": "git",
|
|
65
|
+
"url": "git+https://github.com/AlexC1991/cbx.git",
|
|
66
|
+
"directory": "cli"
|
|
67
|
+
}
|
|
68
|
+
}
|