@coderook/cli 0.8.0 → 0.10.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.
@@ -16,9 +16,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
16
16
  * version, submit it.
17
17
  */
18
18
  const promises_1 = require("node:readline/promises");
19
+ const node_os_1 = __importDefault(require("node:os"));
19
20
  const node_path_1 = __importDefault(require("node:path"));
20
21
  const node_process_1 = __importDefault(require("node:process"));
21
22
  const api_js_1 = require("./api.js");
23
+ const registry_js_1 = require("./registry.js");
24
+ const help_js_1 = require("./help.js");
25
+ const project_commands_js_1 = require("./project_commands.js");
26
+ const service_commands_js_1 = require("./service_commands.js");
22
27
  const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
23
28
  const upload_js_1 = require("../../desktop-app/src/main/upload.js");
24
29
  const download_js_1 = require("../../desktop-app/src/main/download.js");
@@ -26,6 +31,7 @@ const faults_js_1 = require("../../desktop-app/src/main/faults.js");
26
31
  const identify_js_1 = require("../../desktop-app/src/main/identify.js");
27
32
  const cbx_js_1 = require("../../desktop-app/src/main/cbx.js");
28
33
  const api_js_2 = require("./api.js");
34
+ const runner_js_1 = require("./runner.js");
29
35
  const config_js_1 = require("./config.js");
30
36
  /*
31
37
  Read from the package rather than written twice. A hardcoded copy had
@@ -52,6 +58,7 @@ const dim = (text) => (colour ? `${text}` : text);
52
58
  const bold = (text) => (colour ? `${text}` : text);
53
59
  const accent = (text) => (colour ? `${text}` : text);
54
60
  const red = (text) => (colour ? `${text}` : text);
61
+ const green = (text) => (colour ? `${text}` : text);
55
62
  function bytes(value) {
56
63
  if (value >= 1024 ** 3)
57
64
  return `${(value / 1024 ** 3).toFixed(2)} GB`;
@@ -767,42 +774,6 @@ async function commandDoctor() {
767
774
  }
768
775
  return 0;
769
776
  }
770
- const USAGE = `${bold("coderook")} — CodeRook from the command line
771
-
772
- ${bold("Getting started")}
773
- coderook sign-in Store a personal access token
774
- coderook whoami Who this machine is signed in as
775
- coderook projects Every project on your account
776
-
777
- ${bold("Working with a folder")}
778
- coderook status [folder] What is here that is not saved yet
779
- coderook submit [folder] -m "…" Send the changes as a new version
780
- coderook get [folder] Bring this folder up to date
781
- (--replace for an exact copy)
782
- coderook clone <project> [dir] Fetch a project into a new folder
783
- coderook rules [folder] Show the ignore rules (--init to start one)
784
-
785
- ${bold("When somebody saved first")}
786
- coderook merges Uploads of yours waiting on a decision
787
- coderook merge <ref> Look at one (--mine --theirs --both --drop)
788
-
789
- ${bold("Bundles")}
790
- coderook bundle [folder] [out] Pack the project as a .cbx
791
- coderook unbundle <file> [dir] Extract a .cbx
792
- coderook inspect <file> What a .cbx contains (--files to list them)
793
-
794
- ${bold("Other")}
795
- coderook doctor Check the service, config and sign-in
796
- coderook sign-out Forget the token on this machine
797
-
798
- ${bold("Options")}
799
- -m, --message The version message for submit
800
- -n, --dry-run Show what submit would send, without sending
801
- --allow-secrets Send files that look like credentials anyway
802
- --token Supply the token to sign-in instead of being asked
803
-
804
- ${dim("The environment variable CODEROOK_TOKEN is used when set, so automated")}
805
- ${dim("runs need nothing on disk. CODEROOK_API_URL points at another service.")}`;
806
777
  /**
807
778
  * Everything waiting on a decision, for one folder's project.
808
779
  *
@@ -924,43 +895,439 @@ Run coderook merge ${now.mergeTrack.reference} --apply to publish it.`));
924
895
  }
925
896
  return 0;
926
897
  }
927
- const COMMANDS = {
928
- "sign-in": commandSignIn,
929
- login: commandSignIn,
930
- "sign-out": commandSignOut,
931
- logout: commandSignOut,
932
- whoami: () => commandWhoami(),
933
- projects: () => commandProjects(),
934
- status: commandStatus,
935
- submit: commandSubmit,
936
- publish: commandSubmit,
937
- get: commandGet,
938
- clone: commandClone,
939
- rules: commandRules,
940
- bundle: commandBundle,
941
- unbundle: commandUnbundle,
942
- inspect: commandInspect,
943
- merges: commandMerges,
944
- merge: commandMerge,
945
- doctor: () => commandDoctor(),
946
- };
898
+ /**
899
+ * Do this project's work on this machine.
900
+ *
901
+ * The other half of Actions. CodeRook writes down that a run is wanted; this
902
+ * asks whether there is one, fetches the version into a throwaway folder,
903
+ * runs the workflow's command there, sends the output up as it goes, and
904
+ * reports the verdict.
905
+ *
906
+ * The trust boundary is said out loud rather than buried in documentation,
907
+ * because it is the thing somebody should decide on purpose: anybody who can
908
+ * set a workflow's command on this project can run that command on this
909
+ * machine, as whoever started this.
910
+ */
911
+ async function commandRunner(parsed) {
912
+ const reference = parsed.positional[0] ?? flagText(parsed, "project", "p");
913
+ if (!reference) {
914
+ console.error(red("Which project? coderook runner <project>"));
915
+ return 1;
916
+ }
917
+ const project = await (0, api_js_2.findProject)(reference);
918
+ if (!project) {
919
+ console.error(red(`No project of yours matches "${reference}".`));
920
+ return 1;
921
+ }
922
+ const name = flagText(parsed, "name") ?? node_os_1.default.hostname();
923
+ /*
924
+ What this machine will answer to. Its own platform unless told otherwise,
925
+ because that is what somebody means when they write a workflow that has to
926
+ produce a Windows installer.
927
+ */
928
+ const labels = (flagText(parsed, "labels", "label") ?? "")
929
+ .split(",")
930
+ .map((one) => one.trim())
931
+ .filter(Boolean);
932
+ const answersTo = labels.length ? labels : (0, runner_js_1.defaultLabels)();
933
+ const once = hasFlag(parsed, "once");
934
+ const every = Math.min(Math.max(Number(flagText(parsed, "poll") ?? 5), 1), 300);
935
+ console.log(`${bold("CodeRook runner")} ${dim(`· ${name}`)}`);
936
+ console.log(`Taking work for ${accent(project.slug)} ` +
937
+ dim(`· answering to ${answersTo.join(", ")}`));
938
+ /*
939
+ Printed every time rather than once on first use. Somebody who left this
940
+ running in a terminal a fortnight ago should be able to look at it and
941
+ see what it is permitted to do, without going to find the manual.
942
+ */
943
+ console.log(dim("Anyone who can set a workflow's command on this project can run it " +
944
+ "here, as you."));
945
+ /*
946
+ Said separately because it is the part people get wrong. The account token
947
+ is kept out of the command's environment, but a command runs as this user
948
+ and this user can read the token file — so the honest advice is a token
949
+ made for the runner, which can be revoked without taking the desktop
950
+ application and the command line down with it.
951
+ */
952
+ console.log(dim(`A command here runs as you and can read ${(0, config_js_1.configDirectory)()}. ` +
953
+ "Use a token minted for this machine, not your everyday one."));
954
+ console.log(dim("Stop with Ctrl-C."));
955
+ if (once)
956
+ console.log(dim("Taking one job, then stopping."));
957
+ console.log("");
958
+ let stopping = false;
959
+ node_process_1.default.on("SIGINT", () => {
960
+ if (stopping)
961
+ node_process_1.default.exit(130);
962
+ stopping = true;
963
+ console.log("");
964
+ console.log(dim("Finishing the current job, then stopping."));
965
+ });
966
+ for (;;) {
967
+ let job = null;
968
+ try {
969
+ job = await (0, runner_js_1.claim)(project.id, name, VERSION, answersTo);
970
+ }
971
+ catch (error) {
972
+ /*
973
+ Kept going rather than exiting. A runner is meant to be left alone,
974
+ and the network being briefly unavailable is not a reason to need
975
+ somebody to come back and start it again.
976
+ */
977
+ console.error(red(error instanceof Error ? error.message : String(error)));
978
+ if (once)
979
+ return 1;
980
+ await new Promise((wake) => setTimeout(wake, every * 1000));
981
+ continue;
982
+ }
983
+ if (!job) {
984
+ if (once || stopping) {
985
+ if (once)
986
+ console.log(dim("Nothing waiting."));
987
+ return 0;
988
+ }
989
+ await new Promise((wake) => setTimeout(wake, every * 1000));
990
+ continue;
991
+ }
992
+ const label = `#${job.number} ${job.workflowName}`;
993
+ console.log(`${accent("▶")} ${label}${job.attempts > 1 ? dim(` (attempt ${job.attempts})`) : ""}`);
994
+ const began = Date.now();
995
+ const verdict = await (0, runner_js_1.performRun)(project.id, job, name);
996
+ try {
997
+ await (0, runner_js_1.report)(project.id, job.runId, verdict, Date.now() - began);
998
+ }
999
+ catch (error) {
1000
+ console.error(red(`Could not report ${label}: ` +
1001
+ (error instanceof Error ? error.message : String(error))));
1002
+ }
1003
+ console.log(verdict.status === "passed"
1004
+ ? `${green("✓")} ${label} — ${verdict.summary}`
1005
+ : `${red("×")} ${label} — ${verdict.summary}`);
1006
+ if (once || stopping)
1007
+ return verdict.status === "passed" ? 0 : 1;
1008
+ }
1009
+ }
1010
+ /*
1011
+ The commands, described where they are defined.
1012
+
1013
+ This replaced a plain name-to-function map sitting beside a hand-written
1014
+ usage string. The two drifted, as they always do — the help is now generated
1015
+ from these rows, so a command that exists is documented and a command that is
1016
+ documented exists.
1017
+ */
1018
+ const SPECS = [
1019
+ {
1020
+ name: "sign-in",
1021
+ aliases: ["login"],
1022
+ group: "Getting started",
1023
+ summary: "store a personal access token on this machine",
1024
+ usage: "sign-in",
1025
+ detail: "Asks for a personal access token and saves it. Create one in your\n" +
1026
+ "account settings on the website. The token is stored in your user\n" +
1027
+ "configuration, not in the project folder, so it never lands in a\n" +
1028
+ "version by accident.",
1029
+ options: [
1030
+ { flags: "--token <value>", description: "supply it instead of being asked" },
1031
+ ],
1032
+ run: commandSignIn,
1033
+ },
1034
+ {
1035
+ name: "whoami",
1036
+ group: "Getting started",
1037
+ summary: "who this machine is signed in as",
1038
+ usage: "whoami",
1039
+ run: () => commandWhoami(),
1040
+ },
1041
+ {
1042
+ name: "sign-out",
1043
+ aliases: ["logout"],
1044
+ group: "Getting started",
1045
+ summary: "forget the token on this machine",
1046
+ usage: "sign-out",
1047
+ run: commandSignOut,
1048
+ },
1049
+ {
1050
+ name: "status",
1051
+ group: "Working with a folder",
1052
+ summary: "what is here that is not saved yet",
1053
+ usage: "status [folder]",
1054
+ detail: "Compares the folder against the last version you sent and lists what\n" +
1055
+ "changed. Says nothing about other people's work — use `merges` for that.",
1056
+ run: commandStatus,
1057
+ },
1058
+ {
1059
+ name: "submit",
1060
+ aliases: ["publish"],
1061
+ group: "Working with a folder",
1062
+ summary: "send the changes as a new version",
1063
+ usage: 'submit [folder] -m "…"',
1064
+ detail: "Sends everything that changed since the last version. If the folder is\n" +
1065
+ "not linked to a project yet, one is created on your account, named\n" +
1066
+ "after the folder and private to begin with.",
1067
+ options: [
1068
+ { flags: "-m, --message <text>", description: "what changed, in a sentence" },
1069
+ { flags: "-n, --dry-run", description: "show what would be sent, send nothing" },
1070
+ { flags: "--allow-secrets", description: "send files that look like credentials" },
1071
+ ],
1072
+ examples: ['coderook submit -m "Fix the export dialog"'],
1073
+ run: commandSubmit,
1074
+ },
1075
+ {
1076
+ name: "get",
1077
+ group: "Working with a folder",
1078
+ summary: "bring this folder up to date",
1079
+ usage: "get [folder]",
1080
+ options: [
1081
+ { flags: "--replace", description: "make it an exact copy, discarding local changes" },
1082
+ ],
1083
+ run: commandGet,
1084
+ },
1085
+ {
1086
+ name: "clone",
1087
+ group: "Working with a folder",
1088
+ summary: "fetch a project into a new folder",
1089
+ usage: "clone <project> [dir]",
1090
+ run: commandClone,
1091
+ },
1092
+ {
1093
+ name: "ignore",
1094
+ aliases: ["rules"],
1095
+ group: "Working with a folder",
1096
+ summary: "show the ignore rules for this folder",
1097
+ usage: "ignore [folder]",
1098
+ detail: "The local list of things not to send — build output, dependencies,\n" +
1099
+ "anything private. Unrelated to a project's rules on the service, which\n" +
1100
+ "are about who may do what.",
1101
+ options: [{ flags: "--init", description: "write a starting set of rules" }],
1102
+ run: commandRules,
1103
+ },
1104
+ {
1105
+ name: "projects",
1106
+ group: "Your projects",
1107
+ summary: "every project on your account",
1108
+ usage: "projects",
1109
+ run: () => commandProjects(),
1110
+ },
1111
+ {
1112
+ name: "versions",
1113
+ group: "Your projects",
1114
+ summary: "what has been saved to a project",
1115
+ usage: "versions [project]",
1116
+ detail: "Newest first. Run it inside a linked folder to leave the name out.",
1117
+ options: [{ flags: "--limit <n>", description: "how many to show (default 20)" }],
1118
+ run: project_commands_js_1.commandVersions,
1119
+ },
1120
+ {
1121
+ name: "ai",
1122
+ group: "Your projects",
1123
+ summary: "what this project says about machines",
1124
+ usage: "ai [project] [--read off]",
1125
+ detail: "Shows the four answers a project gives about automated clients, and\n" +
1126
+ "changes them. Everything is allowed until you say otherwise.\n\n" +
1127
+ "Turning reading off is the one that refuses model training: it declares\n" +
1128
+ "the project as not-for-training in the page, in the response headers,\n" +
1129
+ "and in the service's robots.txt. Turning downloads off does not — those\n" +
1130
+ "are separate questions and answering one says nothing about the other.",
1131
+ options: [
1132
+ { flags: "--read [off]", description: "reading, indexing, summarising, training" },
1133
+ { flags: "--download [off]", description: "taking the files" },
1134
+ { flags: "--contribute [off]", description: "automated contributions" },
1135
+ { flags: "--request [off]", description: "automated calls to its endpoints" },
1136
+ ],
1137
+ examples: [
1138
+ "coderook ai my-project",
1139
+ "coderook ai my-project --read off",
1140
+ "coderook ai my-project --download off --request off",
1141
+ ],
1142
+ run: project_commands_js_1.commandAi,
1143
+ },
1144
+ {
1145
+ name: "issues",
1146
+ group: "Your projects",
1147
+ summary: "issues on a project, or open one",
1148
+ usage: "issues [project]",
1149
+ options: [
1150
+ { flags: '--new "<title>"', description: "open a new issue" },
1151
+ { flags: "--body <text>", description: "the description for a new one" },
1152
+ ],
1153
+ examples: ['coderook issues my-project --new "Crash on export"'],
1154
+ run: service_commands_js_1.commandIssues,
1155
+ },
1156
+ {
1157
+ name: "releases",
1158
+ group: "Your projects",
1159
+ summary: "what has been released, and its files",
1160
+ usage: "releases [project]",
1161
+ run: service_commands_js_1.commandReleases,
1162
+ },
1163
+ {
1164
+ name: "actions",
1165
+ aliases: ["workflows"],
1166
+ group: "Your projects",
1167
+ summary: "the automations a project has",
1168
+ usage: "actions [project]",
1169
+ detail: "Shows what each action runs, what it runs on, and how many of its\n" +
1170
+ "runs have passed. Use `coderook runner` to execute them on this machine.",
1171
+ run: service_commands_js_1.commandWorkflows,
1172
+ },
1173
+ {
1174
+ name: "delete",
1175
+ group: "Your projects",
1176
+ summary: "delete a project",
1177
+ usage: "delete [project]",
1178
+ detail: "Asks for the project name to be typed before it does anything. The\n" +
1179
+ "service keeps deleted projects for 30 days before reclaiming the\n" +
1180
+ "storage, so a mistake made today is recoverable this month.",
1181
+ options: [{ flags: "--yes", description: "skip the confirmation" }],
1182
+ run: service_commands_js_1.commandDelete,
1183
+ },
1184
+ {
1185
+ name: "people",
1186
+ aliases: ["collaborators"],
1187
+ group: "People",
1188
+ summary: "who can reach a project, and invite somebody",
1189
+ usage: "people [project]",
1190
+ options: [
1191
+ { flags: "--invite <email>", description: "ask somebody to join" },
1192
+ { flags: "--role <role>", description: "what they may do (default member)" },
1193
+ ],
1194
+ examples: ["coderook people my-project --invite sam@example.com"],
1195
+ run: service_commands_js_1.commandCollaborators,
1196
+ },
1197
+ {
1198
+ name: "watch",
1199
+ group: "People",
1200
+ summary: "what reaches you about a project",
1201
+ usage: "watch [project] [--level versions]",
1202
+ options: [
1203
+ {
1204
+ flags: "--level <level>",
1205
+ description: "everything, versions, issues, releases, ignore",
1206
+ },
1207
+ { flags: "--email [off]", description: "include it in the daily email" },
1208
+ ],
1209
+ run: service_commands_js_1.commandWatch,
1210
+ },
1211
+ {
1212
+ name: "tokens",
1213
+ group: "Other",
1214
+ summary: "the tokens that can act as your account",
1215
+ usage: "tokens",
1216
+ detail: "Lists personal access tokens with when each was last used, which is\n" +
1217
+ "how you find one that is no longer needed. Create new ones on the\n" +
1218
+ "website; they are only shown once.",
1219
+ options: [{ flags: "--revoke <id>", description: "stop one working now" }],
1220
+ run: service_commands_js_1.commandTokens,
1221
+ },
1222
+ {
1223
+ name: "merges",
1224
+ group: "When somebody saved first",
1225
+ summary: "uploads of yours waiting on a decision",
1226
+ usage: "merges",
1227
+ run: commandMerges,
1228
+ },
1229
+ {
1230
+ name: "merge",
1231
+ group: "When somebody saved first",
1232
+ summary: "look at one, and decide",
1233
+ usage: "merge <ref>",
1234
+ options: [
1235
+ { flags: "--mine", description: "keep your side" },
1236
+ { flags: "--theirs", description: "keep theirs" },
1237
+ { flags: "--both", description: "keep both" },
1238
+ { flags: "--drop", description: "abandon the upload" },
1239
+ ],
1240
+ run: commandMerge,
1241
+ },
1242
+ {
1243
+ name: "bundle",
1244
+ group: "Bundles",
1245
+ summary: "pack the project as a .cbx",
1246
+ usage: "bundle [folder] [out]",
1247
+ run: commandBundle,
1248
+ },
1249
+ {
1250
+ name: "unbundle",
1251
+ group: "Bundles",
1252
+ summary: "extract a .cbx",
1253
+ usage: "unbundle <file> [dir]",
1254
+ run: commandUnbundle,
1255
+ },
1256
+ {
1257
+ name: "inspect",
1258
+ group: "Bundles",
1259
+ summary: "what a .cbx contains",
1260
+ usage: "inspect <file>",
1261
+ options: [{ flags: "--files", description: "list every file inside" }],
1262
+ run: commandInspect,
1263
+ },
1264
+ {
1265
+ name: "runner",
1266
+ group: "Actions",
1267
+ summary: "take this project's runs and do them here",
1268
+ usage: "runner <project>",
1269
+ detail: "Claims queued runs for a project and executes them on this machine.\n" +
1270
+ "Credentials in your environment are not passed to the commands it runs.",
1271
+ options: [
1272
+ { flags: "--once", description: "do one job and stop" },
1273
+ { flags: "--name <label>", description: "how this machine is listed" },
1274
+ { flags: "--labels <a,b>", description: "what kinds of run it answers to" },
1275
+ { flags: "--poll <seconds>", description: "how long between asks" },
1276
+ ],
1277
+ examples: ["coderook runner my-game --labels windows,signing"],
1278
+ run: commandRunner,
1279
+ },
1280
+ {
1281
+ name: "doctor",
1282
+ group: "Other",
1283
+ summary: "check the service, config and sign-in",
1284
+ usage: "doctor",
1285
+ run: () => commandDoctor(),
1286
+ },
1287
+ ];
1288
+ const REGISTRY = (0, registry_js_1.buildRegistry)(SPECS);
947
1289
  async function main(argv) {
948
1290
  const [name, ...rest] = argv;
949
- if (!name || name === "help" || name === "--help" || name === "-h") {
950
- console.log(USAGE);
1291
+ if (!name || name === "--help" || name === "-h") {
1292
+ console.log((0, help_js_1.renderHelp)(REGISTRY, VERSION));
951
1293
  return 0;
952
1294
  }
953
1295
  if (name === "--version" || name === "-v" || name === "version") {
954
1296
  console.log(VERSION);
955
1297
  return 0;
956
1298
  }
957
- const command = COMMANDS[name];
958
- if (!command) {
959
- console.error(red(`Unknown command: ${name}`));
960
- console.error(dim("Run coderook help to see what there is."));
1299
+ /*
1300
+ `coderook help submit` and `coderook submit --help` reach the same page.
1301
+ People reach for both, and one of them silently doing something else is
1302
+ the kind of small betrayal that makes a tool feel unreliable.
1303
+ */
1304
+ if (name === "help") {
1305
+ const wanted = rest[0];
1306
+ if (!wanted) {
1307
+ console.log((0, help_js_1.renderHelp)(REGISTRY, VERSION));
1308
+ return 0;
1309
+ }
1310
+ const spec = REGISTRY.lookup.get(wanted);
1311
+ if (!spec) {
1312
+ console.error((0, help_js_1.renderUnknown)(wanted, (0, registry_js_1.nearestCommand)(REGISTRY, wanted)));
1313
+ return 1;
1314
+ }
1315
+ console.log((0, help_js_1.renderCommandHelp)(spec));
1316
+ return 0;
1317
+ }
1318
+ const spec = REGISTRY.lookup.get(name);
1319
+ if (!spec) {
1320
+ console.error((0, help_js_1.renderUnknown)(name, (0, registry_js_1.nearestCommand)(REGISTRY, name)));
961
1321
  return 1;
962
1322
  }
963
- return command(parse(rest));
1323
+ if (rest.includes("--help") || rest.includes("-h")) {
1324
+ console.log((0, help_js_1.renderCommandHelp)(spec));
1325
+ return 0;
1326
+ }
1327
+ if (spec.deprecatedBy) {
1328
+ console.error(dim(`"${name}" is now "${spec.deprecatedBy}". The old name still works.`));
1329
+ }
1330
+ return spec.run(parse(rest));
964
1331
  }
965
1332
  /**
966
1333
  * Set the status and let Node wind down on its own.
@@ -42,15 +42,44 @@ function configDirectory() {
42
42
  }
43
43
  const tokenFile = () => node_path_1.default.join(configDirectory(), "token");
44
44
  const linksFile = () => node_path_1.default.join(configDirectory(), "links.json");
45
- /** Write a file only its owner can read, and atomically. */
45
+ /**
46
+ * Write a file only its owner can read, and atomically.
47
+ *
48
+ * The temporary name carries this process's own identity. Sharing one — as
49
+ * `${target}.tmp` did — means two runs at once write the same scratch file
50
+ * and then race to move it, which on Windows fails outright and elsewhere
51
+ * quietly hands one run the other's bytes.
52
+ *
53
+ * The move is retried briefly because Windows refuses a rename while another
54
+ * process still has the destination open, which during a race is ordinary
55
+ * rather than exceptional.
56
+ */
46
57
  async function writePrivate(target, body) {
47
58
  await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
48
- const temporary = `${target}.tmp`;
59
+ const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
49
60
  await (0, promises_1.writeFile)(temporary, body, "utf8");
50
61
  // Windows ignores the mode; on everything else this is what keeps the
51
62
  // token out of other accounts' reach.
52
63
  await (0, promises_1.chmod)(temporary, 0o600).catch(() => undefined);
53
- await (0, promises_1.rename)(temporary, target);
64
+ try {
65
+ for (let attempt = 0;; attempt += 1) {
66
+ try {
67
+ await (0, promises_1.rename)(temporary, target);
68
+ return;
69
+ }
70
+ catch (error) {
71
+ const code = error.code;
72
+ if (attempt >= 20 || (code !== "EPERM" && code !== "EACCES" && code !== "EBUSY")) {
73
+ throw error;
74
+ }
75
+ await new Promise((wake) => setTimeout(wake, 10 + attempt * 5));
76
+ }
77
+ }
78
+ }
79
+ finally {
80
+ // A failed move must not leave scratch files accumulating beside it.
81
+ await (0, promises_1.rm)(temporary, { force: true }).catch(() => undefined);
82
+ }
54
83
  }
55
84
  async function storeToken(token) {
56
85
  await writePrivate(tokenFile(), `${token.trim()}\n`);
@@ -0,0 +1,88 @@
1
+ "use strict";
2
+ /**
3
+ * What `coderook help` prints.
4
+ *
5
+ * Generated from the registry rather than written out, so a command that
6
+ * exists is listed and a command that is listed exists. The previous usage
7
+ * text was maintained by hand next to the dispatch table, which is the
8
+ * arrangement where the two drift and nobody finds out until somebody follows
9
+ * the help and it does not work.
10
+ *
11
+ * Two levels, because they answer different questions. The list answers "what
12
+ * can this thing do", and is scanned rather than read — so it is one line per
13
+ * command, aligned, grouped by what somebody is trying to achieve. The detail
14
+ * answers "how exactly do I use this one", and is read properly, so it can
15
+ * afford whole sentences and examples.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.renderHelp = renderHelp;
19
+ exports.renderCommandHelp = renderCommandHelp;
20
+ exports.renderUnknown = renderUnknown;
21
+ const registry_js_1 = require("./registry.js");
22
+ const bold = (value) => `${value}`;
23
+ const dim = (value) => `${value}`;
24
+ const accent = (value) => `${value}`;
25
+ /** Longest visible name, so the summary column lines up across every group. */
26
+ function nameColumn(specs) {
27
+ return specs.reduce((widest, spec) => Math.max(widest, spec.usage.length), 0);
28
+ }
29
+ function renderHelp(registry, version) {
30
+ const listed = registry.specs.filter((spec) => !spec.deprecatedBy);
31
+ const column = Math.min(nameColumn(listed), 34);
32
+ const lines = [
33
+ `${bold("coderook")} ${dim(version)} — CodeRook from the command line`,
34
+ "",
35
+ ];
36
+ for (const group of registry_js_1.GROUP_ORDER) {
37
+ const inGroup = listed.filter((spec) => spec.group === group);
38
+ if (!inGroup.length)
39
+ continue;
40
+ lines.push(bold(group));
41
+ for (const spec of inGroup) {
42
+ lines.push(` ${spec.usage.padEnd(column)} ${spec.summary}`);
43
+ }
44
+ lines.push("");
45
+ }
46
+ lines.push(dim("coderook help <command> what one command does, in full"), dim("CODEROOK_TOKEN is used when set, so automated runs need nothing on disk."), dim("CODEROOK_API_URL points at another service."));
47
+ return lines.join("\n");
48
+ }
49
+ function renderCommandHelp(spec) {
50
+ const lines = [
51
+ `${bold("coderook " + spec.name)} — ${spec.summary}`,
52
+ "",
53
+ bold("Usage"),
54
+ ` coderook ${spec.usage}`,
55
+ ];
56
+ if (spec.aliases?.length) {
57
+ lines.push("", bold("Also"), ` ${spec.aliases.map((alias) => `coderook ${alias}`).join(" ")}`);
58
+ }
59
+ if (spec.detail) {
60
+ lines.push("", spec.detail.trim());
61
+ }
62
+ if (spec.options?.length) {
63
+ const width = spec.options.reduce((widest, option) => Math.max(widest, option.flags.length), 0);
64
+ lines.push("", bold("Options"));
65
+ for (const option of spec.options) {
66
+ lines.push(` ${option.flags.padEnd(width)} ${option.description}`);
67
+ }
68
+ }
69
+ if (spec.examples?.length) {
70
+ lines.push("", bold("Examples"));
71
+ for (const example of spec.examples)
72
+ lines.push(` ${accent(example)}`);
73
+ }
74
+ return lines.join("\n");
75
+ }
76
+ /**
77
+ * What to print when somebody asks for help on something that is not a command.
78
+ *
79
+ * Suggests a near match where there is one, and otherwise points at the list —
80
+ * a confident wrong suggestion sends somebody off to read the wrong page.
81
+ */
82
+ function renderUnknown(typed, suggestion) {
83
+ const lines = [`Unknown command: ${typed}`];
84
+ if (suggestion)
85
+ lines.push(`Did you mean ${accent("coderook " + suggestion)}?`);
86
+ lines.push(dim("Run coderook help to see everything."));
87
+ return lines.join("\n");
88
+ }