@coderook/cli 0.26.0 → 0.27.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.
@@ -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.26.0",
5
+ "version": "0.27.0",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -251,6 +251,7 @@ async function versions(repositoryId) {
251
251
  : [],
252
252
  authorName: String(row.author?.displayName ?? ""),
253
253
  name: row.name == null ? null : String(row.name),
254
+ notes: row.notes == null ? null : String(row.notes),
254
255
  /*
255
256
  Falls back to the older field, so this keeps working against a service
256
257
  that has not been updated yet rather than reporting everything private.
@@ -93,18 +93,62 @@ const done = (line) => {
93
93
  node_process_1.default.stdout.write("\r");
94
94
  }
95
95
  };
96
- function parse(argv) {
96
+ /**
97
+ * The flags a command declares as taking a value.
98
+ *
99
+ * Read from the option's own help text, which already says so: a value is
100
+ * written as a placeholder after the name, as in `--name <text>`, and a
101
+ * switch has nothing after it. So there is no second list to keep in step
102
+ * with the first — the thing already written for the reader is the thing
103
+ * the parser uses.
104
+ */
105
+ function valueTaking(spec) {
106
+ const names = new Set();
107
+ for (const option of spec?.options ?? []) {
108
+ if (!/[<[]/.test(option.flags))
109
+ continue;
110
+ for (const match of option.flags.matchAll(/--?([A-Za-z0-9][\w-]*)/g)) {
111
+ names.add(match[1]);
112
+ }
113
+ }
114
+ return names;
115
+ }
116
+ /**
117
+ * What was typed, split into positions and flags.
118
+ *
119
+ * A flag takes the word after it only when the command says it takes one.
120
+ * Guessing from the shape of the next word — anything not starting with a
121
+ * dash — meant `cbx take-down 1 --yes my-project` read the project name as
122
+ * the value of `--yes` and then reported that `--yes` had not been given,
123
+ * having also swallowed the argument that said which project. Every switch
124
+ * followed by a positional had the same fault.
125
+ */
126
+ function parse(argv, spec) {
97
127
  const positional = [];
98
128
  const flags = new Map();
129
+ const takesValue = valueTaking(spec);
99
130
  for (let at = 0; at < argv.length; at += 1) {
100
131
  const item = argv[at];
101
132
  if (!item.startsWith("-")) {
102
133
  positional.push(item);
103
134
  continue;
104
135
  }
105
- const name = item.replace(/^--?/, "");
136
+ /* `--name=value` says so itself and needs no declaration. */
137
+ const written = item.replace(/^--?/, "");
138
+ const split = written.indexOf("=");
139
+ if (split > 0) {
140
+ flags.set(written.slice(0, split), written.slice(split + 1));
141
+ continue;
142
+ }
143
+ const name = written;
106
144
  const next = argv[at + 1];
107
- if (next && !next.startsWith("-")) {
145
+ /*
146
+ An undeclared flag keeps the old guess. Commands that never declared
147
+ their options rely on it, and narrowing that here would trade one
148
+ quiet misreading for another.
149
+ */
150
+ const wants = takesValue.size ? takesValue.has(name) : true;
151
+ if (wants && next !== undefined && !next.startsWith("-")) {
108
152
  flags.set(name, next);
109
153
  at += 1;
110
154
  }
@@ -398,7 +442,7 @@ async function commandImport(parsed) {
398
442
  flags: submitFlags,
399
443
  });
400
444
  if (code === 0 && plan.temporary) {
401
- console.log(dim(`The working copy stays at ${plan.folder} until you remove it.
445
+ console.log(dim(`The working copy stays at ${plan.folder} until you remove it.
402
446
  ` +
403
447
  `Run ${accent("cbx get " + plan.name)} anywhere to fetch it fresh.`));
404
448
  }
@@ -526,17 +570,17 @@ async function commandSubmit(parsed) {
526
570
  */
527
571
  const shielded = (await (0, worktree_js_1.detectPrivateDirectories)(folder)).filter((finding) => [...sending].some((file) => file === finding.path || file.startsWith(`${finding.path}/`)));
528
572
  if (shielded.length && !hasFlag(parsed, "allow-private")) {
529
- console.log(red(`
573
+ console.log(red(`
530
574
  ${shielded.length} folder${shielded.length === 1 ? "" : "s"} here belong${shielded.length === 1 ? "s" : ""} to a program, not to your project:`));
531
575
  for (const finding of shielded) {
532
576
  console.log(` ${finding.path} ${dim(`— ${finding.because}`)}`);
533
577
  }
534
- console.log(`
578
+ console.log(`
535
579
  Nothing was sent. To leave them behind:`);
536
580
  for (const finding of shielded) {
537
581
  console.log(` ${accent(`echo "${finding.rule}" >> .gitignore`)}`);
538
582
  }
539
- console.error(`
583
+ console.error(`
540
584
  Or pass ${accent("--allow-private")} if they genuinely belong in the project.`);
541
585
  return 1;
542
586
  }
@@ -571,7 +615,7 @@ Or pass ${accent("--allow-private")} if they genuinely belong in the project.`);
571
615
  const pasted = await (0, worktree_js_1.detectPastedCredentials)(folder, files.map((file) => file.path));
572
616
  const stillPasted = pasted.filter((finding) => !exposed.includes(finding.path));
573
617
  if (stillPasted.length) {
574
- console.log(red(`
618
+ console.log(red(`
575
619
  ${stillPasted.length} file${stillPasted.length === 1 ? " has a credential" : "s have credentials"} inside:`));
576
620
  for (const finding of stillPasted.slice(0, 20)) {
577
621
  console.log(` ${finding.path}`);
@@ -586,11 +630,11 @@ ${stillPasted.length} file${stillPasted.length === 1 ? " has a credential" : "s
586
630
  console.log(dim(` …and ${stillPasted.length - 20} more`));
587
631
  }
588
632
  if (!hasFlag(parsed, "allow-secrets")) {
589
- console.error(`
633
+ console.error(`
590
634
  Nothing was sent. Move the key into an environment variable, and if` +
591
635
  ` it has ever been published, replace it at the service that issued` +
592
636
  ` it — a key that has leaked stays leaked.` +
593
- `
637
+ `
594
638
  Pass ${accent("--allow-secrets")} if these are not real keys.`);
595
639
  return 1;
596
640
  }
@@ -688,7 +732,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
688
732
  const failure = (0, publish_js_1.classifyPublishFailure)(error);
689
733
  const text = error instanceof Error ? error.message : String(error);
690
734
  if (failure?.kind === "interrupted") {
691
- console.error(red(`
735
+ console.error(red(`
692
736
  The connection failed: ${text}`));
693
737
  console.error(`Your work may already have been saved. Run the same command again —` +
694
738
  ` it will not create a second version.`);
@@ -890,7 +934,7 @@ async function commandGet(parsed) {
890
934
  `changed while the interrupted fetch was stopped:`));
891
935
  for (const file of changedInTheGap.slice(0, 20))
892
936
  console.error(` ${file.path}`);
893
- console.error(`
937
+ console.error(`
894
938
  Save them with ${accent("cbx submit")}, or finish the fetch and ` +
895
939
  `discard them with ${accent("cbx get --replace")}.`);
896
940
  return 1;
@@ -1122,8 +1166,8 @@ async function suggestRules(folder, apply) {
1122
1166
  const addition = (0, detect_js_1.rulesFromSuggestions)(recommended);
1123
1167
  const shared = rules.shared.trimEnd();
1124
1168
  await (0, worktree_js_1.writeRules)(folder, {
1125
- shared: shared ? `${shared}
1126
-
1169
+ shared: shared ? `${shared}
1170
+
1127
1171
  ${addition}` : addition,
1128
1172
  local: rules.local,
1129
1173
  });
@@ -1250,7 +1294,7 @@ async function commandMerges(parsed) {
1250
1294
  const counts = merge.conflicts;
1251
1295
  console.log(`${accent(merge.reference)} ${counts ? `${counts.unresolved} of ${counts.total} still to decide` : ""} ${dim(new Date(merge.createdAt).toLocaleString())}`);
1252
1296
  }
1253
- console.log(dim(`
1297
+ console.log(dim(`
1254
1298
  Run cbx merge <reference> to look at one.`));
1255
1299
  return 0;
1256
1300
  }
@@ -1316,7 +1360,7 @@ async function commandMerge(parsed) {
1316
1360
  }
1317
1361
  }
1318
1362
  const now = await (0, api_js_1.mergeTrack)(summary.id);
1319
- console.log(`
1363
+ console.log(`
1320
1364
  ${accent(now.mergeTrack.reference)} · ${now.provisional.fileCount} files · ` +
1321
1365
  (now.provisional.ready
1322
1366
  ? "ready to apply"
@@ -1331,19 +1375,19 @@ ${accent(now.mergeTrack.reference)} · ${now.provisional.fileCount} files · ` +
1331
1375
  return 1;
1332
1376
  }
1333
1377
  const applied = await (0, api_js_1.applyMerge)(summary.id);
1334
- console.log(`
1378
+ console.log(`
1335
1379
  Applied as ${accent(`v${applied.version.sequence}`)}.`);
1336
1380
  console.log(dim("Run cbx get to bring it down to this folder."));
1337
1381
  return 0;
1338
1382
  }
1339
1383
  if (!decision) {
1340
- console.log(dim(`
1384
+ console.log(dim(`
1341
1385
  --mine keeps yours, --theirs keeps what was already saved,` +
1342
- ` --drop removes the file.
1386
+ ` --drop removes the file.
1343
1387
  Add --path <file> for one file, then --apply when ready.`));
1344
1388
  }
1345
1389
  else if (now.provisional.ready) {
1346
- console.log(dim(`
1390
+ console.log(dim(`
1347
1391
  Run cbx merge ${now.mergeTrack.reference} --apply to publish it.`));
1348
1392
  }
1349
1393
  return 0;
@@ -1758,8 +1802,20 @@ const SPECS = [
1758
1802
  group: "Your projects",
1759
1803
  summary: "what has been saved to a project",
1760
1804
  usage: "versions [project]",
1761
- detail: "Newest first. Run it inside a linked folder to leave the name out.",
1762
- options: [{ flags: "--limit <n>", description: "how many to show (default 20)" }],
1805
+ detail: "Newest first. Run it inside a linked folder to leave the name out.\n\n" +
1806
+ "A save is a commit; a commit somebody named is a version, and versions\n" +
1807
+ "are the only thing the public side shows. Both are listed together\n" +
1808
+ "unless you ask for one lane — the same three views the website has.",
1809
+ options: [
1810
+ { flags: "--limit <n>", description: "how many to show (default 20)" },
1811
+ { flags: "--versions", description: "only the ones that were named" },
1812
+ { flags: "--commits", description: "only the ones that were not" },
1813
+ { flags: "--notes", description: "print what each one says about itself" },
1814
+ ],
1815
+ examples: [
1816
+ "cbx versions --versions",
1817
+ "cbx versions my-project --commits",
1818
+ ],
1763
1819
  run: project_commands_js_1.commandVersions,
1764
1820
  },
1765
1821
  {
@@ -1844,6 +1900,8 @@ const SPECS = [
1844
1900
  options: [
1845
1901
  { flags: "--name <text>", description: "what to call it" },
1846
1902
  { flags: "--notes <text>", description: "what changed" },
1903
+ { flags: "--notes-file <path>", description: "what changed, from a file" },
1904
+ { flags: "--edit", description: "write what changed in your editor" },
1847
1905
  ],
1848
1906
  examples: [
1849
1907
  "cbx promote",
@@ -1867,6 +1925,34 @@ const SPECS = [
1867
1925
  that works until somebody scripts it. This marks a save — pins it,
1868
1926
  hides it, labels it — which is what it does anyway.
1869
1927
  */
1928
+ name: "notes",
1929
+ aliases: ["release-notes"],
1930
+ group: "Your projects",
1931
+ summary: "read or write what a version says about itself",
1932
+ usage: "notes [n] [project]",
1933
+ detail: "The release info the website shows on a version, and the same field\n" +
1934
+ "`--notes` writes when promoting. With nothing to write it prints what\n" +
1935
+ "is there, which the terminal could not do before: `cbx releases` shows\n" +
1936
+ "the first line and stops.\n\n" +
1937
+ "Written in Markdown, because that is what the page renders. --edit\n" +
1938
+ "opens $EDITOR (or $VISUAL, or notepad on Windows) the way git does,\n" +
1939
+ "which is the only comfortable way to write more than a sentence.",
1940
+ options: [
1941
+ { flags: "--edit", description: "write it in your editor" },
1942
+ { flags: "--notes-file <path>", description: "take the text from a file" },
1943
+ { flags: "--notes <text>", description: "set it in one line" },
1944
+ { flags: "--clear", description: "remove what is there" },
1945
+ { flags: "--project <name>", description: "which project" },
1946
+ ],
1947
+ examples: [
1948
+ "cbx notes",
1949
+ "cbx notes 41",
1950
+ "cbx notes 41 --edit",
1951
+ "cbx notes 41 --notes-file RELEASE.md",
1952
+ ],
1953
+ run: version_commands_js_1.commandNotes,
1954
+ },
1955
+ {
1870
1956
  name: "mark",
1871
1957
  aliases: ["set"],
1872
1958
  group: "Your projects",
@@ -1879,11 +1965,13 @@ const SPECS = [
1879
1965
  { flags: "--name <text>", description: "give it a code name" },
1880
1966
  { flags: "--unname", description: "take the name off again" },
1881
1967
  { flags: "--notes <text>", description: "what changed" },
1968
+ { flags: "--notes-file <path>", description: "what changed, from a file" },
1969
+ { flags: "--edit", description: "write what changed in your editor" },
1882
1970
  { flags: "--hide", description: "nobody outside the project can read it" },
1883
1971
  { flags: "--show", description: "anybody can read it" },
1884
1972
  {
1885
1973
  flags: "--visibility <who>",
1886
- description: "private, unlisted (link only) or public",
1974
+ description: "public, or private to hold it back",
1887
1975
  },
1888
1976
  { flags: "--pin", description: "keep it at the top of the list" },
1889
1977
  { flags: "--unpin", description: "put it back in order" },
@@ -1892,7 +1980,7 @@ const SPECS = [
1892
1980
  ],
1893
1981
  examples: [
1894
1982
  "cbx mark --name v2.1 --pin",
1895
- "cbx mark 41 --visibility unlisted",
1983
+ "cbx mark 41 --visibility private",
1896
1984
  'cbx mark 41 --labels "shipped,client work"',
1897
1985
  ],
1898
1986
  run: version_commands_js_1.commandVersion,
@@ -2249,7 +2337,7 @@ async function main(argv) {
2249
2337
  if (spec.deprecatedBy) {
2250
2338
  console.error(dim(`"${name}" is now "${spec.deprecatedBy}". The old name still works.`));
2251
2339
  }
2252
- return spec.run(parse(rest));
2340
+ return spec.run(parse(rest, spec));
2253
2341
  }
2254
2342
  /**
2255
2343
  * Set the status and let Node wind down on its own.
@@ -47,9 +47,19 @@ exports.main = main;
47
47
  * `coderook://` URLs, which is what lets every editor's built-in git panel work
48
48
  * with CodeRook without any per-editor work.
49
49
  *
50
- * Both directions are implemented: `push` publishes commits as versions, and
51
- * `import` rebuilds a git history from versions so `git clone` and `git fetch`
52
- * work.
50
+ * Both directions are implemented: `push` publishes each commit as a save, and
51
+ * `import` rebuilds a git history from those saves so `git clone` and `git
52
+ * fetch` work.
53
+ *
54
+ * The vocabulary matters here, because the two products do not agree by
55
+ * accident. A pushed commit becomes a *commit* in CodeRook — a save, inside
56
+ * the project, that nobody outside can see. Tagging it names it, and a named
57
+ * save is a *version*, which is the only thing the public side offers. That
58
+ * is the same distinction git already draws between a commit and a tag, so
59
+ * the mapping is one-to-one rather than an approximation.
60
+ *
61
+ * A save also carries a description of its own, separate from its message.
62
+ * That travels as a git note on `refs/notes/coderook` in both directions.
53
63
  *
54
64
  * ## Why the `push` capability rather than `export`
55
65
  *
@@ -143,6 +153,26 @@ function git(args) {
143
153
  }
144
154
  return result.stdout;
145
155
  }
156
+ /**
157
+ * Where CodeRook keeps a save's description inside a git repository.
158
+ *
159
+ * `refs/notes/coderook` rather than git's default `refs/notes/commits`,
160
+ * because the default is shared ground — anything else attaching notes writes
161
+ * there too, and a fetch that overwrote somebody's own notes with ours would
162
+ * be taking something that was not offered.
163
+ */
164
+ const NOTES_REF = "refs/notes/coderook";
165
+ /**
166
+ * The description attached to a commit, if there is one.
167
+ *
168
+ * Empty rather than throwing for every ordinary reason it can be missing: no
169
+ * notes ref at all, no note on this commit, or a repository where notes have
170
+ * never been used. None of those is a problem worth stopping a push for.
171
+ */
172
+ function noteOn(sha) {
173
+ const result = (0, node_child_process_1.spawnSync)("git", ["notes", `--ref=${NOTES_REF}`, "show", sha], { encoding: "utf8", maxBuffer: 1024 * 1024 * 8 });
174
+ return result.status === 0 ? (result.stdout ?? "").trim() : "";
175
+ }
146
176
  /** Run git for its exit status alone. False rather than throwing. */
147
177
  function gitOk(args) {
148
178
  return ((0, node_child_process_1.spawnSync)("git", args, { encoding: "utf8", stdio: "ignore" }).status === 0);
@@ -665,6 +695,40 @@ async function doImport(refs, url) {
665
695
  say(` ${done}/${fresh.length} versions`);
666
696
  }
667
697
  }
698
+ /*
699
+ What each save says about itself, as git notes.
700
+
701
+ A CodeRook save carries a description separate from its message — the
702
+ thing the website shows as release info and `cbx notes` writes — and a
703
+ fetch used to drop all of it except on the few versions that were also
704
+ releases. Git's answer for text attached to a commit without changing
705
+ the commit is a note, so that is where it goes: `git log
706
+ --notes=coderook` shows them, and pushing them back returns them.
707
+
708
+ Written as one commit on its own ref after every version commit exists,
709
+ because a note has to name a commit that is already in the stream.
710
+ */
711
+ const described = fresh.filter((version) => (version.notes ?? "").trim());
712
+ if (described.length) {
713
+ send(`commit ${(0, git_history_js_1.importRef)(NOTES_REF)}`);
714
+ send(`committer CodeRook <noreply@coderook.com> ` +
715
+ `${(0, git_history_js_1.stamp)(new Date().toISOString())} +0000`);
716
+ const why = Buffer.from("What each save says about itself\n", "utf8");
717
+ send(`data ${why.length}`);
718
+ node_process_1.default.stdout.write(why);
719
+ node_process_1.default.stdout.write("\n");
720
+ for (const version of described) {
721
+ const mark = commitMark.get(version.id);
722
+ if (mark === undefined)
723
+ continue;
724
+ const body = Buffer.from(`${(version.notes ?? "").trim()}\n`, "utf8");
725
+ send(`N inline :${mark}`);
726
+ send(`data ${body.length}`);
727
+ node_process_1.default.stdout.write(body);
728
+ node_process_1.default.stdout.write("\n");
729
+ }
730
+ say(` ${described.length} description${described.length === 1 ? "" : "s"} as git notes`);
731
+ }
668
732
  /* Every ref git asked for, named outright now the commits exist. */
669
733
  settleRefs();
670
734
  /*
@@ -856,7 +920,7 @@ async function doPush(requests, url) {
856
920
  const existing = await releases(repositoryId).catch(() => []);
857
921
  const already = existing.find((release) => release.versionId === versionId && release.name !== tag);
858
922
  if (already) {
859
- say(` note: v${already.sequence} was already released as "${already.name}".
923
+ say(` note: v${already.sequence} was already released as "${already.name}".
860
924
  ` +
861
925
  ` A version carries one name, so it is now "${tag}".`);
862
926
  }
@@ -1098,10 +1162,10 @@ async function doPush(requests, url) {
1098
1162
  happened and go looking for a way to undo it.
1099
1163
  */
1100
1164
  send(`error ${request.dst} the connection failed part way through`);
1101
- say(`
1102
- ${published} of ${commits.length} commits were published.
1165
+ say(`
1166
+ ${published} of ${commits.length} commits were published.
1103
1167
  ` +
1104
- ` Push again — commits already published are not sent twice.
1168
+ ` Push again — commits already published are not sent twice.
1105
1169
  `);
1106
1170
  throw error;
1107
1171
  }
@@ -1113,8 +1177,8 @@ async function doPush(requests, url) {
1113
1177
  anyway.
1114
1178
  */
1115
1179
  send(`error ${request.dst} this commit carries a credential`);
1116
- say(`
1117
- ${failure.message}
1180
+ say(`
1181
+ ${failure.message}
1118
1182
  `);
1119
1183
  /*
1120
1184
  Deleting the file in a later commit does not help, and saying
@@ -1124,14 +1188,14 @@ async function doPush(requests, url) {
1124
1188
  The history has to lose it.
1125
1189
  */
1126
1190
  say(` A later commit that deletes it is not enough — every commit` +
1127
- ` being pushed
1191
+ ` being pushed
1128
1192
  becomes a version, and the one that added` +
1129
- ` the key still carries it.
1193
+ ` the key still carries it.
1130
1194
  ` +
1131
1195
  ` Rewrite it out (git rebase -i, or git commit --amend if it` +
1132
- ` is the last one),
1196
+ ` is the last one),
1133
1197
  or publish this deliberately with` +
1134
- ` cbx submit --allow-secrets.
1198
+ ` cbx submit --allow-secrets.
1135
1199
  `);
1136
1200
  /*
1137
1201
  Reported already, and in more detail than the wrapper can. The
@@ -1141,8 +1205,8 @@ async function doPush(requests, url) {
1141
1205
  }
1142
1206
  if (failure?.kind === "conflict") {
1143
1207
  send(`error ${request.dst} somebody published to "${branch}" while this push was running`);
1144
- say(`
1145
- git fetch, then push again.
1208
+ say(`
1209
+ git fetch, then push again.
1146
1210
  `);
1147
1211
  throw error;
1148
1212
  }
@@ -1154,6 +1218,27 @@ async function doPush(requests, url) {
1154
1218
  }
1155
1219
  currentRepositoryId = result.repositoryId;
1156
1220
  baseVersionId = result.versionId;
1221
+ /*
1222
+ A note on the commit becomes what the version says about itself.
1223
+
1224
+ CodeRook lets a save be described before anybody decides to publish
1225
+ it, and git has exactly one place for text attached to a commit
1226
+ without altering it. Sent after the publish rather than with it,
1227
+ because a note is not part of what was saved and a failure to
1228
+ record one must not fail the push.
1229
+ */
1230
+ const note = noteOn(sha);
1231
+ if (note) {
1232
+ try {
1233
+ const { changeVersion } = await Promise.resolve().then(() => __importStar(require("./api.js")));
1234
+ await changeVersion(result.repositoryId, result.versionId, {
1235
+ notes: note,
1236
+ });
1237
+ }
1238
+ catch {
1239
+ say(` note: the description on ${sha.slice(0, 8)} could not be saved`);
1240
+ }
1241
+ }
1157
1242
  /*
1158
1243
  The version and the commit it came from are the same thing in this
1159
1244
  repository from here on. Recorded so a later fetch does not rebuild
@@ -131,10 +131,39 @@ async function commandVersions(parsed) {
131
131
  console.log(dim("Nothing has been saved to this project yet."));
132
132
  return 0;
133
133
  }
134
+ /*
135
+ The same two lanes the website shows.
136
+
137
+ A version is a commit somebody named, so the lanes are a split of one
138
+ history rather than two histories — asking for commits gives the saves
139
+ still waiting on that decision, and asking for versions gives the ones
140
+ the public side can see. Neither contains the other; with no flag you get
141
+ both, which is what this always did.
142
+ */
143
+ const onlyVersions = parsed.flags.get("versions") === true;
144
+ const onlyCommits = parsed.flags.get("commits") === true;
145
+ if (onlyVersions && onlyCommits) {
146
+ console.error(red("Pick one: --versions or --commits, or neither for both."));
147
+ return 1;
148
+ }
149
+ const lane = saved.filter((version) => {
150
+ if (onlyVersions)
151
+ return Boolean(version.name);
152
+ if (onlyCommits)
153
+ return !version.name;
154
+ return true;
155
+ });
156
+ if (!lane.length) {
157
+ console.log(dim(onlyVersions
158
+ ? "Nothing has been made a version yet."
159
+ : "Every save here has been made a version."));
160
+ return 0;
161
+ }
162
+ const withNotes = parsed.flags.get("notes") === true;
134
163
  const asked = Number(parsed.flags.get("limit") ?? 20);
135
164
  const limit = Number.isFinite(asked) && asked > 0 ? asked : 20;
136
165
  console.log(bold(project.name));
137
- for (const version of saved.slice(0, limit)) {
166
+ for (const version of lane.slice(0, limit)) {
138
167
  const when = version.createdAt
139
168
  ? new Date(version.createdAt).toLocaleString()
140
169
  : "";
@@ -155,8 +184,11 @@ async function commandVersions(parsed) {
155
184
  version.state === "held" ? red("held") : "",
156
185
  version.state === "declined" ? dim("declined") : "",
157
186
  version.removedAt ? dim("taken down") : "",
158
- version.visibility === "private" ? dim("private") : "",
159
- version.visibility === "unlisted" ? dim("link only") : "",
187
+ /*
188
+ Said only of a version, because only a version is offered to anybody.
189
+ A commit nobody has promoted is not hidden — it was never on offer.
190
+ */
191
+ version.name && version.visibility === "private" ? dim("hidden") : "",
160
192
  ...version.labels.map((label) => accent(label.name)),
161
193
  ].filter(Boolean);
162
194
  console.log(` ${accent(`v${version.sequence}`).padEnd(16)} ${when.padEnd(22)}` +
@@ -164,9 +196,18 @@ async function commandVersions(parsed) {
164
196
  (marks.length ? ` ${marks.join(" ")}` : ""));
165
197
  if (version.message)
166
198
  console.log(` ${dim(version.message)}`);
199
+ /*
200
+ Indented under the save it belongs to rather than printed flat, so a
201
+ note of several paragraphs still reads as part of one row.
202
+ */
203
+ if (withNotes && version.notes?.trim()) {
204
+ for (const line of version.notes.trim().split(/\r?\n/)) {
205
+ console.log(` ${line}`);
206
+ }
207
+ }
167
208
  }
168
- if (saved.length > limit) {
169
- console.log(dim(` … ${saved.length - limit} older. Use --limit to see more.`));
209
+ if (lane.length > limit) {
210
+ console.log(dim(` … ${lane.length - limit} older. Use --limit to see more.`));
170
211
  }
171
212
  return 0;
172
213
  }
@@ -16,6 +16,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
16
16
  };
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.split = split;
19
+ exports.commandNotes = commandNotes;
19
20
  exports.commandVersion = commandVersion;
20
21
  exports.commandLabels = commandLabels;
21
22
  exports.commandHeld = commandHeld;
@@ -26,6 +27,10 @@ exports.commandUndo = commandUndo;
26
27
  const api_js_1 = require("./api.js");
27
28
  const project_commands_js_1 = require("./project_commands.js");
28
29
  const promises_1 = require("node:readline/promises");
30
+ const node_child_process_1 = require("node:child_process");
31
+ const node_fs_1 = require("node:fs");
32
+ const node_os_1 = require("node:os");
33
+ const node_path_1 = __importDefault(require("node:path"));
29
34
  const node_process_1 = __importDefault(require("node:process"));
30
35
  /* Written out rather than imported: the same four escapes every other command
31
36
  file in here declares for itself, and a shared module for four one-line
@@ -34,6 +39,7 @@ const dim = (value) => `${value}`;
34
39
  const bold = (value) => `${value}`;
35
40
  const red = (value) => `${value}`;
36
41
  const accent = (value) => `${value}`;
42
+ const green = (value) => `${value}`;
37
43
  /**
38
44
  * Which of `[n] [project]` the arguments actually were.
39
45
  *
@@ -112,6 +118,148 @@ function colourOf(given) {
112
118
  function paint(label) {
113
119
  return accent(label.name);
114
120
  }
121
+ /**
122
+ * Release info, from wherever the writer keeps it.
123
+ *
124
+ * `--notes "..."` is fine for a sentence and hopeless for anything real: a
125
+ * release note has paragraphs and lists in it, and the shell trick people
126
+ * reach for — `--notes "$(cat NOTES.md)"` — is not a thing in PowerShell or
127
+ * cmd, which is most of this product's users. So the text can come from a
128
+ * file, or from the editor already set up for writing commit messages.
129
+ *
130
+ * Returns undefined when none of them were asked for, which is different from
131
+ * an empty string: one means leave it alone, the other means clear it.
132
+ */
133
+ function notesFrom(parsed) {
134
+ const inline = parsed.flags.get("notes");
135
+ const file = parsed.flags.get("notes-file");
136
+ const edit = parsed.flags.get("edit") === true;
137
+ const chosen = [
138
+ typeof inline === "string",
139
+ typeof file === "string",
140
+ edit,
141
+ ].filter(Boolean).length;
142
+ if (chosen > 1) {
143
+ throw new Error("Use one of --notes, --notes-file or --edit, not several.");
144
+ }
145
+ if (typeof inline === "string")
146
+ return inline;
147
+ if (typeof file === "string") {
148
+ try {
149
+ return (0, node_fs_1.readFileSync)(file, "utf8");
150
+ }
151
+ catch {
152
+ throw new Error(`Could not read ${file}.`);
153
+ }
154
+ }
155
+ /*
156
+ --edit is answered by the caller, which has the existing text to seed the
157
+ editor with. Nothing to report from here.
158
+ */
159
+ return undefined;
160
+ }
161
+ /**
162
+ * Write in the editor this person already uses, the way git does.
163
+ *
164
+ * The comment lines are stripped, so the instructions at the bottom of the
165
+ * file cannot end up published as part of the release. Quitting without
166
+ * saving leaves the notes untouched rather than clearing them, because an
167
+ * empty buffer is far more often a change of mind than an instruction.
168
+ */
169
+ function writeInEditor(existing) {
170
+ const editor = node_process_1.default.env.CODEROOK_EDITOR ??
171
+ node_process_1.default.env.VISUAL ??
172
+ node_process_1.default.env.EDITOR ??
173
+ (node_process_1.default.platform === "win32" ? "notepad" : "nano");
174
+ const directory = (0, node_fs_1.mkdtempSync)(node_path_1.default.join((0, node_os_1.tmpdir)(), "cbx-notes-"));
175
+ const file = node_path_1.default.join(directory, "RELEASE_NOTES.md");
176
+ try {
177
+ (0, node_fs_1.writeFileSync)(file, `${existing}\n\n` +
178
+ "# Write what changed in this version, in Markdown.\n" +
179
+ "# Lines starting with # in the first column are removed.\n" +
180
+ "# Save an empty file to leave the notes as they were.\n", "utf8");
181
+ /*
182
+ No shell. Handing the arguments to one concatenates them into a command
183
+ line without escaping, which makes the editor setting — and the path
184
+ beside it — a place to hide a second command. An editor is a program
185
+ and some arguments, so it is split here and run directly, which also
186
+ means a path with a space in it survives.
187
+ */
188
+ const parts = editor.match(/"[^"]+"|\S+/g) ?? [editor];
189
+ const unquote = (value) => value.replace(/^"|"$/g, "");
190
+ const run = (0, node_child_process_1.spawnSync)(unquote(parts[0] ?? editor), [...parts.slice(1).map(unquote), file], { stdio: "inherit" });
191
+ if (run.status !== 0)
192
+ return null;
193
+ const written = (0, node_fs_1.readFileSync)(file, "utf8")
194
+ .split(/\r?\n/)
195
+ .filter((line) => !/^#\s/.test(line) && line !== "#")
196
+ .join("\n")
197
+ .trim();
198
+ return written ? written : null;
199
+ }
200
+ catch {
201
+ return null;
202
+ }
203
+ finally {
204
+ (0, node_fs_1.rmSync)(directory, { recursive: true, force: true });
205
+ }
206
+ }
207
+ /**
208
+ * Read or write what a version says about itself.
209
+ *
210
+ * The website grew a place to write this and the terminal could only set it
211
+ * as a one-line flag while promoting, and could not read it back at all —
212
+ * `cbx releases` prints the first line and stops. Somebody working here could
213
+ * publish release info but never check what they had published.
214
+ */
215
+ async function commandNotes(parsed) {
216
+ const which = split(parsed);
217
+ const project = await (0, project_commands_js_1.resolveProject)(which.project);
218
+ if (!project)
219
+ return 1;
220
+ const target = await pick(project.id, which.n);
221
+ if (!target)
222
+ return 1;
223
+ if (parsed.flags.get("clear") === true) {
224
+ await (0, api_js_1.changeVersion)(project.id, target.id, { notes: null });
225
+ console.log(green(`Cleared the notes on v${target.sequence}.`));
226
+ return 0;
227
+ }
228
+ let next;
229
+ try {
230
+ next = notesFrom(parsed);
231
+ }
232
+ catch (error) {
233
+ console.error(red(error instanceof Error ? error.message : String(error)));
234
+ return 1;
235
+ }
236
+ if (parsed.flags.get("edit") === true) {
237
+ const written = writeInEditor(target.notes ?? "");
238
+ if (written === null) {
239
+ console.log(dim("Left as it was."));
240
+ return 0;
241
+ }
242
+ next = written;
243
+ }
244
+ if (next === undefined) {
245
+ /* Nothing to write, so this is a read. */
246
+ const text = (target.notes ?? "").trim();
247
+ if (!text) {
248
+ console.log(dim(`v${target.sequence} has no notes.`) +
249
+ dim(" Write some with --edit, --notes-file or --notes"));
250
+ return 0;
251
+ }
252
+ console.log(bold(`v${target.sequence}${target.name ? ` ${target.name}` : ""}`));
253
+ console.log(text);
254
+ return 0;
255
+ }
256
+ const text = (next ?? "").trim();
257
+ await (0, api_js_1.changeVersion)(project.id, target.id, { notes: text ? next : null });
258
+ console.log(green(text
259
+ ? `Wrote the notes on v${target.sequence}.`
260
+ : `Cleared the notes on v${target.sequence}.`));
261
+ return 0;
262
+ }
115
263
  async function commandVersion(parsed) {
116
264
  const which = split(parsed);
117
265
  const project = await (0, project_commands_js_1.resolveProject)(which.project);
@@ -126,16 +274,27 @@ async function commandVersion(parsed) {
126
274
  patch.name = name;
127
275
  if (parsed.flags.get("unname") === true)
128
276
  patch.name = null;
129
- const notes = parsed.flags.get("notes");
130
- if (typeof notes === "string")
131
- patch.notes = notes;
277
+ try {
278
+ const notes = notesFrom(parsed);
279
+ if (notes !== undefined)
280
+ patch.notes = notes;
281
+ }
282
+ catch (error) {
283
+ console.error(red(error instanceof Error ? error.message : String(error)));
284
+ return 1;
285
+ }
286
+ if (parsed.flags.get("edit") === true) {
287
+ const written = writeInEditor(target.notes ?? "");
288
+ if (written !== null)
289
+ patch.notes = written;
290
+ }
132
291
  if (parsed.flags.get("hide") === true)
133
292
  patch.visibility = "private";
134
293
  if (parsed.flags.get("show") === true)
135
294
  patch.visibility = "public";
136
295
  const visibility = parsed.flags.get("visibility");
137
296
  if (typeof visibility === "string") {
138
- if (!["private", "unlisted", "public"].includes(visibility)) {
297
+ if (!["private", "public"].includes(visibility)) {
139
298
  console.error(red("Visibility is private, unlisted or public."));
140
299
  return 1;
141
300
  }
@@ -229,6 +229,7 @@ class Downloader {
229
229
  ...(row.objectId ? { objectId: String(row.objectId) } : {}),
230
230
  sha256: String(row.sha256 ?? ""),
231
231
  sourceSize: Number(row.sourceSize ?? 0),
232
+ ...(row.sealed === true ? { sealed: true } : {}),
232
233
  ...(packed?.objectId
233
234
  ? {
234
235
  pack: {
@@ -298,7 +299,7 @@ class Downloader {
298
299
  const packed = new Map();
299
300
  for (const file of files) {
300
301
  const id = file.pack?.objectId;
301
- if (!id)
302
+ if (!id || file.sealed)
302
303
  continue;
303
304
  const group = packed.get(id) ?? [];
304
305
  group.push(file);
@@ -323,7 +324,7 @@ class Downloader {
323
324
  for (const file of files) {
324
325
  this.check();
325
326
  progress(file.path);
326
- const packId = file.pack?.objectId;
327
+ const packId = file.sealed ? undefined : file.pack?.objectId;
327
328
  if (packId && restoredPacks.has(packId))
328
329
  continue;
329
330
  if (packId) {
@@ -372,7 +373,14 @@ class Downloader {
372
373
  }
373
374
  async fetchInto(repositoryId, versionId, file, target) {
374
375
  const whole = (0, node_crypto_1.createHash)("sha256");
375
- const pieces = file.chunks ?? [];
376
+ /*
377
+ A sealed file is read whole, by path, whatever shape it is stored in.
378
+
379
+ Its pieces and its object are the covered bytes; only the per-path route
380
+ puts the real value back. Reading it any other way fetches something
381
+ that cannot match the digest this file was listed with.
382
+ */
383
+ const pieces = file.sealed ? [] : (file.chunks ?? []);
376
384
  // Captured so the generators below do not need `this` rebound.
377
385
  const request = (route) => this.request(route);
378
386
  const check = () => this.check();
@@ -399,7 +407,7 @@ class Downloader {
399
407
  }
400
408
  })()
401
409
  : (async function* () {
402
- const reply = await request(file.objectId
410
+ const reply = await request(file.objectId && !file.sealed
403
411
  ? `/v1/repositories/${repositoryId}/objects/${file.objectId}`
404
412
  : `/v1/repositories/${repositoryId}/versions/${versionId}/file` +
405
413
  `?path=${encodeURIComponent(file.path)}`);
@@ -456,7 +464,8 @@ class Downloader {
456
464
  let bytes = 0;
457
465
  const packed = new Map();
458
466
  for (const file of files) {
459
- const id = file.pack?.objectId;
467
+ /* Sealed members never come out of the pack — see `sealed` above. */
468
+ const id = file.sealed ? undefined : file.pack?.objectId;
460
469
  if (!id)
461
470
  continue;
462
471
  const group = packed.get(id) ?? [];
@@ -479,7 +488,7 @@ class Downloader {
479
488
  if (parts.some((part) => part === ".." || part.includes("\0"))) {
480
489
  throw new Error(`That version contains an unsafe path: ${file.path}`);
481
490
  }
482
- const packId = file.pack?.objectId;
491
+ const packId = file.sealed ? undefined : file.pack?.objectId;
483
492
  if (packId && restoredPacks.has(packId))
484
493
  continue;
485
494
  if (packId) {
@@ -66,6 +66,24 @@ class Tracks {
66
66
  const body = await this.call(`/v1/repositories/${repositoryId}/versions`);
67
67
  return body.versions ?? [];
68
68
  }
69
+ /**
70
+ * What the account says about this project, as distinct from this folder.
71
+ *
72
+ * The local record knows where a folder saves to and nothing about how the
73
+ * project is published — so the history window could show which saves were
74
+ * public without being able to say whether the project itself was, which is
75
+ * the half that decides whether anybody can reach them.
76
+ */
77
+ async project(repositoryId) {
78
+ try {
79
+ const body = await this.call(`/v1/repositories/${repositoryId}`);
80
+ return { visibility: body.visibility ?? null };
81
+ }
82
+ catch {
83
+ /* Not knowing is not worth failing the window for. */
84
+ return { visibility: null };
85
+ }
86
+ }
69
87
  async labels(repositoryId) {
70
88
  const body = await this.call(`/v1/repositories/${repositoryId}/labels`);
71
89
  return body.labels ?? [];
@@ -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.27.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",