@hardfin/cli 0.1.0-dev.23 → 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 +487 -237
  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 = {
@@ -968,17 +969,469 @@ function toBody$1(source) {
968
969
  }
969
970
  }
970
971
  //#endregion
971
- //#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
972
1014
  const SHELLS = [
973
1015
  "bash",
974
1016
  "zsh",
975
1017
  "fish",
976
1018
  "powershell"
977
1019
  ];
1020
+ /** toScript writes a shell's completion, each one asking __complete for the candidates. */
1021
+ function toScript(shell) {
1022
+ return SCRIPTS[shell];
1023
+ }
1024
+ const SCRIPTS = {
1025
+ bash: `# hardfin completion for bash
1026
+ _hardfin_words() {
1027
+ # Bash splits a word at = and :, so the pieces are joined again wherever no space
1028
+ # separates them in the line, and the word under the cursor ends at the cursor.
1029
+ local line=\${COMP_LINE:0:COMP_POINT} offset=0 i word rest gap
1030
+ _hardfin_line_words=()
1031
+
1032
+ for ((i = 0; i <= COMP_CWORD; i++)); do
1033
+ rest=\${line:offset}
1034
+
1035
+ if ((i < COMP_CWORD)); then
1036
+ word=\${COMP_WORDS[i]}
1037
+ gap=\${rest%%"$word"*}
1038
+ offset=$((offset + \${#gap} + \${#word}))
1039
+ else
1040
+ word=\${rest#"\${rest%%[![:space:]]*}"}
1041
+ gap=\${rest%"$word"}
1042
+ fi
1043
+
1044
+ if ((i > 0)) && [[ -z $gap ]]; then
1045
+ _hardfin_line_words[\${#_hardfin_line_words[@]} - 1]+=$word
1046
+ else
1047
+ _hardfin_line_words+=("$word")
1048
+ fi
1049
+ done
1050
+ }
1051
+
1052
+ _hardfin_complete() {
1053
+ local _hardfin_line_words line directive current prefix breaks
1054
+ _hardfin_words
1055
+ COMPREPLY=()
1056
+
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
1065
+ done < <(hardfin __complete -- "\${_hardfin_line_words[@]:1}" 2>/dev/null)
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
+
1075
+ # Readline replaces only the text after the last = or : in the word, so a candidate
1076
+ # loses what comes before it or the line would repeat that part.
1077
+ current=\${_hardfin_line_words[\${#_hardfin_line_words[@]} - 1]}
1078
+ breaks=\${COMP_WORDBREAKS//[^=:]/}
1079
+
1080
+ if [[ -n $breaks && $current == *["$breaks"]* ]]; then
1081
+ prefix=\${current%"\${current##*["$breaks"]}"}
1082
+ COMPREPLY=("\${COMPREPLY[@]#"$prefix"}")
1083
+ fi
1084
+ }
1085
+
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`,
1092
+ zsh: `# hardfin completion for zsh
1093
+ _hardfin_complete() {
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}'
1120
+ }
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
+
1127
+ compdef _hardfin_complete hardfin`,
1128
+ fish: `# hardfin completion for fish
1129
+ function __hardfin_complete
1130
+ # Older fish lacks -x, which replaced -o, so it falls back to the older option.
1131
+ set -l words (commandline -xpc 2>/dev/null; or commandline -opc)
1132
+ set -l current (commandline -ct)
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
1148
+ end
1149
+
1150
+ complete -c hardfin -f -a "(__hardfin_complete)"`,
1151
+ powershell: `# hardfin completion for PowerShell
1152
+ Register-ArgumentCompleter -Native -CommandName hardfin -ScriptBlock {
1153
+ param($wordToComplete, $commandAst, $cursorPosition)
1154
+
1155
+ # Only the words before the cursor decide what is offered, and PowerShell leaves out the
1156
+ # empty word after a trailing space, so it is added back.
1157
+ $words = @($commandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_.Extent.StartOffset -lt $cursorPosition } | ForEach-Object { $_.Extent.Text })
1158
+
1159
+ if ($wordToComplete -eq '') {
1160
+ # Before 7.3, and in Legacy mode, an empty argument never reaches the program unless
1161
+ # it is written as a pair of quotes.
1162
+ $isLegacy = $PSVersionTable.PSVersion -lt [version]'7.3' -or $PSNativeCommandArgumentPassing -eq 'Legacy'
1163
+ $words += if ($isLegacy) { '""' } else { '' }
1164
+ }
1165
+
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
1178
+
1179
+ if ($text -match '[\\s#@$;,''{}()"\`|<>&]') {
1180
+ $text = "'" + ($text -replace "'", "''") + "'"
1181
+ }
1182
+
1183
+ [System.Management.Automation.CompletionResult]::new($text, $value, 'ParameterValue', $(if ($description) { $description } else { $value }))
1184
+ }
1185
+ }`
1186
+ };
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
978
1431
  const completionCommand = defineCommand({
979
1432
  name: "completion",
980
- summary: "Print the shell script that completes hardfin commands",
981
- 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.",
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.",
982
1435
  arguments: [{
983
1436
  name: "shell",
984
1437
  description: `The shell to write for: ${SHELLS.join(", ")}`,
@@ -986,20 +1439,14 @@ const completionCommand = defineCommand({
986
1439
  values: [...SHELLS]
987
1440
  }],
988
1441
  flags: [],
989
- examples: [
990
- {
991
- description: "Complete in this shell, now",
992
- command: "source <(hardfin completion zsh)"
993
- },
994
- {
995
- description: "Complete in every new shell",
996
- command: "hardfin completion zsh > ~/.hardfin-completion.zsh"
997
- },
998
- {
999
- description: "Complete in bash",
1000
- command: "source <(hardfin completion bash)"
1001
- }
1002
- ],
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],
1003
1450
  run: runCompletion
1004
1451
  });
1005
1452
  /** The hidden command a completion script asks, which keeps one implementation for every shell. */
@@ -1076,15 +1523,23 @@ function toCompletion(commands, words) {
1076
1523
  candidates: toFlagCandidates(walked).filter((candidate) => candidate.value.startsWith(partial)),
1077
1524
  isFile: false
1078
1525
  };
1079
- const subcommands = walked.command ? walked.command.subcommands ?? [] : commands;
1080
- if (subcommands.length > 0) return {
1081
- candidates: subcommands.filter((command) => !command.hidden && command.name.startsWith(partial)).map((command) => ({
1082
- value: command.name,
1083
- description: command.summary
1084
- })),
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)],
1085
1536
  isFile: false
1086
1537
  };
1087
- return toArgumentValues(walked, partial);
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 ?? [] : [];
1088
1543
  }
1089
1544
  function toWalked(commands, words) {
1090
1545
  let walked = {
@@ -1108,7 +1563,7 @@ function toWalked(commands, words) {
1108
1563
  if (flag?.valueName && !word.includes("=")) walked.pending = flag;
1109
1564
  continue;
1110
1565
  }
1111
- const found = (walked.command ? walked.command.subcommands ?? [] : commands).find((entry) => entry.name === word);
1566
+ const found = toSubcommands(commands, walked).find((entry) => entry.name === word);
1112
1567
  if (found) {
1113
1568
  walked = {
1114
1569
  command: found,
@@ -1168,10 +1623,7 @@ function toValues(flag, partial, prefix) {
1168
1623
  function toArgumentValues(walked, partial) {
1169
1624
  const declared = walked.command?.arguments ?? [];
1170
1625
  const last = declared[declared.length - 1];
1171
- return {
1172
- candidates: ((walked.positionals < declared.length ? declared[walked.positionals] : last?.variadic ? last : void 0)?.values ?? []).filter((value) => value.startsWith(partial)).map((value) => ({ value })),
1173
- isFile: false
1174
- };
1626
+ return ((walked.positionals < declared.length ? declared[walked.positionals] : last?.variadic ? last : void 0)?.values ?? []).filter((value) => value.startsWith(partial)).map((value) => ({ value }));
1175
1627
  }
1176
1628
  /** toSchemaValues reads the values a schema allows, when it allows only a fixed set. */
1177
1629
  function toSchemaValues(schema) {
@@ -1192,168 +1644,6 @@ async function runCompletion(input) {
1192
1644
  writeData(toScript(shell));
1193
1645
  return ExitCode.OK;
1194
1646
  }
1195
- /** toScript writes a shell's completion, each one asking __complete for the candidates. */
1196
- function toScript(shell) {
1197
- return SCRIPTS[shell];
1198
- }
1199
- const SCRIPTS = {
1200
- bash: `# hardfin completion for bash
1201
- _hardfin_words() {
1202
- # Bash splits a word at = and :, so the pieces are joined again wherever no space
1203
- # separates them in the line, and the word under the cursor ends at the cursor.
1204
- local line=\${COMP_LINE:0:COMP_POINT} offset=0 i word rest gap
1205
- _hardfin_line_words=()
1206
-
1207
- for ((i = 0; i <= COMP_CWORD; i++)); do
1208
- rest=\${line:offset}
1209
-
1210
- if ((i < COMP_CWORD)); then
1211
- word=\${COMP_WORDS[i]}
1212
- gap=\${rest%%"$word"*}
1213
- offset=$((offset + \${#gap} + \${#word}))
1214
- else
1215
- word=\${rest#"\${rest%%[![:space:]]*}"}
1216
- gap=\${rest%"$word"}
1217
- fi
1218
-
1219
- if ((i > 0)) && [[ -z $gap ]]; then
1220
- _hardfin_line_words[\${#_hardfin_line_words[@]} - 1]+=$word
1221
- else
1222
- _hardfin_line_words+=("$word")
1223
- fi
1224
- done
1225
- }
1226
-
1227
- _hardfin_complete() {
1228
- local _hardfin_line_words line directive current prefix breaks
1229
- _hardfin_words
1230
- COMPREPLY=()
1231
-
1232
- # Each line is a candidate with its description after a tab, which bash cannot show, and
1233
- # the last line is the directive.
1234
- while IFS= read -r line; do
1235
- if [[ $line == :* ]]; then
1236
- directive=$line
1237
- else
1238
- COMPREPLY+=("\${line%%$'\\t'*}")
1239
- fi
1240
- done < <(hardfin __complete -- "\${_hardfin_line_words[@]:1}" 2>/dev/null)
1241
-
1242
- # A path is left to readline, which completes it from the file system.
1243
- if [[ $directive == :1 ]]; then
1244
- compopt -o default 2>/dev/null
1245
- COMPREPLY=()
1246
-
1247
- return
1248
- fi
1249
-
1250
- # Readline replaces only the text after the last = or : in the word, so a candidate
1251
- # loses what comes before it or the line would repeat that part.
1252
- current=\${_hardfin_line_words[\${#_hardfin_line_words[@]} - 1]}
1253
- breaks=\${COMP_WORDBREAKS//[^=:]/}
1254
-
1255
- if [[ -n $breaks && $current == *["$breaks"]* ]]; then
1256
- prefix=\${current%"\${current##*["$breaks"]}"}
1257
- COMPREPLY=("\${COMPREPLY[@]#"$prefix"}")
1258
- fi
1259
- }
1260
-
1261
- # Bash before 4.0 has no compopt, so files are offered whenever no candidate is.
1262
- if type compopt >/dev/null 2>&1; then
1263
- complete -F _hardfin_complete hardfin
1264
- else
1265
- complete -o default -F _hardfin_complete hardfin
1266
- fi`,
1267
- zsh: `# hardfin completion for zsh
1268
- _hardfin_complete() {
1269
- local -a lines described
1270
- local line name directive
1271
- lines=(\${(f)"$(hardfin __complete -- "\${(@)words[2,CURRENT]}" 2>/dev/null)"})
1272
- directive=\${lines[-1]}
1273
- lines=(\${lines[1,-2]})
1274
-
1275
- if [[ $directive == :1 ]]; then
1276
- _files
1277
-
1278
- return
1279
- fi
1280
-
1281
- # Each line is a candidate with its description after a tab, and _describe separates the
1282
- # two with a colon, so a colon in the candidate is escaped.
1283
- for line in $lines; do
1284
- name=\${line%%$'\\t'*}
1285
- name=\${name//:/\\\\:}
1286
-
1287
- if [[ $line == *$'\\t'* ]]; then
1288
- described+=("$name:\${line#*$'\\t'}")
1289
- else
1290
- described+=("$name")
1291
- fi
1292
- done
1293
-
1294
- _describe 'hardfin' described -M 'm:{a-zA-Z}={A-Za-z}'
1295
- }
1296
-
1297
- compdef _hardfin_complete hardfin`,
1298
- fish: `# hardfin completion for fish
1299
- function __hardfin_complete
1300
- # Older fish lacks -x, which replaced -o, so it falls back to the older option.
1301
- set -l words (commandline -xpc 2>/dev/null; or commandline -opc)
1302
- set -l current (commandline -ct)
1303
- set -l lines (hardfin __complete -- $words[2..-1] "$current" 2>/dev/null)
1304
- set -l directive $lines[-1]
1305
-
1306
- if set -q lines[1]
1307
- set -e lines[-1]
1308
- end
1309
-
1310
- if test "$directive" = ":1"
1311
- __fish_complete_path "$current"
1312
-
1313
- return
1314
- end
1315
-
1316
- # Fish shows what follows a tab as the description, so candidates pass through as they are.
1317
- string join \\n -- $lines
1318
- end
1319
-
1320
- complete -c hardfin -f -a "(__hardfin_complete)"`,
1321
- powershell: `# hardfin completion for PowerShell
1322
- Register-ArgumentCompleter -Native -CommandName hardfin -ScriptBlock {
1323
- param($wordToComplete, $commandAst, $cursorPosition)
1324
-
1325
- # Only the words before the cursor decide what is offered, and PowerShell leaves out the
1326
- # empty word after a trailing space, so it is added back.
1327
- $words = @($commandAst.CommandElements | Select-Object -Skip 1 | Where-Object { $_.Extent.StartOffset -lt $cursorPosition } | ForEach-Object { $_.Extent.Text })
1328
-
1329
- if ($wordToComplete -eq '') {
1330
- # Before 7.3, and in Legacy mode, an empty argument never reaches the program unless
1331
- # it is written as a pair of quotes.
1332
- $isLegacy = $PSVersionTable.PSVersion -lt [version]'7.3' -or $PSNativeCommandArgumentPassing -eq 'Legacy'
1333
- $words += if ($isLegacy) { '""' } else { '' }
1334
- }
1335
-
1336
- $lines = @(hardfin __complete -- @words 2>$null)
1337
-
1338
- # Returning nothing makes PowerShell complete paths, which is what the last line asks for
1339
- # when it is :1.
1340
- if ($lines.Count -eq 0 -or $lines[-1] -eq ':1') {
1341
- return
1342
- }
1343
-
1344
- # Each line is a candidate with its description after a tab.
1345
- $lines | Select-Object -SkipLast 1 | ForEach-Object {
1346
- $value, $description = $_ -split "\`t", 2
1347
- $text = $value
1348
-
1349
- if ($text -match '[\\s#@$;,''{}()"\`|<>&]') {
1350
- $text = "'" + ($text -replace "'", "''") + "'"
1351
- }
1352
-
1353
- [System.Management.Automation.CompletionResult]::new($text, $value, 'ParameterValue', $(if ($description) { $description } else { $value }))
1354
- }
1355
- }`
1356
- };
1357
1647
  //#endregion
1358
1648
  //#region src/command/config.ts
1359
1649
  const configCommand = defineCommand({
@@ -1405,47 +1695,6 @@ async function runConfig(input) {
1405
1695
  return ExitCode.OK;
1406
1696
  }
1407
1697
  //#endregion
1408
- //#region src/auth/browser.ts
1409
- /** isWSL reports whether this Linux is running under Windows. */
1410
- function isWSL() {
1411
- return existsSync("/proc/sys/fs/binfmt_misc/WSLInterop");
1412
- }
1413
- /**
1414
- * toOpeners lists the ways to open a URL here, best first. WSL needs Windows to do it,
1415
- * because the browser the person is looking at is the Windows one, and xdg-open reaches
1416
- * nothing outside the distribution.
1417
- */
1418
- function toOpeners(url) {
1419
- if (process.platform === "darwin") return [["open", [url]]];
1420
- if (process.platform === "win32") return [["cmd", [
1421
- "/c",
1422
- "start",
1423
- "",
1424
- url
1425
- ]]];
1426
- if (isWSL()) return [
1427
- ["wslview", [url]],
1428
- ["powershell.exe", [
1429
- "-NoProfile",
1430
- "-NonInteractive",
1431
- "-Command",
1432
- "Start-Process",
1433
- `'${url}'`
1434
- ]],
1435
- ["explorer.exe", [url]]
1436
- ];
1437
- return [["xdg-open", [url]]];
1438
- }
1439
- /** openBrowser asks the desktop to show a URL, and reports whether anything took it. */
1440
- function openBrowser(url, launch = spawnSync) {
1441
- for (const [command, args] of toOpeners(url)) if (launch(command, args, {
1442
- stdio: "ignore",
1443
- cwd: command.endsWith(".exe") ? "/mnt/c" : void 0,
1444
- timeout: 1e4
1445
- }).error === void 0) return true;
1446
- return false;
1447
- }
1448
- //#endregion
1449
1698
  //#region src/auth/device.ts
1450
1699
  const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
1451
1700
  /** The interval a server gives is in seconds, and five more are added on a slow_down. */
@@ -1857,7 +2106,8 @@ async function toSignedIn(input, metadata, tokens) {
1857
2106
  `Scope ${tokens.scope ?? "as granted"}`,
1858
2107
  `Stored in ${stored}`,
1859
2108
  "",
1860
- "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"]
1861
2111
  ].join("\n"));
1862
2112
  return ExitCode.OK;
1863
2113
  }
@@ -2151,7 +2401,7 @@ function toCliRunner() {
2151
2401
  }
2152
2402
  /** serve answers requests on stdin until the client closes it. */
2153
2403
  async function serve(commands, run = toCliRunner()) {
2154
- const lines = createInterface({ input: process.stdin });
2404
+ const lines = createInterface$1({ input: process.stdin });
2155
2405
  const answering = /* @__PURE__ */ new Set();
2156
2406
  for await (const line of lines) {
2157
2407
  if (line.trim() === "") continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hardfin/cli",
3
- "version": "0.1.0-dev.23",
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.",