@coderook/cli 0.25.3 → 0.26.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.
@@ -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,
@@ -293,6 +367,7 @@ const READABLE = new Set([
293
367
  ".groovy",
294
368
  ".h",
295
369
  ".hpp",
370
+ ".hcl",
296
371
  ".hs",
297
372
  ".htm",
298
373
  ".html",
@@ -54,6 +54,64 @@ class Tracks {
54
54
  }
55
55
  return (text ? JSON.parse(text) : {});
56
56
  }
57
+ /**
58
+ * A project's saves, as the owner sees them.
59
+ *
60
+ * Every one of them, including the ones held for review and the ones whose
61
+ * files were taken down — this is the working history, not the published
62
+ * one, and the whole point of showing it here is deciding what becomes
63
+ * published.
64
+ */
65
+ async versions(repositoryId) {
66
+ const body = await this.call(`/v1/repositories/${repositoryId}/versions`);
67
+ return body.versions ?? [];
68
+ }
69
+ async labels(repositoryId) {
70
+ const body = await this.call(`/v1/repositories/${repositoryId}/labels`);
71
+ return body.labels ?? [];
72
+ }
73
+ async createLabel(repositoryId, name, colour) {
74
+ const body = await this.call(`/v1/repositories/${repositoryId}/labels`, { method: "POST", body: JSON.stringify({ name, colour }) });
75
+ return body.label;
76
+ }
77
+ async reviewVersion(repositoryId, versionId, decision, note) {
78
+ await this.call(`/v1/repositories/${repositoryId}/versions/${versionId}/review`, {
79
+ method: "POST",
80
+ body: JSON.stringify({ decision, ...(note ? { note } : {}) }),
81
+ });
82
+ }
83
+ async takeVersionDown(repositoryId, versionId, reason) {
84
+ const query = reason ? `?reason=${encodeURIComponent(reason)}` : "";
85
+ await this.call(`/v1/repositories/${repositoryId}/versions/${versionId}/content${query}`, { method: "DELETE" });
86
+ }
87
+ /** Put the project back on an earlier save. Nothing is deleted. */
88
+ async undo(repositoryId, to) {
89
+ return await this.call(`/v1/repositories/${repositoryId}/undo`, {
90
+ method: "POST",
91
+ body: JSON.stringify(to ? { to } : {}),
92
+ });
93
+ }
94
+ /** Publish an older save's content again, as a new save. */
95
+ async restoreVersion(repositoryId, versionId, message) {
96
+ await this.call(`/v1/repositories/${repositoryId}/versions/${versionId}/restore`, {
97
+ method: "POST",
98
+ body: JSON.stringify({ message }),
99
+ });
100
+ }
101
+ /**
102
+ * Everything about a version, changed in one call.
103
+ *
104
+ * Named, hidden, pinned or labelled from the desktop app for the first time.
105
+ * All three clients call the same route with the same patch, which is what
106
+ * stops the next capability being reachable from one of them and not the
107
+ * others.
108
+ */
109
+ async changeVersion(repositoryId, versionId, patch) {
110
+ await this.call(`/v1/repositories/${repositoryId}/versions/${versionId}`, {
111
+ method: "PATCH",
112
+ body: JSON.stringify(patch),
113
+ });
114
+ }
57
115
  /**
58
116
  * Every line and merge this project has.
59
117
  *
@@ -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.
@@ -1232,6 +1233,7 @@ class Uploader {
1232
1233
  repositoryId,
1233
1234
  versionId: completed.version.id,
1234
1235
  sequence: completed.version.sequence,
1236
+ ...(completed.version.state === "held" ? { held: true } : {}),
1235
1237
  ...(completed.mergeTrack ? { mergeTrack: completed.mergeTrack } : {}),
1236
1238
  ...(completed.repeated ? { repeated: true } : {}),
1237
1239
  sourceBytes: completed.version.sourceSize ?? sourceBytes,
@@ -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.26.0",
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",
@@ -81,6 +81,28 @@ cbx submit --track spike -m "…"
81
81
  Switching says where the next save goes and nothing else — no files move.
82
82
  Run `cbx get` afterwards to bring that line's files in.
83
83
 
84
+ ## Versions, and going back
85
+
86
+ Every save is a commit. A commit becomes a *version* — the thing the public
87
+ side of a project offers, and the thing a collaborator pulls — only when
88
+ somebody promotes it.
89
+
90
+ ```bash
91
+ cbx promote # pick from the last ten and name one
92
+ cbx mark 41 --pin # pin, hide, rename or label one save
93
+ cbx labels add shipped green # the labels a project can wear
94
+ cbx undo # put the project back on the save before this one
95
+ ```
96
+
97
+ `undo` moves where the project stands. Nothing is deleted and nothing is
98
+ renumbered — the saves passed over stay in the history, and the next save
99
+ carries on from wherever it now stands. It is the answer to "I pushed the
100
+ wrong thing"; hiding a version stops strangers reading it but leaves the
101
+ project standing on it, so the next person to pull still lands on the mistake.
102
+
103
+ **Ask before `undo`, `promote` and `take-down`.** They change what other
104
+ people see. `cbx versions` first, so the person can say which save they mean.
105
+
84
106
  ## When two saves collide
85
107
 
86
108
  If somebody saved while this folder was behind, the second save becomes a merge
@@ -121,6 +143,8 @@ cbx unbundle project.cbx ./restored
121
143
  again and gains nothing.
122
144
  - Do not guess a project name. `cbx projects` lists them; a folder that is
123
145
  already linked needs no name at all.
146
+ - Do not run `cbx take-down`. It destroys the files in a version and they do
147
+ not come back. Say it exists and let its owner run it.
124
148
 
125
149
  ## If something refuses
126
150