@coderook/cli 0.25.3 → 0.25.4

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, from Claude Code.",
5
- "version": "0.25.3",
5
+ "version": "0.25.4",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.whoami = whoami;
4
4
  exports.projects = projects;
5
+ exports.findPublicProject = findPublicProject;
5
6
  exports.findProject = findProject;
6
7
  exports.health = health;
7
8
  exports.mergeTracks = mergeTracks;
@@ -76,13 +77,77 @@ async function projects() {
76
77
  updatedAt: String(row.updatedAt ?? row.createdAt ?? ""),
77
78
  }));
78
79
  }
79
- /** Find a project by slug or display name, so either reads naturally. */
80
+ /**
81
+ * One public project, by the two names that identify it.
82
+ *
83
+ * Reachable without an account, which is what public means. Only the lookup
84
+ * goes this way: once the project has been named, everything else is fetched
85
+ * through the ordinary repository routes, which already serve a public
86
+ * project to anybody who asks.
87
+ */
88
+ async function findPublicProject(owner, slug) {
89
+ try {
90
+ const response = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/public/projects/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`, { headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() } });
91
+ if (!response.ok)
92
+ return null;
93
+ const row = (await response.json());
94
+ if (!row.id)
95
+ return null;
96
+ return {
97
+ id: String(row.id),
98
+ slug: String(row.slug ?? slug),
99
+ name: String(row.displayName || row.slug || slug),
100
+ visibility: String(row.visibility ?? "public"),
101
+ defaultBranch: String(row.defaultBranch || "main"),
102
+ versionCount: Number(row.versionCount ?? 0),
103
+ fileCount: Number(row.fileCount ?? 0),
104
+ storedBytes: Number(row.storedSize ?? 0),
105
+ updatedAt: String(row.updatedAt ?? row.createdAt ?? ""),
106
+ };
107
+ }
108
+ catch {
109
+ /* Offline, or no such project. Either way there is nothing to return. */
110
+ return null;
111
+ }
112
+ }
113
+ /**
114
+ * Find a project by slug or display name, so either reads naturally.
115
+ *
116
+ * Your own account first, then — for `owner/slug`, or a name that is not
117
+ * yours — the public listing.
118
+ *
119
+ * Without that second step the only projects reachable by name were your
120
+ * own, which made `cbx clone somebody/their-project` answer "No project named
121
+ * … on this account" and `git clone coderook://somebody/their-project` hand
122
+ * back an empty repository. Both are the instruction printed on every public
123
+ * project page, aimed at exactly the people who do not own the thing.
124
+ */
80
125
  async function findProject(reference) {
81
- const wanted = reference.trim().toLowerCase();
82
- const all = await projects();
83
- return (all.find((project) => project.slug.toLowerCase() === wanted) ??
84
- all.find((project) => project.name.toLowerCase() === wanted) ??
85
- null);
126
+ const trimmed = reference.trim();
127
+ const slash = trimmed.lastIndexOf("/");
128
+ const owner = slash > 0 ? trimmed.slice(0, slash) : "";
129
+ const bare = (slash > 0 ? trimmed.slice(slash + 1) : trimmed).toLowerCase();
130
+ /*
131
+ An owner was named, so it is somebody's project rather than a name to
132
+ guess at. Yours is still checked first: naming yourself is allowed, and
133
+ the authenticated listing knows about private projects the public one
134
+ cannot see.
135
+ */
136
+ const all = await projects().catch(() => []);
137
+ const ownersMatch = (project) => project.slug.toLowerCase() === bare || project.name.toLowerCase() === bare;
138
+ if (!owner) {
139
+ const mine = all.find(ownersMatch);
140
+ if (mine)
141
+ return mine;
142
+ return null;
143
+ }
144
+ const me = await whoami().catch(() => null);
145
+ if (me && (me.username ?? "").toLowerCase() === owner.toLowerCase()) {
146
+ const mine = all.find(ownersMatch);
147
+ if (mine)
148
+ return mine;
149
+ }
150
+ return findPublicProject(owner, bare);
86
151
  }
87
152
  /** Whether the service is reachable, and what encodings it accepts. */
88
153
  async function health() {
@@ -637,6 +637,15 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
637
637
  would reject the publication and the import would be lossy.
638
638
  */
639
639
  ...(hasFlag(parsed, "allow-ignored") ? { allowIgnored: true } : {}),
640
+ /*
641
+ The same answer, told to the service.
642
+
643
+ The check above is the one that asks a person, and it is the better
644
+ place to ask — it is where the files are. But it is also the part an
645
+ old build or a patched one does not run, so the service asks again and
646
+ refuses unless this says the question was put and answered.
647
+ */
648
+ ...(hasFlag(parsed, "allow-secrets") ? { allowSecrets: true } : {}),
640
649
  ...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
641
650
  });
642
651
  let result;
@@ -925,7 +934,11 @@ async function commandClone(parsed) {
925
934
  }
926
935
  const project = await (0, api_js_2.findProject)(reference);
927
936
  if (!project) {
928
- console.error(red(`No project named ${reference} on this account.`));
937
+ console.error(red(`No project named ${reference}.` +
938
+ (reference.includes("/")
939
+ ? " Check the owner and the name, and that it is public."
940
+ : " It is not on your account — for somebody else's, name them" +
941
+ " too: cbx clone <owner>/<project>.")));
929
942
  return 1;
930
943
  }
931
944
  if (!project.versionCount) {
@@ -51,14 +51,20 @@ function parseRemoteUrl(url) {
51
51
  }
52
52
  }
53
53
  rest = rest.replace(/^\/+/, "").replace(/\/+$/, "");
54
- // An owner segment is accepted and ignored: a token already says who this
55
- // is, and silently pushing to a different account than the URL named would
56
- // be a worse outcome than not supporting the form at all.
54
+ /*
55
+ The owner is carried, and what it is used for differs by direction.
56
+ Fetching honours it, because `coderook://somebody/their-project` is the
57
+ line printed on every public project page and it has to reach their
58
+ project rather than look for that name on yours. Pushing still ignores
59
+ it: the token says who you are, and quietly publishing to an account the
60
+ URL named instead would be the worse mistake of the two.
61
+ */
57
62
  const parts = rest.split("/").filter(Boolean);
58
63
  const slug = parts[parts.length - 1] ?? "";
64
+ const owner = parts.length > 1 ? parts[parts.length - 2] : "";
59
65
  if (!slug)
60
66
  throw new Error(`Not a CodeRook remote URL: ${url}`);
61
- return { slug };
67
+ return { owner, slug };
62
68
  }
63
69
  /**
64
70
  * Pull the git commit id back out of a version message, if it carries one.
@@ -36,6 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.AlreadyReported = void 0;
39
40
  exports.main = main;
40
41
  /**
41
42
  * `git push coderook` — a git remote helper.
@@ -110,6 +111,20 @@ const version_js_1 = require("./version.js");
110
111
  const publish_js_1 = require("./publish.js");
111
112
  const git_history_js_1 = require("./git_history.js");
112
113
  const api_js_1 = require("./api.js");
114
+ /** A fetch that cannot be served. Carried out so the process can exit non-zero. */
115
+ class ImportFailed extends Error {
116
+ }
117
+ /**
118
+ * A failure this code has already explained.
119
+ *
120
+ * The entry point prints whatever reaches it, which is right for a surprise
121
+ * and wrong for a refusal that has just been set out in full — the person
122
+ * would read the same paragraph twice, the second time with a program name
123
+ * in front of it.
124
+ */
125
+ class AlreadyReported extends Error {
126
+ }
127
+ exports.AlreadyReported = AlreadyReported;
113
128
  /** Everything the helper writes for a person goes to stderr; stdout is protocol. */
114
129
  function say(text) {
115
130
  node_process_1.default.stderr.write(`${text}\n`);
@@ -489,12 +504,19 @@ async function recordPushedCommits(marks) {
489
504
  * unchanged across a thousand versions is downloaded and sent to git once.
490
505
  */
491
506
  async function doImport(refs, url) {
492
- const { slug } = (0, git_history_js_1.parseRemoteUrl)(url);
493
- const project = await (0, api_js_1.findProject)(slug);
507
+ const { owner, slug } = (0, git_history_js_1.parseRemoteUrl)(url);
508
+ const project = await (0, api_js_1.findProject)(owner ? `${owner}/${slug}` : slug);
494
509
  if (!project) {
495
- say(`No CodeRook project called "${slug}", or you cannot read it.`);
510
+ say(`No CodeRook project called "${owner ? `${owner}/${slug}` : slug}",` +
511
+ " or you cannot read it.");
512
+ /*
513
+ `done` closes the stream cleanly, but git reads "no refs" as "an empty
514
+ repository" and reports success — so a clone of something that is not
515
+ there made an empty folder and exited zero. Saying so on the way out is
516
+ what turns that into a failure the person can see.
517
+ */
496
518
  send("done");
497
- return;
519
+ throw new ImportFailed(`No CodeRook project called "${owner ? `${owner}/${slug}` : slug}", or you cannot read it.`);
498
520
  }
499
521
  const repositoryId = project.id;
500
522
  const state = await remoteState(repositoryId);
@@ -693,7 +715,34 @@ async function doImport(refs, url) {
693
715
  }
694
716
  }
695
717
  async function doPush(requests, url) {
696
- const { slug } = (0, git_history_js_1.parseRemoteUrl)(url);
718
+ /*
719
+ A push never goes to the account named in the URL — it goes to the one the
720
+ token belongs to. Publishing somebody's work to a different account on
721
+ their behalf would be the worse mistake of the two.
722
+
723
+ But it is not enough to ignore the name, now that fetching honours it.
724
+ The same URL would then read from one account and write to another: a
725
+ push to `coderook://somebody/their-project` quietly made a private
726
+ project of that name on your own account and reported success, while git
727
+ printed "To coderook://somebody/their-project". Saying no is the honest
728
+ answer, and it leaves the person a working one.
729
+ */
730
+ const { owner, slug } = (0, git_history_js_1.parseRemoteUrl)(url);
731
+ if (owner) {
732
+ const me = await (0, api_js_1.whoami)().catch(() => null);
733
+ const mine = (me?.username ?? "").toLowerCase();
734
+ if (mine && owner.toLowerCase() !== mine) {
735
+ say(`This remote names ${owner}, and you are signed in as ${mine}.` +
736
+ ` A push goes to your own account, so it would land somewhere the` +
737
+ ` URL does not name.`);
738
+ say(`To publish your own copy: git remote set-url origin coderook://${mine}/${slug}`);
739
+ for (const request of requests) {
740
+ send(`error ${request.dst} this remote belongs to ${owner}, not to ${mine}`);
741
+ }
742
+ send("");
743
+ return;
744
+ }
745
+ }
697
746
  const marks = await readMarks();
698
747
  /*
699
748
  Re-read the project before every ref, not once before the batch.
@@ -1056,6 +1105,40 @@ async function doPush(requests, url) {
1056
1105
  `);
1057
1106
  throw error;
1058
1107
  }
1108
+ if (failure?.kind === "credentials") {
1109
+ /*
1110
+ The one refusal a push cannot answer. `cbx submit` can ask and
1111
+ be told yes; git has nowhere to put that question, so the way
1112
+ through is to take the key out — which is the better answer
1113
+ anyway.
1114
+ */
1115
+ send(`error ${request.dst} this commit carries a credential`);
1116
+ say(`
1117
+ ${failure.message}
1118
+ `);
1119
+ /*
1120
+ Deleting the file in a later commit does not help, and saying
1121
+ "take it out and commit again" sends people in circles: a push
1122
+ publishes every commit as its own version, so the commit that
1123
+ introduced the key still carries it however many commits follow.
1124
+ The history has to lose it.
1125
+ */
1126
+ say(` A later commit that deletes it is not enough — every commit` +
1127
+ ` being pushed
1128
+ becomes a version, and the one that added` +
1129
+ ` the key still carries it.
1130
+ ` +
1131
+ ` Rewrite it out (git rebase -i, or git commit --amend if it` +
1132
+ ` is the last one),
1133
+ or publish this deliberately with` +
1134
+ ` cbx submit --allow-secrets.
1135
+ `);
1136
+ /*
1137
+ Reported already, and in more detail than the wrapper can. The
1138
+ marker stops the bin printing the same paragraph a second time.
1139
+ */
1140
+ throw new AlreadyReported(failure.message);
1141
+ }
1059
1142
  if (failure?.kind === "conflict") {
1060
1143
  send(`error ${request.dst} somebody published to "${branch}" while this push was running`);
1061
1144
  say(`
@@ -1124,6 +1207,12 @@ async function main(argv) {
1124
1207
  (0, identify_js_1.declareClient)("cli", version_js_1.VERSION);
1125
1208
  const pending = [];
1126
1209
  const importing = [];
1210
+ /*
1211
+ Whether a fetch asked for something that is not there. Git reads "no
1212
+ refs" as "an empty repository" and reports success, so without this a
1213
+ clone of a project you cannot see left an empty folder and exited zero.
1214
+ */
1215
+ let unservable = false;
1127
1216
  for await (const line of lines()) {
1128
1217
  const command = line.trim();
1129
1218
  if (command === "capabilities") {
@@ -1144,8 +1233,35 @@ async function main(argv) {
1144
1233
  }
1145
1234
  if (command === "list" || command === "list for-push") {
1146
1235
  try {
1147
- const { slug } = (0, git_history_js_1.parseRemoteUrl)(url);
1148
- const project = await (0, api_js_1.findProject)(slug);
1236
+ const { owner, slug } = (0, git_history_js_1.parseRemoteUrl)(url);
1237
+ const named = owner ? `${owner}/${slug}` : slug;
1238
+ const project = await (0, api_js_1.findProject)(named);
1239
+ /*
1240
+ A fetch of something that is not there has to say so here.
1241
+
1242
+ Git asks `list` first and only asks to import the refs it is
1243
+ offered, so a project nobody can read never reaches the import at
1244
+ all — it is simply a listing with nothing in it, which git reports
1245
+ as an empty repository and calls a success. Refusing at this point
1246
+ is the only place the answer can still be "no".
1247
+
1248
+ `list for-push` is exempt: pushing to a name that does not exist
1249
+ yet is how a project gets created.
1250
+ */
1251
+ if (command === "list" && !project) {
1252
+ say(`No CodeRook project called "${named}", or you cannot read it.`);
1253
+ unservable = true;
1254
+ /*
1255
+ The listing is deliberately left unterminated.
1256
+
1257
+ An empty but well-formed list is a valid answer meaning "an empty
1258
+ repository", and git takes it as one: it reports success, leaves
1259
+ a folder with nothing but .git in it, and ignores whatever the
1260
+ helper exits with. Ending the conversation instead is the only
1261
+ answer git reads as a failure.
1262
+ */
1263
+ break;
1264
+ }
1149
1265
  if (command === "list for-push") {
1150
1266
  const known = await remoteRefs(project?.id ?? null);
1151
1267
  for (const [ref, sha] of known)
@@ -1228,10 +1344,18 @@ async function main(argv) {
1228
1344
  await doImport(batch, url);
1229
1345
  }
1230
1346
  catch (error) {
1231
- say(`Import failed: ${error instanceof Error ? error.message : String(error)}`);
1232
- // `done` regardless, so fast-import closes cleanly instead of git
1233
- // waiting on a stream that will never end.
1234
- send("done");
1347
+ /*
1348
+ A project that could not be found has already said so and closed
1349
+ the stream. Anything else has not, and fast-import would wait on
1350
+ a stream that never ends.
1351
+ */
1352
+ if (error instanceof ImportFailed) {
1353
+ unservable = true;
1354
+ }
1355
+ else {
1356
+ say(`Import failed: ${error instanceof Error ? error.message : String(error)}`);
1357
+ send("done");
1358
+ }
1235
1359
  }
1236
1360
  continue;
1237
1361
  }
@@ -1254,5 +1378,5 @@ async function main(argv) {
1254
1378
  continue;
1255
1379
  }
1256
1380
  }
1257
- return 0;
1381
+ return unservable ? 1 : 0;
1258
1382
  }
@@ -14,9 +14,39 @@ Object.defineProperty(exports, "__esModule", { value: true });
14
14
  */
15
15
  const node_process_1 = __importDefault(require("node:process"));
16
16
  const git_remote_js_1 = require("./git_remote.js");
17
+ /**
18
+ * Finish, without cutting the pipe to git mid-sentence.
19
+ *
20
+ * `process.exit()` ends the process immediately, while stdout may still hold
21
+ * writes queued for git. On Windows that surfaced as
22
+ *
23
+ * Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), src\win\async.c
24
+ *
25
+ * on the way out of a clone — libuv tearing down a handle that was still
26
+ * being written to. Setting the code instead lets the loop drain what is
27
+ * owed and end on its own, which is also the only way git reliably sees the
28
+ * last of the protocol.
29
+ *
30
+ * The timer is the belt: it only fires if something else is still holding
31
+ * the loop open, and being unreferenced it cannot be the thing holding it.
32
+ */
33
+ function finish(code) {
34
+ node_process_1.default.exitCode = code;
35
+ /* stdin is already at EOF by the time main returns; this releases it. */
36
+ node_process_1.default.stdin.pause();
37
+ setTimeout(() => node_process_1.default.exit(code), 5000).unref();
38
+ }
17
39
  (0, git_remote_js_1.main)(node_process_1.default.argv.slice(2))
18
- .then((code) => node_process_1.default.exit(code))
40
+ .then(finish)
19
41
  .catch((error) => {
20
- node_process_1.default.stderr.write(`git-remote-coderook: ${error instanceof Error ? error.message : String(error)}\n`);
21
- node_process_1.default.exit(1);
42
+ /*
43
+ Said once. A refusal that has already set itself out in full does not
44
+ want the same paragraph printed again with a program name in front of
45
+ it — which is what a reader got when a push was turned down for
46
+ carrying a credential.
47
+ */
48
+ if (!(error instanceof git_remote_js_1.AlreadyReported)) {
49
+ node_process_1.default.stderr.write(`git-remote-coderook: ${error instanceof Error ? error.message : String(error)}\n`);
50
+ }
51
+ finish(1);
22
52
  });
@@ -15,6 +15,7 @@ function uploadRequestFor(input) {
15
15
  baseVersionId: input.baseVersionId,
16
16
  ...(input.track ? { track: input.track } : {}),
17
17
  ...(input.allowIgnored ? { allowIgnored: true } : {}),
18
+ ...(input.allowSecrets ? { allowSecrets: true } : {}),
18
19
  ...(input.acknowledged ? { acknowledged: true } : {}),
19
20
  ...(input.acknowledgedNoLicence ? { acknowledgedNoLicence: true } : {}),
20
21
  ...(input.baseVersionId
@@ -29,6 +30,14 @@ function classifyPublishFailure(error) {
29
30
  if (code === "merge_required" || code === "track_moved") {
30
31
  return { kind: "conflict", message };
31
32
  }
33
+ /*
34
+ A credential the service refused. Named separately because the answer is
35
+ not "try again" — it is "take the key out", and for a push there is no
36
+ flag to pass instead, because git has nowhere to ask the question.
37
+ */
38
+ if (code === "version_credentials") {
39
+ return { kind: "credentials", message };
40
+ }
32
41
  if (!code &&
33
42
  /fetch failed|ECONNRESET|socket hang up|network|ETIMEDOUT/i.test(message)) {
34
43
  return { kind: "interrupted", message };
@@ -23,6 +23,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
23
23
  exports.CREDENTIAL_PATTERNS = void 0;
24
24
  exports.looksLikePlaceholder = looksLikePlaceholder;
25
25
  exports.findCredentials = findCredentials;
26
+ exports.maskCredentials = maskCredentials;
27
+ exports.maskAssignedValues = maskAssignedValues;
26
28
  exports.worthReading = worthReading;
27
29
  exports.looksLikeText = looksLikeText;
28
30
  /*
@@ -262,6 +264,78 @@ function findCredentials(text) {
262
264
  }
263
265
  return found.sort((left, right) => left.line - right.line);
264
266
  }
267
+ /**
268
+ * The same text with any credential in it covered over.
269
+ *
270
+ * Used where a file's contents are about to be shown rather than scanned —
271
+ * the diff pane being the case that mattered. The upload warning is careful
272
+ * never to repeat a key back, and the pane behind it was printing one in
273
+ * full, so the same window both refused to show a credential and showed one.
274
+ *
275
+ * The prefix survives, because it names the service and is not the secret,
276
+ * and it is what makes the line recognisable as the one to go and fix. The
277
+ * rest becomes bullets of the same length, so nothing about the shape of the
278
+ * value is lost except the value.
279
+ *
280
+ * Deliberately built on the same patterns and the same placeholder rule as
281
+ * `findCredentials`: if the two disagreed, the pane would either cover
282
+ * something the warning ignored or reveal something it flagged.
283
+ */
284
+ function maskCredentials(text) {
285
+ const claimed = [];
286
+ for (const pattern of exports.CREDENTIAL_PATTERNS) {
287
+ const expression = new RegExp(pattern.match.source, pattern.match.flags);
288
+ for (const match of text.matchAll(expression)) {
289
+ const at = match.index ?? 0;
290
+ const to = at + match[0].length;
291
+ if (claimed.some(([from, until]) => at < until && to > from))
292
+ continue;
293
+ if (looksLikePlaceholder(match[0]))
294
+ continue;
295
+ const value = match[0];
296
+ const keep = value.slice(0, 7);
297
+ claimed.push([at, to, `${keep}${"•".repeat(Math.max(3, value.length - 7))}`]);
298
+ }
299
+ }
300
+ if (!claimed.length)
301
+ return text;
302
+ claimed.sort((left, right) => left[0] - right[0]);
303
+ let out = "";
304
+ let cursor = 0;
305
+ for (const [at, to, replacement] of claimed) {
306
+ out += text.slice(cursor, at) + replacement;
307
+ cursor = to;
308
+ }
309
+ return out + text.slice(cursor);
310
+ }
311
+ /**
312
+ * Every assigned value in a line covered over, whatever shape it is.
313
+ *
314
+ * For files that are credentials by their name rather than by their
315
+ * contents. A `.env` holds `TOKEN=<32 random characters>`: it matches no
316
+ * service's format, so the pattern scan cannot see it, and the only thing
317
+ * saying it is a secret is the file it is sitting in. In that file every
318
+ * value is treated as one.
319
+ *
320
+ * The name is kept and the value goes. Which keys are set is the useful part
321
+ * of the diff — that a line was added, and which setting it was — and none
322
+ * of that requires showing what it was set to.
323
+ *
324
+ * A comment is left alone: it is prose, and blanking it makes the file
325
+ * unreadable for no gain.
326
+ */
327
+ function maskAssignedValues(text) {
328
+ const trimmed = text.trimStart();
329
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("//")) {
330
+ return text;
331
+ }
332
+ return text.replace(/^(\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_.-]*\s*[:=]\s*)(\S.*)$/, (_whole, head, value) => {
333
+ const bare = value.replace(/^["']|["']$/g, "");
334
+ if (!bare)
335
+ return text;
336
+ return `${head}${"•".repeat(Math.min(24, Math.max(3, bare.length)))}`;
337
+ });
338
+ }
265
339
  /*
266
340
  Extensions worth reading. Everything else is either compiled, compressed, or
267
341
  media — a credential in a JPEG is not a case worth slowing every upload for,
@@ -1162,6 +1162,7 @@ class Uploader {
1162
1162
  : { expectedHeadVersionId: request.expectedHeadVersionId }),
1163
1163
  ...(request.track ? { track: request.track } : {}),
1164
1164
  ...(request.allowIgnored ? { allowIgnored: true } : {}),
1165
+ ...(request.allowSecrets ? { allowSecrets: true } : {}),
1165
1166
  /*
1166
1167
  Names this attempt so a retry after a lost connection is answered
1167
1168
  with the version already made, rather than making a second one.
@@ -21,6 +21,7 @@ exports.evaluateRules = evaluateRules;
21
21
  exports.detectPrivateDirectories = detectPrivateDirectories;
22
22
  exports.detectPastedCredentials = detectPastedCredentials;
23
23
  exports.uploadConcerns = uploadConcerns;
24
+ exports.isCredentialByName = isCredentialByName;
24
25
  exports.detectSecrets = detectSecrets;
25
26
  /** Reading a project folder: changed files, diffs, and rule measurement. */
26
27
  const node_child_process_1 = require("node:child_process");
@@ -54,49 +55,49 @@ async function git(root, ...args) {
54
55
  * file would produce an incomplete project that looked backed up, so likely
55
56
  * secrets are warned about instead (docs/UPLOAD_POLICY.md).
56
57
  */
57
- exports.STARTER_IGNORE = `# Dependencies and generated output
58
- node_modules/
59
- dist/
60
- build/
61
- out/
62
- .next/
63
- target/
64
- __pycache__/
65
- .venv/
66
- venv/
67
- *.log
68
-
69
- # Large model weights
70
- models/**
71
- *.safetensors
72
- *.ckpt
73
- *.pt
74
- *.pth
75
-
76
- # Caches and local state
77
- .venv/
78
- .pytest_cache/
79
- .mypy_cache/
80
- .ruff_cache/
81
- .cache/
82
- *.pyc
83
-
84
- # A browser or Electron profile that has been left in the project folder.
85
- # These hold files another program keeps open — a LOCK that cannot be read
86
- # while it runs will stop a save outright — and nothing in them is the work.
87
- IndexedDB/
88
- Local Storage/
89
- Session Storage/
90
- Service Worker/
91
- Network/
92
- GPUCache/
93
- Code Cache/
94
- blob_storage/
95
- Local State
96
- Preferences
97
-
98
- # Archives of the project, inside the project
99
- *.cbx
58
+ exports.STARTER_IGNORE = `# Dependencies and generated output
59
+ node_modules/
60
+ dist/
61
+ build/
62
+ out/
63
+ .next/
64
+ target/
65
+ __pycache__/
66
+ .venv/
67
+ venv/
68
+ *.log
69
+
70
+ # Large model weights
71
+ models/**
72
+ *.safetensors
73
+ *.ckpt
74
+ *.pt
75
+ *.pth
76
+
77
+ # Caches and local state
78
+ .venv/
79
+ .pytest_cache/
80
+ .mypy_cache/
81
+ .ruff_cache/
82
+ .cache/
83
+ *.pyc
84
+
85
+ # A browser or Electron profile that has been left in the project folder.
86
+ # These hold files another program keeps open — a LOCK that cannot be read
87
+ # while it runs will stop a save outright — and nothing in them is the work.
88
+ IndexedDB/
89
+ Local Storage/
90
+ Session Storage/
91
+ Service Worker/
92
+ Network/
93
+ GPUCache/
94
+ Code Cache/
95
+ blob_storage/
96
+ Local State
97
+ Preferences
98
+
99
+ # Archives of the project, inside the project
100
+ *.cbx
100
101
  `;
101
102
  /** The shared rules file, committed with the project. */
102
103
  exports.IGNORE_FILE = ".gitignore";
@@ -508,6 +509,21 @@ async function fileSizes(root, files) {
508
509
  /** The unified diff for one file, including files git does not track yet. */
509
510
  async function fileDiff(root, file, ignoreWhitespace = false) {
510
511
  const output = await git(root, "diff", "--no-ext-diff", "--unified=3", ...(ignoreWhitespace ? ["--ignore-all-space"] : []), "--", file);
512
+ /*
513
+ What leaves this function is what the window will show, so a credential
514
+ is covered here rather than in the pane.
515
+
516
+ Masking in the renderer would still have sent the key to the window,
517
+ where a screenshot, the developer tools or an accidental copy all reach
518
+ it. Not sending it is the only version of this that actually holds.
519
+
520
+ Two rules, because there are two kinds. A key pasted into ordinary source
521
+ is recognised by its own format. A `.env` is not — its contents match no
522
+ service's shape — and is recognised only by the name of the file, so
523
+ everything after the first `=` on a line goes.
524
+ */
525
+ const named = isCredentialByName(file);
526
+ const cover = (text) => named ? (0, secret_patterns_js_1.maskAssignedValues)(text) : (0, secret_patterns_js_1.maskCredentials)(text);
511
527
  const hunks = [];
512
528
  let current = null;
513
529
  let oldLine = 0;
@@ -532,7 +548,7 @@ async function fileDiff(root, file, ignoreWhitespace = false) {
532
548
  kind: "add",
533
549
  number: newLine++,
534
550
  oldNumber: null,
535
- text: raw.slice(1),
551
+ text: cover(raw.slice(1)),
536
552
  });
537
553
  }
538
554
  else if (raw.startsWith("-")) {
@@ -540,7 +556,7 @@ async function fileDiff(root, file, ignoreWhitespace = false) {
540
556
  kind: "del",
541
557
  number: oldLine,
542
558
  oldNumber: oldLine++,
543
- text: raw.slice(1),
559
+ text: cover(raw.slice(1)),
544
560
  });
545
561
  }
546
562
  else if (raw.startsWith(" ")) {
@@ -548,7 +564,7 @@ async function fileDiff(root, file, ignoreWhitespace = false) {
548
564
  kind: "ctx",
549
565
  number: newLine,
550
566
  oldNumber: oldLine,
551
- text: raw.slice(1),
567
+ text: cover(raw.slice(1)),
552
568
  });
553
569
  oldLine += 1;
554
570
  newLine += 1;
@@ -570,7 +586,7 @@ async function fileDiff(root, file, ignoreWhitespace = false) {
570
586
  kind: "add",
571
587
  number: index + 1,
572
588
  oldNumber: null,
573
- text,
589
+ text: cover(text),
574
590
  })),
575
591
  },
576
592
  ];
@@ -975,6 +991,22 @@ async function uploadConcerns(root, include) {
975
991
  };
976
992
  }
977
993
  /** Files the flow must ask about before the first upload. */
994
+ /**
995
+ * Whether this path is a credential by its name alone.
996
+ *
997
+ * Pulled out of `detectSecrets` so the diff can ask the same question the
998
+ * upload warning asks. A `.env` holds `TOKEN=<32 random characters>`, which
999
+ * matches no service's format and so is invisible to the pattern scan — the
1000
+ * only thing that identifies it is the name of the file it is sitting in.
1001
+ */
1002
+ function isCredentialByName(relativePath) {
1003
+ const parts = relativePath.split("/");
1004
+ const name = (parts[parts.length - 1] ?? "").toLowerCase();
1005
+ return (SECRET_NAMES.has(name) ||
1006
+ name.startsWith(".env.") ||
1007
+ SECRET_SUFFIXES.some((suffix) => name.endsWith(suffix)) ||
1008
+ parts.slice(0, -1).includes("secrets"));
1009
+ }
978
1010
  async function detectSecrets(root) {
979
1011
  const found = [];
980
1012
  const pending = [root];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.25.3",
3
+ "version": "0.25.4",
4
4
  "description": "CodeRook from the command line, on any operating system",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "homepage": "https://coderook.com",