@coderook/cli 0.38.1 → 0.39.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 +1 -1
- package/dist/cbx/src/core/upload.js +71 -61
- package/dist/cbx/src/core/upload_completion.js +34 -0
- package/dist/cli/src/cli.js +57 -9
- package/dist/cli/src/local_commands.js +6 -1
- package/dist/cli/src/machine.js +80 -0
- package/dist/cli/src/upload_progress.js +41 -0
- package/dist/cli/src/workspace_command.js +3 -0
- package/dist/cli/src/workspace_machine.js +322 -0
- package/package.json +67 -66
- package/skills/coderook/SKILL.md +4 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "coderook",
|
|
3
3
|
"displayName": "CodeRook",
|
|
4
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.
|
|
5
|
+
"version": "0.39.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
|
@@ -30,6 +30,7 @@ const solid_js_1 = require("./solid.js");
|
|
|
30
30
|
const retry_js_1 = require("./retry.js");
|
|
31
31
|
const staging_js_1 = require("./staging.js");
|
|
32
32
|
const transfer_progress_js_1 = require("./transfer_progress.js");
|
|
33
|
+
const upload_completion_js_1 = require("./upload_completion.js");
|
|
33
34
|
const delta_js_1 = require("../shared/delta.js");
|
|
34
35
|
const telemetry_js_1 = require("../shared/telemetry.js");
|
|
35
36
|
/** Above this the API insists on a multipart session. */
|
|
@@ -230,6 +231,7 @@ class Uploader {
|
|
|
230
231
|
// waiting rather than serve out its backoff first.
|
|
231
232
|
this.check();
|
|
232
233
|
try {
|
|
234
|
+
init.onAttempt?.(attempt);
|
|
233
235
|
return await this.attempt(route, init);
|
|
234
236
|
}
|
|
235
237
|
catch (error) {
|
|
@@ -243,6 +245,7 @@ class Uploader {
|
|
|
243
245
|
if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
|
|
244
246
|
throw error;
|
|
245
247
|
this.telemetry.retry();
|
|
248
|
+
init.onRetry?.(attempt + 1, wait);
|
|
246
249
|
await this.pause(wait);
|
|
247
250
|
wait *= 2;
|
|
248
251
|
}
|
|
@@ -928,27 +931,22 @@ class Uploader {
|
|
|
928
931
|
(details ? ` Details: ${details}` : ""));
|
|
929
932
|
}
|
|
930
933
|
async publishVersion(at) {
|
|
934
|
+
const initial = {
|
|
935
|
+
stage: "publish", files: at.declarations.length, totalFiles: at.declarations.length,
|
|
936
|
+
bytes: at.progress.sentBytes, totalBytes: at.totalBytes, percent: 96,
|
|
937
|
+
bytesPerSecond: 0, path: "Preparing the file manifest",
|
|
938
|
+
completion: { phase: "preparing", elapsedSeconds: 0 },
|
|
939
|
+
};
|
|
940
|
+
return (0, upload_completion_js_1.withCompletionProgress)(initial, at.report, (report) => this.recordVersion({ ...at, report }));
|
|
941
|
+
}
|
|
942
|
+
async recordVersion(at) {
|
|
931
943
|
const { request, repositoryId, declarations, uploaded, reused, prior, everything, ticked, deletions, readFailures, vanished, totalBytes, progress, report, } = at;
|
|
932
944
|
// 4. Name the version, which is what makes the upload visible.
|
|
933
945
|
this.check();
|
|
934
|
-
report({
|
|
935
|
-
stage: "publish",
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
bytes: progress.sentBytes,
|
|
939
|
-
totalBytes,
|
|
940
|
-
/*
|
|
941
|
-
A save, not a version.
|
|
942
|
-
|
|
943
|
-
Sending produces a commit; it becomes a version when somebody names
|
|
944
|
-
it, which happens later and on purpose. This line said "Recording the
|
|
945
|
-
version" while doing neither — it was the last wording left over from
|
|
946
|
-
before the two were separate, and it sat on the one screen a person
|
|
947
|
-
watches to the end.
|
|
948
|
-
*/
|
|
949
|
-
path: "Writing down what was sent",
|
|
950
|
-
percent: 96,
|
|
951
|
-
bytesPerSecond: progress.rate(),
|
|
946
|
+
const completing = (phase, path) => report({
|
|
947
|
+
stage: "publish", files: declarations.length, totalFiles: declarations.length,
|
|
948
|
+
bytes: progress.sentBytes, totalBytes, percent: 96, bytesPerSecond: 0,
|
|
949
|
+
path, completion: { phase, elapsedSeconds: 0 },
|
|
952
950
|
});
|
|
953
951
|
// The version names everything the project holds: what was just sent,
|
|
954
952
|
// plus every file that kept the object the last version pointed at.
|
|
@@ -1081,53 +1079,61 @@ class Uploader {
|
|
|
1081
1079
|
},
|
|
1082
1080
|
}
|
|
1083
1081
|
: null;
|
|
1084
|
-
const send = (asChanges) =>
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1082
|
+
const send = (asChanges) => {
|
|
1083
|
+
completing("confirming", "Files uploaded · waiting for CodeRook to confirm the save");
|
|
1084
|
+
return (0, profile_js_1.timed)("publish the version", () => this.call(`/v1/repositories/${repositoryId}/versions`, {
|
|
1085
|
+
method: "POST",
|
|
1086
|
+
contentType: "application/json",
|
|
1087
|
+
onAttempt: (attempt) => {
|
|
1088
|
+
if (attempt > 1)
|
|
1089
|
+
completing("confirming", `Retry ${attempt} · waiting for CodeRook to confirm the save`);
|
|
1090
|
+
},
|
|
1091
|
+
onRetry: (attempt, waitMs) => completing("retrying", `Confirmation interrupted · retry ${attempt} in ${Math.ceil(waitMs / 1000)} s · uploaded files are reused`),
|
|
1092
|
+
body: JSON.stringify({
|
|
1093
|
+
message: request.message.trim() || "Saved from the CodeRook desktop app",
|
|
1094
|
+
...(request.baseVersionId === undefined
|
|
1095
|
+
? {}
|
|
1096
|
+
: {
|
|
1097
|
+
ancestryMode: "required",
|
|
1098
|
+
baseVersionId: request.baseVersionId,
|
|
1099
|
+
}),
|
|
1100
|
+
...(request.expectedHeadVersionId === undefined
|
|
1101
|
+
? {}
|
|
1102
|
+
: { expectedHeadVersionId: request.expectedHeadVersionId }),
|
|
1103
|
+
...(request.track ? { track: request.track } : {}),
|
|
1104
|
+
...(request.allowIgnored ? { allowIgnored: true } : {}),
|
|
1105
|
+
...(this.trackedByGit.length ? { trackedByGit: this.trackedByGit } : {}),
|
|
1106
|
+
...(request.allowSecrets ? { allowSecrets: true } : {}),
|
|
1107
|
+
...(linesAdded === undefined ? {} : { linesAdded }),
|
|
1108
|
+
...(linesRemoved === undefined ? {} : { linesRemoved }),
|
|
1109
|
+
/*
|
|
1110
|
+
Names this attempt so a retry after a lost connection is answered
|
|
1111
|
+
with the version already made, rather than making a second one.
|
|
1112
|
+
*/
|
|
1113
|
+
idempotencyKey: (0, publish_name_js_1.publishAttemptName)({
|
|
1114
|
+
repositoryId,
|
|
1115
|
+
expectedHeadVersionId: request.expectedHeadVersionId,
|
|
1116
|
+
message: request.message,
|
|
1117
|
+
/*
|
|
1118
|
+
Named by what each file is. A whole file is its object; a chunked
|
|
1119
|
+
one is the digest of its contents — so retrying the same publish
|
|
1120
|
+
produces the same key either way, which is the point of it.
|
|
1121
|
+
*/
|
|
1122
|
+
files: contents.map((item) => ({
|
|
1123
|
+
path: item.path,
|
|
1124
|
+
objectId: item.objectId ?? item.sha256 ?? "",
|
|
1125
|
+
})),
|
|
1094
1126
|
}),
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
: { expectedHeadVersionId: request.expectedHeadVersionId }),
|
|
1098
|
-
...(request.track ? { track: request.track } : {}),
|
|
1099
|
-
...(request.allowIgnored ? { allowIgnored: true } : {}),
|
|
1100
|
-
...(this.trackedByGit.length ? { trackedByGit: this.trackedByGit } : {}),
|
|
1101
|
-
...(request.allowSecrets ? { allowSecrets: true } : {}),
|
|
1102
|
-
...(linesAdded === undefined ? {} : { linesAdded }),
|
|
1103
|
-
...(linesRemoved === undefined ? {} : { linesRemoved }),
|
|
1104
|
-
/*
|
|
1105
|
-
Names this attempt so a retry after a lost connection is answered
|
|
1106
|
-
with the version already made, rather than making a second one.
|
|
1107
|
-
*/
|
|
1108
|
-
idempotencyKey: (0, publish_name_js_1.publishAttemptName)({
|
|
1109
|
-
repositoryId,
|
|
1110
|
-
expectedHeadVersionId: request.expectedHeadVersionId,
|
|
1111
|
-
message: request.message,
|
|
1127
|
+
sourceSize: sourceBytes,
|
|
1128
|
+
storedSize: storedBytes,
|
|
1112
1129
|
/*
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
produces the same key either way, which is the point of it.
|
|
1130
|
+
One shape or the other, never both — the service refuses a file that
|
|
1131
|
+
names an object and a chunk list, and so does its database.
|
|
1116
1132
|
*/
|
|
1117
|
-
files:
|
|
1118
|
-
path: item.path,
|
|
1119
|
-
objectId: item.objectId ?? item.sha256 ?? "",
|
|
1120
|
-
})),
|
|
1133
|
+
...(asChanges && changesOnly ? changesOnly : { files: wire }),
|
|
1121
1134
|
}),
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
/*
|
|
1125
|
-
One shape or the other, never both — the service refuses a file that
|
|
1126
|
-
names an object and a chunk list, and so does its database.
|
|
1127
|
-
*/
|
|
1128
|
-
...(asChanges && changesOnly ? changesOnly : { files: wire }),
|
|
1129
|
-
}),
|
|
1130
|
-
}));
|
|
1135
|
+
}));
|
|
1136
|
+
};
|
|
1131
1137
|
/*
|
|
1132
1138
|
A service that cannot build on the version named (it was taken down,
|
|
1133
1139
|
say) answers files_from_*; the whole list is then sent instead, which
|
|
@@ -1147,6 +1153,7 @@ class Uploader {
|
|
|
1147
1153
|
instead. A repeated attempt predates manifest echoing, so read that
|
|
1148
1154
|
Version once rather than guessing.
|
|
1149
1155
|
*/
|
|
1156
|
+
completing("local", "CodeRook confirmed the save · updating local records");
|
|
1150
1157
|
let acceptedManifest = (0, publication_js_1.acceptedManifestDigests)(completed.manifest?.files);
|
|
1151
1158
|
/*
|
|
1152
1159
|
A service that lists version-delta-v1 also says when it combined this
|
|
@@ -1162,6 +1169,7 @@ class Uploader {
|
|
|
1162
1169
|
!request.allowSecrets &&
|
|
1163
1170
|
(await this.versionDeltaAllowed());
|
|
1164
1171
|
if (!completed.mergeTrack && !landedAsSent && !Object.keys(acceptedManifest).length) {
|
|
1172
|
+
completing("local", "CodeRook confirmed the save · reading the accepted file manifest");
|
|
1165
1173
|
acceptedManifest = Object.fromEntries([...(await this.versionFiles(repositoryId, completed.version.id, request.localPath))].map(([file, descriptor]) => [file, descriptor.sha256]));
|
|
1166
1174
|
}
|
|
1167
1175
|
const manifest = Object.keys(acceptedManifest).length
|
|
@@ -1181,6 +1189,7 @@ class Uploader {
|
|
|
1181
1189
|
file, which on a 47,000-file project was over a billion comparisons at
|
|
1182
1190
|
the very end of a save.
|
|
1183
1191
|
*/
|
|
1192
|
+
completing("local", "CodeRook confirmed the save · updating local file records");
|
|
1184
1193
|
const sentDigest = new Map(declarations.map((declaration) => [declaration.path, declaration.sha256]));
|
|
1185
1194
|
/*
|
|
1186
1195
|
A file named from bytes already stored, at a path the last version did
|
|
@@ -1231,6 +1240,7 @@ class Uploader {
|
|
|
1231
1240
|
if (rows.every((row) => typeof row.sha256 === "string" && row.sha256.length === 64)) {
|
|
1232
1241
|
const key = `${repositoryId}/${completed.version.id}`;
|
|
1233
1242
|
versionListings.set(key, Promise.resolve(rows));
|
|
1243
|
+
completing("local", "CodeRook confirmed the save · caching the file listing for next time");
|
|
1234
1244
|
await writeListing(request.localPath, repositoryId, completed.version.id, rows);
|
|
1235
1245
|
}
|
|
1236
1246
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.withCompletionProgress = withCompletionProgress;
|
|
4
|
+
/** Keep a pending completion visible without inventing server-side progress.
|
|
5
|
+
* Every phase change comes from the uploader; ticks only measure elapsed time.
|
|
6
|
+
*/
|
|
7
|
+
async function withCompletionProgress(initial, report, work) {
|
|
8
|
+
const started = performance.now();
|
|
9
|
+
let latest = initial;
|
|
10
|
+
let active = true;
|
|
11
|
+
const emit = (progress) => {
|
|
12
|
+
latest = progress;
|
|
13
|
+
if (progress.stage !== "publish")
|
|
14
|
+
active = false;
|
|
15
|
+
report(progress.stage === "publish" ? {
|
|
16
|
+
...progress,
|
|
17
|
+
completion: {
|
|
18
|
+
phase: progress.completion?.phase ?? "preparing",
|
|
19
|
+
elapsedSeconds: Math.max(0, (performance.now() - started) / 1000),
|
|
20
|
+
},
|
|
21
|
+
} : progress);
|
|
22
|
+
};
|
|
23
|
+
emit(initial);
|
|
24
|
+
const timer = setInterval(() => { if (active)
|
|
25
|
+
emit(latest); }, 1000);
|
|
26
|
+
timer.unref?.();
|
|
27
|
+
try {
|
|
28
|
+
return await work(emit);
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
active = false;
|
|
32
|
+
clearInterval(timer);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -27,6 +27,7 @@ const api_js_1 = require("./api.js");
|
|
|
27
27
|
const registry_js_1 = require("./registry.js");
|
|
28
28
|
const help_js_1 = require("./help.js");
|
|
29
29
|
const progress_js_1 = require("./progress.js");
|
|
30
|
+
const upload_progress_js_1 = require("./upload_progress.js");
|
|
30
31
|
const publish_js_1 = require("./publish.js");
|
|
31
32
|
const attach_command_js_1 = require("./attach_command.js");
|
|
32
33
|
const build_command_js_1 = require("./build_command.js");
|
|
@@ -51,6 +52,8 @@ const worktree_js_1 = require("../../cbx/src/core/worktree.js");
|
|
|
51
52
|
const rules_js_1 = require("../../cbx/src/core/rules.js");
|
|
52
53
|
const upload_js_1 = require("../../cbx/src/core/upload.js");
|
|
53
54
|
const download_js_1 = require("../../cbx/src/core/download.js");
|
|
55
|
+
const machine_js_1 = require("./machine.js");
|
|
56
|
+
const workspace_machine_js_1 = require("./workspace_machine.js");
|
|
54
57
|
const safe_path_js_1 = require("../../cbx/src/shared/safe_path.js");
|
|
55
58
|
const faults_js_1 = require("../../cbx/src/core/faults.js");
|
|
56
59
|
const version_commands_js_1 = require("./version_commands.js");
|
|
@@ -429,6 +432,30 @@ async function commandLink(parsed) {
|
|
|
429
432
|
console.log(dim(` ${accent("cbx status")} shows how this folder differs from it; nothing was sent or fetched.`));
|
|
430
433
|
return 0;
|
|
431
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Run a folder command, then tell the project which version this machine's
|
|
437
|
+
* folder now holds (machine.ts): after a save nothing is left unsent, after a
|
|
438
|
+
* fetch the count is not known without scanning again.
|
|
439
|
+
*/
|
|
440
|
+
function announcing(run, saved) {
|
|
441
|
+
return async (parsed) => {
|
|
442
|
+
const code = await run(parsed);
|
|
443
|
+
if (code === 0) {
|
|
444
|
+
const folder = folderFor(parsed);
|
|
445
|
+
const link = await (0, config_js_1.readLink)(folder).catch(() => null);
|
|
446
|
+
if (link) {
|
|
447
|
+
await (0, machine_js_1.checkIn)(link.repositoryId, {
|
|
448
|
+
kind: "folder",
|
|
449
|
+
folder,
|
|
450
|
+
track: link.track ?? null,
|
|
451
|
+
baseVersionId: link.baseVersionId ?? link.versionId ?? null,
|
|
452
|
+
changedFiles: saved ? 0 : null,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return code;
|
|
457
|
+
};
|
|
458
|
+
}
|
|
432
459
|
async function commandStatus(parsed) {
|
|
433
460
|
const folder = folderFor(parsed);
|
|
434
461
|
let link;
|
|
@@ -460,6 +487,14 @@ async function commandStatus(parsed) {
|
|
|
460
487
|
const found = await (0, worktree_js_1.changedFiles)(folder, rules, baseline, "synchronize");
|
|
461
488
|
const gone = found.filter((file) => file.deleted);
|
|
462
489
|
const files = found.filter((file) => !file.deleted);
|
|
490
|
+
// Tells the project's Actions page what this machine's folder holds; never delays or fails status.
|
|
491
|
+
void (0, machine_js_1.checkIn)(link?.repositoryId, {
|
|
492
|
+
kind: "folder",
|
|
493
|
+
folder,
|
|
494
|
+
track: link?.track ?? null,
|
|
495
|
+
baseVersionId: link?.baseVersionId ?? link?.versionId ?? null,
|
|
496
|
+
changedFiles: found.length,
|
|
497
|
+
});
|
|
463
498
|
console.log(bold(node_path_1.default.basename(folder)) + dim(` ${folder}`));
|
|
464
499
|
console.log(link
|
|
465
500
|
? `Account holds ${accent(`v${link.sequence}`)} · ${Object.keys(link.manifest).length} ${Object.keys(link.manifest).length === 1 ? "file" : "files"} ${dim(`(${link.slug})`)}`
|
|
@@ -1071,6 +1106,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
|
1071
1106
|
console.log("");
|
|
1072
1107
|
}
|
|
1073
1108
|
const line = progressLine();
|
|
1109
|
+
const uploadDisplay = new upload_progress_js_1.UploadDisplay();
|
|
1074
1110
|
let uploader = new upload_js_1.Uploader(config_js_1.credentials);
|
|
1075
1111
|
/*
|
|
1076
1112
|
Built by the shared description, so this and the git remote helper cannot
|
|
@@ -1226,14 +1262,12 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
|
|
|
1226
1262
|
console.log("Nothing was uploaded and no Version was created.");
|
|
1227
1263
|
return 0;
|
|
1228
1264
|
}
|
|
1229
|
-
result = await uploader.execute(uploadRequest, plan, (progress) =>
|
|
1230
|
-
|
|
1231
|
-
`${progress.stage.padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
|
|
1232
|
-
dim(progress.path.slice(-40)));
|
|
1233
|
-
});
|
|
1265
|
+
result = await uploader.execute(uploadRequest, plan, (progress) => uploadDisplay.report(progress));
|
|
1266
|
+
uploadDisplay.done();
|
|
1234
1267
|
break;
|
|
1235
1268
|
}
|
|
1236
1269
|
catch (error) {
|
|
1270
|
+
uploadDisplay.done();
|
|
1237
1271
|
done(line);
|
|
1238
1272
|
/*
|
|
1239
1273
|
Classified by the shared description rather than by a private regular
|
|
@@ -2347,7 +2381,7 @@ const SPECS = [
|
|
|
2347
2381
|
'cbx submit --into voxai-coder -m "First save from the Linux machine"',
|
|
2348
2382
|
'cbx submit --track spike -m "Try the other encoder"',
|
|
2349
2383
|
],
|
|
2350
|
-
run: commandSubmit,
|
|
2384
|
+
run: announcing(commandSubmit, true),
|
|
2351
2385
|
},
|
|
2352
2386
|
{
|
|
2353
2387
|
name: "diff",
|
|
@@ -2605,7 +2639,7 @@ const SPECS = [
|
|
|
2605
2639
|
options: [
|
|
2606
2640
|
{ flags: "--replace", description: "make it an exact copy, discarding local changes" },
|
|
2607
2641
|
],
|
|
2608
|
-
run: commandGet,
|
|
2642
|
+
run: announcing(commandGet, false),
|
|
2609
2643
|
},
|
|
2610
2644
|
{
|
|
2611
2645
|
name: "clone",
|
|
@@ -2718,7 +2752,7 @@ const SPECS = [
|
|
|
2718
2752
|
name: "workspace",
|
|
2719
2753
|
group: "Your projects",
|
|
2720
2754
|
summary: "edit, save and run a private project remotely without a checkout",
|
|
2721
|
-
usage: "workspace <open|list|show|read|files|search|diff|patch|save|run|logs|cancel|deploy|preview|handoff|close|recipe> <project> [workspace] [job]",
|
|
2755
|
+
usage: "workspace <open|list|show|read|files|search|diff|patch|save|run|logs|cancel|deploy|preview|handoff|close|recipe|assign|assignments|unassign|checkout> <project> [workspace] [job] | workspace push [folder]",
|
|
2722
2756
|
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. Saving and deploying are separate operations.",
|
|
2723
2757
|
options: [
|
|
2724
2758
|
{ flags: "--data-file <path>", description: "read an operation's JSON from this local file" },
|
|
@@ -2737,10 +2771,24 @@ const SPECS = [
|
|
|
2737
2771
|
{ flags: "--version <id>", description: "saved workspace version to deploy" },
|
|
2738
2772
|
{ flags: "-m, --message <text>", description: "describe the version being saved" },
|
|
2739
2773
|
{ flags: "--note <text>", description: "task context for another computer/assistant" },
|
|
2774
|
+
{ flags: "--days <number>", description: "assign: how long the handed-over token lasts (1-90, default 14)" },
|
|
2775
|
+
{ flags: "--save", description: "push: save the draft after sending the changes" },
|
|
2776
|
+
{ flags: "--dry-run", description: "push: list what would be sent, send nothing" },
|
|
2740
2777
|
],
|
|
2741
|
-
examples: ["cbx workspace open my-project --name \"Laptop work\" --key laptop-open-001", "cbx workspace list my-project"],
|
|
2778
|
+
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\""],
|
|
2742
2779
|
run: workspace_command_js_1.commandWorkspace,
|
|
2743
2780
|
},
|
|
2781
|
+
{
|
|
2782
|
+
name: "machines",
|
|
2783
|
+
group: "Your projects",
|
|
2784
|
+
summary: "the PCs working on a project, how far behind each is, and its runners",
|
|
2785
|
+
usage: "machines [project]",
|
|
2786
|
+
detail: "Every PC with a folder linked to the project, or working on a draft handed to it, says which version it holds when cbx runs there. " +
|
|
2787
|
+
"This lists them with how many versions behind the project's line each is and how many files it has not saved, then the project's runners. " +
|
|
2788
|
+
"Machines not seen for 30 days are left out. Set CODEROOK_NO_PRESENCE to keep a machine off the list.",
|
|
2789
|
+
examples: ["cbx machines", "cbx machines my-project"],
|
|
2790
|
+
run: async (parsed) => (0, workspace_machine_js_1.commandMachines)(parsed, (await (0, config_js_1.readLink)(folderFor(parsed)).catch(() => null))?.repositoryId ?? null),
|
|
2791
|
+
},
|
|
2744
2792
|
{
|
|
2745
2793
|
name: "ai",
|
|
2746
2794
|
group: "Your projects",
|
|
@@ -26,6 +26,7 @@ exports.localMerge = localMerge;
|
|
|
26
26
|
*/
|
|
27
27
|
const node_path_1 = __importDefault(require("node:path"));
|
|
28
28
|
const node_process_1 = __importDefault(require("node:process"));
|
|
29
|
+
const upload_progress_js_1 = require("./upload_progress.js");
|
|
29
30
|
const history_js_1 = require("../../cbx/src/local/history.js");
|
|
30
31
|
const merge_js_1 = require("../../cbx/src/local/merge.js");
|
|
31
32
|
const repository_js_1 = require("../../cbx/src/local/repository.js");
|
|
@@ -384,6 +385,7 @@ async function localPush(parsed, repository) {
|
|
|
384
385
|
return 1;
|
|
385
386
|
}
|
|
386
387
|
const interactive = node_process_1.default.stdout.isTTY;
|
|
388
|
+
const uploadDisplay = new upload_progress_js_1.UploadDisplay();
|
|
387
389
|
const result = await (0, remote_js_1.push)(repository, {
|
|
388
390
|
credentials: config_js_1.credentials,
|
|
389
391
|
projectName: link?.slug ?? (chosen ? ("id" in chosen ? chosen.slug : chosen.name) : node_path_1.default.basename(repository.root)),
|
|
@@ -392,10 +394,13 @@ async function localPush(parsed, repository) {
|
|
|
392
394
|
allowSecrets: has(parsed, "allow-secrets"),
|
|
393
395
|
acknowledged: has(parsed, "yes", "y"),
|
|
394
396
|
report: (event) => {
|
|
397
|
+
if (event.kind === "upload")
|
|
398
|
+
uploadDisplay.report(event.progress);
|
|
395
399
|
if (event.kind === "save" && interactive) {
|
|
396
400
|
node_process_1.default.stdout.write(`\r${dim(`Sending ${event.index + 1} of ${event.total}: ${event.save.message.split("\n")[0].slice(0, 50)}`)} `);
|
|
397
401
|
}
|
|
398
402
|
if (event.kind === "pushed" || event.kind === "unchanged") {
|
|
403
|
+
uploadDisplay.done();
|
|
399
404
|
if (interactive)
|
|
400
405
|
node_process_1.default.stdout.write(`\r${" ".repeat(80)}\r`);
|
|
401
406
|
console.log(` ${accent(short(event.save.id))} ` +
|
|
@@ -403,7 +408,7 @@ async function localPush(parsed, repository) {
|
|
|
403
408
|
` ${event.save.message.split("\n")[0]}`);
|
|
404
409
|
}
|
|
405
410
|
},
|
|
406
|
-
});
|
|
411
|
+
}).finally(() => uploadDisplay.done());
|
|
407
412
|
if (!result.pushed.length) {
|
|
408
413
|
console.log(`Everything on ${accent(result.line)} is already on CodeRook.`);
|
|
409
414
|
return 0;
|
|
@@ -0,0 +1,80 @@
|
|
|
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.machineIdentity = machineIdentity;
|
|
7
|
+
exports.checkIn = checkIn;
|
|
8
|
+
/**
|
|
9
|
+
* This machine, as a project's Actions page shows it (backend machines.ts).
|
|
10
|
+
*
|
|
11
|
+
* Each machine gets a random id the first time cbx runs there, kept in its
|
|
12
|
+
* settings folder, and is named after the computer. When cbx works in a
|
|
13
|
+
* folder linked to a project, or on a draft handed to this machine, it says
|
|
14
|
+
* so: which version it holds, which line it saves to, how many files it has
|
|
15
|
+
* not saved. Only the folder's name goes, never its path.
|
|
16
|
+
*
|
|
17
|
+
* Saying so is best effort. It never delays a command by more than a moment,
|
|
18
|
+
* never fails one, and is skipped when CODEROOK_NO_PRESENCE is set.
|
|
19
|
+
*/
|
|
20
|
+
const node_crypto_1 = require("node:crypto");
|
|
21
|
+
const promises_1 = require("node:fs/promises");
|
|
22
|
+
const node_os_1 = require("node:os");
|
|
23
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
24
|
+
const identify_js_1 = require("../../cbx/src/core/identify.js");
|
|
25
|
+
const config_js_1 = require("./config.js");
|
|
26
|
+
let cached = null;
|
|
27
|
+
/** This machine's id and name, made once and kept. */
|
|
28
|
+
async function machineIdentity() {
|
|
29
|
+
if (cached)
|
|
30
|
+
return cached;
|
|
31
|
+
const file = node_path_1.default.join((0, config_js_1.configDirectory)(), "machine.json");
|
|
32
|
+
let id = "";
|
|
33
|
+
try {
|
|
34
|
+
const saved = JSON.parse(await (0, promises_1.readFile)(file, "utf8"));
|
|
35
|
+
if (typeof saved.id === "string" && /^[A-Za-z0-9_-]{8,64}$/.test(saved.id))
|
|
36
|
+
id = saved.id;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* First run on this machine, or the file was removed. */
|
|
40
|
+
}
|
|
41
|
+
if (!id) {
|
|
42
|
+
id = (0, node_crypto_1.randomBytes)(12).toString("base64url");
|
|
43
|
+
await (0, promises_1.mkdir)(node_path_1.default.dirname(file), { recursive: true }).catch(() => undefined);
|
|
44
|
+
await (0, promises_1.writeFile)(file, JSON.stringify({ id }, null, 2) + "\n").catch(() => undefined);
|
|
45
|
+
}
|
|
46
|
+
cached = { id, name: (process.env.CODEROOK_MACHINE_NAME || (0, node_os_1.hostname)() || "This machine").slice(0, 120) };
|
|
47
|
+
return cached;
|
|
48
|
+
}
|
|
49
|
+
/** Tell the project this machine is here. Never throws, and gives up after a moment. */
|
|
50
|
+
async function checkIn(repositoryId, state) {
|
|
51
|
+
if (!repositoryId || process.env.CODEROOK_NO_PRESENCE)
|
|
52
|
+
return;
|
|
53
|
+
try {
|
|
54
|
+
const token = await (0, config_js_1.loadToken)();
|
|
55
|
+
if (!token)
|
|
56
|
+
return;
|
|
57
|
+
const me = await machineIdentity();
|
|
58
|
+
const client = (0, identify_js_1.clientHeaders)()["x-coderook-client"] ?? "";
|
|
59
|
+
await fetch(`${(0, config_js_1.apiOrigin)()}/v1/repositories/${encodeURIComponent(repositoryId)}/machines`, {
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json", ...(0, identify_js_1.clientHeaders)() },
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
machineId: me.id,
|
|
64
|
+
name: me.name,
|
|
65
|
+
kind: state.kind,
|
|
66
|
+
platform: `${(0, node_os_1.platform)()} ${(0, node_os_1.release)()}`.slice(0, 80),
|
|
67
|
+
clientVersion: client.startsWith("cli/") ? client.slice(4) : null,
|
|
68
|
+
folderName: state.folder ? node_path_1.default.basename(node_path_1.default.resolve(state.folder)) : null,
|
|
69
|
+
track: state.track ?? null,
|
|
70
|
+
baseVersionId: state.baseVersionId ?? null,
|
|
71
|
+
changedFiles: state.changedFiles ?? null,
|
|
72
|
+
workspaceId: state.workspaceId ?? null,
|
|
73
|
+
}),
|
|
74
|
+
signal: AbortSignal.timeout(2500),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* Offline, signed out or slow: the command's own work is what matters. */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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.UploadDisplay = void 0;
|
|
7
|
+
const node_process_1 = __importDefault(require("node:process"));
|
|
8
|
+
const progress_1 = require("./progress");
|
|
9
|
+
/** The timer in the shared uploader keeps finalization visible in every client. */
|
|
10
|
+
class UploadDisplay {
|
|
11
|
+
interactive;
|
|
12
|
+
write;
|
|
13
|
+
phase = "";
|
|
14
|
+
frame = 0;
|
|
15
|
+
constructor(interactive = Boolean(node_process_1.default.stdout.isTTY), write = (text) => { node_process_1.default.stdout.write(text); }) {
|
|
16
|
+
this.interactive = interactive;
|
|
17
|
+
this.write = write;
|
|
18
|
+
}
|
|
19
|
+
report(progress) {
|
|
20
|
+
const finishing = progress.stage === "publish";
|
|
21
|
+
const phase = finishing ? progress.path : progress.stage;
|
|
22
|
+
if (!this.interactive) {
|
|
23
|
+
if (finishing && phase !== this.phase)
|
|
24
|
+
this.write(` ${phase}\n`);
|
|
25
|
+
this.phase = phase;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
this.phase = phase;
|
|
29
|
+
const seconds = Math.floor(progress.completion?.elapsedSeconds ?? 0);
|
|
30
|
+
const clock = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
31
|
+
const detail = finishing
|
|
32
|
+
? `${["|", "/", "-", "\\"][this.frame++ % 4]} Finalizing save · ${clock} elapsed · ${progress.path}`
|
|
33
|
+
: `${(0, progress_1.bar)(progress.percent / 100, 18, (text) => text)} ${progress.percent}% ${progress.stage} ${progress.files}/${progress.totalFiles} ${progress.path}`;
|
|
34
|
+
// Keep status on one terminal row, including narrow windows.
|
|
35
|
+
const width = Math.max(1, (node_process_1.default.stdout.columns ?? 120) - 3);
|
|
36
|
+
this.write(`\r\x1b[2K ${detail.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, width)}`);
|
|
37
|
+
}
|
|
38
|
+
done() { if (this.interactive)
|
|
39
|
+
this.write("\r\x1b[2K"); }
|
|
40
|
+
}
|
|
41
|
+
exports.UploadDisplay = UploadDisplay;
|
|
@@ -6,8 +6,11 @@ const promises_1 = require("node:fs/promises");
|
|
|
6
6
|
const node_readline_1 = require("node:readline");
|
|
7
7
|
const api_js_1 = require("./api.js");
|
|
8
8
|
const config_js_1 = require("./config.js");
|
|
9
|
+
const workspace_machine_js_1 = require("./workspace_machine.js");
|
|
9
10
|
async function commandWorkspace(parsed) {
|
|
10
11
|
const [operation, projectRef, workspace, runId] = parsed.positional;
|
|
12
|
+
if (operation && workspace_machine_js_1.MACHINE_OPERATIONS.has(operation))
|
|
13
|
+
return (0, workspace_machine_js_1.commandWorkspaceMachine)(parsed);
|
|
11
14
|
if (!operation || !projectRef)
|
|
12
15
|
throw new Error("Use cbx workspace <operation> <project> [workspace]. See cbx help workspace.");
|
|
13
16
|
const project = await (0, api_js_1.findProject)(projectRef);
|
|
@@ -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,66 +1,67 @@
|
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@coderook/cli",
|
|
3
|
+
"version": "0.39.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
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "24.10.1",
|
|
60
|
+
"typescript": "5.9.3"
|
|
61
|
+
},
|
|
62
|
+
"repository": {
|
|
63
|
+
"type": "git",
|
|
64
|
+
"url": "git+https://github.com/AlexC1991/cbx.git",
|
|
65
|
+
"directory": "cli"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/skills/coderook/SKILL.md
CHANGED
|
@@ -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.
|