@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/src/api.js +156 -6
- package/dist/cli/src/cli.js +170 -1
- package/dist/cli/src/git_history.js +10 -4
- package/dist/cli/src/git_remote.js +136 -12
- package/dist/cli/src/git_remote_bin.js +33 -3
- package/dist/cli/src/project_commands.js +23 -1
- package/dist/cli/src/publish.js +9 -0
- package/dist/cli/src/service_commands.js +2 -1
- package/dist/cli/src/version_commands.js +493 -0
- package/dist/desktop-app/src/main/secret_patterns.js +75 -0
- package/dist/desktop-app/src/main/tracks.js +58 -0
- package/dist/desktop-app/src/main/upload.js +2 -0
- package/dist/desktop-app/src/main/worktree.js +79 -47
- package/package.json +1 -1
- package/skills/coderook/SKILL.md +24 -0
|
@@ -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
package/skills/coderook/SKILL.md
CHANGED
|
@@ -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
|
|