@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/src/api.js +71 -6
- package/dist/cli/src/cli.js +14 -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/publish.js +9 -0
- package/dist/desktop-app/src/main/secret_patterns.js +74 -0
- package/dist/desktop-app/src/main/upload.js +1 -0
- package/dist/desktop-app/src/main/worktree.js +79 -47
- package/package.json +1 -1
|
@@ -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.
|
|
5
|
+
"version": "0.25.4",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/dist/cli/src/api.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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() {
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -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}
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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(
|
|
40
|
+
.then(finish)
|
|
19
41
|
.catch((error) => {
|
|
20
|
-
|
|
21
|
-
|
|
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
|
});
|
package/dist/cli/src/publish.js
CHANGED
|
@@ -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];
|