@coderook/cli 0.38.2 → 0.40.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.
@@ -0,0 +1,322 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.MACHINE_OPERATIONS = void 0;
7
+ exports.commandWorkspaceMachine = commandWorkspaceMachine;
8
+ exports.commandMachines = commandMachines;
9
+ /**
10
+ * Working on a remote draft from a machine's own folder, and handing a draft
11
+ * to another machine (backend workspace_assignments.ts).
12
+ *
13
+ * cbx workspace assign <project> <draft> [--name "Laptop"] [--days 14]
14
+ * make a token that reaches that one draft; shown once
15
+ * cbx workspace assignments <project> <draft> the live ones
16
+ * cbx workspace unassign <project> <draft> <id> end one
17
+ * cbx workspace checkout <project> <draft> [folder]
18
+ * the draft as files: its base version, with the draft's edits on top
19
+ * cbx workspace push [folder] [--save -m "…"] [--dry-run]
20
+ * send this folder's text changes into the draft, and save it if asked
21
+ *
22
+ * A checked-out folder is not a linked folder: `cbx submit` does not apply to
23
+ * it, and its record lives in .coderook/workspace.json. Edits go in as the
24
+ * draft's own changes, each checked against the copy this folder last had,
25
+ * so somebody else's edit to the same file in the meantime is refused rather
26
+ * than overwritten. Text only, 256 KiB a file, as the draft itself allows.
27
+ */
28
+ const node_crypto_1 = require("node:crypto");
29
+ const node_fs_1 = require("node:fs");
30
+ const promises_1 = require("node:fs/promises");
31
+ const node_path_1 = __importDefault(require("node:path"));
32
+ const download_js_1 = require("../../cbx/src/core/download.js");
33
+ const worktree_js_1 = require("../../cbx/src/core/worktree.js");
34
+ const api_js_1 = require("./api.js");
35
+ const config_js_1 = require("./config.js");
36
+ const machine_js_1 = require("./machine.js");
37
+ const colour_js_1 = require("./colour.js");
38
+ const RECORD = node_path_1.default.join(".coderook", "workspace.json");
39
+ const MAX_FILE = 256 * 1024;
40
+ const MAX_BATCH = 900 * 1024;
41
+ function flag(parsed, ...names) {
42
+ for (const name of names) {
43
+ const value = parsed.flags.get(name);
44
+ if (typeof value === "string")
45
+ return value;
46
+ }
47
+ return undefined;
48
+ }
49
+ const sha = (bytes) => (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex");
50
+ const key = (what) => `${what}-${(0, node_crypto_1.randomBytes)(9).toString("base64url")}`;
51
+ async function readRecord(folder) {
52
+ try {
53
+ return JSON.parse(await (0, promises_1.readFile)(node_path_1.default.join(folder, RECORD), "utf8"));
54
+ }
55
+ catch {
56
+ throw new Error(`${folder} is not a checked-out draft. Get one with: cbx workspace checkout <project> <draft> [folder]`);
57
+ }
58
+ }
59
+ async function writeRecord(folder, record) {
60
+ await (0, promises_1.mkdir)(node_path_1.default.join(folder, ".coderook"), { recursive: true });
61
+ await (0, promises_1.writeFile)(node_path_1.default.join(folder, RECORD), JSON.stringify(record, null, 2) + "\n");
62
+ }
63
+ async function project(reference) {
64
+ if (!reference)
65
+ throw new Error("Which project? Name it after the operation.");
66
+ const found = await (0, api_js_1.findProject)(reference);
67
+ if (!found)
68
+ throw new Error(`No accessible project ${reference}`);
69
+ return found;
70
+ }
71
+ /** The operations this file handles; anything else is the generic workspace command's. */
72
+ exports.MACHINE_OPERATIONS = new Set(["assign", "assignments", "unassign", "checkout", "push"]);
73
+ async function commandWorkspaceMachine(parsed) {
74
+ const [operation, first, second, third] = parsed.positional;
75
+ if (operation === "push")
76
+ return push(parsed, first ?? ".");
77
+ const found = await project(first);
78
+ const root = `/v1/repositories/${encodeURIComponent(found.id)}/workspaces`;
79
+ if (!second)
80
+ throw new Error("Name the draft (its ID) after the project. List them with: cbx workspace list <project>");
81
+ const draft = `${root}/${encodeURIComponent(second)}`;
82
+ if (operation === "assign") {
83
+ const days = flag(parsed, "days");
84
+ const made = await (0, api_js_1.callApi)(`${draft}/assignments`, {
85
+ method: "POST",
86
+ body: { name: flag(parsed, "name"), ...(days ? { days: Number(days) } : {}) },
87
+ });
88
+ console.log(`${(0, colour_js_1.green)("Handed over.")} This token reaches draft ${second} only, until ${made.assignment.expiresAt ? new Date(made.assignment.expiresAt).toLocaleDateString() : "it is revoked"}.`);
89
+ console.log((0, colour_js_1.dim)("It is shown once. Give it to the other machine privately."));
90
+ console.log(`\n ${(0, colour_js_1.bold)(made.token)}\n`);
91
+ console.log("On that machine:");
92
+ console.log(` ${(0, colour_js_1.accent)("npm install -g @coderook/cli")}`);
93
+ console.log(` ${(0, colour_js_1.accent)("cbx sign-in")} ${(0, colour_js_1.dim)("paste the token when asked")}`);
94
+ console.log(` ${(0, colour_js_1.accent)(`cbx workspace checkout ${found.slug} ${second}`)}`);
95
+ console.log(` ${(0, colour_js_1.dim)("…work, then")} ${(0, colour_js_1.accent)('cbx workspace push --save -m "What changed"')}`);
96
+ console.log((0, colour_js_1.dim)(`\nEnd it early: cbx workspace unassign ${found.slug} ${second} ${made.assignment.id}`));
97
+ return 0;
98
+ }
99
+ if (operation === "assignments") {
100
+ const listed = await (0, api_js_1.callApi)(`${draft}/assignments`);
101
+ if (!listed.assignments.length)
102
+ console.log((0, colour_js_1.dim)("This draft is not handed to any machine."));
103
+ for (const one of listed.assignments) {
104
+ console.log(`${(0, colour_js_1.bold)(one.name)} ${(0, colour_js_1.dim)(one.prefix + "…")} ${(0, colour_js_1.dim)(`until ${one.expiresAt ? new Date(one.expiresAt).toLocaleDateString() : "revoked"}`)} ` +
105
+ (0, colour_js_1.dim)(one.lastUsedAt ? `last used ${new Date(one.lastUsedAt).toLocaleString()}` : "not used yet") +
106
+ (0, colour_js_1.dim)(` ${one.id}`));
107
+ }
108
+ return 0;
109
+ }
110
+ if (operation === "unassign") {
111
+ if (!third)
112
+ throw new Error("Name the assignment's ID after the draft. See them with: cbx workspace assignments <project> <draft>");
113
+ await (0, api_js_1.callApi)(`${draft}/assignments/${encodeURIComponent(third)}`, { method: "DELETE" });
114
+ console.log(`${(0, colour_js_1.green)("Ended.")} That machine can no longer reach the draft.`);
115
+ return 0;
116
+ }
117
+ return checkout(found, draft, second, third);
118
+ }
119
+ async function checkout(found, draft, workspaceId, into) {
120
+ const folder = node_path_1.default.resolve(into ?? `${found.slug}-draft`);
121
+ if ((0, node_fs_1.existsSync)(node_path_1.default.join(folder, RECORD))) {
122
+ console.error((0, colour_js_1.red)(`${folder} already holds a checked-out draft. Use cbx workspace push there, or choose another folder.`));
123
+ return 1;
124
+ }
125
+ const info = await (0, api_js_1.callApi)(draft);
126
+ const downloader = new download_js_1.Downloader(config_js_1.credentials);
127
+ let manifest;
128
+ try {
129
+ const result = await downloader.run(found.id, info.workspace.baseVersionId, folder, () => undefined, null, "replace");
130
+ manifest = { ...result.manifest };
131
+ }
132
+ catch (error) {
133
+ if (error instanceof download_js_1.DownloadFailed) {
134
+ console.error((0, colour_js_1.red)(error.message));
135
+ return 1;
136
+ }
137
+ throw error;
138
+ }
139
+ // The draft's own edits on top, each checked against the hash the draft gives for it.
140
+ let revision = info.workspace.revision;
141
+ let edited = 0;
142
+ for (let offset = 0; offset !== null;) {
143
+ const page = await (0, api_js_1.callApi)(`${draft}/diff?offset=${offset}`);
144
+ revision = page.revision;
145
+ for (const change of page.changes) {
146
+ const target = node_path_1.default.join(folder, ...change.path.split("/"));
147
+ if (change.removed || !change.after) {
148
+ await (0, promises_1.rm)(target, { force: true });
149
+ delete manifest[change.path];
150
+ }
151
+ else {
152
+ if (change.after.content === null) {
153
+ throw new Error(`The draft's copy of ${change.path} could not be read as text (${change.after.reason ?? "unreadable"}).`);
154
+ }
155
+ if (sha(change.after.content) !== change.after.sha256) {
156
+ throw new Error(`The draft's copy of ${change.path} did not arrive exactly; nothing was overwritten past it. Try again.`);
157
+ }
158
+ await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
159
+ await (0, promises_1.writeFile)(target, change.after.content);
160
+ manifest[change.path] = change.after.sha256;
161
+ }
162
+ edited += 1;
163
+ }
164
+ offset = page.nextOffset;
165
+ }
166
+ await writeRecord(folder, {
167
+ projectId: found.id,
168
+ projectSlug: found.slug,
169
+ workspaceId,
170
+ name: info.workspace.name,
171
+ revision,
172
+ baseVersionId: info.workspace.baseVersionId,
173
+ track: info.workspace.track,
174
+ files: manifest,
175
+ });
176
+ await (0, machine_js_1.checkIn)(found.id, { kind: "workspace", folder, workspaceId, track: info.workspace.track, baseVersionId: info.workspace.baseVersionId, changedFiles: 0 });
177
+ console.log(`${(0, colour_js_1.green)("Checked out")} ${(0, colour_js_1.bold)(info.workspace.name)} into ${folder}`);
178
+ console.log((0, colour_js_1.dim)(` ${Object.keys(manifest).length} files · revision ${revision}${edited ? ` · ${edited} edited in the draft` : ""}`));
179
+ console.log((0, colour_js_1.dim)(" Send changes back with ") + (0, colour_js_1.accent)("cbx workspace push") + (0, colour_js_1.dim)(", and save with ") + (0, colour_js_1.accent)('cbx workspace push --save -m "…"'));
180
+ return 0;
181
+ }
182
+ async function push(parsed, where) {
183
+ const folder = node_path_1.default.resolve(where);
184
+ const record = await readRecord(folder);
185
+ const draft = `/v1/repositories/${encodeURIComponent(record.projectId)}/workspaces/${encodeURIComponent(record.workspaceId)}`;
186
+ const rules = await (0, worktree_js_1.readRules)(folder);
187
+ const found = await (0, worktree_js_1.changedFiles)(folder, rules, new Map(Object.entries(record.files)), "synchronize");
188
+ const changes = [];
189
+ const refused = [];
190
+ for (const file of found) {
191
+ if (file.path === RECORD.replaceAll("\\", "/") || file.path.startsWith(".coderook/"))
192
+ continue;
193
+ if (file.deleted) {
194
+ changes.push({ path: file.path, remove: true, expectedSha256: record.files[file.path] ?? null });
195
+ continue;
196
+ }
197
+ const bytes = await (0, promises_1.readFile)(node_path_1.default.join(folder, ...file.path.split("/")));
198
+ if (bytes.length > MAX_FILE || bytes.includes(0)) {
199
+ refused.push(file.path);
200
+ continue;
201
+ }
202
+ changes.push({ path: file.path, content: bytes.toString("utf8"), expectedSha256: record.files[file.path] ?? null });
203
+ }
204
+ if (refused.length) {
205
+ console.error((0, colour_js_1.red)(`${refused.length} file${refused.length === 1 ? " is" : "s are"} binary or over 256 KiB, which a draft cannot take:`));
206
+ for (const one of refused.slice(0, 20))
207
+ console.error(` ${one}`);
208
+ console.error((0, colour_js_1.dim)("Nothing was sent. Leave them out, or save them with a normal upload from a linked folder."));
209
+ return 1;
210
+ }
211
+ if (!changes.length && !parsed.flags.has("save")) {
212
+ console.log("Nothing changed since this folder's last push.");
213
+ return 0;
214
+ }
215
+ if (parsed.flags.has("dry-run")) {
216
+ for (const one of changes)
217
+ console.log(` ${one.remove ? (0, colour_js_1.red)("removed") : record.files[one.path] ? "changed" : (0, colour_js_1.green)("new")} ${one.path}`);
218
+ console.log((0, colour_js_1.dim)(`${changes.length} change${changes.length === 1 ? "" : "s"} would go to draft ${record.name}.`));
219
+ return 0;
220
+ }
221
+ // In batches the draft accepts, each against the revision the last one made.
222
+ let revision = record.revision;
223
+ for (let start = 0; start < changes.length;) {
224
+ const batch = [];
225
+ let bytes = 0;
226
+ while (start < changes.length) {
227
+ const next = changes[start];
228
+ const size = next.content ? Buffer.byteLength(next.content) : 0;
229
+ if (batch.length && bytes + size > MAX_BATCH)
230
+ break;
231
+ batch.push(next);
232
+ bytes += size;
233
+ start += 1;
234
+ }
235
+ const result = await (0, api_js_1.callApi)(`${draft}/changes`, {
236
+ method: "POST",
237
+ body: { expectedRevision: revision, operationKey: key("push"), changes: batch },
238
+ });
239
+ revision = result.workspace.revision;
240
+ for (const one of batch) {
241
+ if (one.remove)
242
+ delete record.files[one.path];
243
+ else
244
+ record.files[one.path] = sha(one.content);
245
+ }
246
+ record.revision = revision;
247
+ await writeRecord(folder, record);
248
+ }
249
+ if (changes.length)
250
+ console.log(`${(0, colour_js_1.green)("Sent")} ${changes.length} change${changes.length === 1 ? "" : "s"} to ${(0, colour_js_1.bold)(record.name)} · revision ${revision}`);
251
+ if (parsed.flags.has("save")) {
252
+ const message = flag(parsed, "m", "message");
253
+ if (!message) {
254
+ console.error((0, colour_js_1.red)('Say what changed: cbx workspace push --save -m "…"'));
255
+ return 1;
256
+ }
257
+ const saved = await (0, api_js_1.callApi)(`${draft}/save`, {
258
+ method: "POST",
259
+ body: { expectedRevision: revision, operationKey: key("save"), message },
260
+ });
261
+ if (saved.mergeTrack) {
262
+ console.log(`${(0, colour_js_1.accent)("Saved onto a merge track")} ${saved.mergeTrack.reference ?? ""}: somebody else changed the same lines, so it waits to be settled.`);
263
+ }
264
+ else if (saved.review) {
265
+ // A machine the draft was handed to saves to the draft's own line, for the owner to merge.
266
+ console.log(`${(0, colour_js_1.green)("Saved")} as v${saved.version?.sequence ?? "?"} on ${(0, colour_js_1.accent)(saved.review.line)}.`);
267
+ console.log(saved.review.pull
268
+ ? (0, colour_js_1.dim)(` Pull request ${saved.review.pull.reference} carries it to ${saved.review.into}; it lands when the owner or a reviewer merges it.`)
269
+ : (0, colour_js_1.dim)(` The owner merges it into ${saved.review.into} from that line.`));
270
+ }
271
+ else {
272
+ console.log(`${(0, colour_js_1.green)("Saved")} as v${saved.version?.sequence ?? "?"} on ${record.track}.`);
273
+ }
274
+ }
275
+ await (0, machine_js_1.checkIn)(record.projectId, { kind: "workspace", folder, workspaceId: record.workspaceId, track: record.track, baseVersionId: record.baseVersionId, changedFiles: 0 });
276
+ return 0;
277
+ }
278
+ function ago(when) {
279
+ const minutes = Math.round((Date.now() - Date.parse(when)) / 60000);
280
+ if (minutes < 1)
281
+ return "just now";
282
+ if (minutes < 60)
283
+ return `${minutes} min ago`;
284
+ const hours = Math.round(minutes / 60);
285
+ return hours < 48 ? `${hours} h ago` : `${Math.round(hours / 24)} days ago`;
286
+ }
287
+ /** cbx machines [project]: the PCs working on a project, and its runners. */
288
+ async function commandMachines(parsed, linkedProject) {
289
+ const reference = parsed.positional[0] ?? linkedProject;
290
+ if (!reference)
291
+ throw new Error("Which project? Try: cbx machines <project>, or run it in a linked folder.");
292
+ const found = await project(reference);
293
+ const root = `/v1/repositories/${encodeURIComponent(found.id)}`;
294
+ const [machines, runners] = await Promise.all([
295
+ (0, api_js_1.callApi)(`${root}/machines`),
296
+ (0, api_js_1.callApi)(`${root}/runners`).catch(() => ({ runners: [] })),
297
+ ]);
298
+ console.log((0, colour_js_1.bold)(found.slug));
299
+ console.log((0, colour_js_1.dim)("Linked folders and handed-over drafts"));
300
+ if (!machines.machines.length)
301
+ console.log((0, colour_js_1.dim)(" None in the last 30 days."));
302
+ for (const one of machines.machines) {
303
+ const behind = one.baseSequence !== null && one.lineSequence !== null
304
+ ? one.lineSequence > one.baseSequence
305
+ ? (0, colour_js_1.red)(`${one.lineSequence - one.baseSequence} behind`)
306
+ : (0, colour_js_1.green)("up to date")
307
+ : (0, colour_js_1.dim)("version unknown");
308
+ const what = one.kind === "workspace" ? `draft ${one.workspace?.name ?? "(private)"}` : `folder ${one.folderName ?? "?"}`;
309
+ console.log(` ${(0, colour_js_1.bold)(one.name)}${one.mine ? (0, colour_js_1.dim)(" (you)") : ""} ${(0, colour_js_1.dim)(one.person.displayName)} ${what}` +
310
+ ` ${one.baseSequence !== null ? `v${one.baseSequence}` : ""} ${behind}` +
311
+ ` ${one.changedFiles === null ? "" : one.changedFiles ? `${one.changedFiles} unsaved` : (0, colour_js_1.dim)("nothing unsaved")}` +
312
+ ` ${(0, colour_js_1.dim)(ago(one.lastSeenAt))}`);
313
+ }
314
+ console.log((0, colour_js_1.dim)("Runners"));
315
+ if (!runners.runners.length)
316
+ console.log((0, colour_js_1.dim)(" None connected."));
317
+ for (const one of runners.runners) {
318
+ const online = Date.now() - Date.parse(one.lastSeenAt) < 120000;
319
+ console.log(` ${(0, colour_js_1.bold)(one.name)} ${(0, colour_js_1.dim)(one.platform ?? "")} ${online ? (0, colour_js_1.green)("online") : (0, colour_js_1.dim)("offline")} ${(0, colour_js_1.dim)(`${one.runsClaimed} runs · ${ago(one.lastSeenAt)}`)}`);
320
+ }
321
+ return 0;
322
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.38.2",
3
+ "version": "0.40.0",
4
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
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://coderook.com",
@@ -25,6 +25,10 @@ Use `cbx workspace` when the person wants to continue a CodeRook project from a
25
25
 
26
26
  `cbx workspace show <project> <id>` includes the administrator-configured recipe commands and target information. Run a named recipe, inspect its job with `logs`, and use `cancel` if needed. Only deploy an explicitly requested target and a saved version with applicable checks. A missing recipe requires configuration; running a local terminal command does not change remote files.
27
27
 
28
+ To hand a draft to a machine that is not signed in as the owner, run `cbx workspace assign <project> <draft> --name "Laptop"`. It prints a token that reaches that one draft (read the project, edit, save and run its checks; no deploys, settings, other drafts or more tokens). Tell the person to give it to the other machine privately; never repeat the token yourself. On that machine: `cbx sign-in` (paste the token), `cbx workspace checkout <project> <draft> [folder]`, edit the files, then `cbx workspace push` (add `--save -m "…"` to save, `--dry-run` to preview). A checked-out folder is not a linked folder, so `cbx submit` does not apply there. A handed-over machine's saves never land on the draft's line directly: they go to the draft's own line `drafts/<name>-<id>` and open a pull request into it, which the owner or a reviewer approves and merges (see it with `cbx pr list`). Saves made signed in as the owner go straight to the line. `cbx workspace assignments` lists live tokens and `cbx workspace unassign` ends one; archiving the draft ends them all.
29
+
30
+ `cbx machines [project]` shows the PCs working on a project: linked folders and handed-over drafts, the version each holds, how far behind the line it is and its unsaved file count, then the runners. A machine appears when cbx status, get, submit or a workspace command runs there; `CODEROOK_NO_PRESENCE=1` keeps one off the list. Check it before assuming nobody else is mid-change.
31
+
28
32
  `cbx mcp --workspaces` opts into the remote workspace tools using this account's stored token. The original `cbx mcp` stays read-only. A token needs explicit workspace edit/run/deploy grants for those actions; never print its value. The remote plugin can connect directly without this CLI.
29
33
 
30
34
  Native jobs require CLI 0.38.0 or later on the selected Windows laptop and a matching `cbx runner --name` and label. The named recipe can select `includePaths` so only needed inputs download to a temporary job folder. Hosted jobs continue with the desktop PC off. Native execution, local GPU features, toolchains and signed-in services need their own suitable machine/setup. Remote task notes share project handover state, not proprietary chat history.