@coderook/cli 0.1.0 → 0.2.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.
@@ -24,7 +24,19 @@ const download_js_1 = require("../../desktop-app/src/main/download.js");
24
24
  const cbx_js_1 = require("../../desktop-app/src/main/cbx.js");
25
25
  const api_js_1 = require("./api.js");
26
26
  const config_js_1 = require("./config.js");
27
- const VERSION = "0.1.0";
27
+ /*
28
+ Read from the package rather than written twice. A hardcoded copy had
29
+ already drifted from the published version, which makes `coderook doctor`
30
+ worse than useless when working out what somebody is actually running.
31
+ */
32
+ const VERSION = (() => {
33
+ try {
34
+ return (require("../../../package.json").version ?? "0.0.0");
35
+ }
36
+ catch {
37
+ return "0.0.0";
38
+ }
39
+ })();
28
40
  // ── presentation ────────────────────────────────────────────────────────────
29
41
  const colour = node_process_1.default.stdout.isTTY && !node_process_1.default.env.NO_COLOR;
30
42
  const dim = (text) => (colour ? `${text}` : text);
@@ -94,6 +106,18 @@ const folderFor = (parsed, index = 0) => node_path_1.default.resolve(parsed.posi
94
106
  * what that repository already holds. The account is the authority, exactly
95
107
  * as it is in the desktop application.
96
108
  */
109
+ /**
110
+ * Work out what this folder is based on, and whether the account has moved.
111
+ *
112
+ * The base is the version this folder was last brought level with — not
113
+ * whatever happens to be newest. Silently re-basing on the newest is what
114
+ * makes a folder look up to date when somebody else has published: the
115
+ * changes are then measured against their work, and saving quietly lays
116
+ * this machine's copy over theirs.
117
+ *
118
+ * So a folder that has fallen behind keeps its own base and is told, which
119
+ * is what lets the service merge the two rather than pick a winner.
120
+ */
97
121
  async function reconcile(folder, options = {}) {
98
122
  const link = await (0, config_js_1.readLink)(folder);
99
123
  const reference = link?.slug ?? node_path_1.default.basename(folder);
@@ -102,12 +126,31 @@ async function reconcile(folder, options = {}) {
102
126
  : await (0, api_js_1.findProject)(reference);
103
127
  if (!project?.versionCount) {
104
128
  // Nothing saved on the account, so everything here is outstanding.
105
- return { link: link ?? null, baseline: null };
129
+ return { link: link ?? null, baseline: null, behind: null };
106
130
  }
107
131
  const downloader = new download_js_1.Downloader(config_js_1.credentials);
108
132
  const latest = (await downloader.versions(project.id))[0];
109
133
  if (!latest)
110
- return { link: link ?? null, baseline: null };
134
+ return { link: link ?? null, baseline: null, behind: null };
135
+ /*
136
+ A folder that already knows which version it stands on keeps it. Only a
137
+ folder that has never been reconciled — or was linked before versions
138
+ were recorded — adopts the newest as its starting point.
139
+ */
140
+ const known = link?.versionId && link.manifest ? link : null;
141
+ const behind = known && known.versionId !== latest.id
142
+ ? { sequence: latest.sequence, id: latest.id }
143
+ : null;
144
+ if (known) {
145
+ if (behind && !options.quiet) {
146
+ console.log(dim(`The account is on v${behind.sequence}; this folder is based on v${known.sequence}.`));
147
+ }
148
+ return {
149
+ link: known,
150
+ baseline: new Map(Object.entries(known.manifest)),
151
+ behind,
152
+ };
153
+ }
111
154
  const baseline = new Map();
112
155
  for (const file of await downloader.files(project.id, latest.id)) {
113
156
  baseline.set(file.path, file.sha256);
@@ -116,13 +159,14 @@ async function reconcile(folder, options = {}) {
116
159
  repositoryId: project.id,
117
160
  slug: project.slug,
118
161
  sequence: latest.sequence,
162
+ versionId: latest.id,
119
163
  manifest: Object.fromEntries(baseline),
120
164
  };
121
165
  await (0, config_js_1.writeLink)(folder, fresh);
122
166
  if (!options.quiet && !link) {
123
167
  console.log(dim(`Linked this folder to ${project.slug}.`));
124
168
  }
125
- return { link: fresh, baseline };
169
+ return { link: fresh, baseline, behind: null };
126
170
  }
127
171
  // ── commands ────────────────────────────────────────────────────────────────
128
172
  async function commandSignIn(parsed) {
@@ -206,6 +250,31 @@ async function commandSubmit(parsed) {
206
250
  console.log("Nothing to submit; this folder matches the saved version.");
207
251
  return 0;
208
252
  }
253
+ /*
254
+ The policy is that credentials are never quietly dropped and never
255
+ quietly sent — they are asked about. The desktop application has always
256
+ put this question in front of the first upload; the command line was
257
+ sending them without a word, which is the worse half of the two
258
+ behaviours the policy exists to prevent.
259
+ */
260
+ const secrets = await (0, worktree_js_1.detectSecrets)(folder);
261
+ const sending = new Set(files.map((file) => file.path));
262
+ const exposed = secrets.filter((secret) => sending.has(secret));
263
+ if (exposed.length) {
264
+ console.log(red(`\n${exposed.length} file${exposed.length === 1 ? " looks like a credential" : "s look like credentials"}:`));
265
+ for (const secret of exposed.slice(0, 20))
266
+ console.log(` ${secret}`);
267
+ if (exposed.length > 20) {
268
+ console.log(dim(` …and ${exposed.length - 20} more`));
269
+ }
270
+ if (!hasFlag(parsed, "allow-secrets")) {
271
+ console.error(`\nNothing was sent. Add them to ${accent(".gitignore")} to leave them` +
272
+ ` behind, or pass ${accent("--allow-secrets")} if they genuinely belong` +
273
+ ` in the project.`);
274
+ return 1;
275
+ }
276
+ console.log(dim("Sending them anyway, because --allow-secrets was given."));
277
+ }
209
278
  if (hasFlag(parsed, "dry-run", "n")) {
210
279
  console.log(`${files.length} file${files.length === 1 ? "" : "s"} would be sent:`);
211
280
  for (const file of files)
@@ -214,21 +283,49 @@ async function commandSubmit(parsed) {
214
283
  }
215
284
  const line = progressLine();
216
285
  const uploader = new upload_js_1.Uploader(config_js_1.credentials);
217
- const result = await uploader.run({
218
- localPath: folder,
219
- include: files.map((file) => file.path),
220
- message,
221
- projectName: link?.slug ?? node_path_1.default.basename(folder),
222
- repositoryId: link?.repositoryId ?? null,
223
- }, (progress) => {
224
- line(` ${String(progress.percent).padStart(3)}% ${progress.stage.padEnd(7)} ` +
225
- `${progress.files}/${progress.totalFiles} ${progress.path.slice(-48)}`);
226
- });
286
+ let result;
287
+ try {
288
+ result = await uploader.run({
289
+ localPath: folder,
290
+ include: files.map((file) => file.path),
291
+ message,
292
+ projectName: link?.slug ?? node_path_1.default.basename(folder),
293
+ repositoryId: link?.repositoryId ?? null,
294
+ /*
295
+ Only claimed when this folder has actually been reconciled with a
296
+ known version. A folder linked before versions were recorded says
297
+ nothing rather than guessing, and publishes as it always did.
298
+ */
299
+ ...(link?.versionId ? { expectedHeadVersionId: link.versionId } : {}),
300
+ }, (progress) => {
301
+ line(` ${String(progress.percent).padStart(3)}% ${progress.stage.padEnd(7)} ` +
302
+ `${progress.files}/${progress.totalFiles} ${progress.path.slice(-48)}`);
303
+ });
304
+ }
305
+ catch (error) {
306
+ done(line);
307
+ const code = error.code;
308
+ if (code === "merge_required" || code === "track_moved") {
309
+ /*
310
+ Somebody else saved to this project first. Everything that did not
311
+ overlap has already been combined by the service; what is left is a
312
+ genuine decision, so the answer is to bring their work down and look
313
+ at it rather than to try again harder.
314
+ */
315
+ console.error(red(error instanceof Error ? error.message : String(error)));
316
+ console.error(`\nRun ${accent("coderook get")} to bring the latest version down, then` +
317
+ ` submit again. Nothing was changed on your account.`);
318
+ return 1;
319
+ }
320
+ throw error;
321
+ }
227
322
  done(line);
228
323
  await (0, config_js_1.writeLink)(folder, {
229
324
  repositoryId: result.repositoryId,
230
325
  slug: link?.slug ?? node_path_1.default.basename(folder),
231
326
  sequence: result.sequence,
327
+ // What this folder is now working from, so the next submit can say so.
328
+ versionId: result.versionId,
232
329
  manifest: result.manifest,
233
330
  });
234
331
  console.log(`Saved ${accent(`v${result.sequence}`)} · sent ${bytes(result.sentBytes)} in ` +
@@ -277,10 +374,12 @@ async function fetchInto(repositoryId, destination, slug) {
277
374
  ` ${progress.path.slice(-48)}`);
278
375
  });
279
376
  done(line);
377
+ // Fetching is how a folder catches up, so this is what moves its base.
280
378
  await (0, config_js_1.writeLink)(destination, {
281
379
  repositoryId,
282
380
  slug,
283
381
  sequence: latest.sequence,
382
+ versionId: latest.id,
284
383
  manifest: result.manifest,
285
384
  });
286
385
  console.log(`${accent(`v${latest.sequence}`)} · ${result.files} files · ${bytes(result.bytes)} into ${destination}`);
@@ -402,6 +501,7 @@ ${bold("Other")}
402
501
  ${bold("Options")}
403
502
  -m, --message The version message for submit
404
503
  -n, --dry-run Show what submit would send, without sending
504
+ --allow-secrets Send files that look like credentials anyway
405
505
  --token Supply the token to sign-in instead of being asked
406
506
 
407
507
  ${dim("The environment variable CODEROOK_TOKEN is used when set, so automated")}
@@ -95,7 +95,16 @@ class Uploader {
95
95
  const body = text ? JSON.parse(text) : {};
96
96
  if (!response.ok) {
97
97
  const message = body?.error?.message ?? `${route} failed (${response.status})`;
98
- throw new Error(message);
98
+ /*
99
+ The service says what kind of failure this is, and callers need that
100
+ to say anything useful — "someone else saved first" deserves a
101
+ different suggestion from "the disk is full". Carrying the code on
102
+ the error keeps them from having to match on wording.
103
+ */
104
+ throw Object.assign(new Error(message), {
105
+ code: typeof body?.error?.code === "string" ? body.error.code : "",
106
+ status: response.status,
107
+ });
99
108
  }
100
109
  return body;
101
110
  }
@@ -288,6 +297,9 @@ class Uploader {
288
297
  contentType: "application/json",
289
298
  body: JSON.stringify({
290
299
  message: request.message.trim() || "Saved from the CodeRook desktop app",
300
+ ...(request.expectedHeadVersionId === undefined
301
+ ? {}
302
+ : { expectedHeadVersionId: request.expectedHeadVersionId }),
291
303
  sourceSize: sourceBytes,
292
304
  storedSize: storedBytes,
293
305
  files: contents.map((item) => ({
package/package.json CHANGED
@@ -1,36 +1,36 @@
1
- {
2
- "name": "@coderook/cli",
3
- "version": "0.1.0",
4
- "description": "CodeRook from the command line, on any operating system",
5
- "license": "SEE LICENSE IN LICENSE.txt",
6
- "homepage": "https://coderook.com",
7
- "bugs": {
8
- "url": "https://coderook.com/contact"
9
- },
10
- "keywords": [
11
- "coderook",
12
- "versioning",
13
- "backup",
14
- "cbx"
15
- ],
16
- "engines": {
17
- "node": ">=20.11.0"
18
- },
19
- "bin": {
20
- "coderook": "dist/cli/src/cli.js"
21
- },
22
- "files": [
23
- "dist"
24
- ],
25
- "scripts": {
26
- "build": "tsc -p tsconfig.json",
27
- "check": "tsc -p tsconfig.json --noEmit",
28
- "test": "tsc -p tsconfig.json && node --test --experimental-strip-types test/*.test.ts",
29
- "start": "node dist/cli/src/cli.js",
30
- "prepublishOnly": "npm run build"
31
- },
32
- "devDependencies": {
33
- "@types/node": "24.10.1",
34
- "typescript": "5.9.3"
35
- }
36
- }
1
+ {
2
+ "name": "@coderook/cli",
3
+ "version": "0.2.0",
4
+ "description": "CodeRook from the command line, on any operating system",
5
+ "license": "SEE LICENSE IN LICENSE.txt",
6
+ "homepage": "https://coderook.com",
7
+ "bugs": {
8
+ "url": "https://coderook.com/contact"
9
+ },
10
+ "keywords": [
11
+ "coderook",
12
+ "versioning",
13
+ "backup",
14
+ "cbx"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20.11.0"
18
+ },
19
+ "bin": {
20
+ "coderook": "dist/cli/src/cli.js"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "check": "tsc -p tsconfig.json --noEmit",
28
+ "test": "tsc -p tsconfig.json && node --test --experimental-strip-types test/*.test.ts",
29
+ "start": "node dist/cli/src/cli.js",
30
+ "prepublishOnly": "npm run build"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "24.10.1",
34
+ "typescript": "5.9.3"
35
+ }
36
+ }