@fluid-app/fluid-cli 0.1.34 → 0.1.36

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.
package/README.md CHANGED
@@ -50,17 +50,65 @@ Plugins are auto-discovered from:
50
50
  2. `node_modules/@fluid-app/` where the CLI itself is installed (global installs)
51
51
  3. Sibling packages in a pnpm workspace (monorepo development)
52
52
 
53
+ ## Discovering commands
54
+
55
+ ```bash
56
+ fluid commands # every installed command, one line each
57
+ fluid commands domains # only `fluid domains` and its subcommands
58
+ fluid commands --json # the full catalog, for agents and doc generators
59
+ ```
60
+
61
+ The JSON catalog lists every command with its arguments, flags (and the
62
+ parent commands whose flags it also accepts), whether it changes data, and
63
+ the API endpoints it calls. `writes` is `true` or `false` when the plugin
64
+ declares it or the command confirms with `--yes`, and `null` when unknown.
65
+ `endpoints` is `null` until a plugin declares them: unknown, not "calls no
66
+ API". Plugins declare endpoints with `describeCommand`:
67
+
68
+ ```ts
69
+ import { describeCommand } from "@fluid-app/fluid-cli";
70
+
71
+ describeCommand(parent.command("status <domain>"), {
72
+ endpoints: [{ method: "GET", path: "/api/domains/{id}" }],
73
+ writes: false,
74
+ });
75
+ ```
76
+
77
+ ## Shell completion
78
+
79
+ ```bash
80
+ echo 'eval "$(fluid completion bash)"' >> ~/.bashrc
81
+ fluid completion zsh > "${fpath[1]}/_fluid"
82
+ fluid completion fish > ~/.config/fish/completions/fluid.fish
83
+ ```
84
+
85
+ Completion asks the installed CLI on each tab, so new plugins complete
86
+ without reinstalling the script. It completes commands, aliases, flags and
87
+ values that have a fixed set of choices. The scripts pipe the typed words to
88
+ the CLI on stdin rather than as arguments, so a typed `--token` never shows
89
+ in the process list.
90
+
91
+ ## Updates
92
+
93
+ Once a day an interactive run starts a detached background check of npm for
94
+ a newer `@fluid-app/fluid-cli`; no command waits on it. When the last check
95
+ found one, a short notice prints on stderr after the command finishes.
96
+ A failed check is retried the next day. It never runs in CI, when output is piped, or for a
97
+ workspace build. Set `FLUID_NO_UPDATE_CHECK=1` (or `NO_UPDATE_NOTIFIER=1`)
98
+ to turn it off.
99
+
53
100
  ## Environment Variables
54
101
 
55
- | Variable | Description |
56
- | ------------------ | ------------------------------------------------ |
57
- | `FLUID_TOKEN` | API token (alternative to `fluid login`) |
58
- | `FLUID_API_BASE` | API base URL (default: `https://api.fluid.app`) |
59
- | `FLUID_CONFIG_DIR` | Override config directory (default: `~/.fluid/`) |
102
+ | Variable | Description |
103
+ | ----------------------- | ------------------------------------------------ |
104
+ | `FLUID_TOKEN` | API token (alternative to `fluid login`) |
105
+ | `FLUID_API_BASE` | API base URL (default: `https://api.fluid.app`) |
106
+ | `FLUID_CONFIG_DIR` | Override config directory (default: `~/.fluid/`) |
107
+ | `FLUID_NO_UPDATE_CHECK` | Turn off the daily update notice |
60
108
 
61
109
  ## Development
62
110
 
63
- For contributors working in `fluid-mono`:
111
+ For contributors working in this monorepo:
64
112
 
65
113
  ```bash
66
114
  pnpm --filter @fluid-app/fluid-cli build
@@ -1,13 +1,249 @@
1
1
  #!/usr/bin/env node
2
- import { _ as validateToken, a as readConfig, c as getConfigDir, d as FLUID_API_ERROR, f as confirmMfa, g as switchCompany, h as sendMfa, m as getFluidApiBase, n as getAuthToken, o as updateConfig, p as fetchUserCompanies, s as writeConfig, t as getActiveProfile } from "../token--hQqx3DU.mjs";
2
+ import { F as walkCommands, M as rootCommand, N as visibleOptions, P as visibleSubcommands, _ as validateToken, a as readConfig, c as getConfigDir, d as FLUID_API_ERROR, f as confirmMfa, g as switchCompany, h as sendMfa, j as filterCatalog, m as getFluidApiBase, n as getAuthToken, o as updateConfig, p as fetchUserCompanies, s as writeConfig, t as getActiveProfile } from "../token-6SEVoGMw.mjs";
3
3
  import { createRequire } from "node:module";
4
- import { Command } from "commander";
4
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { Argument, Command, Option } from "commander";
5
7
  import chalk from "chalk";
6
8
  import prompts from "prompts";
7
9
  import ora from "ora";
8
- import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
9
- import { dirname, join } from "node:path";
10
- import { fileURLToPath, pathToFileURL } from "node:url";
10
+ import { dirname, join, sep } from "node:path";
11
+ import { randomBytes } from "node:crypto";
12
+ import { spawn } from "node:child_process";
13
+ //#region src/commands/commands.ts
14
+ /**
15
+ * fluid commands — list every installed command, or print the catalog as
16
+ * JSON for agents and documentation generators.
17
+ */
18
+ const { version: version$1 } = createRequire(import.meta.url)("../../package.json");
19
+ function usage(entry) {
20
+ const args = entry.arguments.map((arg) => {
21
+ const name = arg.variadic ? `${arg.name}...` : arg.name;
22
+ return arg.required ? `<${name}>` : `[${name}]`;
23
+ });
24
+ return [entry.command, ...args].join(" ");
25
+ }
26
+ function formatCatalogText(entries) {
27
+ const runnable = entries.filter((entry) => entry.runnable);
28
+ const width = Math.min(48, Math.max(0, ...runnable.map((entry) => usage(entry).length)));
29
+ return runnable.map((entry) => {
30
+ const line = usage(entry);
31
+ const summary = entry.description.split("\n", 1)[0] ?? "";
32
+ return summary ? `${line.padEnd(width)} ${summary}` : line;
33
+ }).join("\n");
34
+ }
35
+ const commandsCommand = new Command("commands").description("List installed commands; --json prints the full catalog").argument("[prefix...]", "Only list commands under this one, e.g. `domains`").option("--json", "Print the catalog as JSON", false).action((prefix, opts, command) => {
36
+ const root = rootCommand(command);
37
+ const entries = filterCatalog(walkCommands(root), root, prefix);
38
+ if (entries.length === 0) {
39
+ process.stderr.write(JSON.stringify({ error: `No command named "${prefix.join(" ")}".` }) + "\n");
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+ if (opts.json) {
44
+ process.stdout.write(JSON.stringify({
45
+ cli: root.name(),
46
+ version: version$1,
47
+ commands: entries
48
+ }, null, 2) + "\n");
49
+ return;
50
+ }
51
+ process.stdout.write(formatCatalogText(entries) + "\n");
52
+ });
53
+ //#endregion
54
+ //#region src/completion/complete.ts
55
+ const SHELLS = [
56
+ "bash",
57
+ "zsh",
58
+ "fish"
59
+ ];
60
+ function findOption(chain, word) {
61
+ const flag = word.split("=", 1)[0] ?? word;
62
+ for (const command of [...chain].reverse()) {
63
+ const match = command.options.find((option) => option.long === flag || option.short === flag);
64
+ if (match) return match;
65
+ }
66
+ return null;
67
+ }
68
+ function splitAssignment(word) {
69
+ const at = word.indexOf("=");
70
+ return [word.slice(0, at), word.slice(at + 1)];
71
+ }
72
+ function choicesFor(option, typed) {
73
+ return (option.argChoices ?? []).filter((choice) => choice.startsWith(typed)).map((choice) => ({
74
+ value: choice,
75
+ description: ""
76
+ }));
77
+ }
78
+ function findSubcommand(command, word) {
79
+ return visibleSubcommands(command).find((sub) => sub.name() === word || sub.aliases().includes(word)) ?? null;
80
+ }
81
+ /**
82
+ * Complete the last of `words`, the words after the program name.
83
+ * Where a flag's value is expected, offers the flag's choices if it has
84
+ * any; otherwise returns nothing so the shell falls back to files.
85
+ */
86
+ function completeWords(root, words) {
87
+ const partial = words.at(-1) ?? "";
88
+ const chain = [root];
89
+ let current = root;
90
+ let awaitingValue = null;
91
+ let operandsOnly = false;
92
+ let operands = 0;
93
+ const prior = words.slice(0, -1);
94
+ for (const [index, word] of prior.entries()) {
95
+ if (awaitingValue) {
96
+ if (word !== "=" || prior[index - 1] === "=") awaitingValue = null;
97
+ continue;
98
+ }
99
+ if (!operandsOnly && word === "--") {
100
+ operandsOnly = true;
101
+ continue;
102
+ }
103
+ if (!operandsOnly && word.startsWith("-")) {
104
+ const option = findOption(chain, word);
105
+ awaitingValue = option?.required && !word.includes("=") ? option : null;
106
+ continue;
107
+ }
108
+ const sub = operands === 0 ? findSubcommand(current, word) : null;
109
+ if (sub) {
110
+ current = sub;
111
+ chain.push(sub);
112
+ } else operands += 1;
113
+ }
114
+ if (awaitingValue) return choicesFor(awaitingValue, partial === "=" ? "" : partial);
115
+ if (!operandsOnly && partial.startsWith("--") && partial.includes("=")) {
116
+ const [flag = "", typed = ""] = splitAssignment(partial);
117
+ const option = findOption(chain, flag);
118
+ if (!option?.required && !option?.optional) return [];
119
+ return choicesFor(option, typed).map((choice) => ({
120
+ ...choice,
121
+ value: `${flag}=${choice.value}`
122
+ }));
123
+ }
124
+ if (!operandsOnly && partial.startsWith("-")) {
125
+ const seen = /* @__PURE__ */ new Set();
126
+ const candidates = [];
127
+ for (const command of [...chain].reverse()) for (const option of visibleOptions(command)) {
128
+ if (option.long === "--version" && command !== current) continue;
129
+ for (const value of [option.long, option.short]) {
130
+ if (!value || seen.has(value)) continue;
131
+ seen.add(value);
132
+ candidates.push({
133
+ value,
134
+ description: option.description
135
+ });
136
+ }
137
+ }
138
+ candidates.push({
139
+ value: "--help",
140
+ description: "Show help"
141
+ });
142
+ return candidates.filter((c) => c.value.startsWith(partial));
143
+ }
144
+ const candidates = [];
145
+ if (operands === 0) for (const sub of visibleSubcommands(current)) for (const name of [sub.name(), ...sub.aliases()]) candidates.push({
146
+ value: name,
147
+ description: sub.description()
148
+ });
149
+ const args = current.registeredArguments;
150
+ const last = args.at(-1);
151
+ const argument = args[operands] ?? (last?.variadic ? last : void 0);
152
+ for (const choice of argument?.argChoices ?? []) candidates.push({
153
+ value: choice,
154
+ description: argument?.description ?? ""
155
+ });
156
+ return candidates.filter((c) => c.value.startsWith(partial));
157
+ }
158
+ /**
159
+ * The words the scripts pipe in: each followed by a NUL, the program name
160
+ * first. Returns the words after the program name, at least one.
161
+ */
162
+ function parseCompletionInput(input) {
163
+ const words = input.split("\0");
164
+ words.pop();
165
+ const typed = words.slice(1);
166
+ return typed.length > 0 ? typed : [""];
167
+ }
168
+ function firstLine(text) {
169
+ return text.split("\n", 1)[0]?.trim() ?? "";
170
+ }
171
+ /** One candidate per line, in the shape each shell's script reads. */
172
+ function formatCandidates(candidates, shell) {
173
+ const lines = candidates.map(({ value, description }) => {
174
+ const summary = firstLine(description);
175
+ switch (shell) {
176
+ case "bash": return value;
177
+ case "zsh": return summary ? `${value.replaceAll(":", "\\:")}:${summary}` : value.replaceAll(":", "\\:");
178
+ case "fish": return summary ? `${value}\t${summary}` : value;
179
+ }
180
+ });
181
+ return lines.length > 0 ? `${lines.join("\n")}\n` : "";
182
+ }
183
+ function completionScript(shell) {
184
+ switch (shell) {
185
+ case "bash": return `# fluid bash completion
186
+ _fluid_complete() {
187
+ local -a words
188
+ local i
189
+ for ((i = 0; i <= COMP_CWORD; i++)); do words+=("\${COMP_WORDS[i]}"); done
190
+ local IFS=$'\\n'
191
+ COMPREPLY=($(printf '%s\\0' "\${words[@]}" | fluid __complete --shell bash 2>/dev/null))
192
+ }
193
+ complete -o default -F _fluid_complete fluid
194
+ `;
195
+ case "zsh": return `#compdef fluid
196
+ # fluid zsh completion
197
+ _fluid() {
198
+ local -a candidates
199
+ candidates=("\${(@f)$(printf '%s\\0' "\${(@)words[1,CURRENT]}" | fluid __complete --shell zsh 2>/dev/null)}")
200
+ candidates=(\${candidates:#})
201
+ if (( \${#candidates} )); then
202
+ _describe -t fluid 'fluid' candidates
203
+ else
204
+ _files
205
+ fi
206
+ }
207
+ if [[ \${zsh_eval_context[-1]} == loadautofunc ]]; then
208
+ _fluid "$@"
209
+ else
210
+ compdef _fluid fluid
211
+ fi
212
+ `;
213
+ case "fish": return `# fluid fish completion
214
+ function __fluid_complete
215
+ set -l current (commandline -ct)
216
+ set -l candidates (string join0 -- (commandline -opc) "$current" | fluid __complete --shell fish 2>/dev/null)
217
+ if set -q candidates[1]
218
+ printf '%s\\n' $candidates
219
+ else
220
+ __fish_complete_path "$current"
221
+ end
222
+ end
223
+ complete -c fluid -f -a '(__fluid_complete)'
224
+ `;
225
+ }
226
+ }
227
+ //#endregion
228
+ //#region src/commands/completion.ts
229
+ /**
230
+ * fluid completion <shell> — print a shell completion script.
231
+ * fluid __complete — the hidden command that script pipes the typed words
232
+ * into on each tab.
233
+ */
234
+ const completionCommand = new Command("completion").description("Print a shell completion script for bash, zsh or fish").addArgument(new Argument("<shell>", "Shell to complete in").choices(SHELLS)).addHelpText("after", `
235
+ Install:
236
+ bash echo 'eval "$(fluid completion bash)"' >> ~/.bashrc
237
+ zsh fluid completion zsh > "\${fpath[1]}/_fluid" (or eval "$(fluid completion zsh)" in ~/.zshrc)
238
+ fish fluid completion fish > ~/.config/fish/completions/fluid.fish
239
+ `).action((shell) => {
240
+ process.stdout.write(completionScript(shell));
241
+ });
242
+ const completeCommand = new Command("__complete").description("Print completions for the words typed so far").addOption(new Option("--shell <shell>", "Output format").choices(SHELLS).default("bash")).action((opts, command) => {
243
+ const typed = parseCompletionInput(readFileSync(0, "utf8"));
244
+ process.stdout.write(formatCandidates(completeWords(rootCommand(command), typed), opts.shell));
245
+ });
246
+ //#endregion
11
247
  //#region src/auth/profiles.ts
12
248
  function createFluidProfile({ name, token, companyName, baseUrl, storedAt }) {
13
249
  return {
@@ -590,7 +826,7 @@ function resolveLoadPath(importSpecifier, resolve = defaultResolver) {
590
826
  /**
591
827
  * Decide whether to print the per-plugin "Loaded plugin …" diagnostics.
592
828
  *
593
- * A fluid-mono checkout resolves ~a dozen plugin packages, so emitting a line
829
+ * A monorepo checkout resolves ~a dozen plugin packages, so emitting a line
594
830
  * for each on every command (including `--help` and tab-completion, and into
595
831
  * Mist's captured stderr dialog) is noise. The success diagnostics are gated
596
832
  * behind an explicit opt-in; load *failures* are always surfaced by the loader
@@ -653,8 +889,232 @@ async function loadPlugins(program, basePath) {
653
889
  }
654
890
  }
655
891
  //#endregion
892
+ //#region src/update/notifier.ts
893
+ /**
894
+ * Tell an interactive user, at most once a day, that a newer fluid CLI is
895
+ * on npm. The check never holds a command open: a run prints what the last
896
+ * check found, and a stale cache is refreshed by a detached child process
897
+ * for next time.
898
+ */
899
+ const PACKAGE_NAME = "@fluid-app/fluid-cli";
900
+ /** The argument the CLI entry point treats as "refresh the cache and exit". */
901
+ const REFRESH_ARG = "__refresh-update-check";
902
+ const FETCH_TIMEOUT_MS = 5e3;
903
+ const CACHE_FILE = "update-check.json";
904
+ /** Whether an Electron app, such as Mist, is running the CLI it bundles. */
905
+ function isElectronRun(env, versions = {}) {
906
+ return Boolean(env["ELECTRON_RUN_AS_NODE"] || versions["electron"]);
907
+ }
908
+ /**
909
+ * Only an installed CLI used by a person checks. Scripts, CI, piped output
910
+ * (completion and `--json` among them), a workspace build and a copy an
911
+ * Electron app bundles (the app updates it) stay quiet.
912
+ */
913
+ function shouldCheckForUpdates({ env, stdoutIsTTY, stderrIsTTY, entryPath, versions }) {
914
+ if (env["FLUID_NO_UPDATE_CHECK"] || env["NO_UPDATE_NOTIFIER"]) return false;
915
+ if (env["CI"]) return false;
916
+ if (isElectronRun(env, versions)) return false;
917
+ if (!stdoutIsTTY || !stderrIsTTY) return false;
918
+ return entryPath.includes(`${sep}node_modules${sep}`);
919
+ }
920
+ function parseVersion(version) {
921
+ const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version.trim());
922
+ if (!match) return null;
923
+ return [
924
+ Number(match[1]),
925
+ Number(match[2]),
926
+ Number(match[3])
927
+ ];
928
+ }
929
+ /** True when `latest` is a newer release than `current`. Prereleases never count. */
930
+ function isNewer(current, latest) {
931
+ const a = parseVersion(current);
932
+ const b = parseVersion(latest);
933
+ if (!a || !b) return false;
934
+ for (let i = 0; i < 3; i += 1) if (b[i] !== a[i]) return b[i] > a[i];
935
+ return false;
936
+ }
937
+ const LOCKFILES = [
938
+ ["pnpm-lock.yaml", "pnpm"],
939
+ ["bun.lock", "bun"],
940
+ ["bun.lockb", "bun"],
941
+ ["yarn.lock", "yarn"],
942
+ ["package-lock.json", "npm"],
943
+ ["npm-shrinkwrap.json", "npm"]
944
+ ];
945
+ function projectManager(projectDir, exists) {
946
+ for (let dir = projectDir;; dir = dirname(dir)) {
947
+ for (const [file, manager] of LOCKFILES) {
948
+ if (!exists(join(dir, file))) continue;
949
+ if (manager !== "yarn") return manager;
950
+ return exists(join(dir, ".yarnrc.yml")) ? "yarn" : "yarn-classic";
951
+ }
952
+ if (dirname(dir) === dir) return "npm";
953
+ }
954
+ }
955
+ /**
956
+ * Work out from the entry point's real path how the CLI was installed: an
957
+ * npx cache run, a global install (npm's `lib/node_modules`, or npm on
958
+ * Windows, pnpm's and yarn's global directories), or else a project's own
959
+ * `node_modules`.
960
+ */
961
+ function detectInstallKind(entryPath, exists = existsSync) {
962
+ const path = entryPath.replaceAll("\\", "/");
963
+ if (path.includes("/_npx/")) return { kind: "npx" };
964
+ if (/\/pnpm\/global\//i.test(path)) return {
965
+ kind: "global",
966
+ manager: "pnpm"
967
+ };
968
+ if (/\/yarn\/global\//i.test(path) || path.includes("/.yarn-global/")) return {
969
+ kind: "global",
970
+ manager: "yarn-classic"
971
+ };
972
+ if (/\/\.bun\/install\/global\//.test(path)) return {
973
+ kind: "global",
974
+ manager: "bun"
975
+ };
976
+ const modules = path.indexOf("/node_modules/");
977
+ const before = modules === -1 ? path : path.slice(0, modules);
978
+ const projectDir = entryPath.slice(0, modules === -1 ? 0 : modules);
979
+ if ((before.endsWith("/lib") || /\/npm$/i.test(before)) && !exists(join(projectDir, "package.json"))) return {
980
+ kind: "global",
981
+ manager: "npm"
982
+ };
983
+ return {
984
+ kind: "local",
985
+ manager: projectManager(projectDir, exists),
986
+ projectDir
987
+ };
988
+ }
989
+ const PLUGINS_NOTE = "(and any @fluid-app/fluid-cli-* plugins you use)";
990
+ function globalCommand(manager) {
991
+ switch (manager) {
992
+ case "npm": return `npm install -g ${PACKAGE_NAME}`;
993
+ case "pnpm": return `pnpm add -g ${PACKAGE_NAME}@latest`;
994
+ case "yarn":
995
+ case "yarn-classic": return `yarn global add ${PACKAGE_NAME}@latest`;
996
+ case "bun": return `bun add -g ${PACKAGE_NAME}@latest`;
997
+ }
998
+ }
999
+ function localCommand(manager) {
1000
+ switch (manager) {
1001
+ case "npm": return `npm install ${PACKAGE_NAME}@latest`;
1002
+ case "pnpm": return `pnpm update --latest ${PACKAGE_NAME}`;
1003
+ case "yarn": return `yarn up ${PACKAGE_NAME}@latest`;
1004
+ case "yarn-classic": return `yarn upgrade --latest ${PACKAGE_NAME}`;
1005
+ case "bun": return `bun update --latest ${PACKAGE_NAME}`;
1006
+ }
1007
+ }
1008
+ function updateInstruction(install) {
1009
+ switch (install.kind) {
1010
+ case "global": return `Run: ${globalCommand(install.manager)} ${PLUGINS_NOTE}\n`;
1011
+ case "npx": return `Run: npx ${PACKAGE_NAME}@latest to use the latest version\n`;
1012
+ case "local": return `Run in ${install.projectDir}: ${localCommand(install.manager)} ${PLUGINS_NOTE}\n`;
1013
+ }
1014
+ }
1015
+ function updateNotice(current, latest, install = {
1016
+ kind: "global",
1017
+ manager: "npm"
1018
+ }) {
1019
+ if (!isNewer(current, latest)) return null;
1020
+ return `\nUpdate available for fluid: ${current} → ${latest}\n` + updateInstruction(install) + `Set FLUID_NO_UPDATE_CHECK=1 to stop these notices.\n`;
1021
+ }
1022
+ function readUpdateCache(configDir) {
1023
+ try {
1024
+ const data = JSON.parse(readFileSync(join(configDir, CACHE_FILE), "utf8"));
1025
+ if (typeof data.checkedAt !== "string") return null;
1026
+ const latest = typeof data.latest === "string" ? data.latest : null;
1027
+ return {
1028
+ checkedAt: data.checkedAt,
1029
+ latest
1030
+ };
1031
+ } catch {
1032
+ return null;
1033
+ }
1034
+ }
1035
+ /**
1036
+ * Write the cache, reporting whether it landed. The JSON goes to a fresh
1037
+ * file opened exclusively, then is renamed over the cache: a rename replaces
1038
+ * a symlink planted at the cache path instead of writing through it, so a
1039
+ * shared FLUID_CONFIG_DIR can't aim this write at another of the user's files.
1040
+ */
1041
+ function writeUpdateCache(configDir, cache) {
1042
+ const temp = join(configDir, `.${CACHE_FILE}.${randomBytes(6).toString("hex")}`);
1043
+ try {
1044
+ mkdirSync(configDir, { recursive: true });
1045
+ writeFileSync(temp, JSON.stringify(cache) + "\n", { flag: "wx" });
1046
+ renameSync(temp, join(configDir, CACHE_FILE));
1047
+ return true;
1048
+ } catch {
1049
+ try {
1050
+ rmSync(temp, { force: true });
1051
+ } catch {}
1052
+ return false;
1053
+ }
1054
+ }
1055
+ function isStale(cache, now) {
1056
+ if (!cache) return true;
1057
+ const checkedAt = Date.parse(cache.checkedAt);
1058
+ return Number.isNaN(checkedAt) || now.getTime() - checkedAt >= 864e5;
1059
+ }
1060
+ async function fetchLatestVersion(fetchImpl = fetch) {
1061
+ try {
1062
+ const response = await fetchImpl(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
1063
+ if (!response.ok) return null;
1064
+ const body = await response.json();
1065
+ return typeof body.version === "string" ? body.version : null;
1066
+ } catch {
1067
+ return null;
1068
+ }
1069
+ }
1070
+ /**
1071
+ * Ask npm for the latest version and record it. Runs in the detached child;
1072
+ * a failure leaves the last known version and the throttle stamp in place.
1073
+ */
1074
+ async function refreshUpdateCache(configDir, fetchImpl = fetch, now = /* @__PURE__ */ new Date()) {
1075
+ const latest = await fetchLatestVersion(fetchImpl);
1076
+ if (latest) writeUpdateCache(configDir, {
1077
+ checkedAt: now.toISOString(),
1078
+ latest
1079
+ });
1080
+ }
1081
+ /** Start `node <entry> __refresh-update-check` detached, so this run never waits on npm. */
1082
+ function spawnRefresh(entryPath) {
1083
+ try {
1084
+ const child = spawn(process.execPath, [entryPath, REFRESH_ARG], {
1085
+ detached: true,
1086
+ stdio: "ignore",
1087
+ windowsHide: true
1088
+ });
1089
+ child.on("error", () => {});
1090
+ child.unref();
1091
+ } catch {}
1092
+ }
1093
+ /**
1094
+ * Print the cached notice when the command finishes and, when the last
1095
+ * check started a day ago or more, stamp the cache and refresh it in a
1096
+ * detached process. The stamp throttles failed and concurrent checks to one
1097
+ * a day while keeping the last known version, and its notice, in place.
1098
+ */
1099
+ function startUpdateCheck(options) {
1100
+ if (!shouldCheckForUpdates(options)) return;
1101
+ const now = options.now ?? /* @__PURE__ */ new Date();
1102
+ const cache = readUpdateCache(options.configDir);
1103
+ const notice = cache?.latest ? updateNotice(options.currentVersion, cache.latest, detectInstallKind(options.entryPath)) : null;
1104
+ if (notice) (options.onExit ?? ((listener) => process.once("exit", listener)))(() => process.stderr.write(notice));
1105
+ if (!isStale(cache, now)) return;
1106
+ if (writeUpdateCache(options.configDir, {
1107
+ checkedAt: now.toISOString(),
1108
+ latest: cache?.latest ?? null
1109
+ })) (options.refresh ?? spawnRefresh)(options.entryPath);
1110
+ }
1111
+ //#endregion
656
1112
  //#region src/bin/fluid.ts
657
1113
  const { version } = createRequire(import.meta.url)("../../package.json");
1114
+ if (process.argv[2] === "__refresh-update-check") {
1115
+ await refreshUpdateCache(getConfigDir());
1116
+ process.exit(0);
1117
+ }
658
1118
  const packageRoot = process.cwd();
659
1119
  const program = new Command();
660
1120
  program.name("fluid").description("Fluid Commerce CLI").version(version);
@@ -662,6 +1122,18 @@ program.addCommand(loginCommand);
662
1122
  program.addCommand(logoutCommand);
663
1123
  program.addCommand(whoamiCommand);
664
1124
  program.addCommand(switchCommand);
1125
+ program.addCommand(commandsCommand);
1126
+ program.addCommand(completionCommand);
1127
+ program.addCommand(completeCommand, { hidden: true });
1128
+ startUpdateCheck({
1129
+ currentVersion: version,
1130
+ configDir: getConfigDir(),
1131
+ env: process.env,
1132
+ stdoutIsTTY: Boolean(process.stdout.isTTY),
1133
+ stderrIsTTY: Boolean(process.stderr.isTTY),
1134
+ entryPath: realpathSync(fileURLToPath(import.meta.url)),
1135
+ versions: process.versions
1136
+ });
665
1137
  await loadPlugins(program, packageRoot);
666
1138
  program.parse(process.argv, { from: "node" });
667
1139
  //#endregion