@coderook/cli 0.42.7 → 0.44.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/cli/src/api.js +30 -0
- package/dist/cli/src/cli.js +26 -20
- package/dist/cli/src/workspace_command.js +21 -0
- package/dist/cli/src/workspace_machine.js +16 -22
- package/dist/cli/src/workspace_put.js +150 -0
- package/dist/cli/src/workspace_review.js +99 -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.44.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
|
+
}
|
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
|
@@ -739,7 +739,7 @@ async function commandImport(parsed) {
|
|
|
739
739
|
flags: submitFlags,
|
|
740
740
|
});
|
|
741
741
|
if (code === 0 && plan.temporary) {
|
|
742
|
-
console.log(dim(`The working copy stays at ${plan.folder} until you remove it.
|
|
742
|
+
console.log(dim(`The working copy stays at ${plan.folder} until you remove it.
|
|
743
743
|
` +
|
|
744
744
|
`Run ${accent("cbx get " + plan.name)} anywhere to fetch it fresh.`));
|
|
745
745
|
}
|
|
@@ -1025,17 +1025,17 @@ async function commandSubmit(parsed) {
|
|
|
1025
1025
|
*/
|
|
1026
1026
|
const shielded = (await (0, worktree_js_1.detectPrivateDirectories)(folder)).filter((finding) => [...sending].some((file) => file === finding.path || file.startsWith(`${finding.path}/`)));
|
|
1027
1027
|
if (shielded.length && !hasFlag(parsed, "allow-private")) {
|
|
1028
|
-
console.log(red(`
|
|
1028
|
+
console.log(red(`
|
|
1029
1029
|
${shielded.length} folder${shielded.length === 1 ? "" : "s"} here belong${shielded.length === 1 ? "s" : ""} to a program, not to your project:`));
|
|
1030
1030
|
for (const finding of shielded) {
|
|
1031
1031
|
console.log(` ${finding.path} ${dim(`— ${finding.because}`)}`);
|
|
1032
1032
|
}
|
|
1033
|
-
console.log(`
|
|
1033
|
+
console.log(`
|
|
1034
1034
|
Nothing was sent. To leave them behind:`);
|
|
1035
1035
|
for (const finding of shielded) {
|
|
1036
1036
|
console.log(` ${accent(`echo "${finding.rule}" >> .gitignore`)}`);
|
|
1037
1037
|
}
|
|
1038
|
-
console.error(`
|
|
1038
|
+
console.error(`
|
|
1039
1039
|
Or pass ${accent("--allow-private")} if they genuinely belong in the project.`);
|
|
1040
1040
|
return 1;
|
|
1041
1041
|
}
|
|
@@ -1084,7 +1084,7 @@ Or pass ${accent("--allow-private")} if they genuinely belong in the project.`);
|
|
|
1084
1084
|
`rest were not opened.`));
|
|
1085
1085
|
}
|
|
1086
1086
|
if (stillPasted.length) {
|
|
1087
|
-
console.log(red(`
|
|
1087
|
+
console.log(red(`
|
|
1088
1088
|
${stillPasted.length} file${stillPasted.length === 1 ? " has a credential" : "s have credentials"} inside:`));
|
|
1089
1089
|
for (const finding of stillPasted.slice(0, 20)) {
|
|
1090
1090
|
console.log(` ${finding.path}`);
|
|
@@ -1099,11 +1099,11 @@ ${stillPasted.length} file${stillPasted.length === 1 ? " has a credential" : "s
|
|
|
1099
1099
|
console.log(dim(` …and ${stillPasted.length - 20} more`));
|
|
1100
1100
|
}
|
|
1101
1101
|
if (!hasFlag(parsed, "allow-secrets")) {
|
|
1102
|
-
console.error(`
|
|
1102
|
+
console.error(`
|
|
1103
1103
|
Nothing was sent. Move the key into an environment variable, and if` +
|
|
1104
1104
|
` it has ever been published, replace it at the service that issued` +
|
|
1105
1105
|
` it — a key that has leaked stays leaked.` +
|
|
1106
|
-
`
|
|
1106
|
+
`
|
|
1107
1107
|
Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
1108
1108
|
return 1;
|
|
1109
1109
|
}
|
|
@@ -1309,7 +1309,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
|
1309
1309
|
continue;
|
|
1310
1310
|
}
|
|
1311
1311
|
if (failure?.kind === "interrupted") {
|
|
1312
|
-
console.error(red(`
|
|
1312
|
+
console.error(red(`
|
|
1313
1313
|
The connection failed: ${text}`));
|
|
1314
1314
|
console.error(`Your work may already have been saved. Run the same command again —` +
|
|
1315
1315
|
` it will not create a second version.`);
|
|
@@ -1566,7 +1566,7 @@ async function commandGet(parsed) {
|
|
|
1566
1566
|
`changed while the interrupted fetch was stopped:`));
|
|
1567
1567
|
for (const file of changedInTheGap.slice(0, 20))
|
|
1568
1568
|
console.error(` ${file.path}`);
|
|
1569
|
-
console.error(`
|
|
1569
|
+
console.error(`
|
|
1570
1570
|
Save them with ${accent("cbx submit")}, or finish the fetch and ` +
|
|
1571
1571
|
`discard them with ${accent("cbx get --replace")}.`);
|
|
1572
1572
|
return 1;
|
|
@@ -1849,8 +1849,8 @@ async function suggestRules(folder, apply) {
|
|
|
1849
1849
|
const addition = (0, detect_js_1.rulesFromSuggestions)(recommended);
|
|
1850
1850
|
const shared = rules.shared.trimEnd();
|
|
1851
1851
|
await (0, worktree_js_1.writeRules)(folder, {
|
|
1852
|
-
shared: shared ? `${shared}
|
|
1853
|
-
|
|
1852
|
+
shared: shared ? `${shared}
|
|
1853
|
+
|
|
1854
1854
|
${addition}` : addition,
|
|
1855
1855
|
local: rules.local,
|
|
1856
1856
|
});
|
|
@@ -2055,7 +2055,7 @@ async function commandMerges(parsed) {
|
|
|
2055
2055
|
const counts = merge.conflicts;
|
|
2056
2056
|
console.log(`${accent(merge.reference)} ${counts ? `${counts.unresolved} of ${counts.total} still to decide` : ""} ${dim(new Date(merge.createdAt).toLocaleString())}`);
|
|
2057
2057
|
}
|
|
2058
|
-
console.log(dim(`
|
|
2058
|
+
console.log(dim(`
|
|
2059
2059
|
Run cbx merge <reference> to look at one.`));
|
|
2060
2060
|
return 0;
|
|
2061
2061
|
}
|
|
@@ -2158,7 +2158,7 @@ async function commandMerge(parsed) {
|
|
|
2158
2158
|
console.log(dim(` waiting on ${waiting.map((person) => person.displayName).join(", ")}`));
|
|
2159
2159
|
}
|
|
2160
2160
|
}
|
|
2161
|
-
console.log(`
|
|
2161
|
+
console.log(`
|
|
2162
2162
|
${accent(now.mergeTrack.reference)} · ` +
|
|
2163
2163
|
/*
|
|
2164
2164
|
The combined version is only counted once it can be built; until then
|
|
@@ -2177,7 +2177,7 @@ ${accent(now.mergeTrack.reference)} · ` +
|
|
|
2177
2177
|
return 1;
|
|
2178
2178
|
}
|
|
2179
2179
|
const applied = await (0, api_js_1.applyMerge)(summary.id);
|
|
2180
|
-
console.log(`
|
|
2180
|
+
console.log(`
|
|
2181
2181
|
Applied as ${accent(`v${applied.version.sequence}`)}.`);
|
|
2182
2182
|
if (applied.closedIssues?.length) {
|
|
2183
2183
|
console.log(`Closed ${applied.closedIssues.map((number) => `#${number}`).join(", ")}.`);
|
|
@@ -2187,13 +2187,13 @@ Applied as ${accent(`v${applied.version.sequence}`)}.`);
|
|
|
2187
2187
|
}
|
|
2188
2188
|
const stillOpen = now.conflicts.some((conflict) => !conflict.resolvedAt);
|
|
2189
2189
|
if (!decision && stillOpen) {
|
|
2190
|
-
console.log(dim(`
|
|
2190
|
+
console.log(dim(`
|
|
2191
2191
|
--mine keeps yours, --theirs keeps what was already saved,` +
|
|
2192
|
-
` --both keeps theirs and saves yours beside it, --drop removes the file.
|
|
2192
|
+
` --both keeps theirs and saves yours beside it, --drop removes the file.
|
|
2193
2193
|
Add --path <file> for one file, then --apply when ready.`));
|
|
2194
2194
|
}
|
|
2195
2195
|
else if (now.provisional.ready) {
|
|
2196
|
-
console.log(dim(`
|
|
2196
|
+
console.log(dim(`
|
|
2197
2197
|
Run cbx merge ${now.mergeTrack.reference} --apply to publish it.`));
|
|
2198
2198
|
}
|
|
2199
2199
|
return 0;
|
|
@@ -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|review|reviewers|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. review shows who must OK a draft before it is saved or deployed; --ok or --changes -m gives your verdict on the current revision, and counts only from the reviewer's own sign-in or token, never a name given with --as. reviewers lists the project's rules, and an administrator sets them with --data-file. 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,14 @@ 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: "--ok", description: "review: approve the draft's current revision" },
|
|
2819
|
+
{ flags: "--changes", description: "review: ask for changes (say what with -m)" },
|
|
2820
|
+
{ flags: "--json", description: "watch: one JSON object per line; starter: the plan as JSON" },
|
|
2821
|
+
{ flags: "--interval <seconds>", description: "watch: how often to look (default 20, at least 5)" },
|
|
2816
2822
|
],
|
|
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"],
|
|
2823
|
+
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", "cbx workspace review my-project <draft> --ok -m \"Matches the concept\""],
|
|
2818
2824
|
run: workspace_command_js_1.commandWorkspace,
|
|
2819
2825
|
},
|
|
2820
2826
|
{
|
|
@@ -9,6 +9,10 @@ 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_review_js_1 = require("./workspace_review.js");
|
|
14
|
+
const workspace_starter_js_1 = require("./workspace_starter.js");
|
|
15
|
+
const workspace_watch_js_1 = require("./workspace_watch.js");
|
|
12
16
|
async function commandWorkspace(parsed) {
|
|
13
17
|
const [operation, projectRef, workspace, runId] = parsed.positional;
|
|
14
18
|
if (operation && workspace_machine_js_1.MACHINE_OPERATIONS.has(operation))
|
|
@@ -16,12 +20,29 @@ async function commandWorkspace(parsed) {
|
|
|
16
20
|
/* On its own at a terminal: choose a workspace and see what is being discussed. Scripts get the usage line as before. */
|
|
17
21
|
if (!operation && process.stdin.isTTY && process.stdout.isTTY)
|
|
18
22
|
return (0, workspace_threads_js_1.interactiveWorkspaces)(parsed);
|
|
23
|
+
/* Every draft you can read when no project is named. */
|
|
24
|
+
if (operation === "watch")
|
|
25
|
+
return (0, workspace_watch_js_1.commandWorkspaceWatch)(parsed);
|
|
19
26
|
if (!operation || !projectRef)
|
|
20
27
|
throw new Error("Use cbx workspace <operation> <project> [workspace]. See cbx help workspace.");
|
|
21
28
|
const project = await (0, api_js_1.findProject)(projectRef);
|
|
22
29
|
if (!project)
|
|
23
30
|
throw new Error(`No accessible project ${projectRef}`);
|
|
24
31
|
const root = `/v1/repositories/${encodeURIComponent(project.id)}`;
|
|
32
|
+
if (operation === "starter")
|
|
33
|
+
return (0, workspace_starter_js_1.commandWorkspaceStarter)(parsed, root);
|
|
34
|
+
if (operation === "reviewers")
|
|
35
|
+
return (0, workspace_review_js_1.commandWorkspaceReviewers)(parsed, root);
|
|
36
|
+
if (operation === "review") {
|
|
37
|
+
if (!workspace)
|
|
38
|
+
throw new Error("Supply the workspace ID after the project");
|
|
39
|
+
return (0, workspace_review_js_1.commandWorkspaceReview)(parsed, root, workspace);
|
|
40
|
+
}
|
|
41
|
+
if (operation === "put") {
|
|
42
|
+
if (!workspace)
|
|
43
|
+
throw new Error("Supply the workspace ID after the project");
|
|
44
|
+
return (0, workspace_put_js_1.commandWorkspacePut)(parsed, root, workspace);
|
|
45
|
+
}
|
|
25
46
|
if (workspace_threads_js_1.THREAD_OPERATIONS.has(operation)) {
|
|
26
47
|
if (!workspace)
|
|
27
48
|
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,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.describeReviewer = describeReviewer;
|
|
4
|
+
exports.commandWorkspaceReview = commandWorkspaceReview;
|
|
5
|
+
exports.commandWorkspaceReviewers = commandWorkspaceReviewers;
|
|
6
|
+
/**
|
|
7
|
+
* cbx workspace review <project> <workspace> --ok [-m "…"] | --changes -m "…"
|
|
8
|
+
* cbx workspace reviewers <project> [--data-file rules.json]
|
|
9
|
+
*
|
|
10
|
+
* The CLI side of the review gate (backend workspace_reviews.ts). A review
|
|
11
|
+
* counts only from the reviewer's own sign-in or token, so --as is refused
|
|
12
|
+
* here before the service refuses it, with the reason.
|
|
13
|
+
*/
|
|
14
|
+
const promises_1 = require("node:fs/promises");
|
|
15
|
+
const api_js_1 = require("./api.js");
|
|
16
|
+
const colour_js_1 = require("./colour.js");
|
|
17
|
+
const flag = (parsed, ...names) => {
|
|
18
|
+
for (const name of names) {
|
|
19
|
+
const value = parsed.flags.get(name);
|
|
20
|
+
if (typeof value === "string")
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
};
|
|
25
|
+
/** One line per reviewer: a word for where it stands, never only a colour. */
|
|
26
|
+
function describeReviewer(s, revision) {
|
|
27
|
+
const when = [s.beforeSave && "save", s.beforeDeploy && "deploy"].filter(Boolean).join(" + ");
|
|
28
|
+
if (!s.applies)
|
|
29
|
+
return `${(0, colour_js_1.dim)("not needed")} ${s.label} ${(0, colour_js_1.dim)(`(no changes in its paths)`)}`;
|
|
30
|
+
const word = s.state === "ok"
|
|
31
|
+
? (0, colour_js_1.green)("OK ")
|
|
32
|
+
: s.state === "changes"
|
|
33
|
+
? (0, colour_js_1.red)("changes ")
|
|
34
|
+
: s.state === "outdated"
|
|
35
|
+
? "outdated "
|
|
36
|
+
: (0, colour_js_1.dim)("waiting ");
|
|
37
|
+
const detail = s.state === "outdated"
|
|
38
|
+
? `OK was for revision ${s.revision}, draft is at ${revision}`
|
|
39
|
+
: s.state === "changes" || (s.state === "ok" && s.note)
|
|
40
|
+
? s.note
|
|
41
|
+
: "";
|
|
42
|
+
return `${word}${(0, colour_js_1.bold)(s.label)} ${(0, colour_js_1.dim)(`before ${when}`)}${detail ? ` ${detail}` : ""}`;
|
|
43
|
+
}
|
|
44
|
+
async function commandWorkspaceReview(parsed, root, workspace) {
|
|
45
|
+
if (parsed.flags.has("as") || process.env.CBX_NAME)
|
|
46
|
+
throw new Error("A review counts only from the reviewer's own sign-in or token; run it without --as or CBX_NAME.");
|
|
47
|
+
const ok = parsed.flags.has("ok");
|
|
48
|
+
const changes = parsed.flags.has("changes");
|
|
49
|
+
const draft = `${root}/workspaces/${encodeURIComponent(workspace)}`;
|
|
50
|
+
if (ok === changes) {
|
|
51
|
+
/* Neither: show where the draft stands. */
|
|
52
|
+
const shown = await (0, api_js_1.callApi)(draft);
|
|
53
|
+
const review = shown.review ?? [];
|
|
54
|
+
if (parsed.flags.has("json"))
|
|
55
|
+
console.log(JSON.stringify({ revision: shown.workspace.revision, review }, null, 2));
|
|
56
|
+
else if (!review.length)
|
|
57
|
+
console.log("This project names no reviewers. An administrator sets them with cbx workspace reviewers.");
|
|
58
|
+
else
|
|
59
|
+
for (const s of review)
|
|
60
|
+
console.log(describeReviewer(s, shown.workspace.revision));
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
const note = flag(parsed, "m", "message") ?? "";
|
|
64
|
+
if (changes && !note.trim())
|
|
65
|
+
throw new Error('Say what needs to change: --changes -m "…"');
|
|
66
|
+
const revision = parsed.flags.has("revision")
|
|
67
|
+
? Number(parsed.flags.get("revision"))
|
|
68
|
+
: (await (0, api_js_1.callApi)(draft)).workspace.revision;
|
|
69
|
+
const out = await (0, api_js_1.callApi)(`${draft}/reviews`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
body: { verdict: ok ? "ok" : "changes", note, expectedRevision: revision },
|
|
72
|
+
});
|
|
73
|
+
if (parsed.flags.has("json"))
|
|
74
|
+
console.log(JSON.stringify(out, null, 2));
|
|
75
|
+
else {
|
|
76
|
+
console.log(`${ok ? (0, colour_js_1.green)("OK") : (0, colour_js_1.red)("Changes asked")} on revision ${revision}.`);
|
|
77
|
+
for (const s of out.review)
|
|
78
|
+
console.log(` ${describeReviewer(s, revision)}`);
|
|
79
|
+
}
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
async function commandWorkspaceReviewers(parsed, root) {
|
|
83
|
+
const source = flag(parsed, "data-file");
|
|
84
|
+
const rules = source
|
|
85
|
+
? (await (0, api_js_1.callApi)(`${root}/workspace-review-rules`, {
|
|
86
|
+
method: "PUT",
|
|
87
|
+
body: { rules: JSON.parse(await (0, promises_1.readFile)(source, "utf8")) },
|
|
88
|
+
})).rules
|
|
89
|
+
: (await (0, api_js_1.callApi)(`${root}/workspace-review-rules`)).rules;
|
|
90
|
+
if (parsed.flags.has("json") || source)
|
|
91
|
+
console.log(JSON.stringify({ rules }, null, 2));
|
|
92
|
+
else if (!rules.length)
|
|
93
|
+
console.log(`No reviewers. Set them from a JSON list with ${(0, colour_js_1.accent)("cbx workspace reviewers <project> --data-file rules.json")}:\n` +
|
|
94
|
+
(0, colour_js_1.dim)(' [{"reviewer":"token:<id>","label":"UX Bot","paths":["website/"],"beforeSave":true,"beforeDeploy":false,"ownerMayOverride":false}]'));
|
|
95
|
+
else
|
|
96
|
+
for (const r of rules)
|
|
97
|
+
console.log(`${(0, colour_js_1.bold)(r.label)} ${(0, colour_js_1.dim)(r.reviewer)} ${r.paths.length ? r.paths.join(", ") : "every path"} ${(0, colour_js_1.dim)(`before ${[r.beforeSave && "save", r.beforeDeploy && "deploy"].filter(Boolean).join(" + ")}${r.ownerMayOverride ? "; owner may go ahead without" : ""}`)}`);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
@@ -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.44.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
|
+
}
|