@coderook/cli 0.26.0 → 0.28.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.
@@ -1,8 +1,66 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Tracks = void 0;
4
+ /**
5
+ * The lines a project is saved on, and the merges waiting on a person.
6
+ *
7
+ * The service has had all of this since Tracks existed: a project has one or
8
+ * more lines of versions, and a publish that diverges from what the account
9
+ * holds is put on a Merge Track of its own rather than laid over work the
10
+ * uploader had not seen. That is the right behaviour and it already happens.
11
+ *
12
+ * What did not exist was any way to know. The app never asked for a track, so
13
+ * a save that diverted reported "done" and said nothing about where the work
14
+ * went — the version was safe, on a track nobody could see, and the only way
15
+ * to find it was the website. This is the half that was missing.
16
+ */
17
+ const node_crypto_1 = require("node:crypto");
4
18
  const identify_js_1 = require("./identify.js");
5
19
  const retry_js_1 = require("./retry.js");
20
+ /** A media type from the name, so an edited file is stored as what it is. */
21
+ function mediaTypeForPath(path) {
22
+ const known = {
23
+ css: "text/css",
24
+ html: "text/html",
25
+ js: "text/javascript",
26
+ json: "application/json",
27
+ md: "text/markdown",
28
+ py: "text/x-python",
29
+ ts: "text/typescript",
30
+ tsx: "text/typescript",
31
+ xml: "application/xml",
32
+ yaml: "application/yaml",
33
+ yml: "application/yaml",
34
+ };
35
+ return known[path.split(".").pop()?.toLowerCase() ?? ""] ?? "text/plain";
36
+ }
37
+ /**
38
+ * The error to raise for a failed request, with a refusal said properly.
39
+ *
40
+ * A build refused for being too old is not a network failure and must not
41
+ * read like one: the person can fix it, and only if they are told how. The
42
+ * two requests below build their own fetches rather than going through
43
+ * `call`, so without this a refused desktop was told only "(426)".
44
+ */
45
+ function refusalOrError(body, status, fallback) {
46
+ const tooOld = (0, identify_js_1.readRefusal)(status, body);
47
+ if (tooOld) {
48
+ return new Error(`${tooOld.message} You are running ${tooOld.yourVersion ?? "an older build"}; ` +
49
+ `update from ${tooOld.upgradeUrl}.`);
50
+ }
51
+ let message = `${fallback} (${status})`;
52
+ if (body.trimStart().startsWith("{")) {
53
+ try {
54
+ message =
55
+ JSON.parse(body).error?.message ??
56
+ message;
57
+ }
58
+ catch {
59
+ /* keep the generic message */
60
+ }
61
+ }
62
+ return Object.assign(new Error(message), { status });
63
+ }
6
64
  class Tracks {
7
65
  credentials;
8
66
  constructor(credentials) {
@@ -66,6 +124,24 @@ class Tracks {
66
124
  const body = await this.call(`/v1/repositories/${repositoryId}/versions`);
67
125
  return body.versions ?? [];
68
126
  }
127
+ /**
128
+ * What the account says about this project, as distinct from this folder.
129
+ *
130
+ * The local record knows where a folder saves to and nothing about how the
131
+ * project is published — so the history window could show which saves were
132
+ * public without being able to say whether the project itself was, which is
133
+ * the half that decides whether anybody can reach them.
134
+ */
135
+ async project(repositoryId) {
136
+ try {
137
+ const body = await this.call(`/v1/repositories/${repositoryId}`);
138
+ return { visibility: body.visibility ?? null };
139
+ }
140
+ catch {
141
+ /* Not knowing is not worth failing the window for. */
142
+ return { visibility: null };
143
+ }
144
+ }
69
145
  async labels(repositoryId) {
70
146
  const body = await this.call(`/v1/repositories/${repositoryId}/labels`);
71
147
  return body.labels ?? [];
@@ -159,17 +235,106 @@ class Tracks {
159
235
  return [];
160
236
  }
161
237
  }
162
- /** One merge and every path it is waiting on. */
238
+ /**
239
+ * One merge and every path it is waiting on.
240
+ *
241
+ * The merge itself is nested under `mergeTrack`, which this used to spread
242
+ * flat — so `track.reference` was undefined and the window's title fell
243
+ * back to the word "Merge" on every merge there has ever been. The rest of
244
+ * the reply was thrown away with it, including the two version ids that
245
+ * make showing the conflict possible at all.
246
+ */
163
247
  async merge(mergeTrackId) {
164
248
  const body = await this.call(`/v1/merge-tracks/${mergeTrackId}`);
165
- const { conflicts = [], ...track } = body;
166
- return { track: track, conflicts };
249
+ return {
250
+ track: body.mergeTrack,
251
+ conflicts: body.conflicts ?? [],
252
+ repositoryId: body.mergeTrack.repositoryId,
253
+ candidateVersionId: body.mergeTrack.candidateVersionId ?? null,
254
+ currentTargetVersionId: body.currentTargetVersionId ?? null,
255
+ checks: body.checks ?? [],
256
+ ready: body.provisional?.ready ?? false,
257
+ unresolvedPaths: body.provisional?.unresolvedPaths ?? [],
258
+ blockedByChecks: body.provisional?.blockedByChecks ?? [],
259
+ };
260
+ }
261
+ /**
262
+ * One side of a conflict, as text.
263
+ *
264
+ * Both sides of a merge are versions, so this is the ordinary file route.
265
+ * Null means there is genuinely no file on that side, which is half of
266
+ * these conflicts: one person edited what another deleted.
267
+ */
268
+ async fileAt(repositoryId, versionId, path) {
269
+ if (!versionId)
270
+ return null;
271
+ const token = await this.credentials.token();
272
+ if (!token)
273
+ throw new Error("Sign in again");
274
+ const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}` +
275
+ `/versions/${versionId}/file?path=${encodeURIComponent(path)}`, {
276
+ headers: {
277
+ authorization: `Bearer ${token}`,
278
+ "user-agent": "CodeRook/0.1",
279
+ ...(0, identify_js_1.clientHeaders)(),
280
+ },
281
+ });
282
+ if (response.status === 404)
283
+ return null;
284
+ if (!response.ok) {
285
+ throw refusalOrError(await response.text().catch(() => ""), response.status, `Could not read ${path}`);
286
+ }
287
+ const bytes = new Uint8Array(await response.arrayBuffer());
288
+ /*
289
+ Decoded strictly, so a file that is not UTF-8 reports itself as binary
290
+ rather than arriving as a screenful of replacement characters somebody
291
+ might then save over the real thing.
292
+ */
293
+ let text = null;
294
+ try {
295
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
296
+ if (text.includes("\u0000"))
297
+ text = null;
298
+ }
299
+ catch {
300
+ text = null;
301
+ }
302
+ return { text, bytes: bytes.byteLength };
303
+ }
304
+ /**
305
+ * Send a file somebody wrote, and answer with the object it became.
306
+ *
307
+ * The other half of an edited resolution: the service takes an object id,
308
+ * and something has to put the bytes there first.
309
+ */
310
+ async putText(repositoryId, path, text) {
311
+ const token = await this.credentials.token();
312
+ if (!token)
313
+ throw new Error("Sign in again");
314
+ const bytes = new TextEncoder().encode(text);
315
+ const digest = (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex");
316
+ const response = await fetch(`${this.credentials.origin()}/v1/repositories/${repositoryId}` +
317
+ `/objects/${digest}?kind=chunk&role=chunk&logicalSize=${bytes.byteLength}` +
318
+ `&mediaType=${encodeURIComponent(mediaTypeForPath(path))}`, {
319
+ method: "PUT",
320
+ headers: {
321
+ authorization: `Bearer ${token}`,
322
+ "content-type": "application/octet-stream",
323
+ "user-agent": "CodeRook/0.1",
324
+ ...(0, identify_js_1.clientHeaders)(),
325
+ },
326
+ body: bytes,
327
+ });
328
+ const body = await response.text();
329
+ if (!response.ok)
330
+ throw refusalOrError(body, response.status, "Could not send the file");
331
+ return JSON.parse(body).objectId;
167
332
  }
168
- /** Settle one path. */
169
- async resolve(mergeTrackId, conflictId, resolution) {
333
+ /** Settle one path. An edit also names the object it produced. */
334
+ async resolve(mergeTrackId, conflictId, resolution, objectId) {
170
335
  await this.call(`/v1/merge-tracks/${mergeTrackId}/conflicts/${conflictId}`, {
171
336
  method: "PUT",
172
- body: JSON.stringify({ resolution }),
337
+ body: JSON.stringify(objectId ? { resolution, objectId } : { resolution }),
173
338
  });
174
339
  }
175
340
  /**
@@ -1163,6 +1163,10 @@ class Uploader {
1163
1163
  ...(request.track ? { track: request.track } : {}),
1164
1164
  ...(request.allowIgnored ? { allowIgnored: true } : {}),
1165
1165
  ...(request.allowSecrets ? { allowSecrets: true } : {}),
1166
+ ...(request.linesAdded === undefined ? {} : { linesAdded: request.linesAdded }),
1167
+ ...(request.linesRemoved === undefined
1168
+ ? {}
1169
+ : { linesRemoved: request.linesRemoved }),
1166
1170
  /*
1167
1171
  Names this attempt so a retry after a lost connection is answered
1168
1172
  with the version already made, rather than making a second one.
@@ -999,9 +999,26 @@ async function uploadConcerns(root, include) {
999
999
  * matches no service's format and so is invisible to the pattern scan — the
1000
1000
  * only thing that identifies it is the name of the file it is sitting in.
1001
1001
  */
1002
+ /**
1003
+ * The `.env.<something>` files that exist to be committed.
1004
+ *
1005
+ * `.env.example` is the file a project is *supposed* to publish — it is the
1006
+ * documentation of which variables exist, with the values left empty. Refusing
1007
+ * to send it, and then advising it be added to .gitignore, is advice that
1008
+ * breaks the project for the next person who clones it.
1009
+ *
1010
+ * The same list the service uses when it decides what to cover, so a file is
1011
+ * not a template in one half of the system and a credential in the other.
1012
+ */
1013
+ const TEMPLATE_SUFFIXES = [".example", ".sample", ".template", ".dist", ".defaults"];
1014
+ function isTemplateName(name) {
1015
+ return TEMPLATE_SUFFIXES.some((suffix) => name.endsWith(suffix));
1016
+ }
1002
1017
  function isCredentialByName(relativePath) {
1003
1018
  const parts = relativePath.split("/");
1004
1019
  const name = (parts[parts.length - 1] ?? "").toLowerCase();
1020
+ if (isTemplateName(name))
1021
+ return false;
1005
1022
  return (SECRET_NAMES.has(name) ||
1006
1023
  name.startsWith(".env.") ||
1007
1024
  SECRET_SUFFIXES.some((suffix) => name.endsWith(suffix)) ||
@@ -1032,14 +1049,15 @@ async function detectSecrets(root) {
1032
1049
  continue;
1033
1050
  }
1034
1051
  seen += 1;
1035
- const name = entry.name.toLowerCase();
1036
- const isSecret = SECRET_NAMES.has(name) ||
1037
- name.startsWith(".env.") ||
1038
- SECRET_SUFFIXES.some((suffix) => name.endsWith(suffix)) ||
1039
- node_path_1.default.relative(root, directory).split(node_path_1.default.sep).includes("secrets");
1040
- if (isSecret) {
1041
- found.push(node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/"));
1042
- }
1052
+ /*
1053
+ Asked of the one function rather than repeated here. These were two
1054
+ copies of the same rule, which is how `.env.example` came to be
1055
+ refused by the command line long after the service had learned that
1056
+ templates are not credentials.
1057
+ */
1058
+ const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
1059
+ if (isCredentialByName(relative))
1060
+ found.push(relative);
1043
1061
  }
1044
1062
  }
1045
1063
  return found;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.26.0",
3
+ "version": "0.28.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",