@coderook/cli 0.37.0 → 0.38.2

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.
@@ -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.37.0",
5
+ "version": "0.38.2",
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
- files: declarations.length,
937
- totalFiles: declarations.length,
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) => (0, profile_js_1.timed)("publish the version", () => this.call(`/v1/repositories/${repositoryId}/versions`, {
1085
- method: "POST",
1086
- contentType: "application/json",
1087
- body: JSON.stringify({
1088
- message: request.message.trim() || "Saved from the CodeRook desktop app",
1089
- ...(request.baseVersionId === undefined
1090
- ? {}
1091
- : {
1092
- ancestryMode: "required",
1093
- baseVersionId: request.baseVersionId,
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
- ...(request.expectedHeadVersionId === undefined
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
- Named by what each file is. A whole file is its object; a chunked
1114
- one is the digest of its contents — so retrying the same publish
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: contents.map((item) => ({
1118
- path: item.path,
1119
- objectId: item.objectId ?? item.sha256 ?? "",
1120
- })),
1133
+ ...(asChanges && changesOnly ? changesOnly : { files: wire }),
1121
1134
  }),
1122
- sourceSize: sourceBytes,
1123
- storedSize: storedBytes,
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
+ }
@@ -22,10 +22,12 @@ const node_crypto_1 = require("node:crypto");
22
22
  const node_fs_1 = require("node:fs");
23
23
  const node_path_1 = __importDefault(require("node:path"));
24
24
  const node_process_1 = __importDefault(require("node:process"));
25
+ const workspace_command_js_1 = require("./workspace_command.js");
25
26
  const api_js_1 = require("./api.js");
26
27
  const registry_js_1 = require("./registry.js");
27
28
  const help_js_1 = require("./help.js");
28
29
  const progress_js_1 = require("./progress.js");
30
+ const upload_progress_js_1 = require("./upload_progress.js");
29
31
  const publish_js_1 = require("./publish.js");
30
32
  const attach_command_js_1 = require("./attach_command.js");
31
33
  const build_command_js_1 = require("./build_command.js");
@@ -686,7 +688,7 @@ async function commandImport(parsed) {
686
688
  flags: submitFlags,
687
689
  });
688
690
  if (code === 0 && plan.temporary) {
689
- console.log(dim(`The working copy stays at ${plan.folder} until you remove it.
691
+ console.log(dim(`The working copy stays at ${plan.folder} until you remove it.
690
692
  ` +
691
693
  `Run ${accent("cbx get " + plan.name)} anywhere to fetch it fresh.`));
692
694
  }
@@ -972,17 +974,17 @@ async function commandSubmit(parsed) {
972
974
  */
973
975
  const shielded = (await (0, worktree_js_1.detectPrivateDirectories)(folder)).filter((finding) => [...sending].some((file) => file === finding.path || file.startsWith(`${finding.path}/`)));
974
976
  if (shielded.length && !hasFlag(parsed, "allow-private")) {
975
- console.log(red(`
977
+ console.log(red(`
976
978
  ${shielded.length} folder${shielded.length === 1 ? "" : "s"} here belong${shielded.length === 1 ? "s" : ""} to a program, not to your project:`));
977
979
  for (const finding of shielded) {
978
980
  console.log(` ${finding.path} ${dim(`— ${finding.because}`)}`);
979
981
  }
980
- console.log(`
982
+ console.log(`
981
983
  Nothing was sent. To leave them behind:`);
982
984
  for (const finding of shielded) {
983
985
  console.log(` ${accent(`echo "${finding.rule}" >> .gitignore`)}`);
984
986
  }
985
- console.error(`
987
+ console.error(`
986
988
  Or pass ${accent("--allow-private")} if they genuinely belong in the project.`);
987
989
  return 1;
988
990
  }
@@ -1031,7 +1033,7 @@ Or pass ${accent("--allow-private")} if they genuinely belong in the project.`);
1031
1033
  `rest were not opened.`));
1032
1034
  }
1033
1035
  if (stillPasted.length) {
1034
- console.log(red(`
1036
+ console.log(red(`
1035
1037
  ${stillPasted.length} file${stillPasted.length === 1 ? " has a credential" : "s have credentials"} inside:`));
1036
1038
  for (const finding of stillPasted.slice(0, 20)) {
1037
1039
  console.log(` ${finding.path}`);
@@ -1046,11 +1048,11 @@ ${stillPasted.length} file${stillPasted.length === 1 ? " has a credential" : "s
1046
1048
  console.log(dim(` …and ${stillPasted.length - 20} more`));
1047
1049
  }
1048
1050
  if (!hasFlag(parsed, "allow-secrets")) {
1049
- console.error(`
1051
+ console.error(`
1050
1052
  Nothing was sent. Move the key into an environment variable, and if` +
1051
1053
  ` it has ever been published, replace it at the service that issued` +
1052
1054
  ` it — a key that has leaked stays leaked.` +
1053
- `
1055
+ `
1054
1056
  Pass ${accent("--allow-secrets")} if these are not real keys.`);
1055
1057
  return 1;
1056
1058
  }
@@ -1070,6 +1072,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
1070
1072
  console.log("");
1071
1073
  }
1072
1074
  const line = progressLine();
1075
+ const uploadDisplay = new upload_progress_js_1.UploadDisplay();
1073
1076
  let uploader = new upload_js_1.Uploader(config_js_1.credentials);
1074
1077
  /*
1075
1078
  Built by the shared description, so this and the git remote helper cannot
@@ -1225,14 +1228,12 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
1225
1228
  console.log("Nothing was uploaded and no Version was created.");
1226
1229
  return 0;
1227
1230
  }
1228
- result = await uploader.execute(uploadRequest, plan, (progress) => {
1229
- line(` ${track(progress.percent)} ${String(progress.percent).padStart(3)}% ` +
1230
- `${progress.stage.padEnd(7)} ${progress.files}/${progress.totalFiles} ` +
1231
- dim(progress.path.slice(-40)));
1232
- });
1231
+ result = await uploader.execute(uploadRequest, plan, (progress) => uploadDisplay.report(progress));
1232
+ uploadDisplay.done();
1233
1233
  break;
1234
1234
  }
1235
1235
  catch (error) {
1236
+ uploadDisplay.done();
1236
1237
  done(line);
1237
1238
  /*
1238
1239
  Classified by the shared description rather than by a private regular
@@ -1257,7 +1258,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
1257
1258
  continue;
1258
1259
  }
1259
1260
  if (failure?.kind === "interrupted") {
1260
- console.error(red(`
1261
+ console.error(red(`
1261
1262
  The connection failed: ${text}`));
1262
1263
  console.error(`Your work may already have been saved. Run the same command again —` +
1263
1264
  ` it will not create a second version.`);
@@ -1500,7 +1501,7 @@ async function commandGet(parsed) {
1500
1501
  `changed while the interrupted fetch was stopped:`));
1501
1502
  for (const file of changedInTheGap.slice(0, 20))
1502
1503
  console.error(` ${file.path}`);
1503
- console.error(`
1504
+ console.error(`
1504
1505
  Save them with ${accent("cbx submit")}, or finish the fetch and ` +
1505
1506
  `discard them with ${accent("cbx get --replace")}.`);
1506
1507
  return 1;
@@ -1783,8 +1784,8 @@ async function suggestRules(folder, apply) {
1783
1784
  const addition = (0, detect_js_1.rulesFromSuggestions)(recommended);
1784
1785
  const shared = rules.shared.trimEnd();
1785
1786
  await (0, worktree_js_1.writeRules)(folder, {
1786
- shared: shared ? `${shared}
1787
-
1787
+ shared: shared ? `${shared}
1788
+
1788
1789
  ${addition}` : addition,
1789
1790
  local: rules.local,
1790
1791
  });
@@ -1989,7 +1990,7 @@ async function commandMerges(parsed) {
1989
1990
  const counts = merge.conflicts;
1990
1991
  console.log(`${accent(merge.reference)} ${counts ? `${counts.unresolved} of ${counts.total} still to decide` : ""} ${dim(new Date(merge.createdAt).toLocaleString())}`);
1991
1992
  }
1992
- console.log(dim(`
1993
+ console.log(dim(`
1993
1994
  Run cbx merge <reference> to look at one.`));
1994
1995
  return 0;
1995
1996
  }
@@ -2092,7 +2093,7 @@ async function commandMerge(parsed) {
2092
2093
  console.log(dim(` waiting on ${waiting.map((person) => person.displayName).join(", ")}`));
2093
2094
  }
2094
2095
  }
2095
- console.log(`
2096
+ console.log(`
2096
2097
  ${accent(now.mergeTrack.reference)} · ` +
2097
2098
  /*
2098
2099
  The combined version is only counted once it can be built; until then
@@ -2111,7 +2112,7 @@ ${accent(now.mergeTrack.reference)} · ` +
2111
2112
  return 1;
2112
2113
  }
2113
2114
  const applied = await (0, api_js_1.applyMerge)(summary.id);
2114
- console.log(`
2115
+ console.log(`
2115
2116
  Applied as ${accent(`v${applied.version.sequence}`)}.`);
2116
2117
  if (applied.closedIssues?.length) {
2117
2118
  console.log(`Closed ${applied.closedIssues.map((number) => `#${number}`).join(", ")}.`);
@@ -2121,13 +2122,13 @@ Applied as ${accent(`v${applied.version.sequence}`)}.`);
2121
2122
  }
2122
2123
  const stillOpen = now.conflicts.some((conflict) => !conflict.resolvedAt);
2123
2124
  if (!decision && stillOpen) {
2124
- console.log(dim(`
2125
+ console.log(dim(`
2125
2126
  --mine keeps yours, --theirs keeps what was already saved,` +
2126
- ` --both keeps theirs and saves yours beside it, --drop removes the file.
2127
+ ` --both keeps theirs and saves yours beside it, --drop removes the file.
2127
2128
  Add --path <file> for one file, then --apply when ready.`));
2128
2129
  }
2129
2130
  else if (now.provisional.ready) {
2130
- console.log(dim(`
2131
+ console.log(dim(`
2131
2132
  Run cbx merge ${now.mergeTrack.reference} --apply to publish it.`));
2132
2133
  }
2133
2134
  return 0;
@@ -2713,6 +2714,33 @@ const SPECS = [
2713
2714
  ],
2714
2715
  run: project_commands_js_1.commandVersions,
2715
2716
  },
2717
+ {
2718
+ name: "workspace",
2719
+ group: "Your projects",
2720
+ 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]",
2722
+ 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
+ options: [
2724
+ { flags: "--data-file <path>", description: "read an operation's JSON from this local file" },
2725
+ { flags: "--name <name>", description: "name a new workspace" },
2726
+ { flags: "--track <track>", description: "base the workspace on this project Track" },
2727
+ { flags: "--base-version <id>", description: "select a saved base version" },
2728
+ { flags: "--revision <number>", description: "the exact reviewed workspace revision" },
2729
+ { flags: "--key <operation-key>", description: "unique idempotency key; preserve it for retries" },
2730
+ { flags: "--path <path>", description: "read this project-relative file" },
2731
+ { flags: "--query <text>", description: "text to find in the remote workspace" },
2732
+ { flags: "--offset <number>", description: "continue a paginated listing, search or diff" },
2733
+ { flags: "--start-line <number>", description: "first text line to read" },
2734
+ { flags: "--lines <number>", description: "maximum text lines to read" },
2735
+ { flags: "--recipe <name>", description: "configured hosted execution recipe" },
2736
+ { flags: "--target <target>", description: "exact configured deployment target" },
2737
+ { flags: "--version <id>", description: "saved workspace version to deploy" },
2738
+ { flags: "-m, --message <text>", description: "describe the version being saved" },
2739
+ { flags: "--note <text>", description: "task context for another computer/assistant" },
2740
+ ],
2741
+ examples: ["cbx workspace open my-project --name \"Laptop work\" --key laptop-open-001", "cbx workspace list my-project"],
2742
+ run: workspace_command_js_1.commandWorkspace,
2743
+ },
2716
2744
  {
2717
2745
  name: "ai",
2718
2746
  group: "Your projects",
@@ -3670,7 +3698,8 @@ const SPECS = [
3670
3698
  name: "mcp",
3671
3699
  group: "Other",
3672
3700
  summary: "serve CodeRook to Claude Code, Codex and other assistants",
3673
- usage: "mcp",
3701
+ usage: "mcp [--workspaces]",
3702
+ options: [{ flags: "--workspaces", description: "opt into the remote connector's write/run/deploy tools and workspace permissions" }],
3674
3703
  detail: "Speaks the Model Context Protocol on stdin and stdout, so an assistant\n" +
3675
3704
  "can look at your projects while it works. Not run by hand — point the\n" +
3676
3705
  "assistant at it and it starts and stops the process itself.\n" +
@@ -3691,7 +3720,7 @@ const SPECS = [
3691
3720
  "A project whose owner has turned off machine reading is refused, in\n" +
3692
3721
  "words rather than as a status code.\n",
3693
3722
  examples: ["claude mcp add coderook -- cbx mcp"],
3694
- run: () => (0, mcp_js_1.commandMcp)(version_js_1.VERSION),
3723
+ run: parsed => parsed.flags.has("workspaces") ? (0, workspace_command_js_1.commandWorkspaceMcp)() : (0, mcp_js_1.commandMcp)(version_js_1.VERSION),
3695
3724
  },
3696
3725
  {
3697
3726
  name: "doctor",
@@ -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;
@@ -32,6 +32,9 @@ exports.report = report;
32
32
  const node_child_process_1 = require("node:child_process");
33
33
  const node_crypto_1 = require("node:crypto");
34
34
  const promises_1 = require("node:fs/promises");
35
+ const node_fs_1 = require("node:fs");
36
+ const node_stream_1 = require("node:stream");
37
+ const promises_2 = require("node:stream/promises");
35
38
  const node_os_1 = require("node:os");
36
39
  const node_os_2 = __importDefault(require("node:os"));
37
40
  const node_path_1 = __importDefault(require("node:path"));
@@ -395,7 +398,38 @@ async function performRun(repositoryId, run, runner) {
395
398
  }
396
399
  });
397
400
  }, HEARTBEAT_MS);
398
- if (run.versionId) {
401
+ if (run.workspaceInput) {
402
+ const input = await call(`/v1/repositories/${repositoryId}/workspace-runs/${run.runId}/input`);
403
+ run.command = input.command;
404
+ run.workingDirectory = input.workingDirectory;
405
+ run.artifactPaths = input.artifactPaths;
406
+ logs.add(`Fetching ${input.files.length} selected files from frozen workspace revision ${input.revision}…`, 'stdout');
407
+ const token = await (0, config_js_1.loadToken)();
408
+ for (const file of input.files) {
409
+ if (cancelled)
410
+ throw new Error('Cancelled while fetching workspace input');
411
+ if (!/^[a-f0-9]{64}$/.test(file.sha256 ?? ''))
412
+ throw new Error(`The frozen file ${file.path} has no verified content digest`);
413
+ const destination = insideWorkspace(workspace, file.path);
414
+ await (0, promises_1.mkdir)(node_path_1.default.dirname(destination), { recursive: true });
415
+ const response = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/repositories/${repositoryId}/workspace-runs/${run.runId}/input/${file.index}`, { headers: { authorization: `Bearer ${token}`, ...(0, identify_js_1.clientHeaders)() }, signal: AbortSignal.timeout(REQUEST_DEADLINE_MS) });
416
+ if (!response.ok || !response.body)
417
+ throw new Error(`Could not fetch frozen workspace file ${file.path} (${response.status})`);
418
+ const hash = (0, node_crypto_1.createHash)('sha256');
419
+ let bytes = 0;
420
+ const verify = new node_stream_1.Transform({ transform(chunk, _encoding, callback) { bytes += chunk.length; if (bytes > file.size) {
421
+ callback(new Error('Frozen workspace file exceeds its declared size'));
422
+ return;
423
+ } hash.update(chunk); callback(null, chunk); } });
424
+ await (0, promises_2.pipeline)(node_stream_1.Readable.fromWeb(response.body), verify, (0, node_fs_1.createWriteStream)(destination));
425
+ if (bytes !== file.size || hash.digest('hex') !== file.sha256)
426
+ throw new Error(`Frozen content verification failed for ${file.path}; credential-bearing source must use separately configured credentials`);
427
+ if (file.executable && process.platform !== 'win32')
428
+ await (0, promises_1.chmod)(destination, 0o755);
429
+ }
430
+ logs.add('Selected workspace inputs verified.', 'stdout');
431
+ }
432
+ else if (run.versionId) {
399
433
  logs.add(`Fetching v${run.versionSequence ?? "?"}…`, "stdout");
400
434
  /*
401
435
  The one phase that knows its own size. The version says how many files
@@ -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;