@hardfin/cli 0.1.0-dev.22 → 0.1.0-dev.24

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.
Files changed (3) hide show
  1. package/README.md +29 -5
  2. package/dist/cli.js +605 -150
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -224,13 +224,37 @@ environment, so `hardfin login` works out of the box.
224
224
  ## Completing commands in your shell
225
225
 
226
226
  ```sh
227
- source <(hardfin completion zsh) # this shell, now
228
- hardfin completion zsh >> ~/.zshrc # every shell after this one
227
+ hardfin completion install # every shell after this one
229
228
  ```
230
229
 
231
- `bash`, `zsh`, `fish` and `powershell` each get a script. The script asks the CLI what may
232
- follow what you have typed, so completions never fall behind the commands, including the ones
233
- generated from the API document.
230
+ `install` finds your shell from `$SHELL`, shows what it will change, and asks before changing
231
+ it. Name the shell to install for another one, and pass `--yes` to skip the question.
232
+
233
+ | Shell | The script is written to | And loaded from |
234
+ | ---------- | ----------------------------------------- | ------------------------------------------ |
235
+ | bash | `~/.local/share/hardfin/completion.bash` | `~/.bashrc`, or `~/.bash_profile` on macOS |
236
+ | zsh | `~/.local/share/hardfin/completion.zsh` | `~/.zshrc` |
237
+ | fish | `~/.config/fish/completions/hardfin.fish` | fish, by itself |
238
+ | PowerShell | `~/.local/share/hardfin/completion.ps1` | the profile PowerShell reports |
239
+
240
+ The lines added to a startup file sit between `# >>> hardfin completion >>>` and
241
+ `# <<< hardfin completion <<<`. Installing again replaces them, and
242
+ `hardfin completion uninstall` removes them with the script. Install again after upgrading, so
243
+ the script matches the CLI.
244
+
245
+ To complete in the current shell without installing anything:
246
+
247
+ ```sh
248
+ source <(hardfin completion zsh)
249
+ ```
250
+
251
+ The script asks the CLI what may follow what you have typed, so completions never fall behind
252
+ the commands, including the ones generated from the API document. It offers commands, flags,
253
+ the values a flag accepts, and file paths where a flag takes one. zsh, fish and PowerShell
254
+ show a description beside each.
255
+
256
+ The script calls `hardfin` by name, so it needs `hardfin` on your `PATH`. Running
257
+ `node dist/cli.js` from a checkout leaves nothing for it to call, and `npm link` fixes that.
234
258
 
235
259
  ## Serving an agent
236
260
 
package/dist/cli.js CHANGED
@@ -2,13 +2,14 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { Command, Option } from "commander";
4
4
  import { z } from "zod";
5
- import { chmodSync, closeSync, existsSync, mkdirSync, openAsBlob, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
6
- import { basename, dirname, join, resolve } from "node:path";
5
+ import { accessSync, chmodSync, closeSync, constants, existsSync, mkdirSync, openAsBlob, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
6
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
7
7
  import { createHash, randomBytes, randomUUID } from "node:crypto";
8
8
  import { arch, cpus, homedir, release, totalmem, type, version } from "node:os";
9
9
  import { spawn, spawnSync } from "node:child_process";
10
+ import { createInterface } from "node:readline/promises";
10
11
  import { createServer } from "node:http";
11
- import { createInterface } from "node:readline";
12
+ import { createInterface as createInterface$1 } from "node:readline";
12
13
  //#region src/command/registry.ts
13
14
  /** ExitCode is what the process returns, and what an agent branches on. */
14
15
  const ExitCode = {
@@ -21,6 +22,18 @@ const ExitCode = {
21
22
  function defineCommand(command) {
22
23
  return command;
23
24
  }
25
+ /** GLOBAL_FLAGS are the options the CLI takes before any command. */
26
+ const GLOBAL_FLAGS = [{
27
+ name: "api-url",
28
+ description: "The API to call, whose host also holds the authorization server",
29
+ valueName: "url",
30
+ schema: z.string()
31
+ }, {
32
+ name: "issuer-url",
33
+ description: "The authorization server, when it does not sit at the API's host",
34
+ valueName: "url",
35
+ schema: z.string()
36
+ }];
24
37
  //#endregion
25
38
  //#region src/config/settings.ts
26
39
  /** The API version this build was written against, sent on every request. */
@@ -956,104 +969,54 @@ function toBody$1(source) {
956
969
  }
957
970
  }
958
971
  //#endregion
959
- //#region src/command/completion.ts
972
+ //#region src/auth/browser.ts
973
+ /** isWSL reports whether this Linux is running under Windows. */
974
+ function isWSL() {
975
+ return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
976
+ }
977
+ /**
978
+ * toOpeners lists the ways to open a URL here, best first. WSL needs Windows to do it,
979
+ * because the browser the person is looking at is the Windows one, and xdg-open reaches
980
+ * nothing outside the distribution.
981
+ */
982
+ function toOpeners(url) {
983
+ if (process.platform === "darwin") return [["open", [url]]];
984
+ if (process.platform === "win32") return [["cmd", [
985
+ "/c",
986
+ "start",
987
+ "",
988
+ url
989
+ ]]];
990
+ if (isWSL()) return [
991
+ ["wslview", [url]],
992
+ ["powershell.exe", [
993
+ "-NoProfile",
994
+ "-NonInteractive",
995
+ "-Command",
996
+ "Start-Process",
997
+ `'${url}'`
998
+ ]],
999
+ ["explorer.exe", [url]]
1000
+ ];
1001
+ return [["xdg-open", [url]]];
1002
+ }
1003
+ /** openBrowser asks the desktop to show a URL, and reports whether anything took it. */
1004
+ function openBrowser(url, launch = spawnSync) {
1005
+ for (const [command, args] of toOpeners(url)) if (launch(command, args, {
1006
+ stdio: "ignore",
1007
+ cwd: command.endsWith(".exe") ? "/mnt/c" : void 0,
1008
+ timeout: 1e4
1009
+ }).error === void 0) return true;
1010
+ return false;
1011
+ }
1012
+ //#endregion
1013
+ //#region src/command/completion-scripts.ts
960
1014
  const SHELLS = [
961
1015
  "bash",
962
1016
  "zsh",
963
1017
  "fish",
964
1018
  "powershell"
965
1019
  ];
966
- const completionCommand = defineCommand({
967
- name: "completion",
968
- summary: "Print the shell script that completes hardfin commands",
969
- description: "Writes a script for your shell. The script asks this CLI what may follow what you have typed, so completions never fall behind the commands.",
970
- arguments: [{
971
- name: "shell",
972
- description: `The shell to write for: ${SHELLS.join(", ")}`,
973
- required: true
974
- }],
975
- flags: [],
976
- examples: [
977
- {
978
- description: "Complete in this shell, now",
979
- command: "source <(hardfin completion zsh)"
980
- },
981
- {
982
- description: "Complete in every new shell",
983
- command: "hardfin completion zsh > ~/.hardfin-completion.zsh"
984
- },
985
- {
986
- description: "Complete in bash",
987
- command: "source <(hardfin completion bash)"
988
- }
989
- ],
990
- run: runCompletion
991
- });
992
- /** The hidden command a completion script asks, which keeps one implementation for every shell. */
993
- const completeCommand = defineCommand({
994
- name: "__complete",
995
- summary: "Answer what may follow the words typed so far",
996
- hidden: true,
997
- arguments: [{
998
- name: "words",
999
- description: "The words typed so far",
1000
- required: false,
1001
- variadic: true
1002
- }],
1003
- flags: [{
1004
- name: "json",
1005
- description: "Accepted for consistency, and ignored",
1006
- schema: z.boolean()
1007
- }],
1008
- examples: [],
1009
- run: async (input) => {
1010
- writeData(toCandidates(input.commands, input.args).join("\n"));
1011
- return ExitCode.OK;
1012
- }
1013
- });
1014
- /**
1015
- * toCandidates answers what may follow the words typed so far. A word starting with a dash
1016
- * asks for the current command's flags, and anything else asks for its subcommands.
1017
- */
1018
- function toCandidates(commands, words) {
1019
- const partial = words[words.length - 1] ?? "";
1020
- const walked = toWalked(commands, words.slice(0, -1));
1021
- if (partial.startsWith("-")) return toFlagNames(walked.command).filter((name) => name.startsWith(partial));
1022
- if (walked.isUnknown) return [];
1023
- return (walked.command?.subcommands ?? walked.remaining).map((command) => command.name).filter((name) => !name.startsWith("__")).filter((name) => name.startsWith(partial));
1024
- }
1025
- function toWalked(commands, words) {
1026
- let remaining = commands;
1027
- let command;
1028
- for (const word of words) {
1029
- if (word.startsWith("-") || command?.arguments.length) continue;
1030
- const found = remaining.find((entry) => entry.name === word);
1031
- if (!found) return {
1032
- command,
1033
- remaining,
1034
- isUnknown: true
1035
- };
1036
- command = found;
1037
- remaining = found.subcommands ?? [];
1038
- }
1039
- return {
1040
- command,
1041
- remaining,
1042
- isUnknown: false
1043
- };
1044
- }
1045
- function toFlagNames(command) {
1046
- return [...(command?.flags ?? []).map((flag) => `--${flag.name}`), "--help"];
1047
- }
1048
- async function runCompletion(input) {
1049
- const shell = input.args[0] ?? "";
1050
- if (!SHELLS.includes(shell)) {
1051
- writeFailure(`${shell || "no shell"} is not one this CLI writes for. Choose ${SHELLS.join(", ")}`, input.isJSON);
1052
- return ExitCode.USAGE;
1053
- }
1054
- writeData(toScript(shell));
1055
- return ExitCode.OK;
1056
- }
1057
1020
  /** toScript writes a shell's completion, each one asking __complete for the candidates. */
1058
1021
  function toScript(shell) {
1059
1022
  return SCRIPTS[shell];
@@ -1087,14 +1050,28 @@ _hardfin_words() {
1087
1050
  }
1088
1051
 
1089
1052
  _hardfin_complete() {
1090
- local _hardfin_line_words candidate current prefix breaks
1053
+ local _hardfin_line_words line directive current prefix breaks
1091
1054
  _hardfin_words
1092
1055
  COMPREPLY=()
1093
1056
 
1094
- while IFS= read -r candidate; do
1095
- COMPREPLY+=("$candidate")
1057
+ # Each line is a candidate with its description after a tab, which bash cannot show, and
1058
+ # the last line is the directive.
1059
+ while IFS= read -r line; do
1060
+ if [[ $line == :* ]]; then
1061
+ directive=$line
1062
+ else
1063
+ COMPREPLY+=("\${line%%$'\\t'*}")
1064
+ fi
1096
1065
  done < <(hardfin __complete -- "\${_hardfin_line_words[@]:1}" 2>/dev/null)
1097
1066
 
1067
+ # A path is left to readline, which completes it from the file system.
1068
+ if [[ $directive == :1 ]]; then
1069
+ compopt -o default 2>/dev/null
1070
+ COMPREPLY=()
1071
+
1072
+ return
1073
+ fi
1074
+
1098
1075
  # Readline replaces only the text after the last = or : in the word, so a candidate
1099
1076
  # loses what comes before it or the line would repeat that part.
1100
1077
  current=\${_hardfin_line_words[\${#_hardfin_line_words[@]} - 1]}
@@ -1106,21 +1083,68 @@ _hardfin_complete() {
1106
1083
  fi
1107
1084
  }
1108
1085
 
1109
- complete -F _hardfin_complete hardfin`,
1086
+ # Bash before 4.0 has no compopt, so files are offered whenever no candidate is.
1087
+ if type compopt >/dev/null 2>&1; then
1088
+ complete -F _hardfin_complete hardfin
1089
+ else
1090
+ complete -o default -F _hardfin_complete hardfin
1091
+ fi`,
1110
1092
  zsh: `# hardfin completion for zsh
1111
1093
  _hardfin_complete() {
1112
- local -a candidates
1113
- candidates=(\${(f)"$(hardfin __complete -- "\${(@)words[2,CURRENT]}" 2>/dev/null)"})
1114
- compadd -a candidates
1094
+ local -a lines described
1095
+ local line name directive
1096
+ lines=(\${(f)"$(hardfin __complete -- "\${(@)words[2,CURRENT]}" 2>/dev/null)"})
1097
+ directive=\${lines[-1]}
1098
+ lines=(\${lines[1,-2]})
1099
+
1100
+ if [[ $directive == :1 ]]; then
1101
+ _files
1102
+
1103
+ return
1104
+ fi
1105
+
1106
+ # Each line is a candidate with its description after a tab, and _describe separates the
1107
+ # two with a colon, so a colon in the candidate is escaped.
1108
+ for line in $lines; do
1109
+ name=\${line%%$'\\t'*}
1110
+ name=\${name//:/\\\\:}
1111
+
1112
+ if [[ $line == *$'\\t'* ]]; then
1113
+ described+=("$name:\${line#*$'\\t'}")
1114
+ else
1115
+ described+=("$name")
1116
+ fi
1117
+ done
1118
+
1119
+ _describe 'hardfin' described -M 'm:{a-zA-Z}={A-Za-z}'
1115
1120
  }
1116
1121
 
1122
+ # compdef comes from compinit, which a startup file without completion never runs.
1123
+ if ! (( $+functions[compdef] )); then
1124
+ autoload -Uz compinit && compinit
1125
+ fi
1126
+
1117
1127
  compdef _hardfin_complete hardfin`,
1118
1128
  fish: `# hardfin completion for fish
1119
1129
  function __hardfin_complete
1120
1130
  # Older fish lacks -x, which replaced -o, so it falls back to the older option.
1121
1131
  set -l words (commandline -xpc 2>/dev/null; or commandline -opc)
1122
1132
  set -l current (commandline -ct)
1123
- hardfin __complete -- $words[2..-1] "$current" 2>/dev/null
1133
+ set -l lines (hardfin __complete -- $words[2..-1] "$current" 2>/dev/null)
1134
+ set -l directive $lines[-1]
1135
+
1136
+ if set -q lines[1]
1137
+ set -e lines[-1]
1138
+ end
1139
+
1140
+ if test "$directive" = ":1"
1141
+ __fish_complete_path "$current"
1142
+
1143
+ return
1144
+ end
1145
+
1146
+ # Fish shows what follows a tab as the description, so candidates pass through as they are.
1147
+ string join \\n -- $lines
1124
1148
  end
1125
1149
 
1126
1150
  complete -c hardfin -f -a "(__hardfin_complete)"`,
@@ -1139,18 +1163,488 @@ Register-ArgumentCompleter -Native -CommandName hardfin -ScriptBlock {
1139
1163
  $words += if ($isLegacy) { '""' } else { '' }
1140
1164
  }
1141
1165
 
1142
- hardfin __complete -- @words 2>$null | ForEach-Object {
1143
- $text = $_
1166
+ $lines = @(hardfin __complete -- @words 2>$null)
1167
+
1168
+ # Returning nothing makes PowerShell complete paths, which is what the last line asks for
1169
+ # when it is :1.
1170
+ if ($lines.Count -eq 0 -or $lines[-1] -eq ':1') {
1171
+ return
1172
+ }
1173
+
1174
+ # Each line is a candidate with its description after a tab.
1175
+ $lines | Select-Object -SkipLast 1 | ForEach-Object {
1176
+ $value, $description = $_ -split "\`t", 2
1177
+ $text = $value
1144
1178
 
1145
1179
  if ($text -match '[\\s#@$;,''{}()"\`|<>&]') {
1146
1180
  $text = "'" + ($text -replace "'", "''") + "'"
1147
1181
  }
1148
1182
 
1149
- [System.Management.Automation.CompletionResult]::new($text, $_, 'ParameterValue', $_)
1183
+ [System.Management.Automation.CompletionResult]::new($text, $value, 'ParameterValue', $(if ($description) { $description } else { $value }))
1150
1184
  }
1151
1185
  }`
1152
1186
  };
1153
1187
  //#endregion
1188
+ //#region src/command/completion-install.ts
1189
+ const BLOCK_START = "# >>> hardfin completion >>>";
1190
+ const BLOCK_END = "# <<< hardfin completion <<<";
1191
+ const SHELL_ARGUMENT = {
1192
+ name: "shell",
1193
+ description: `The shell to install for, found from $SHELL when not given: ${SHELLS.join(", ")}`,
1194
+ required: false,
1195
+ values: [...SHELLS]
1196
+ };
1197
+ const YES_FLAG = {
1198
+ name: "yes",
1199
+ short: "y",
1200
+ description: "Make the change without asking",
1201
+ schema: z.boolean()
1202
+ };
1203
+ const JSON_FLAG$1 = {
1204
+ name: "json",
1205
+ description: "Print machine-readable output, which is the default when stdout is not a terminal",
1206
+ schema: z.boolean()
1207
+ };
1208
+ const installCommand = defineCommand({
1209
+ name: "install",
1210
+ summary: "Set up completion in your shell",
1211
+ description: "Writes the completion script under your data directory and loads it from your shell's startup file, between two marked lines. Fish loads it from its completions directory, so no startup file changes. Run it again after upgrading, so the script matches the CLI.",
1212
+ arguments: [SHELL_ARGUMENT],
1213
+ flags: [YES_FLAG, JSON_FLAG$1],
1214
+ examples: [{
1215
+ description: "Install for the shell you are using",
1216
+ command: "hardfin completion install"
1217
+ }, {
1218
+ description: "Install for zsh without being asked",
1219
+ command: "hardfin completion install zsh --yes"
1220
+ }],
1221
+ run: (input) => runChange(input, "install")
1222
+ });
1223
+ const uninstallCommand = defineCommand({
1224
+ name: "uninstall",
1225
+ summary: "Remove completion from your shell",
1226
+ description: "Removes the script install wrote, and the marked lines it added to your shell's startup file.",
1227
+ arguments: [SHELL_ARGUMENT],
1228
+ flags: [YES_FLAG, JSON_FLAG$1],
1229
+ examples: [{
1230
+ description: "Remove completion from zsh",
1231
+ command: "hardfin completion uninstall zsh"
1232
+ }],
1233
+ run: (input) => runChange(input, "uninstall")
1234
+ });
1235
+ /**
1236
+ * toPlan decides where a shell's completion goes. Fish loads a file from its completions
1237
+ * directory by itself, and every other shell is told to load the script from its startup
1238
+ * file, because not every bash has bash-completion to load it lazily.
1239
+ */
1240
+ function toPlan(shell, place) {
1241
+ const content = `${toScript(shell)}\n`;
1242
+ if (shell === "fish") {
1243
+ const config = place.env["XDG_CONFIG_HOME"] || join(place.home, ".config");
1244
+ return {
1245
+ shell,
1246
+ script: {
1247
+ path: join(config, "fish", "completions", "hardfin.fish"),
1248
+ content
1249
+ }
1250
+ };
1251
+ }
1252
+ const data = toDataDirectory(place);
1253
+ const extension = {
1254
+ bash: "bash",
1255
+ zsh: "zsh",
1256
+ powershell: "ps1"
1257
+ }[shell];
1258
+ const path = join(data, `completion.${extension}`);
1259
+ if (shell === "powershell") {
1260
+ const profile = place.toProfile();
1261
+ const block = `if (Test-Path '${path}') { . '${path}' }`;
1262
+ return {
1263
+ shell,
1264
+ script: {
1265
+ path,
1266
+ content
1267
+ },
1268
+ startup: profile ? {
1269
+ path: profile,
1270
+ block
1271
+ } : void 0
1272
+ };
1273
+ }
1274
+ const block = `[ -f '${path}' ] && . '${path}'`;
1275
+ return {
1276
+ shell,
1277
+ script: {
1278
+ path,
1279
+ content
1280
+ },
1281
+ startup: {
1282
+ path: toStartupFile(shell, place),
1283
+ block
1284
+ }
1285
+ };
1286
+ }
1287
+ /** toDataDirectory is where hardfin keeps files that are not settings or credentials. */
1288
+ function toDataDirectory(place) {
1289
+ if (place.platform === "win32") return join(place.env["LOCALAPPDATA"] || join(place.home, "AppData", "Local"), "hardfin");
1290
+ return join(place.env["XDG_DATA_HOME"] || join(place.home, ".local", "share"), "hardfin");
1291
+ }
1292
+ /**
1293
+ * toStartupFile names the file a shell reads when a person opens one. A terminal on macOS
1294
+ * starts bash as a login shell, which reads .bash_profile and never .bashrc.
1295
+ */
1296
+ function toStartupFile(shell, place) {
1297
+ if (shell === "zsh") return join(place.env["ZDOTDIR"] || place.home, ".zshrc");
1298
+ return join(place.home, place.platform === "darwin" ? ".bash_profile" : ".bashrc");
1299
+ }
1300
+ /** withBlock answers a startup file holding the marked block once, replacing any earlier copy. */
1301
+ function withBlock(text, block) {
1302
+ const marked = `${BLOCK_START}\n${block}\n${BLOCK_END}`;
1303
+ const without = withoutBlock(text);
1304
+ if (without === "") return `${marked}\n`;
1305
+ return `${without.endsWith("\n") ? without : `${without}\n`}\n${marked}\n`;
1306
+ }
1307
+ /** withoutBlock answers a startup file with the marked block removed, and the blank line before it. */
1308
+ function withoutBlock(text) {
1309
+ const start = text.indexOf(BLOCK_START);
1310
+ const end = text.indexOf(BLOCK_END, start);
1311
+ if (start === -1 || end === -1) return text;
1312
+ return text.slice(0, start).replace(/\n\n$/, "\n") + text.slice(end + 28).replace(/^\n/, "");
1313
+ }
1314
+ /** toShell finds the shell a person is using, from what the environment says. */
1315
+ function toShell(place) {
1316
+ const name = basename(place.env["SHELL"] ?? "").replace(/\.exe$/, "");
1317
+ if (SHELLS.includes(name)) return name;
1318
+ if (name === "pwsh") return "powershell";
1319
+ if (place.platform === "win32") return "powershell";
1320
+ }
1321
+ /** isInstalled answers whether install has written a script for any shell. */
1322
+ function isInstalled(place = toPlace()) {
1323
+ return SHELLS.some((shell) => existsSync(toPlan(shell, {
1324
+ ...place,
1325
+ toProfile: () => void 0
1326
+ }).script.path));
1327
+ }
1328
+ function toPlace() {
1329
+ return {
1330
+ home: homedir(),
1331
+ platform: process.platform,
1332
+ env: process.env,
1333
+ toProfile: toPowerShellProfile
1334
+ };
1335
+ }
1336
+ /** toPowerShellProfile asks PowerShell where its profile is, because it moves between versions. */
1337
+ function toPowerShellProfile() {
1338
+ for (const program of ["pwsh", "powershell"]) {
1339
+ const result = spawnSync(program, [
1340
+ "-NoProfile",
1341
+ "-NoLogo",
1342
+ "-Command",
1343
+ "$PROFILE.CurrentUserAllHosts"
1344
+ ], { encoding: "utf8" });
1345
+ const profile = result.status === 0 ? result.stdout.trim() : "";
1346
+ if (profile !== "") return profile;
1347
+ }
1348
+ }
1349
+ async function runChange(input, change) {
1350
+ const place = toPlace();
1351
+ const shell = input.args[0] ?? toShell(place);
1352
+ if (!shell || !SHELLS.includes(shell)) {
1353
+ writeFailure(`name the shell to ${change} for: ${SHELLS.join(", ")}`, input.isJSON);
1354
+ return ExitCode.USAGE;
1355
+ }
1356
+ const plan = toPlan(shell, place);
1357
+ if (change === "install" && shell === "powershell" && !plan.startup) {
1358
+ writeFailure("PowerShell did not report its profile, so there is nowhere to load the script from", input.isJSON);
1359
+ return ExitCode.ERROR;
1360
+ }
1361
+ process.stderr.write(toDescription(plan, change));
1362
+ if (!await isConfirmed(input.flags["yes"] === true)) {
1363
+ writeFailure(process.stdin.isTTY ? "nothing was changed" : "nothing was changed. Pass --yes to change it without a terminal", input.isJSON);
1364
+ return ExitCode.USAGE;
1365
+ }
1366
+ if (change === "install") {
1367
+ mkdirSync(dirname(plan.script.path), { recursive: true });
1368
+ writeFileSync(plan.script.path, plan.script.content);
1369
+ if (plan.startup) {
1370
+ const text = existsSync(plan.startup.path) ? readFileSync(plan.startup.path, "utf8") : "";
1371
+ mkdirSync(dirname(plan.startup.path), { recursive: true });
1372
+ writeFileSync(plan.startup.path, withBlock(text, plan.startup.block));
1373
+ }
1374
+ for (const warning of toWarnings(place)) process.stderr.write(`warning: ${warning}\n`);
1375
+ } else {
1376
+ rmSync(plan.script.path, { force: true });
1377
+ if (plan.startup && existsSync(plan.startup.path)) writeFileSync(plan.startup.path, withoutBlock(readFileSync(plan.startup.path, "utf8")));
1378
+ }
1379
+ writeData({
1380
+ shell,
1381
+ [change === "install" ? "installed" : "uninstalled"]: true,
1382
+ script: plan.script.path,
1383
+ startupFile: plan.startup?.path ?? null,
1384
+ next: change === "install" ? "Open a new shell to complete hardfin commands with Tab" : "Open a new shell to stop completing hardfin commands"
1385
+ });
1386
+ return ExitCode.OK;
1387
+ }
1388
+ function toDescription(plan, change) {
1389
+ const lines = change === "install" ? [`This writes the ${plan.shell} completion script to ${plan.script.path}`] : [`This removes the ${plan.shell} completion script at ${plan.script.path}`];
1390
+ if (plan.startup) lines.push(change === "install" ? `and adds these lines to ${plan.startup.path}:\n\n${BLOCK_START}\n${plan.startup.block}\n${BLOCK_END}\n` : `and the lines between the hardfin completion markers in ${plan.startup.path}`);
1391
+ return `${lines.join("\n")}\n`;
1392
+ }
1393
+ /** isConfirmed asks before changing a person's files, unless they already said yes. */
1394
+ async function isConfirmed(isYes) {
1395
+ if (isYes) return true;
1396
+ if (!process.stdin.isTTY) return false;
1397
+ const reader = createInterface({
1398
+ input: process.stdin,
1399
+ output: process.stderr
1400
+ });
1401
+ try {
1402
+ const answer = await reader.question("Continue? [y/N] ");
1403
+ return /^y(es)?$/i.test(answer.trim());
1404
+ } finally {
1405
+ reader.close();
1406
+ }
1407
+ }
1408
+ /**
1409
+ * toWarnings names what would stop the script from working once installed. The script
1410
+ * calls hardfin by name, so a CLI run from a checkout has nothing for it to call.
1411
+ */
1412
+ function toWarnings(place, find = (name) => toOnPath(name, place), isInsideWSL = isWSL) {
1413
+ const found = find(place.platform === "win32" ? "hardfin.cmd" : "hardfin");
1414
+ if (!found) return ["hardfin is not on your PATH, and the script calls it by name. Install it with npm install -g @hardfin/cli, or npm link in a checkout"];
1415
+ if (place.platform === "linux" && found.startsWith("/mnt/") && isInsideWSL()) return [`the hardfin on your PATH is the Windows one at ${found}. Install it inside WSL, so each Tab runs the Linux one`];
1416
+ return [];
1417
+ }
1418
+ function toOnPath(name, place) {
1419
+ return (place.env["PATH"] ?? "").split(delimiter).filter((directory) => directory !== "").map((directory) => join(directory, name)).find(isExecutable);
1420
+ }
1421
+ function isExecutable(path) {
1422
+ try {
1423
+ accessSync(path, constants.X_OK);
1424
+ return true;
1425
+ } catch {
1426
+ return false;
1427
+ }
1428
+ }
1429
+ //#endregion
1430
+ //#region src/command/completion.ts
1431
+ const completionCommand = defineCommand({
1432
+ name: "completion",
1433
+ summary: "Complete hardfin commands in your shell",
1434
+ description: "Prints the completion script for a shell. The script asks this CLI what may follow what you have typed, so completions never fall behind the commands. Run hardfin completion install to load it in every new shell.",
1435
+ arguments: [{
1436
+ name: "shell",
1437
+ description: `The shell to write for: ${SHELLS.join(", ")}`,
1438
+ required: true,
1439
+ values: [...SHELLS]
1440
+ }],
1441
+ flags: [],
1442
+ examples: [{
1443
+ description: "Complete in every new shell",
1444
+ command: "hardfin completion install"
1445
+ }, {
1446
+ description: "Complete in this shell, now",
1447
+ command: "source <(hardfin completion zsh)"
1448
+ }],
1449
+ subcommands: [installCommand, uninstallCommand],
1450
+ run: runCompletion
1451
+ });
1452
+ /** The hidden command a completion script asks, which keeps one implementation for every shell. */
1453
+ const completeCommand = defineCommand({
1454
+ name: "__complete",
1455
+ summary: "Answer what may follow the words typed so far",
1456
+ hidden: true,
1457
+ arguments: [{
1458
+ name: "words",
1459
+ description: "The words typed so far",
1460
+ required: false,
1461
+ variadic: true
1462
+ }],
1463
+ flags: [{
1464
+ name: "json",
1465
+ description: "Accepted for consistency, and ignored",
1466
+ schema: z.boolean()
1467
+ }],
1468
+ examples: [],
1469
+ run: async (input) => {
1470
+ writeData(toAnswer$1(toCompletion(input.commands, input.args)));
1471
+ return ExitCode.OK;
1472
+ }
1473
+ });
1474
+ /** Directive is the last line of an answer, which tells the script what to do beyond the candidates. */
1475
+ const Directive = {
1476
+ NONE: ":0",
1477
+ FILES: ":1"
1478
+ };
1479
+ const HELP_FLAG = {
1480
+ name: "help",
1481
+ short: "h",
1482
+ description: "Print help for the command",
1483
+ schema: z.boolean()
1484
+ };
1485
+ const VERSION_FLAG = {
1486
+ name: "version",
1487
+ short: "v",
1488
+ description: "Print the version",
1489
+ schema: z.boolean()
1490
+ };
1491
+ /** The value names that mean a path, so the shell offers files for them. */
1492
+ const PATH_VALUES = /* @__PURE__ */ new Set(["path", "file"]);
1493
+ const NOTHING = {
1494
+ candidates: [],
1495
+ isFile: false
1496
+ };
1497
+ /**
1498
+ * toAnswer writes a completion as a script reads it. Each line is a candidate, with its
1499
+ * description after a tab, and the last line is the directive.
1500
+ */
1501
+ function toAnswer$1(completion) {
1502
+ return [...completion.candidates.map((candidate) => candidate.description ? `${candidate.value}\t${toLine(candidate.description)}` : candidate.value), completion.isFile ? Directive.FILES : Directive.NONE].join("\n");
1503
+ }
1504
+ /**
1505
+ * toCompletion answers what may follow the words typed so far. The last word is what is
1506
+ * being typed, and it is empty when the cursor sits after a space, which asks what may come
1507
+ * next rather than how to finish the word before it.
1508
+ */
1509
+ function toCompletion(commands, words) {
1510
+ const partial = words[words.length - 1] ?? "";
1511
+ const walked = toWalked(commands, words.slice(0, -1));
1512
+ if (walked.isUnknown) return NOTHING;
1513
+ if (walked.pending) return toValues(walked.pending, partial, "");
1514
+ const assigned = /^(--?[^=]+)=(.*)$/.exec(partial);
1515
+ if (assigned?.[1] !== void 0 && assigned[2] !== void 0) {
1516
+ const flag = toFlag(walked.flags, assigned[1]);
1517
+ return flag?.valueName ? {
1518
+ candidates: toValues(flag, assigned[2], `${assigned[1]}=`).candidates,
1519
+ isFile: false
1520
+ } : NOTHING;
1521
+ }
1522
+ if (partial.startsWith("-")) return {
1523
+ candidates: toFlagCandidates(walked).filter((candidate) => candidate.value.startsWith(partial)),
1524
+ isFile: false
1525
+ };
1526
+ const subcommands = toSubcommands(commands, walked).filter((command) => !command.hidden && command.name.startsWith(partial)).map((command) => ({
1527
+ value: command.name,
1528
+ description: command.summary
1529
+ }));
1530
+ if (walked.command && walked.command.subcommands?.length && !walked.command.run) return {
1531
+ candidates: subcommands,
1532
+ isFile: false
1533
+ };
1534
+ return {
1535
+ candidates: [...subcommands, ...toArgumentValues(walked, partial)],
1536
+ isFile: false
1537
+ };
1538
+ }
1539
+ /** toSubcommands lists the commands the next word may name. */
1540
+ function toSubcommands(commands, walked) {
1541
+ if (!walked.command) return commands;
1542
+ return walked.positionals === 0 ? walked.command.subcommands ?? [] : [];
1543
+ }
1544
+ function toWalked(commands, words) {
1545
+ let walked = {
1546
+ flags: [
1547
+ ...GLOBAL_FLAGS,
1548
+ VERSION_FLAG,
1549
+ HELP_FLAG
1550
+ ],
1551
+ used: /* @__PURE__ */ new Set(),
1552
+ positionals: 0,
1553
+ isUnknown: false
1554
+ };
1555
+ for (const word of words) {
1556
+ if (walked.pending) {
1557
+ walked.pending = void 0;
1558
+ continue;
1559
+ }
1560
+ if (word.startsWith("-")) {
1561
+ const flag = toFlag(walked.flags, word.split("=")[0] ?? word);
1562
+ if (flag) walked.used.add(flag.name);
1563
+ if (flag?.valueName && !word.includes("=")) walked.pending = flag;
1564
+ continue;
1565
+ }
1566
+ const found = toSubcommands(commands, walked).find((entry) => entry.name === word);
1567
+ if (found) {
1568
+ walked = {
1569
+ command: found,
1570
+ flags: [...found.flags, HELP_FLAG],
1571
+ used: /* @__PURE__ */ new Set(),
1572
+ positionals: 0,
1573
+ isUnknown: false
1574
+ };
1575
+ continue;
1576
+ }
1577
+ if (walked.command?.arguments.length) {
1578
+ walked.positionals += 1;
1579
+ continue;
1580
+ }
1581
+ return {
1582
+ ...walked,
1583
+ isUnknown: true
1584
+ };
1585
+ }
1586
+ return walked;
1587
+ }
1588
+ /** toFlag finds the flag a word names, whether long, short, or the --no- form of a boolean. */
1589
+ function toFlag(flags, word) {
1590
+ if (word.startsWith("--")) {
1591
+ const name = word.slice(2);
1592
+ return flags.find((flag) => flag.name === name || flag.negatable && `no-${flag.name}` === name);
1593
+ }
1594
+ return flags.find((flag) => flag.short !== void 0 && `-${flag.short}` === word);
1595
+ }
1596
+ function toFlagCandidates(walked) {
1597
+ return walked.flags.filter((flag) => flag.repeatable || !walked.used.has(flag.name)).flatMap((flag) => {
1598
+ const candidate = {
1599
+ value: `--${flag.name}`,
1600
+ description: flag.description
1601
+ };
1602
+ return flag.negatable ? [candidate, {
1603
+ value: `--no-${flag.name}`,
1604
+ description: `${flag.description}, turned off`
1605
+ }] : [candidate];
1606
+ });
1607
+ }
1608
+ /** toValues offers the values a flag takes, each written after the prefix the word already has. */
1609
+ function toValues(flag, partial, prefix) {
1610
+ const values = toSchemaValues(flag.schema);
1611
+ if (values) {
1612
+ const typed = partial.toLowerCase();
1613
+ return {
1614
+ candidates: values.filter((value) => value.toLowerCase().startsWith(typed)).map((value) => ({ value: `${prefix}${value}` })),
1615
+ isFile: false
1616
+ };
1617
+ }
1618
+ return {
1619
+ candidates: [],
1620
+ isFile: PATH_VALUES.has(flag.valueName ?? "")
1621
+ };
1622
+ }
1623
+ function toArgumentValues(walked, partial) {
1624
+ const declared = walked.command?.arguments ?? [];
1625
+ const last = declared[declared.length - 1];
1626
+ return ((walked.positionals < declared.length ? declared[walked.positionals] : last?.variadic ? last : void 0)?.values ?? []).filter((value) => value.startsWith(partial)).map((value) => ({ value }));
1627
+ }
1628
+ /** toSchemaValues reads the values a schema allows, when it allows only a fixed set. */
1629
+ function toSchemaValues(schema) {
1630
+ if (schema instanceof z.ZodEnum) return schema.options.map(String);
1631
+ if (schema instanceof z.ZodPipe) return toSchemaValues(schema.out);
1632
+ if (schema instanceof z.ZodOptional || schema instanceof z.ZodDefault) return toSchemaValues(schema.unwrap());
1633
+ }
1634
+ /** toLine keeps a description on one line, because a newline would start another candidate. */
1635
+ function toLine(description) {
1636
+ return description.replace(/\s+/g, " ").trim();
1637
+ }
1638
+ async function runCompletion(input) {
1639
+ const shell = input.args[0] ?? "";
1640
+ if (!SHELLS.includes(shell)) {
1641
+ writeFailure(`${shell || "no shell"} is not one this CLI writes for. Choose ${SHELLS.join(", ")}`, input.isJSON);
1642
+ return ExitCode.USAGE;
1643
+ }
1644
+ writeData(toScript(shell));
1645
+ return ExitCode.OK;
1646
+ }
1647
+ //#endregion
1154
1648
  //#region src/command/config.ts
1155
1649
  const configCommand = defineCommand({
1156
1650
  name: "config",
@@ -1201,47 +1695,6 @@ async function runConfig(input) {
1201
1695
  return ExitCode.OK;
1202
1696
  }
1203
1697
  //#endregion
1204
- //#region src/auth/browser.ts
1205
- /** isWSL reports whether this Linux is running under Windows. */
1206
- function isWSL() {
1207
- return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
1208
- }
1209
- /**
1210
- * toOpeners lists the ways to open a URL here, best first. WSL needs Windows to do it,
1211
- * because the browser the person is looking at is the Windows one, and xdg-open reaches
1212
- * nothing outside the distribution.
1213
- */
1214
- function toOpeners(url) {
1215
- if (process.platform === "darwin") return [["open", [url]]];
1216
- if (process.platform === "win32") return [["cmd", [
1217
- "/c",
1218
- "start",
1219
- "",
1220
- url
1221
- ]]];
1222
- if (isWSL()) return [
1223
- ["wslview", [url]],
1224
- ["powershell.exe", [
1225
- "-NoProfile",
1226
- "-NonInteractive",
1227
- "-Command",
1228
- "Start-Process",
1229
- `'${url}'`
1230
- ]],
1231
- ["explorer.exe", [url]]
1232
- ];
1233
- return [["xdg-open", [url]]];
1234
- }
1235
- /** openBrowser asks the desktop to show a URL, and reports whether anything took it. */
1236
- function openBrowser(url, launch = spawnSync) {
1237
- for (const [command, args] of toOpeners(url)) if (launch(command, args, {
1238
- stdio: "ignore",
1239
- cwd: command.endsWith(".exe") ? "/mnt/c" : void 0,
1240
- timeout: 1e4
1241
- }).error === void 0) return true;
1242
- return false;
1243
- }
1244
- //#endregion
1245
1698
  //#region src/auth/device.ts
1246
1699
  const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
1247
1700
  /** The interval a server gives is in seconds, and five more are added on a slow_down. */
@@ -1653,7 +2106,8 @@ async function toSignedIn(input, metadata, tokens) {
1653
2106
  `Scope ${tokens.scope ?? "as granted"}`,
1654
2107
  `Stored in ${stored}`,
1655
2108
  "",
1656
- "Run hardfin status to see what this CLI is using"
2109
+ "Run hardfin status to see what this CLI is using",
2110
+ ...isInstalled() ? [] : ["Run hardfin completion install to complete commands with Tab"]
1657
2111
  ].join("\n"));
1658
2112
  return ExitCode.OK;
1659
2113
  }
@@ -1947,7 +2401,7 @@ function toCliRunner() {
1947
2401
  }
1948
2402
  /** serve answers requests on stdin until the client closes it. */
1949
2403
  async function serve(commands, run = toCliRunner()) {
1950
- const lines = createInterface({ input: process.stdin });
2404
+ const lines = createInterface$1({ input: process.stdin });
1951
2405
  const answering = /* @__PURE__ */ new Set();
1952
2406
  for await (const line of lines) {
1953
2407
  if (line.trim() === "") continue;
@@ -5201,7 +5655,8 @@ function toRejectedFlag(command, flags) {
5201
5655
  /** toCli builds the parser from the registry, which is what every surface reads. */
5202
5656
  function toCli(entries = commands) {
5203
5657
  const program = new Command();
5204
- program.name("hardfin").description("Call the Hardfin API from a terminal or an agent").version(version$1, "-v, --version").option("--api-url <url>", "The API to call, whose host also holds the authorization server").option("--issuer-url <url>", "The authorization server, when it does not sit at the API's host").showHelpAfterError().enablePositionalOptions();
5658
+ program.name("hardfin").description("Call the Hardfin API from a terminal or an agent").version(version$1, "-v, --version").showHelpAfterError().enablePositionalOptions();
5659
+ for (const flag of GLOBAL_FLAGS) program.option(`--${flag.name} <${flag.valueName}>`, flag.description);
5205
5660
  for (const command of entries) program.addCommand(toProgram(program, command), { hidden: command.hidden });
5206
5661
  return program;
5207
5662
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardfin/cli",
3
- "version": "0.1.0-dev.22",
3
+ "version": "0.1.0-dev.24",
4
4
  "description": "Command line interface for the Hardfin API",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Hardfin, Inc.",