@fluid-app/fluid-cli 0.1.35 → 0.1.37
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 +53 -5
- package/dist/bin/fluid.mjs +477 -5
- package/dist/bin/fluid.mjs.map +1 -1
- package/dist/index.d.mts +60 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{token--hQqx3DU.mjs → token-6SEVoGMw.mjs} +102 -2
- package/dist/token-6SEVoGMw.mjs.map +1 -0
- package/package.json +1 -1
- package/dist/token--hQqx3DU.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -50,13 +50,61 @@ 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
|
|
56
|
-
|
|
|
57
|
-
| `FLUID_TOKEN`
|
|
58
|
-
| `FLUID_API_BASE`
|
|
59
|
-
| `FLUID_CONFIG_DIR`
|
|
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
|
|
package/dist/bin/fluid.mjs
CHANGED
|
@@ -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
|
|
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 {
|
|
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 {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
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 {
|
|
@@ -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
|